formlab-mcp 0.6.51 → 0.6.53
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 +1 -1
- package/data.js +26 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/tools/analytics.js +22 -2
- package/tools/formulations.js +26 -1
- package/vendor/cost.js +68 -60
- package/vendor/theoretical-params.js +39 -39
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Ask Claude (or another MCP client) things like:
|
|
|
29
29
|
|
|
30
30
|
| Tool | Purpose |
|
|
31
31
|
|---|---|
|
|
32
|
-
| `list_formulations` | Filtered list of recipes |
|
|
32
|
+
| `list_formulations` | Filtered list of recipes, each with exact `batchCount` / `sampleCount` / `testReportCount`; `sort` (`batches`, `samples`, `test_reports`, `name`, `updated`) ranks every match before the limit — "which formulas have the most batch runs?" in one call |
|
|
33
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 |
|
package/data.js
CHANGED
|
@@ -97,9 +97,35 @@ function _buildIndexes(db) {
|
|
|
97
97
|
templatesById: byId(db.templates),
|
|
98
98
|
parametersById: byId(db.parameters),
|
|
99
99
|
equipmentById: byId(db.equipment),
|
|
100
|
+
formulationCounts: _formulationCounts(db),
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
// Exact batch / sample / test-report counts per formula id, built once per load so
|
|
105
|
+
// list_formulations can rank every formula in O(n). Same rule as the app's AI context
|
|
106
|
+
// (js/gemini.js _fCounts, build 22m): a sample counts toward its own formulationId, or
|
|
107
|
+
// its batch's formula; a test report through its sample, else its own formulationId.
|
|
108
|
+
function _formulationCounts(db) {
|
|
109
|
+
const counts = new Map();
|
|
110
|
+
const bump = (fid, k) => {
|
|
111
|
+
if (!fid) return;
|
|
112
|
+
let c = counts.get(fid);
|
|
113
|
+
if (!c) counts.set(fid, c = { batches: 0, samples: 0, testReports: 0 });
|
|
114
|
+
c[k]++;
|
|
115
|
+
};
|
|
116
|
+
const batchFid = new Map();
|
|
117
|
+
(db.batches || []).forEach(b => { if (b) { bump(b.formulationId, 'batches'); batchFid.set(b.id, b.formulationId); } });
|
|
118
|
+
const sampleFid = new Map();
|
|
119
|
+
(db.samples || []).forEach(s => {
|
|
120
|
+
if (!s) return;
|
|
121
|
+
const fid = s.formulationId || batchFid.get(s.batchId);
|
|
122
|
+
sampleFid.set(s.id, fid);
|
|
123
|
+
bump(fid, 'samples');
|
|
124
|
+
});
|
|
125
|
+
(db.testResults || []).forEach(t => { if (t) bump(sampleFid.get(t.sampleId) || t.formulationId, 'testReports'); });
|
|
126
|
+
return counts;
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
// ============================================================
|
|
104
130
|
// Shared helpers used across tools
|
|
105
131
|
// ============================================================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "formlab-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.53",
|
|
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.
|
|
5
|
+
"version": "0.6.53",
|
|
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.
|
|
16
|
+
"version": "0.6.53",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
package/tools/analytics.js
CHANGED
|
@@ -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') {
|
|
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;
|
package/tools/formulations.js
CHANGED
|
@@ -13,6 +13,7 @@ import { theoreticalParams } from '../theoretical.js';
|
|
|
13
13
|
// while keeping the human-facing fields the user cares about.
|
|
14
14
|
function _trimFormulation(f) {
|
|
15
15
|
if (!f) return null;
|
|
16
|
+
const counts = getStore().indexes.formulationCounts.get(f.id);
|
|
16
17
|
return {
|
|
17
18
|
id: f.id,
|
|
18
19
|
uid: f.uid,
|
|
@@ -39,13 +40,23 @@ function _trimFormulation(f) {
|
|
|
39
40
|
compositionRowCount: (f.composition || []).length,
|
|
40
41
|
hasSubFormulas: (f.composition || []).some(c => c && c.formulationId),
|
|
41
42
|
updatedAt: f.updatedAt || null,
|
|
43
|
+
// Exact counts (a sample through its batch, a test report through its sample).
|
|
44
|
+
batchCount: counts ? counts.batches : 0,
|
|
45
|
+
sampleCount: counts ? counts.samples : 0,
|
|
46
|
+
testReportCount: counts ? counts.testReports : 0,
|
|
42
47
|
};
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
const _SORTS = {
|
|
51
|
+
batches: (c) => (c ? c.batches : 0),
|
|
52
|
+
samples: (c) => (c ? c.samples : 0),
|
|
53
|
+
test_reports: (c) => (c ? c.testReports : 0),
|
|
54
|
+
};
|
|
55
|
+
|
|
45
56
|
const list_formulations = {
|
|
46
57
|
definition: {
|
|
47
58
|
name: 'list_formulations',
|
|
48
|
-
description: 'List formulations (recipes) in the FormLab database, optionally filtered by status, project, family, tag, or name pattern. Returns one row per formula with key metadata. Use get_formulation for the full composition.',
|
|
59
|
+
description: 'List formulations (recipes) in the FormLab database, optionally filtered by status, project, family, tag, or name pattern. Returns one row per formula with key metadata and exact batchCount / sampleCount / testReportCount (a sample counts toward its batch\'s formula, a test report toward its sample\'s). To rank formulas (e.g. "which formulas have the most batch runs?") pass sort — it orders every matching formula before the limit is applied, so do not page through the list or call get_formulation per formula. Use get_formulation for the full composition.',
|
|
49
60
|
inputSchema: {
|
|
50
61
|
type: 'object',
|
|
51
62
|
properties: {
|
|
@@ -54,6 +65,7 @@ const list_formulations = {
|
|
|
54
65
|
family: { type: 'string', description: 'Filter by formula family (e.g. "Acrylic Coating", "Skin Serum"). Case-insensitive substring.' },
|
|
55
66
|
tag: { type: 'string', description: 'Filter to formulas containing this tag.' },
|
|
56
67
|
name_contains: { type: 'string', description: 'Case-insensitive substring match on formula name.' },
|
|
68
|
+
sort: { type: 'string', enum: ['batches', 'samples', 'test_reports', 'name', 'updated'], description: 'Order before the limit: batches / samples / test_reports = most first; name = A–Z; updated = most recently updated first. Default: database order.' },
|
|
57
69
|
limit: { type: 'number', description: 'Max rows to return (default 50, max 500).' },
|
|
58
70
|
},
|
|
59
71
|
},
|
|
@@ -75,9 +87,22 @@ const list_formulations = {
|
|
|
75
87
|
if (args.name_contains && !norm(f.name).includes(norm(args.name_contains))) return false;
|
|
76
88
|
return true;
|
|
77
89
|
});
|
|
90
|
+
if (args.sort && Object.hasOwn(_SORTS, args.sort)) {
|
|
91
|
+
const key = _SORTS[args.sort];
|
|
92
|
+
const val = (f) => key(indexes.formulationCounts.get(f.id));
|
|
93
|
+
filtered.sort((a, b) => val(b) - val(a));
|
|
94
|
+
} else if (args.sort === 'name') {
|
|
95
|
+
filtered.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' }));
|
|
96
|
+
} else if (args.sort === 'updated') {
|
|
97
|
+
const ts = (v) => (typeof v === 'number' ? v : Date.parse(v) || 0);
|
|
98
|
+
filtered.sort((a, b) => ts(b.updatedAt) - ts(a.updatedAt));
|
|
99
|
+
} else if (args.sort) {
|
|
100
|
+
return { error: `Unknown sort "${args.sort}". Use one of: batches, samples, test_reports, name, updated.` };
|
|
101
|
+
}
|
|
78
102
|
return {
|
|
79
103
|
totalMatching: filtered.length,
|
|
80
104
|
returned: Math.min(filtered.length, limit),
|
|
105
|
+
...(args.sort ? { sortedBy: args.sort } : {}),
|
|
81
106
|
formulations: filtered.slice(0, limit).map(_trimFormulation),
|
|
82
107
|
};
|
|
83
108
|
},
|
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
|
|
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
|
|
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
|
|
1675
|
+
return `<span title="${DEVIATION_TIP}" style="font-size:10.5px;color:var(--dk-muted, #64748b);white-space:nowrap">Δ vs plan: ${dot('#15803d')}<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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2010
|
-
return `≈ ${fmt(v)} <span translate="no" style="color
|
|
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
|
|
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
|
|
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
|
|
2024
|
-
const head = `<tr style="background
|
|
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
|
|
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
|
|
2034
|
-
<td style="padding:4px 8px">${esc(r.name)}${r._added ? ` <span style="font-size:9.5px;color
|
|
2035
|
-
<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;font-weight:600;color
|
|
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
|
|
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
|
|
2042
|
-
${_altAny ? `<td style="padding:4px 8px;text-align:right;font-variant-numeric:tabular-nums;color
|
|
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
|
|
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
|
|
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
|
|
2061
|
-
<td style="padding:5px 8px;color
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2123
|
-
<span style="font-size:11.5px;color
|
|
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
|
|
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
|
|
2260
|
-
else if (d <= 30) { style = 'background
|
|
2261
|
-
else if (d <= 90) { style = 'color
|
|
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
|
|
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
|
|
2496
|
-
: `<sup style="color
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
814
|
-
tableHtml = `<div style="font-size:11.5px;color
|
|
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(' · ');
|
|
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
|
|
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(' · ')}</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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
982
|
-
<th style="text-align:left;padding:8px 12px;font-size:11px;color
|
|
983
|
-
<th style="text-align:right;padding:8px 12px;font-size:11px;color
|
|
984
|
-
<th style="text-align:center;padding:8px 12px;font-size:11px;color
|
|
985
|
-
<th style="text-align:left;padding:8px 12px;font-size:11px;color
|
|
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
|
|
1042
|
-
<strong style="color:${fg};font-variant-numeric:tabular-nums">${esc(valueStr)}</strong>${miss ? '<span style="color
|
|
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
|
|
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
|
|
1081
|
-
: `<span style="display:inline-block;padding:1px 7px;border-radius:9999px;background
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('')}
|