Skip to main content
Auric Artisan · Documentation

Dye in Solution Developer Reference

Maintain the spectral dye bath: the numeric core, the two-species titration and its isosbestic point, the illuminant the tool did not have, the tolerance sweep that replaced a bootstrap, exports, Library integration — and the hazard gate that was withdrawn.

Published: June 2, 2026 Updated: June 2, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

Overview

The bench is a static page plus four plain scripts: a numeric core that touches no document, a register of what every figure rests on, a page half that draws, and a views half for the Method, Safety, Data, Export and Reference tabs. The spectral architecture was sound — Beer–Lambert, additive over components, integrated against the observer. What sat on top of it was not: a pH control that reached nothing for three quarters of the presets, a titration modelling the wrong phenomenon, a confidence interval over the wrong quantity, a colour with no light behind it, and a compliance gate built from a table of chemical classes.

Table of contents

  1. 1. Code map
  2. 2. What was found, and how
  3. 3. The grid and the observer
  4. 4. The two-species dye
  5. 5. The isosbestic point
  6. 6. Illuminants, and the one not held
  7. 7. Colour, inconstancy and metamerism
  8. 8. The tolerance sweep
  9. 9. Safety, and why it is withdrawn
  10. 10. UI contract
  11. 11. Exports and URL state
  12. 12. Public API and Library binding
  13. 13. Tests

1. Code map

+
FileHoldsReaches the DOM
js/tool/dy/dy-core.js Every calculation. Installs as AADYCore. No — deliberately, so the suite runs the shipped file.
js/tool/dy/dy-sources.js The 18-entry register, as AADYSources. No.
js/tool/industrial-dye-chemistry.js State, the canvas, the readouts, exports, tab switching. Yes. AADYEngine, AAIndustrialDyeChemistry.
js/tool/industrial-dye-chemistry-views.js Method, Safety, Data, Export, Reference, as AADYViews. Yes.
css/dye-chemistry-shell.css The workbench shell plus this route’s blocks under §V. —

Load order matters and none of these is a module: dy-core.js must be present before the page half, which throws a clear error rather than failing silently if it is not. The route is in the PAGES list of scripts/extract-anz-shell.mjs, so a class used only here still survives the cut into css/anz-shell.css.

2. What was found, and how

+

Six findings, each measured against the shipped code rather than reasoned about.

The pH control reached nothing

A dye’s band centre moved by lambda0 + pHShift * (baseFraction - 0.5) * 2, and pHShift defaults to 0. Fifteen of the twenty presets never set it — including both dyes in the default. Driving the live page from pH 0 to 14 in seven steps:

PresetDistinct colours across pH 0–14
standard (the default)1 of 7 — #6279F1, L* 54.8, every time
food1 of 7
natural4 of 7
acid5 of 7

The titration modelled the wrong phenomenon

One Gaussian sliding sideways at constant height. Over the tool’s own Acid Orange the peak never left 3.358 across the entire titration, and the curves never came within 20.6% of agreeing at any wavelength — so there was no isosbestic point to find.

The confidence interval resampled the recipe

runBootstrap() drew dyes from the formulation with replacement. One dye returned [1.6176, 1.6176], a 95% interval of zero width; two returned [2.6576, 5.6176] identically on every run, because only three resamples exist.

There was no light source

spectrumToXYZ integrated the transmittance against the observer alone, then xyzToLab took the result against D65’s white. Two different lights inside one number.

Dominant wavelength was an argmax

It returned the wavelength of maximum transmittance, which lands on whichever end of the window is least absorbed: 380 nm on the default preset at every setting, 778–780 nm on two others.

GHS hazard was assigned per chemical class

And it gated the export button. See §9.

3. The grid and the observer

+

81 samples, 380–780 nm at 5 nm — the grid the CIE 1931 2° observer is published on. The observer array is copied from js/tool/illuminants/il-tables.js, this repository’s checked table, rather than retyped.

This is a reduction in stated resolution and it is the honest direction. The engine it replaces sampled at 2 nm — 201 points — built by interpolating a 10 nm table. Four out of every five samples were invented between published rows, which is 201 numbers carrying 41 numbers’ worth of information.

The D-series basis has its own 10 nm grid and is resampled onto the working one by sAt(). Reading all 46 values against the working grid stretches every daylight by a factor of two in wavelength; this repository has made that mistake twice and caught it twice, so the acceptance test is that D65 and D50 land on their published chromaticities. They land within 0.0003.

4. The two-species dye

+

A dye that responds to pH is two species, not one band that slides:

A(λ) = c · l · [ (1−f) · ε_HA(λ) + f · ε_A(λ) ]
f = 1 / (1 + 10^(pKa − pH))

Each dye carries an acid and a base object, each with lambdaMax, eps, sigma and a measured slot. speciesEpsilon() returns the measured curve when one is installed and the Gaussian otherwise; epsAtWavelength() does the same off the grid, which the isosbestic bisection needs.

The band SHAPE is a stand-in and the register says so: real electronic absorption bands are asymmetric, carrying a vibronic tail to the blue. Peak position and height are about right and the wings are not, which matters most where two dyes overlap — and that overlap is what a mixture’s colour is made of.

The presets name classes, not substances. The old list named Direct Blue 86, Reactive Blue 19 and Tartrazine beside unsourced λmax and ε values, mixed with eight inventions such as “Textile Red” that no reader could tell apart from the real ones. Putting a real substance’s name next to an unsourced constant is the worse of the two failures, because the name lends the number an authority it has not earned.

5. The isosbestic point

+

The universal signature of a two-state equilibrium: one wavelength at which every curve in the family agrees exactly, because the total dye is conserved and both forms absorb equally there. It is where ε_HA(λ) = ε_A(λ).

isosbesticPoint(dye) scans the grid for a sign change in the difference, then bisects the continuous absorptivities for sixty halvings. Locating it by interpolating between 5 nm samples instead left a residual of up to 1.7% — the locator’s error being read as the model’s. Bisected, the family agrees to 1×10−12 of the peak at the located wavelength, and the suite checks that off-grid for every preset.

It also carries fractionOfPeak and strong. Two bands far apart cross deep in each other’s tails, where the crossing is exact and invisible: the mid-range preset crosses at 0.8% of its peak, the yellow one at 99.8%. Drawing a confident marker on a flat line would be worse than saying which kind you have, so the canvas only marks a strong crossing and the readout appends “(weak)” otherwise.

Two things that are NOT universal, and the page does not claim them: the peak dips at the pKa only when the two forms have comparable absorptivity (where one is much stronger the peak migrates to it instead), and the area under the curve is conserved only when their integrated absorptivities match. The isosbestic point holds either way.

6. Illuminants, and the one not held

+

spectrumToXYZ(transmittance, ill) takes the light as an argument and has no default. Four are offered — D65, D50, A and E — and each lands within 0.001 of its published chromaticity. whitePointOf(ill) gives the white a perfect transmitter would make under it, which is what every L*a*b* here is taken against.

F11 is not offered. It is the light a dyer most wants, because a shop’s fluorescent lighting is where a match fails. That is exactly why there is no stand-in: the CIE 15 fluorescent tables are measured spectra carrying narrow mercury emission lines, they cannot be computed from a formula, and js/tool/illuminants/il-tables.js already declares them absent in this repository with the note that they are “never substituted with an approximation”. A smooth curve under the name F11 would make the one comparison a dyer most needs the least trustworthy number on the page.

ILLUMINANT_ABSENT carries the reason and what it would take, and the Lab tab renders it in the row where F11 would be, so the absence is visible rather than merely unmentioned.

7. Colour, inconstancy and metamerism

+

Two white points, on purpose:

  • The swatch is un-adapted. A bath under illuminant A is meant to look warmer, and that is the question this tool answers.
  • L*a*b* is against the illuminant’s own white, so a difference between two lights measures the dye moving rather than restating that the lamps differ.

inconstancy(t, from, to) reports how far one bath moves between two lights and returns isMetamerism: false. That distinction is not pedantry: metamerism is a property of a PAIR of samples that match under one light and part under another, which is the thing a dyehouse fails on. One sample changing under two lights is colour inconstancy. Calling it a metamerism index would be the same class of error as a dominant wavelength that was an argmax.

metamerismIndex(a, b, reference, test) is the real thing and takes the pair. It reports underReference alongside the index, because the index means nothing when the two do not match to begin with — above 1 ΔE₀₀ it sets usable: false and says so.

dominantWavelength() is the colorimetric construction: the ray from the illuminant’s white through the sample to the spectral locus, with the excitation purity. A neutral returns NaN and says why; a purple, whose ray leaves through the purple line, is flagged complementary rather than being given a number regardless.

A note on deltaE00: the mean-hue term splits the >180° case in two, and the split matters even though the two answers differ by exactly 360°. T reads the mean hue through cosines, which are periodic and cannot tell them apart; RT reads it through exp(-((h-275)/25)^2), which is not. Folding both cases into a single +180 leaves RT hunting for hue 275 at a hue 360° away. Worst observed effect 1.3×10−4 ΔE₀₀, in the high-chroma blues — small, and the seven published Sharma pairs do not catch it. It was found by transcribing CIE 142-2001 a second time inside the test and comparing over 20,000 random pairs, and the same defect was fixed in js/tool/ml/ml-core.js and js/tool/convert.js.

8. The tolerance sweep

+

sensitivitySweep(bath, opts) perturbs the three quantities a formulator can state a tolerance for — the weighed concentration (±2% by default), the cell’s path length (±0.1 mm) and the molar absorptivity taken from a reference (±5%) — and reports the spread in ΔE₀₀, the unit a dyer works to.

It replaces an interval that resampled the dye LIST. That one answered “what if my recipe were a random draw from my recipe”, which is not a question about measurement error, and it collapsed to zero width with a single component because there was nothing to draw.

The defaults are this tool’s guess at your laboratory, are on the control so they can be replaced, and are a stand-in in the register. They are at least tolerances on quantities that have tolerances.

9. Safety, and why it is withdrawn

+

This is the one finding with a consequence outside the screen, which is why it has its own tab.

The page assigned GHS hazard statements — Acute Tox. 4, Skin Sens. 1B, Aquatic Chronic 2 — to a chemical class, gave every dye in that class the same level, and refused to export until the reader ticked boxes confirming PPE, an SDS, ventilation and acceptance of responsibility.

GHS classification is determined per substance from its own toxicological data and published on its safety data sheet. The giveaway in the old table: indigo appears in the preset list twice, once as “Vat Blue 1” and once as “natural”, and the two entries carry different hazard levels and different PPE for the same molecule. One molecule cannot have two classifications because of which menu it was picked from.

What replaces it is standing practice with nothing gated on it: gloves, eye protection and a lab coat as a floor; the supplier’s SDS before the container is opened; dye liquor treated as wastewater. A tool that blocks a button until a box is ticked teaches that the box is the safety measure.

When editing this page, do not reintroduce a computed hazard level. The register entry ghs records what it would take: the classification for each substance from its own SDS, keyed by CAS number and kept current with the supplier’s revisions. A static table compiled once and shipped inside a page would go stale silently, which is worse than holding none.

10. UI contract

+

<main> must carry class="anz-main ax-lab": the shell scopes every slider, select and button to .ax-lab, and without it the controls lose all styling. Rails are class="panel dy-rail", content columns class="panel dy-stage", inside a div.dy-band.

.dy-row__head and .dy-cite are four-column grids and need exactly four direct children. Wrapping two in a div collapses them into one cell and overflows it.

.dy-cap is a flex-laid amber callout expecting a <p> inside it; plain section prose uses .dy-caption. A bare paragraph given the first class makes every text node and every <em> a separate flex item.

The Data tab’s count badge is written from AADYSources.counts().all at render time, not typed into the markup, so it cannot fall behind the register. The Reference tab’s filter chips use the same vocabulary as its row tags — Runs, Partly, Absent — and both are derived from the same array.

Tab switching lives in industrial-dye-chemistry.js (wireTabs()), not in a generated inline script, and data-dy-goto anywhere in the document opens a tab. The canvas has no width while its panel is hidden, so opening the Lab tab schedules a redraw.

Any run of text carrying a Greek letter needs class="dy-sym": the shell uppercases labels, and uppercasing λ gives Λ.

11. Exports and URL state

+

csv() writes 81 rows — wavelength, the bath’s absorbance and transmittance, the selected dye’s two absorptivities, and the three observer functions — under a header from headerLines(), and a line naming the columns.

The header carries the sampling and the fact that no sample is interpolated, the illuminant, the bath, every component with its provenance in capitals, its base fraction at this pH and its isosbestic point with strength, the Beer–Lambert range, which white each colour used, and the colour inconstancy to every other light. Four lines say what the file is NOT: not a metamerism index, no fluorescent illuminant, not a fastness test, no hazard data.

bathObject() is the same as JSON plus the full spectrum arrays, every absent entry with what it would take, and the register’s status for every entry. shareLink() serialises the bath, its components and the light; loadURL() restores them at boot.

12. Public API and Library binding

+

window.AADYEngine exposes core, getBath(), last(), sweep(), selectedDye(), headerLines(), csv(), bathObject(), runSweep() and refresh().

window.AAIndustrialDyeChemistry is the shape the Library integration and js/common/url-scroll-state.js expect: getState() returning {config}, restoreState(saved) and refresh().

window.AADYViews exposes init(), refresh(), oldWayAbsorbance(), tightestAgreement() and REFS. oldWayAbsorbance() is the only arithmetic in the views file; it is the sliding-band model being argued against, and it lives there rather than in the core because the core is what the tool actually computes.

13. Tests

+

npm run test:colorimetry runs the whole colorimetry suite, including tests/colorimetry/dy-reference.test.js and dy-sources.test.js — 57 assertions between them. dy-harness.js loads the shipped core and register in a vm context, so every assertion runs the code the page runs.

The ones that matter most when changing this file:

  • the isosbestic point is exact — checked off-grid at the located wavelength, for every preset;
  • pH changes the colour of every preset — the regression guard for the defect this rebuild exists for;
  • every illuminant against its published chromaticity, and a daylight that is not a blackbody;
  • F11 absent rather than approximated;
  • ΔE₀₀ against an independent transcription of CIE 142-2001 over 20,000 random pairs — the published pairs pin seven points, not the surface between them;
  • the sweep separating a tolerance from a recipe change.

The suite was proved by injecting 51 defects — a stretched daylight basis, a daylight that returns a blackbody, a stand-in shipped under the name F11, the two species not summing to one, an isosbestic point located on the grid, an argmax back in place of the dominant wavelength, inconstancy claiming to be metamerism, a stand-in relabelled verbatim — and all 51 were caught. Six survived the first run and one more the second; every one was a gap in the tests, and two of them exposed real bugs in this file (a loop counter shadowing an array, and the mean-hue branch above). The assertions that close them are marked in place.