@reekon-tools/boldr-utils 1.8.5 → 1.9.0

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.
@@ -0,0 +1,84 @@
1
+ import { type MathNode } from 'mathjs';
2
+ import type { CalculatorEquation, CalculatorField } from './schema.js';
3
+ import { type CalculatorUnit, type FieldDimension } from './units.js';
4
+ /** Constants mathjs resolves without a scope entry. */
5
+ export declare const MATHJS_CONSTANTS: Set<string>;
6
+ export type ParseResult = {
7
+ ok: true;
8
+ node: MathNode;
9
+ } | {
10
+ ok: false;
11
+ error: string;
12
+ };
13
+ /** Shared mathjs parse — the one place expression text becomes a tree. */
14
+ export declare const parseExpression: (expression: string) => ParseResult;
15
+ /**
16
+ * Bases whose values are ALREADY canonical, so an equation in them needs no
17
+ * `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
18
+ * µm / µm² / µm³ / degrees).
19
+ */
20
+ export declare const CANONICAL_BASES: Set<string>;
21
+ export type InferenceFailureReason =
22
+ /** The expression does not parse. */
23
+ 'unparseable'
24
+ /** A symbol is neither a mapped variable nor a mathjs constant. */
25
+ | 'unknown-symbol'
26
+ /** A function or operator whose unit behaviour we don't model. */
27
+ | 'unsupported-operation'
28
+ /** Dimensioned terms added or compared against a different dimension. */
29
+ | 'inconsistent-terms'
30
+ /** A real exponent, but one this unit vocabulary can't name (µm⁴, 1/ft). */
31
+ | 'unnameable-dimension';
32
+ export interface InferredExpressionUnit {
33
+ /** The dimension of the expression's raw value. */
34
+ dimension: FieldDimension;
35
+ /**
36
+ * The unit the raw value carries, or null when no single unit names it
37
+ * (mixed bases, or a canonical/yard/liter base with no entry).
38
+ */
39
+ unit: CalculatorUnit | null;
40
+ /** Distinct base units among the variables carrying that dimension. */
41
+ bases: string[];
42
+ }
43
+ export type ExpressionUnitInference = ({
44
+ ok: true;
45
+ } & InferredExpressionUnit) | {
46
+ ok: false;
47
+ reason: InferenceFailureReason;
48
+ };
49
+ /**
50
+ * The dimension and unit of an equation's raw expression value — what the
51
+ * number means BEFORE `resultUnit` is applied.
52
+ */
53
+ export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits">) => ExpressionUnitInference;
54
+ /**
55
+ * True when an expression's raw value is already canonical, so omitting
56
+ * `resultUnit` is correct rather than a 25400× mistake. Only the
57
+ * no-variable-units form (and dimensionless results) qualifies.
58
+ */
59
+ export declare const producesCanonicalValue: (inferred: InferredExpressionUnit) => boolean;
60
+ /** Same physical unit, ignoring display form: `in` ≡ `in_frac`, `ft` ≡ `ft_in_frac`. */
61
+ export declare const isEquivalentUnit: (a: CalculatorUnit, b: CalculatorUnit) => boolean;
62
+ /**
63
+ * How far off a value is when it carries `actual` but was declared as
64
+ * `declared` — 144 for in² labelled ft². Used to make validation messages
65
+ * say what the author will actually see.
66
+ */
67
+ export declare const unitScaleRatio: (declared: CalculatorUnit, actual: CalculatorUnit) => number;
68
+ /**
69
+ * The `resultUnit` an equation should carry, or null when it should be omitted
70
+ * (dimensionless or already-canonical result) or cannot be derived. An
71
+ * existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
72
+ */
73
+ export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
74
+ /**
75
+ * Bring an equation's unit annotations back in line with the fields it
76
+ * references. Field edits (a dimension switch, a kind change, a deletion) are
77
+ * written independently of equations, so a stale `variableUnits` entry or
78
+ * `resultUnit` would otherwise keep converting against a unit the field no
79
+ * longer uses — the failure mode this whole module exists to prevent.
80
+ */
81
+ export declare const reconcileEquationUnits: (fields: readonly CalculatorField[], equations: readonly CalculatorEquation[]) => {
82
+ equations: CalculatorEquation[];
83
+ changed: boolean;
84
+ };
@@ -0,0 +1,439 @@
1
+ import { create, all } from 'mathjs';
2
+ import { Units } from '../types/firestore.js';
3
+ import { fieldDefaultUnit, fieldDimension } from './schema.js';
4
+ import { CALCULATOR_UNIT_INFO, toCanonical, unitDimension, } from './units.js';
5
+ const math = create(all);
6
+ // ---------------------------------------------------------------------------
7
+ // Expression unit inference.
8
+ //
9
+ // An equation's `variableUnits` / `resultUnit` (schema.ts) evaluate it in the
10
+ // author's display units: each variable is converted OUT of canonical before
11
+ // entering the scope, and the raw result is converted back FROM `resultUnit`.
12
+ // Nothing in the evaluator checks that `resultUnit` is the unit the expression
13
+ // actually produces — it is an assertion, not a conversion. Assert wrongly and
14
+ // the answer is silently off by a scale factor: three inch-valued variables
15
+ // multiplied yield in³, so declaring the result `cu_ft` overstates it 1728×,
16
+ // with every unit label on screen still reading correctly.
17
+ //
18
+ // This module recovers the missing information by propagating unit exponents
19
+ // through the expression tree. Each variable contributes its dimension's
20
+ // exponent (length 1, area 2, volume 3), `*` adds them, `/` subtracts, `sqrt`
21
+ // halves, and the result's exponent names the dimension. Pair that with the
22
+ // base unit the variables share and the correct `resultUnit` is fully
23
+ // determined — which is why the authoring UI derives it rather than asking,
24
+ // and why validation can flag the scale errors it otherwise cannot see.
25
+ //
26
+ // Inference is deliberately conservative. Anything it cannot reason about —
27
+ // unknown functions, non-integer powers, symbols with no mapping — returns
28
+ // `ok: false` so callers fall back to prior behaviour instead of reporting a
29
+ // false positive on valid math.
30
+ // ---------------------------------------------------------------------------
31
+ /** Constants mathjs resolves without a scope entry. */
32
+ export const MATHJS_CONSTANTS = new Set([
33
+ 'pi',
34
+ 'PI',
35
+ 'e',
36
+ 'E',
37
+ 'tau',
38
+ 'phi',
39
+ 'i',
40
+ 'true',
41
+ 'false',
42
+ 'Infinity',
43
+ 'NaN',
44
+ 'null',
45
+ ]);
46
+ /** Shared mathjs parse — the one place expression text becomes a tree. */
47
+ export const parseExpression = (expression) => {
48
+ try {
49
+ return { ok: true, node: math.parse(expression) };
50
+ }
51
+ catch (err) {
52
+ return {
53
+ ok: false,
54
+ error: err instanceof Error ? err.message : String(err),
55
+ };
56
+ }
57
+ };
58
+ // The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in',
59
+ // 'liter' -> 'L'. Two units share a base exactly when they are the same
60
+ // physical unit at different exponents, which is what makes a result unit
61
+ // nameable.
62
+ const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split('^')[0];
63
+ /**
64
+ * Bases whose values are ALREADY canonical, so an equation in them needs no
65
+ * `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
66
+ * µm / µm² / µm³ / degrees).
67
+ */
68
+ export const CANONICAL_BASES = new Set(['um', 'deg']);
69
+ const CANONICAL_LENGTH_BASE = 'um';
70
+ const CANONICAL_ANGLE_BASE = 'deg';
71
+ // Base + length exponent -> the unit that names it. Yards have no length
72
+ // entry (the Units enum has no yard) and liters/gallons no length base at all,
73
+ // so both simply fail to name a unit rather than guessing.
74
+ const UNIT_BY_BASE = {
75
+ mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
76
+ cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
77
+ m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
78
+ in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
79
+ ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
80
+ yd: { 2: 'sq_yd', 3: 'cu_yd' },
81
+ };
82
+ const SCALAR = { len: 0, ang: 0 };
83
+ const isScalar = (e) => e.len === 0 && e.ang === 0;
84
+ const sameExponents = (a, b) => a.len === b.len && a.ang === b.ang;
85
+ const EXPONENTS_BY_DIMENSION = {
86
+ none: SCALAR,
87
+ length: { len: 1, ang: 0 },
88
+ area: { len: 2, ang: 0 },
89
+ volume: { len: 3, ang: 0 },
90
+ angle: { len: 0, ang: 1 },
91
+ };
92
+ // Functions that return their arguments' dimension (scalar arguments — a digit
93
+ // count, a clamp bound — are ignored).
94
+ const DIMENSION_PRESERVING_FNS = new Set([
95
+ 'abs',
96
+ 'min',
97
+ 'max',
98
+ 'mean',
99
+ 'median',
100
+ 'sum',
101
+ 'round',
102
+ 'floor',
103
+ 'ceil',
104
+ 'fix',
105
+ 'mod',
106
+ ]);
107
+ // Trig works in DEGREES here (evaluate.ts overrides mathjs), so an inverse
108
+ // function's result is an angle in the canonical angle unit.
109
+ const DIRECT_TRIG_FNS = new Set(['sin', 'cos', 'tan', 'sec', 'csc', 'cot']);
110
+ const INVERSE_TRIG_FNS = new Set(['asin', 'acos', 'atan', 'atan2']);
111
+ // Functions that require and return plain numbers.
112
+ const SCALAR_FNS = new Set(['log', 'log10', 'log2', 'exp', 'sign']);
113
+ /**
114
+ * The dimension and unit of an equation's raw expression value — what the
115
+ * number means BEFORE `resultUnit` is applied.
116
+ */
117
+ export const inferExpressionUnit = (fields, equation) => {
118
+ const parsed = parseExpression(equation.expression);
119
+ if (!parsed.ok)
120
+ return { ok: false, reason: 'unparseable' };
121
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
122
+ const lengthBases = new Set();
123
+ const angleBases = new Set();
124
+ let failure = null;
125
+ const fail = (reason) => {
126
+ failure ?? (failure = reason);
127
+ return null;
128
+ };
129
+ const noteBases = (exponents, base) => {
130
+ if (exponents.len !== 0)
131
+ lengthBases.add(base);
132
+ if (exponents.ang !== 0)
133
+ angleBases.add(base);
134
+ };
135
+ const resolveSymbol = (name) => {
136
+ const unit = equation.variableUnits?.[name];
137
+ if (unit != null) {
138
+ const exponents = EXPONENTS_BY_DIMENSION[unitDimension(unit)];
139
+ noteBases(exponents, baseUnitOf(unit));
140
+ return exponents;
141
+ }
142
+ const fieldId = equation.variableToFieldId[name];
143
+ const field = fieldId != null ? fieldById.get(fieldId) : undefined;
144
+ if (field) {
145
+ // No variable unit: the value enters the scope canonically (units.ts).
146
+ const dimension = fieldDimension(field);
147
+ const exponents = EXPONENTS_BY_DIMENSION[dimension];
148
+ noteBases(exponents, dimension === 'angle' ? CANONICAL_ANGLE_BASE : CANONICAL_LENGTH_BASE);
149
+ return exponents;
150
+ }
151
+ // Unmapped: fine if mathjs resolves it, otherwise validation's
152
+ // unmapped-symbol check owns the error and we just decline to infer.
153
+ return MATHJS_CONSTANTS.has(name) ? SCALAR : fail('unknown-symbol');
154
+ };
155
+ // Scale a term's exponents by a constant power, e.g. `^2` or `sqrt`. Only
156
+ // whole-number results are nameable: sqrt of an area is a length, sqrt of a
157
+ // length is not anything we can express.
158
+ const scaled = (exponents, factor) => {
159
+ const len = exponents.len * factor;
160
+ const ang = exponents.ang * factor;
161
+ return Number.isInteger(len) && Number.isInteger(ang)
162
+ ? { len, ang }
163
+ : fail('unnameable-dimension');
164
+ };
165
+ const constantValue = (node) => {
166
+ if (node.type === 'ParenthesisNode' && node.content) {
167
+ return constantValue(node.content);
168
+ }
169
+ if (node.type === 'ConstantNode' && typeof node.value === 'number') {
170
+ return node.value;
171
+ }
172
+ if (node.type === 'OperatorNode' && node.fn === 'unaryMinus') {
173
+ const inner = node.args?.[0] ? constantValue(node.args[0]) : null;
174
+ return inner == null ? null : -inner;
175
+ }
176
+ return null;
177
+ };
178
+ const walk = (node) => {
179
+ switch (node.type) {
180
+ case 'ConstantNode':
181
+ return SCALAR;
182
+ case 'ParenthesisNode':
183
+ return node.content
184
+ ? walk(node.content)
185
+ : fail('unsupported-operation');
186
+ case 'SymbolNode':
187
+ return node.name ? resolveSymbol(node.name) : fail('unknown-symbol');
188
+ case 'OperatorNode':
189
+ return walkOperator(node);
190
+ case 'FunctionNode':
191
+ return walkFunction(node);
192
+ default:
193
+ // ConditionalNode, AssignmentNode, ranges, matrices, ...
194
+ return fail('unsupported-operation');
195
+ }
196
+ };
197
+ const walkArgs = (node) => {
198
+ const args = node.args ?? [];
199
+ const out = [];
200
+ for (const arg of args) {
201
+ const exponents = walk(arg);
202
+ if (!exponents)
203
+ return null;
204
+ out.push(exponents);
205
+ }
206
+ return out;
207
+ };
208
+ const walkOperator = (node) => {
209
+ const args = walkArgs(node);
210
+ if (!args)
211
+ return null;
212
+ switch (node.fn) {
213
+ case 'multiply':
214
+ return args.reduce((acc, e) => ({ len: acc.len + e.len, ang: acc.ang + e.ang }), SCALAR);
215
+ case 'divide':
216
+ return args.length === 2
217
+ ? {
218
+ len: args[0].len - args[1].len,
219
+ ang: args[0].ang - args[1].ang,
220
+ }
221
+ : fail('unsupported-operation');
222
+ case 'add':
223
+ case 'subtract': {
224
+ // A bare literal added to a dimensioned term adopts its unit — that is
225
+ // the whole point of evaluating in display units ("wall + 2 inches").
226
+ // Two DIFFERENT dimensions added is an author error.
227
+ const dimensioned = args.filter((e) => !isScalar(e));
228
+ if (dimensioned.length === 0)
229
+ return SCALAR;
230
+ return dimensioned.every((e) => sameExponents(e, dimensioned[0]))
231
+ ? dimensioned[0]
232
+ : fail('inconsistent-terms');
233
+ }
234
+ case 'unaryMinus':
235
+ case 'unaryPlus':
236
+ return args[0] ?? fail('unsupported-operation');
237
+ case 'pow': {
238
+ if (args.length !== 2)
239
+ return fail('unsupported-operation');
240
+ if (isScalar(args[0]))
241
+ return SCALAR;
242
+ const exponent = node.args?.[1] ? constantValue(node.args[1]) : null;
243
+ return exponent == null
244
+ ? fail('unsupported-operation')
245
+ : scaled(args[0], exponent);
246
+ }
247
+ case 'mod':
248
+ return args[0] ?? fail('unsupported-operation');
249
+ default:
250
+ // Comparisons, logic, bit ops — not meaningful on dimensioned values.
251
+ return fail('unsupported-operation');
252
+ }
253
+ };
254
+ const walkFunction = (node) => {
255
+ const args = walkArgs(node);
256
+ if (!args)
257
+ return null;
258
+ const name = typeof node.fn === 'string' ? node.fn : node.fn?.name;
259
+ if (!name)
260
+ return fail('unsupported-operation');
261
+ if (name === 'sqrt')
262
+ return scaled(args[0] ?? SCALAR, 0.5);
263
+ if (name === 'cbrt')
264
+ return scaled(args[0] ?? SCALAR, 1 / 3);
265
+ if (name === 'nthRoot') {
266
+ const root = node.args?.[1] ? constantValue(node.args[1]) : 2;
267
+ return root == null || root === 0
268
+ ? fail('unsupported-operation')
269
+ : scaled(args[0] ?? SCALAR, 1 / root);
270
+ }
271
+ if (DIMENSION_PRESERVING_FNS.has(name)) {
272
+ const dimensioned = args.filter((e) => !isScalar(e));
273
+ if (dimensioned.length === 0)
274
+ return SCALAR;
275
+ return dimensioned.every((e) => sameExponents(e, dimensioned[0]))
276
+ ? dimensioned[0]
277
+ : fail('inconsistent-terms');
278
+ }
279
+ if (DIRECT_TRIG_FNS.has(name)) {
280
+ // Degrees in, plain ratio out.
281
+ const arg = args[0] ?? SCALAR;
282
+ return arg.len === 0 && arg.ang <= 1
283
+ ? SCALAR
284
+ : fail('inconsistent-terms');
285
+ }
286
+ if (INVERSE_TRIG_FNS.has(name)) {
287
+ // atan2's two arguments cancel, so they only need to match.
288
+ if (!args.every((e) => sameExponents(e, args[0] ?? SCALAR))) {
289
+ return fail('inconsistent-terms');
290
+ }
291
+ angleBases.add(CANONICAL_ANGLE_BASE);
292
+ return { len: 0, ang: 1 };
293
+ }
294
+ if (SCALAR_FNS.has(name)) {
295
+ return args.every(isScalar) ? SCALAR : fail('inconsistent-terms');
296
+ }
297
+ return fail('unsupported-operation');
298
+ };
299
+ const exponents = walk(parsed.node);
300
+ if (!exponents) {
301
+ return { ok: false, reason: failure ?? 'unsupported-operation' };
302
+ }
303
+ if (exponents.ang !== 0) {
304
+ // Angles don't combine with lengths into anything nameable (a length·deg
305
+ // has no unit here), and neither does deg².
306
+ if (exponents.len !== 0 || exponents.ang !== 1) {
307
+ return { ok: false, reason: 'unnameable-dimension' };
308
+ }
309
+ const bases = [...angleBases];
310
+ return {
311
+ ok: true,
312
+ dimension: 'angle',
313
+ unit: bases.length === 1 ? bases[0] : null,
314
+ bases,
315
+ };
316
+ }
317
+ if (exponents.len === 0) {
318
+ return { ok: true, dimension: 'none', unit: null, bases: [] };
319
+ }
320
+ if (exponents.len < 1 || exponents.len > 3) {
321
+ return { ok: false, reason: 'unnameable-dimension' };
322
+ }
323
+ const dimension = exponents.len === 1 ? 'length' : exponents.len === 2 ? 'area' : 'volume';
324
+ const bases = [...lengthBases];
325
+ return {
326
+ ok: true,
327
+ dimension,
328
+ unit: bases.length === 1
329
+ ? (UNIT_BY_BASE[bases[0]]?.[exponents.len] ?? null)
330
+ : null,
331
+ bases,
332
+ };
333
+ };
334
+ /**
335
+ * True when an expression's raw value is already canonical, so omitting
336
+ * `resultUnit` is correct rather than a 25400× mistake. Only the
337
+ * no-variable-units form (and dimensionless results) qualifies.
338
+ */
339
+ export const producesCanonicalValue = (inferred) => inferred.dimension === 'none' ||
340
+ (inferred.bases.length > 0 &&
341
+ inferred.bases.every((b) => CANONICAL_BASES.has(b)));
342
+ /** Same physical unit, ignoring display form: `in` ≡ `in_frac`, `ft` ≡ `ft_in_frac`. */
343
+ export const isEquivalentUnit = (a, b) => a === b ||
344
+ (unitDimension(a) === unitDimension(b) && baseUnitOf(a) === baseUnitOf(b));
345
+ /**
346
+ * How far off a value is when it carries `actual` but was declared as
347
+ * `declared` — 144 for in² labelled ft². Used to make validation messages
348
+ * say what the author will actually see.
349
+ */
350
+ export const unitScaleRatio = (declared, actual) => toCanonical(1, declared) / toCanonical(1, actual);
351
+ /**
352
+ * The `resultUnit` an equation should carry, or null when it should be omitted
353
+ * (dimensionless or already-canonical result) or cannot be derived. An
354
+ * existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
355
+ */
356
+ export const deriveResultUnit = (fields, equation) => {
357
+ const inferred = inferExpressionUnit(fields, equation);
358
+ if (!inferred.ok || inferred.unit == null)
359
+ return null;
360
+ if (producesCanonicalValue(inferred))
361
+ return null;
362
+ const current = equation.resultUnit;
363
+ return current != null && isEquivalentUnit(current, inferred.unit)
364
+ ? current
365
+ : inferred.unit;
366
+ };
367
+ // Rebuild an equation without a key, since Firestore rejects `undefined`.
368
+ const withoutResultUnit = (equation) => {
369
+ const { resultUnit: _dropped, ...rest } = equation;
370
+ return rest;
371
+ };
372
+ /**
373
+ * Bring an equation's unit annotations back in line with the fields it
374
+ * references. Field edits (a dimension switch, a kind change, a deletion) are
375
+ * written independently of equations, so a stale `variableUnits` entry or
376
+ * `resultUnit` would otherwise keep converting against a unit the field no
377
+ * longer uses — the failure mode this whole module exists to prevent.
378
+ */
379
+ export const reconcileEquationUnits = (fields, equations) => {
380
+ const fieldById = new Map(fields.map((f) => [f.id, f]));
381
+ let changed = false;
382
+ const next = equations.map((equation) => {
383
+ let repaired = equation;
384
+ // 1. Variable units must match the dimension of the field they annotate.
385
+ if (equation.variableUnits) {
386
+ const variableUnits = {};
387
+ let unitsChanged = false;
388
+ for (const [variable, unit] of Object.entries(equation.variableUnits)) {
389
+ const field = fieldById.get(equation.variableToFieldId[variable] ?? '');
390
+ if (!field) {
391
+ unitsChanged = true; // variable or field is gone
392
+ continue;
393
+ }
394
+ if (unitDimension(unit) === fieldDimension(field)) {
395
+ variableUnits[variable] = unit;
396
+ continue;
397
+ }
398
+ const replacement = fieldDefaultUnit(field);
399
+ unitsChanged = true;
400
+ if (replacement)
401
+ variableUnits[variable] = replacement;
402
+ }
403
+ if (unitsChanged) {
404
+ repaired =
405
+ Object.keys(variableUnits).length > 0
406
+ ? { ...repaired, variableUnits }
407
+ : (() => {
408
+ const { variableUnits: _dropped, ...rest } = repaired;
409
+ return rest;
410
+ })();
411
+ }
412
+ }
413
+ // 2. The result unit follows from the repaired variable units.
414
+ const target = fieldById.get(equation.targetFieldId);
415
+ const targetDimension = target ? fieldDimension(target) : 'none';
416
+ if (targetDimension === 'none') {
417
+ if (repaired.resultUnit != null)
418
+ repaired = withoutResultUnit(repaired);
419
+ }
420
+ else {
421
+ const derived = deriveResultUnit(fields, repaired);
422
+ if (derived != null) {
423
+ if (repaired.resultUnit !== derived) {
424
+ repaired = { ...repaired, resultUnit: derived };
425
+ }
426
+ }
427
+ else if (repaired.resultUnit != null &&
428
+ unitDimension(repaired.resultUnit) !== targetDimension) {
429
+ // Not derivable AND not even the right dimension: drop it rather than
430
+ // keep converting a length factor into an area field.
431
+ repaired = withoutResultUnit(repaired);
432
+ }
433
+ }
434
+ if (repaired !== equation)
435
+ changed = true;
436
+ return repaired;
437
+ });
438
+ return { equations: next, changed };
439
+ };
@@ -1,6 +1,7 @@
1
1
  export * from './schema.js';
2
2
  export * from './categories.js';
3
3
  export * from './units.js';
4
+ export * from './expressionUnits.js';
4
5
  export * from './evaluate.js';
5
6
  export * from './solve.js';
6
7
  export * from './instance.js';
@@ -5,6 +5,7 @@
5
5
  export * from './schema.js';
6
6
  export * from './categories.js';
7
7
  export * from './units.js';
8
+ export * from './expressionUnits.js';
8
9
  export * from './evaluate.js';
9
10
  export * from './solve.js';
10
11
  export * from './instance.js';
@@ -171,6 +171,12 @@ export declare const findEquation: (definition: Pick<CalculatorDefinition, "equa
171
171
  export declare const equationForField: (definition: Pick<CalculatorDefinition, "equations">, fieldId: string) => CalculatorEquation | undefined;
172
172
  /** The dimension a field's numeric value carries. */
173
173
  export declare const fieldDimension: (field: CalculatorField) => FieldDimension;
174
+ /**
175
+ * The unit a field's value is entered and displayed in, or null for fields
176
+ * that carry no unit. Also the unit an equation referencing the field starts
177
+ * out annotated with.
178
+ */
179
+ export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
174
180
  /** Fields whose value participates in equations as a number. */
175
181
  export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
176
182
  /** Field kinds an equation may target (compute into). */
@@ -59,6 +59,21 @@ export const fieldDimension = (field) => {
59
59
  return 'none';
60
60
  }
61
61
  };
62
+ /**
63
+ * The unit a field's value is entered and displayed in, or null for fields
64
+ * that carry no unit. Also the unit an equation referencing the field starts
65
+ * out annotated with.
66
+ */
67
+ export const fieldDefaultUnit = (field) => {
68
+ switch (field.kind) {
69
+ case ColumnType.Measurement:
70
+ return field.unit.defaultUnit;
71
+ case ColumnType.Angle:
72
+ return field.angleUnit ?? 'deg';
73
+ default:
74
+ return null;
75
+ }
76
+ };
62
77
  /** Fields whose value participates in equations as a number. */
63
78
  export const isNumericFieldKind = (kind) => kind === ColumnType.Number ||
64
79
  kind === ColumnType.Measurement ||
@@ -184,8 +184,16 @@ export interface ValidationResult {
184
184
  ok: boolean;
185
185
  issues: ValidationIssue[];
186
186
  }
187
- /** Free variables of an expression (function names and constants excluded). */
188
- export declare const expressionSymbols: (expression: string) => {
187
+ /**
188
+ * Free variables of an expression (function names and constants excluded).
189
+ *
190
+ * `knownVariables` are treated as variables even when mathjs would resolve
191
+ * them as constants: the authoring UI names variables A, B, C… and `E` is
192
+ * Euler's number, so without this a mapped `E` reads as a constant and gets
193
+ * reported as an unused variable. Evaluation is unaffected — a scope entry
194
+ * shadows the constant — so this only realigns validation with reality.
195
+ */
196
+ export declare const expressionSymbols: (expression: string, knownVariables?: Iterable<string>) => {
189
197
  ok: true;
190
198
  symbols: string[];
191
199
  } | {
@@ -1,10 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { create, all } from 'mathjs';
3
2
  import { ColumnType, DecimalTolerance, FractionalTolerance, } from '../types/firestore.js';
4
3
  import { CALCULATOR_SCHEMA_VERSION, equationForField, fieldDimension, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
5
- import { CALCULATOR_UNIT_INFO, unitDimension } from './units.js';
4
+ import { CALCULATOR_UNIT_INFO, unitDimension, } from './units.js';
5
+ import { MATHJS_CONSTANTS, inferExpressionUnit, isEquivalentUnit, parseExpression, producesCanonicalValue, unitScaleRatio, } from './expressionUnits.js';
6
6
  import { isCalculatorCategoryId } from './categories.js';
7
- const math = create(all);
8
7
  // ---------------------------------------------------------------------------
9
8
  // Definition validation: zod for structure, plus the cross-field invariants
10
9
  // zod can't express (reference resolution, cycles, solve wiring). Callable
@@ -129,45 +128,43 @@ export const calculatorDefinitionSchema = z.object({
129
128
  createdAt: z.unknown().optional(),
130
129
  updatedAt: z.unknown().optional(),
131
130
  });
132
- // Constants mathjs resolves without a scope entry.
133
- const KNOWN_SYMBOLS = new Set([
134
- 'pi',
135
- 'PI',
136
- 'e',
137
- 'E',
138
- 'tau',
139
- 'phi',
140
- 'i',
141
- 'true',
142
- 'false',
143
- 'Infinity',
144
- 'NaN',
145
- 'null',
146
- ]);
147
- /** Free variables of an expression (function names and constants excluded). */
148
- export const expressionSymbols = (expression) => {
149
- try {
150
- const node = math.parse(expression);
151
- const symbols = new Set();
152
- node.traverse((n, path, parent) => {
153
- const sym = n;
154
- const par = parent;
155
- if (sym.isSymbolNode &&
156
- sym.name &&
157
- !(par?.isFunctionNode && path === 'fn') &&
158
- !KNOWN_SYMBOLS.has(sym.name)) {
159
- symbols.add(sym.name);
160
- }
161
- });
162
- return { ok: true, symbols: [...symbols] };
163
- }
164
- catch (err) {
165
- return {
166
- ok: false,
167
- error: err instanceof Error ? err.message : String(err),
168
- };
169
- }
131
+ /**
132
+ * Free variables of an expression (function names and constants excluded).
133
+ *
134
+ * `knownVariables` are treated as variables even when mathjs would resolve
135
+ * them as constants: the authoring UI names variables A, B, C… and `E` is
136
+ * Euler's number, so without this a mapped `E` reads as a constant and gets
137
+ * reported as an unused variable. Evaluation is unaffected — a scope entry
138
+ * shadows the constant — so this only realigns validation with reality.
139
+ */
140
+ export const expressionSymbols = (expression, knownVariables) => {
141
+ const parsed = parseExpression(expression);
142
+ if (!parsed.ok)
143
+ return { ok: false, error: parsed.error };
144
+ const known = new Set(knownVariables ?? []);
145
+ const symbols = new Set();
146
+ parsed.node.traverse((n, path, parent) => {
147
+ const sym = n;
148
+ const par = parent;
149
+ if (sym.isSymbolNode &&
150
+ sym.name &&
151
+ !(par?.isFunctionNode && path === 'fn') &&
152
+ (!MATHJS_CONSTANTS.has(sym.name) || known.has(sym.name))) {
153
+ symbols.add(sym.name);
154
+ }
155
+ });
156
+ return { ok: true, symbols: [...symbols] };
170
157
  };
158
+ const DIMENSION_LABELS = {
159
+ length: 'a length',
160
+ area: 'an area',
161
+ volume: 'a volume',
162
+ angle: 'an angle',
163
+ none: 'a plain number',
164
+ };
165
+ const describeDimension = (dimension) => DIMENSION_LABELS[dimension];
166
+ // 1728 stays 1728; 57.29577951308232 becomes 57.3.
167
+ const trimRatio = (ratio) => Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1);
171
168
  // Does `equation` depend on `fieldId`, directly or through the equations of
172
169
  // output fields it references? (Chained solving: the unknown may reach the
173
170
  // governing equation through intermediate outputs.)
@@ -324,13 +321,47 @@ export const validateCalculatorDefinition = (input) => {
324
321
  push('variable-unit-dimension-mismatch', unitPath, `Unit "${unit}" is not a ${dim} unit (variable ${variable} maps to "${mapped.name}")`);
325
322
  }
326
323
  }
327
- if (eq.resultUnit != null && target) {
324
+ if (target) {
328
325
  const dim = fieldDimension(target);
329
- if (dim === 'none') {
330
- push('result-unit-on-number-target', `${path}.resultUnit`, `Equation target "${target.name}" is a number field and takes the result as-is; remove the result unit`);
326
+ if (eq.resultUnit != null) {
327
+ if (dim === 'none') {
328
+ push('result-unit-on-number-target', `${path}.resultUnit`, `Equation target "${target.name}" is a number field and takes the result as-is; remove the result unit`);
329
+ }
330
+ else if (unitDimension(eq.resultUnit) !== dim) {
331
+ push('result-unit-dimension-mismatch', `${path}.resultUnit`, `Result unit "${eq.resultUnit}" is not a ${dim} unit (target "${target.name}")`);
332
+ }
333
+ }
334
+ // The unit the EXPRESSION produces, which is what `resultUnit` claims to
335
+ // be. Dimension checks against the target field can't see a same-
336
+ // dimension scale error: three inch variables multiplied are in³, so
337
+ // `cu_ft` passes every check above while reporting 1728× the truth.
338
+ const inferred = inferExpressionUnit(def.fields, eq);
339
+ if (inferred.ok && dim !== 'none') {
340
+ if (inferred.dimension !== dim) {
341
+ push('expression-dimension-mismatch', `${path}.expression`, `Expression produces ${describeDimension(inferred.dimension)} but "${target.name}" is ${describeDimension(dim)} — check the variable units and the expression`);
342
+ }
343
+ else if (eq.resultUnit == null) {
344
+ if (!producesCanonicalValue(inferred)) {
345
+ push('missing-result-unit', `${path}.resultUnit`, `Expression produces a value in ${inferred.unit ? `"${inferred.unit}"` : inferred.bases.join('/')} but no result unit is set, so it will be stored as a canonical value. Set the result unit${inferred.unit ? ` to "${inferred.unit}"` : ''}.`);
346
+ }
347
+ }
348
+ else if (inferred.unit != null &&
349
+ // A result unit from the wrong dimension is already reported above;
350
+ // quoting a scale factor across dimensions would be meaningless.
351
+ unitDimension(eq.resultUnit) === inferred.dimension &&
352
+ !isEquivalentUnit(eq.resultUnit, inferred.unit)) {
353
+ const ratio = unitScaleRatio(eq.resultUnit, inferred.unit);
354
+ const off = ratio > 1
355
+ ? `${trimRatio(ratio)}× too large`
356
+ : `${trimRatio(1 / ratio)}× too small`;
357
+ push('result-unit-scale-mismatch', `${path}.resultUnit`, `Expression produces "${inferred.unit}" but the result unit is "${eq.resultUnit}" — computed values will be ${off}. Use "${inferred.unit}", or change the variable units to match.`);
358
+ }
359
+ }
360
+ if (inferred.ok && inferred.bases.length > 1) {
361
+ push('mixed-base-units', `${path}.variableUnits`, `Variables mix ${inferred.bases.join(' and ')} in one expression, so the result has no single unit. Put them all in the same unit.`, 'warning');
331
362
  }
332
- else if (unitDimension(eq.resultUnit) !== dim) {
333
- push('result-unit-dimension-mismatch', `${path}.resultUnit`, `Result unit "${eq.resultUnit}" is not a ${dim} unit (target "${target.name}")`);
363
+ if (!inferred.ok && inferred.reason === 'inconsistent-terms') {
364
+ push('expression-mixed-dimensions', `${path}.expression`, 'Expression adds or compares terms of different dimensions (a length and an area, say) — one of them is probably the wrong field', 'warning');
334
365
  }
335
366
  }
336
367
  // The canonical-leak trap: a number target consuming a unit-carrying
@@ -345,7 +376,7 @@ export const validateCalculatorDefinition = (input) => {
345
376
  }
346
377
  }
347
378
  }
348
- const symbols = expressionSymbols(eq.expression);
379
+ const symbols = expressionSymbols(eq.expression, Object.keys(eq.variableToFieldId));
349
380
  if (!symbols.ok) {
350
381
  push('expression-invalid', `${path}.expression`, `Expression does not parse: ${symbols.error}`);
351
382
  }
@@ -0,0 +1,3 @@
1
+ export * from './colors.js';
2
+ export * from './semantic.js';
3
+ export * from './preset.js';
@@ -0,0 +1,3 @@
1
+ export * from './colors.js';
2
+ export * from './semantic.js';
3
+ export * from './preset.js';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared Tailwind preset. COLORS ONLY, by design.
3
+ *
4
+ * Must never define `content`, `plugins`, `presets`, or `darkMode`:
5
+ * NativeWind recurses `config.presets[].presets` hunting for its own marker
6
+ * preset, and a `content` key here would silently narrow the consuming app's
7
+ * globs.
8
+ *
9
+ * Consumed from CJS Tailwind configs, so this is a NAMED export with no
10
+ * default — that keeps ESM/CJS interop semantics irrelevant:
11
+ *
12
+ * const { boldrPreset } = require('@reekon-tools/boldr-utils/theme/preset');
13
+ * module.exports = { presets: [require('nativewind/preset'), boldrPreset] };
14
+ *
15
+ * Tailwind loads configs through jiti + sucrase, so requiring this ESM file
16
+ * from a CJS config works regardless of Node version.
17
+ *
18
+ * Note that Tailwind merges `theme.extend` per key with the *app* winning, and
19
+ * replaces a color key wholesale rather than deep-merging. An app that keeps
20
+ * its own `colors.background` will silently shadow this preset.
21
+ */
22
+ export declare const boldrPreset: {
23
+ theme: {
24
+ extend: {
25
+ colors: {
26
+ background: "#1D1F21";
27
+ foreground: {
28
+ DEFAULT: "#FFFFFF";
29
+ disabled: "#626364";
30
+ };
31
+ card: {
32
+ DEFAULT: "#2B2C2E";
33
+ foreground: "#FFFFFF";
34
+ };
35
+ popover: {
36
+ DEFAULT: "#2B2C2E";
37
+ foreground: "#FFFFFF";
38
+ };
39
+ muted: {
40
+ DEFAULT: "#424345";
41
+ foreground: "#9FA0A2";
42
+ };
43
+ accent: {
44
+ DEFAULT: "#424345";
45
+ foreground: "#FFFFFF";
46
+ };
47
+ secondary: {
48
+ DEFAULT: "#424345";
49
+ foreground: "#FFFFFF";
50
+ };
51
+ border: {
52
+ DEFAULT: "#424345";
53
+ strong: "#626364";
54
+ };
55
+ input: "#424345";
56
+ ring: "#FFAD00";
57
+ primary: {
58
+ DEFAULT: "#FFAD00";
59
+ foreground: "#1D1F21";
60
+ };
61
+ destructive: {
62
+ DEFAULT: "#DF6358";
63
+ foreground: "#1D1F21";
64
+ };
65
+ elevated: {
66
+ DEFAULT: "#424345";
67
+ foreground: "#FFFFFF";
68
+ };
69
+ interactive: {
70
+ DEFAULT: "#0066FF";
71
+ foreground: "#FFFFFF";
72
+ };
73
+ nav: {
74
+ DEFAULT: "#2D3843";
75
+ foreground: "#FFFFFF";
76
+ };
77
+ paper: {
78
+ DEFAULT: "#E8EAED";
79
+ foreground: "#1D1F21";
80
+ field: "#FFFFFF";
81
+ line: "#BCBEC2";
82
+ muted: "#626364";
83
+ };
84
+ tile: {
85
+ foreground: "#FFFFFF";
86
+ measurement: {
87
+ DEFAULT: "#0066FF";
88
+ badge: "#50A6FF";
89
+ };
90
+ formula: {
91
+ DEFAULT: "#BD30A8";
92
+ badge: "#6C155F";
93
+ };
94
+ count: {
95
+ DEFAULT: "#02274B";
96
+ foreground: "#FFFFFF";
97
+ };
98
+ };
99
+ diagram: {
100
+ 1: "#2E4C6A";
101
+ 2: "#4B6074";
102
+ 3: "#67819A";
103
+ };
104
+ tolerance: {
105
+ in: {
106
+ DEFAULT: "#58A942";
107
+ warning: "#00E920";
108
+ muted: "#8BB380";
109
+ };
110
+ out: {
111
+ DEFAULT: "#E83D2F";
112
+ warning: "#DA1100";
113
+ muted: "#DF6358";
114
+ };
115
+ };
116
+ };
117
+ };
118
+ };
119
+ };
120
+ export type BoldrPreset = typeof boldrPreset;
@@ -0,0 +1,25 @@
1
+ import { boldrColors } from './semantic.js';
2
+ /**
3
+ * Shared Tailwind preset. COLORS ONLY, by design.
4
+ *
5
+ * Must never define `content`, `plugins`, `presets`, or `darkMode`:
6
+ * NativeWind recurses `config.presets[].presets` hunting for its own marker
7
+ * preset, and a `content` key here would silently narrow the consuming app's
8
+ * globs.
9
+ *
10
+ * Consumed from CJS Tailwind configs, so this is a NAMED export with no
11
+ * default — that keeps ESM/CJS interop semantics irrelevant:
12
+ *
13
+ * const { boldrPreset } = require('@reekon-tools/boldr-utils/theme/preset');
14
+ * module.exports = { presets: [require('nativewind/preset'), boldrPreset] };
15
+ *
16
+ * Tailwind loads configs through jiti + sucrase, so requiring this ESM file
17
+ * from a CJS config works regardless of Node version.
18
+ *
19
+ * Note that Tailwind merges `theme.extend` per key with the *app* winning, and
20
+ * replaces a color key wholesale rather than deep-merging. An app that keeps
21
+ * its own `colors.background` will silently shadow this preset.
22
+ */
23
+ export const boldrPreset = {
24
+ theme: { extend: { colors: boldrColors } },
25
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Structural shape of a Tailwind `theme.extend.colors` tree.
3
+ *
4
+ * Declared locally on purpose: annotating with tailwindcss's own `Config` type
5
+ * would put `import type { Config } from 'tailwindcss'` into the emitted .d.ts
6
+ * and make tailwindcss a hard type dependency of this package.
7
+ */
8
+ export type ColorTree = {
9
+ [key: string]: string | ColorTree;
10
+ };
11
+ /**
12
+ * Semantic design tokens — the layer apps actually consume.
13
+ *
14
+ * `colors.ts` is the palette (raw Figma variables, named by value).
15
+ * This file maps those values onto *roles*, and is the only color surface the
16
+ * apps should reference. The raw palette is deliberately NOT exposed as Tailwind
17
+ * classes; naming by value is how both apps accumulated hundreds of arbitrary
18
+ * hex classes in the first place.
19
+ *
20
+ * The shadcn/ui token names (background, card, popover, foreground, muted,
21
+ * accent, secondary, border, input, ring, primary, destructive) are reused
22
+ * rather than paralleled, so every shadcn component is re-skinned without any
23
+ * component edits and `shadcn add` keeps generating markup that lands on these
24
+ * tokens.
25
+ *
26
+ * The design is dark-only. The light calculator / form surfaces are the
27
+ * explicitly-named `paper.*` tokens, not a light/dark variant pair — on native
28
+ * NativeWind's `dark:` variant is global, and portal-rendered surfaces
29
+ * (bottom sheets, dialogs, selects) mount outside any scoped subtree, so a
30
+ * scoped override would leak dark chrome onto a light page.
31
+ */
32
+ export declare const boldrColors: {
33
+ background: "#1D1F21";
34
+ foreground: {
35
+ DEFAULT: "#FFFFFF";
36
+ disabled: "#626364";
37
+ };
38
+ card: {
39
+ DEFAULT: "#2B2C2E";
40
+ foreground: "#FFFFFF";
41
+ };
42
+ popover: {
43
+ DEFAULT: "#2B2C2E";
44
+ foreground: "#FFFFFF";
45
+ };
46
+ muted: {
47
+ DEFAULT: "#424345";
48
+ foreground: "#9FA0A2";
49
+ };
50
+ accent: {
51
+ DEFAULT: "#424345";
52
+ foreground: "#FFFFFF";
53
+ };
54
+ secondary: {
55
+ DEFAULT: "#424345";
56
+ foreground: "#FFFFFF";
57
+ };
58
+ border: {
59
+ DEFAULT: "#424345";
60
+ strong: "#626364";
61
+ };
62
+ input: "#424345";
63
+ ring: "#FFAD00";
64
+ primary: {
65
+ DEFAULT: "#FFAD00";
66
+ foreground: "#1D1F21";
67
+ };
68
+ destructive: {
69
+ DEFAULT: "#DF6358";
70
+ foreground: "#1D1F21";
71
+ };
72
+ /** Chips, inset controls, thumbnail placeholders. */
73
+ elevated: {
74
+ DEFAULT: "#424345";
75
+ foreground: "#FFFFFF";
76
+ };
77
+ /**
78
+ * Steppers and other interactive fills. FILL ONLY — #0066FF as text on
79
+ * #2B2C2E is 2.89:1. Never `text-interactive`.
80
+ */
81
+ interactive: {
82
+ DEFAULT: "#0066FF";
83
+ foreground: "#FFFFFF";
84
+ };
85
+ /** Nav chrome (the web app's sidebar). */
86
+ nav: {
87
+ DEFAULT: "#2D3843";
88
+ foreground: "#FFFFFF";
89
+ };
90
+ paper: {
91
+ DEFAULT: "#E8EAED";
92
+ foreground: "#1D1F21";
93
+ field: "#FFFFFF";
94
+ line: "#BCBEC2";
95
+ muted: "#626364";
96
+ };
97
+ tile: {
98
+ /**
99
+ * Ink for any tile fill — `text-tile-foreground`. Shared across the
100
+ * measurement and formula variants because both carry white type; keeping it
101
+ * at the family level avoids two identical `foreground` keys that could
102
+ * drift apart by accident.
103
+ */
104
+ foreground: "#FFFFFF";
105
+ measurement: {
106
+ DEFAULT: "#0066FF";
107
+ badge: "#50A6FF";
108
+ };
109
+ formula: {
110
+ DEFAULT: "#BD30A8";
111
+ badge: "#6C155F";
112
+ };
113
+ count: {
114
+ DEFAULT: "#02274B";
115
+ foreground: "#FFFFFF";
116
+ };
117
+ };
118
+ diagram: {
119
+ 1: "#2E4C6A";
120
+ 2: "#4B6074";
121
+ 3: "#67819A";
122
+ };
123
+ tolerance: {
124
+ in: {
125
+ DEFAULT: "#58A942";
126
+ warning: "#00E920";
127
+ muted: "#8BB380";
128
+ };
129
+ out: {
130
+ DEFAULT: "#E83D2F";
131
+ warning: "#DA1100";
132
+ muted: "#DF6358";
133
+ };
134
+ };
135
+ };
136
+ export type BoldrColors = typeof boldrColors;
@@ -0,0 +1,116 @@
1
+ import { GeneralColors, SecondaryColors, FormColors, TileColors, FormulaColors, YellowColors, RedColors, GreenColors, } from './colors.js';
2
+ /**
3
+ * Semantic design tokens — the layer apps actually consume.
4
+ *
5
+ * `colors.ts` is the palette (raw Figma variables, named by value).
6
+ * This file maps those values onto *roles*, and is the only color surface the
7
+ * apps should reference. The raw palette is deliberately NOT exposed as Tailwind
8
+ * classes; naming by value is how both apps accumulated hundreds of arbitrary
9
+ * hex classes in the first place.
10
+ *
11
+ * The shadcn/ui token names (background, card, popover, foreground, muted,
12
+ * accent, secondary, border, input, ring, primary, destructive) are reused
13
+ * rather than paralleled, so every shadcn component is re-skinned without any
14
+ * component edits and `shadcn add` keeps generating markup that lands on these
15
+ * tokens.
16
+ *
17
+ * The design is dark-only. The light calculator / form surfaces are the
18
+ * explicitly-named `paper.*` tokens, not a light/dark variant pair — on native
19
+ * NativeWind's `dark:` variant is global, and portal-rendered surfaces
20
+ * (bottom sheets, dialogs, selects) mount outside any scoped subtree, so a
21
+ * scoped override would leak dark chrome onto a light page.
22
+ */
23
+ export const boldrColors = {
24
+ // ── dark app chrome — shadcn-compatible names, re-pointed ────────────────
25
+ background: GeneralColors.darkGrey, // screen + header
26
+ foreground: {
27
+ DEFAULT: GeneralColors.white,
28
+ disabled: GeneralColors.grey3, // text-foreground-disabled
29
+ },
30
+ card: { DEFAULT: GeneralColors.grey1, foreground: GeneralColors.white },
31
+ popover: { DEFAULT: GeneralColors.grey1, foreground: GeneralColors.white },
32
+ // muted / accent / secondary exist so unmodified shadcn components look
33
+ // right. Prefer `elevated` in new boldr components — it gives future
34
+ // divergence a seam.
35
+ muted: { DEFAULT: GeneralColors.grey2, foreground: GeneralColors.grey4 },
36
+ accent: { DEFAULT: GeneralColors.grey2, foreground: GeneralColors.white },
37
+ secondary: { DEFAULT: GeneralColors.grey2, foreground: GeneralColors.white },
38
+ border: { DEFAULT: GeneralColors.grey2, strong: GeneralColors.grey3 },
39
+ input: GeneralColors.grey2,
40
+ ring: YellowColors.mainYellow,
41
+ primary: {
42
+ DEFAULT: YellowColors.mainYellow,
43
+ // White on #FFAD00 is 1.87:1. Dark ink is 8.84:1, and matches the mockups.
44
+ foreground: GeneralColors.darkGrey,
45
+ },
46
+ destructive: {
47
+ DEFAULT: RedColors.mutedRed,
48
+ // Confirmed against the Settings frame: the Log Out label is dark ink.
49
+ // White on #DF6358 would be 3.46:1; #1D1F21 is 4.77:1.
50
+ foreground: GeneralColors.darkGrey,
51
+ },
52
+ // ── boldr additions the shadcn vocabulary has no word for ───────────────
53
+ /** Chips, inset controls, thumbnail placeholders. */
54
+ elevated: { DEFAULT: GeneralColors.grey2, foreground: GeneralColors.white },
55
+ /**
56
+ * Steppers and other interactive fills. FILL ONLY — #0066FF as text on
57
+ * #2B2C2E is 2.89:1. Never `text-interactive`.
58
+ */
59
+ interactive: {
60
+ DEFAULT: TileColors.activeBlue,
61
+ foreground: GeneralColors.white,
62
+ },
63
+ /** Nav chrome (the web app's sidebar). */
64
+ nav: {
65
+ DEFAULT: SecondaryColors.accentBlue1,
66
+ foreground: GeneralColors.white,
67
+ },
68
+ // ── light "paper": the calculator page and form fields ──────────────────
69
+ paper: {
70
+ DEFAULT: FormColors.lightGrey2, // bg-paper page / canvas
71
+ foreground: GeneralColors.darkGrey, // text-paper-foreground
72
+ field: GeneralColors.white, // bg-paper-field inputs, cards on paper
73
+ line: FormColors.lightGrey1, // border-paper-line field borders, dividers
74
+ // grey3, not lightGrey1: #BCBEC2 on #E8EAED is 1.54:1 — invisible.
75
+ // lightGrey1 is a border color only, which is why it's `paper.line`.
76
+ muted: GeneralColors.grey3, // text-paper-muted
77
+ },
78
+ // ── product tiles ───────────────────────────────────────────────────────
79
+ tile: {
80
+ /**
81
+ * Ink for any tile fill — `text-tile-foreground`. Shared across the
82
+ * measurement and formula variants because both carry white type; keeping it
83
+ * at the family level avoids two identical `foreground` keys that could
84
+ * drift apart by accident.
85
+ */
86
+ foreground: GeneralColors.white,
87
+ measurement: {
88
+ DEFAULT: TileColors.activeBlue,
89
+ badge: TileColors.lightBlue1,
90
+ },
91
+ // Note the badge is DARKER than the tile here, inverting the old app-local
92
+ // values. Confirmed in the mockups: a magenta tile with a darker magenta
93
+ // label bar.
94
+ formula: { DEFAULT: FormulaColors.purple, badge: FormulaColors.darkPurple },
95
+ count: { DEFAULT: TileColors.darkBlue, foreground: GeneralColors.white },
96
+ },
97
+ // ── 3D / diagram depth ramp (ordered; the steps have no individual meaning) ─
98
+ diagram: {
99
+ 1: SecondaryColors.accentBlue2,
100
+ 2: SecondaryColors.accentBlue3,
101
+ 3: SecondaryColors.accentBlue4,
102
+ },
103
+ // ── measurement tolerance ───────────────────────────────────────────────
104
+ tolerance: {
105
+ in: {
106
+ DEFAULT: GreenColors.green,
107
+ warning: GreenColors.warningGreen,
108
+ muted: GreenColors.mutedGreen,
109
+ },
110
+ out: {
111
+ DEFAULT: RedColors.red,
112
+ warning: RedColors.warningRed,
113
+ muted: RedColors.mutedRed,
114
+ },
115
+ },
116
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.8.5",
3
+ "version": "1.9.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",