U of I × MindRouter App Gallery

Virtual Titration Lab

Education Built with ForgeCode v2.13.21 Critic status: shipped

Virtual Titration Lab

Acid–base titration and buffer simulator

Run acid–base titrations as many times as you want: choose the analyte and titrant, add drops from an animated burette, watch the indicator change, and compare your curve with the exact equilibrium calculation.

Virtual Titration Lab running on a desktop screen, acid–base titration and buffer simulator Desktop · click to enlarge
Virtual Titration Lab running on a phone, acid–base titration and buffer simulator
Phone · click to enlarge

What it is ✎ blurb written by the AI critic

A virtual acid-base titration bench: choose an acid or base, add titrant drop by drop from an animated burette, and watch the indicator color and the titration curve change while an exact charge-balance solver computes the pH. Derivative plots locate each equivalence point, a buffer builder compares the exact pH with Henderson-Hasselbalch and charts buffer capacity, and an unknown-acid challenge scores how well you identify a sample.

Try this

  1. Choose phosphoric acid, set the indicator to thymol blue and press Auto-titrate: two jumps appear in the curve while the flask goes from orange-red to yellow to blue.
  2. Switch to 'Titrate a base', titrate ammonia with HCl, and compare methyl red with phenolphthalein to see which one changes color at the equivalence point.
  3. Open Unknown challenge, draw a sample, titrate a little past the jump, hover the curve at half the equivalence volume to read the pKa, then submit the acid and its concentration.

Highlights ✎ written by the AI critic

  • Exact charge-balance pH for 10 acids (including polyprotic phosphoric, carbonic, oxalic and sulfurous) and 6 bases, drawn point by point against the exact curve
  • First- and second-derivative plots that find each equivalence point automatically, such as 25.00 and 50.00 mL for phosphoric acid
  • Animated burette, stopcock and flask with 20 indicators whose colors follow their pH ranges, including two-step indicators like thymol blue
  • Buffer builder with exact pH, Henderson-Hasselbalch comparison, final volume and a buffer-capacity chart
  • Unknown-acid challenge with a pKa candidate list and itemized score, plus a lab notebook with CSV export

Who it's for

General chemistry students (CHEM 111/112), lab instructors, high-school outreach

Features

  • Animated lab bench: burette with stopcock slider and drop button, flask whose color follows the chosen indicator's transition range
  • Exact pH computation (charge-balance solver, including polyprotic acids) and a live titration curve that grows point by point
  • Equivalence and half-equivalence detection with first- and second-derivative plots
  • Buffer builder: pick a weak acid/conjugate base, set concentrations, see buffer capacity and Henderson–Hasselbalch comparison
  • Unknown-sample challenge mode: identify the acid and its concentration from your own titration, with scoring
  • Lab-notebook table of readings with CSV export

Data & sources

  • openstax.org — OpenStax Chemistry 2e, Appendix H: Ionization Constants of Weak Acids (Ka values only).
  • openstax.org — OpenStax Chemistry 2e, Appendix I: Ionization Constants of Weak Bases (Kb values only).
  • openstax.org — OpenStax Chemistry 2e, Section 14.1 (Kw = 1.0e-14 only).
  • openstax.org — OpenStax Chemistry 2e, Section 14.7: indicator color-change pH ranges and colors read from Figure 14.19 (litmus from Figure 14.20).
  • Solver check: reviewer's own exact charge-balance calculation (2026-09-13) for 25.00 mL of 0.100 M HCl titrated with 0.100 M NaOH at 13 volumes. It is not taken from any textbook and is embedded as data/embed/solver_check.json.
Full data provenance ✎ written by the AI reviewer

Raw data sources — titration-lab

Retrieval date for every file: 2026-09-13 (UTC 18:06). All files are the unmodified HTTP response bodies (HTTP GET, no request body, User-Agent UIdaho-gallery-data-prep/1.0, all HTTP 200). No filtering or reshaping was done. Total size ≈ 1.9 MB.

Publisher of every file: OpenStax, Rice University (Houston, Texas), textbook Chemistry 2e, by Paul Flowers, Klaus Theopold, Richard Langley, William R. Robinson, PhD. Publication date Feb 14, 2019. The page footer says "© Jul 8, 2026 OpenStax". Page build: archiveVersion 20260604.144757, contentVersion 3be4b60.


READ FIRST: license and terms (this differs from the brief)

  • License is CC BY-NC-SA 4.0, not CC BY 4.0. Every page says: "This book uses the Creative Commons Attribution-NonCommercial-ShareAlike License and you must attribute OpenStax". The embedded book metadata is "license":{"url":"http://creativecommons.org/licenses/by-nc-sa/4.0/","name":"Creative Commons Attribution-NonCommercial-ShareAlike License"}. Required digital attribution, quoted from the page: "Access for free at https://openstax.org/books/chemistry-2e/pages/1-introduction". The OpenStax name, logo and book covers are not covered by the CC license. Do not use them in the app.

  • AI notice on every page, verbatim: "This book may not be used in the training of large language models or otherwise be ingested into large language models or generative AI offerings without OpenStax's permission." Coding agent: do not read or parse the HTML files. Every number the app needs is already transcribed below. Ionization constants and indicator ranges are physical facts. Use the tables in this file, and cite and link OpenStax in the app's "Data & sources" panel. The HTML and JPEG files are kept only as provenance. A human owner may delete them if the notice is judged to forbid keeping them in an AI-built project.


Build-driver note (2026-09-13): provenance files removed from this project

Because of the OpenStax AI notice above, the build driver moved the six downloaded files (4 HTML pages, 2 JPEG figures) out of this project before any coding-agent round. They are kept outside the repository as provenance only. Their URLs, byte sizes and sha256 hashes are still listed below, so anyone can re-fetch and check them. data/raw/ now contains only this SOURCES.md. Every value the app needs is in the tables in this file: Ka, Kb, Kw, indicator ranges and colors. The app should put its small constants table in data/embed/ (e.g. generated by a script in scripts/ from the numbers in this file) and cite OpenStax in its "Data & sources" panel with the links below. Wherever the section headings below say "file 1…6", the file is no longer present; the transcribed values remain authoritative.


Owner decision (2026-09-13): only physical constants from OpenStax

The project owner decided that the app, data/embed/ and scripts/ keep only physical-constant facts transcribed from OpenStax Chemistry 2e: the Ka, Kb and Kw values and the indicator color-change pH ranges (with the indicators' acid/base colors). They are credited with the CC BY-NC-SA 4.0 attribution: title, authors, a link to the book and a link to the license (https://creativecommons.org/licenses/by-nc-sa/4.0/). Everything else derived from the book is out of scope and must not be used, in particular the Table 14.2 worked-example titration data. That table's transcription was removed from this file on 2026-09-13. The solver is validated instead against the reviewer's own calculation of 25.00 mL of 0.100 M HCl titrated with 0.100 M NaOH; FEEDBACK.md gives those values when a round needs them. Do not use the OpenStax logo, covers or branding.


Files

FileBytesContent-TypeURL (GET)
openstax_chem2e_appendixH_weak_acids.html328637text/htmlhttps://openstax.org/books/chemistry-2e/pages/h-ionization-constants-of-weak-acids
openstax_chem2e_appendixI_weak_bases.html289405text/htmlhttps://openstax.org/books/chemistry-2e/pages/i-ionization-constants-of-weak-bases
openstax_chem2e_14-7_acid-base_titrations.html359796text/htmlhttps://openstax.org/books/chemistry-2e/pages/14-7-acid-base-titrations
openstax_chem2e_14-1_bronsted-lowry_acids_bases.html339435text/htmlhttps://openstax.org/books/chemistry-2e/pages/14-1-bronsted-lowry-acids-and-bases
openstax_chem2e_fig14-19_indicator_ranges.jpg282823image/jpeghttps://openstax.org/apps/archive/20260604.144757/resources/620c6acfc027775e49197e1995601ce664fbe1a3
openstax_chem2e_fig14-20_titration_indicator_choice.jpg275499image/jpeghttps://openstax.org/apps/archive/20260604.144757/resources/457d4944b09a09950dc7b5347a5807e9fc3db019
Filesha256Server ETag
openstax_chem2e_appendixH_weak_acids.htmleb401fdc1421b1f13258cbe3e2ab272e8c67f5abc824056956d0761ce71262564d653fd7d73ed70737a22efe4e9ce5e6
openstax_chem2e_appendixI_weak_bases.html9d5a089be726e632790bf8e656146fd1d6b8910d855622b9b59aa0472c5664c6bda867b0a1cdff354b0f0c6a29ee094c
openstax_chem2e_14-7_acid-base_titrations.htmle93f4522a0d98f68b579a8e576102f4bc50257276ec36fdc15317d4638aeca10d159c54581d7d404ac00f9846f4dfea6
openstax_chem2e_14-1_bronsted-lowry_acids_bases.html91f7f0d24e42e24b5ca9f7698c64a8c864aa8f0732339ab165ae9eb50be5d37c2c83923745b54095dc972969a6ebe2d8
openstax_chem2e_fig14-19_indicator_ranges.jpg801e18e7707d5bc664a12d5bc8e26d9bd277ae1b603982a74ad0581a7cf23391a3c8649896a38030cedfac666d870b67
openstax_chem2e_fig14-20_titration_indicator_choice.jpg8bf90762acee00f92aa03e4af33aa39f1b3db45a748537cac65c8a253e0e98063212f39b09f7561b08864503523cff50

The four HTML files have server Last-Modified dates of 2026-09-03. The two JPEGs are dated 2026-06-11. Each HTML file was checked to be the real, server-prerendered book page (title <Section> - Chemistry 2e | OpenStax), not an error or consent page. The content table is present in the markup. Each file was downloaded twice, and both copies had identical sha256.

1. openstax_chem2e_appendixH_weak_acids.html
  • Contents: Appendix H, "Ionization Constants of Weak Acids". One <table> with header row Acid | Formula | Ka at 25 °C | Lewis Structure. It has 22 acids and 33 data rows, one row per ionization step. The acid name cell uses rowspan for polyprotic acids.

  • Parsing notes: Values look like 7.5 <math>…<mo>×</mo>…<annotation-xml><mo>×</mo></annotation-xml></math> 10<sup>−3</sup>. A naive text dump shows "××" because the × appears twice, once in the MathML and once in its annotation. The exponent uses U+2212 MINUS SIGN, not an ASCII hyphen. Second and third ionization formulas (HCO₃⁻, H₂PO₄⁻, HPO₄²⁻) are images. Their text is only in the data-alt attribute. (Verifier addition.) Rows have 4, 3 or 2 <td> cells, so the Ka cell is not always at the same index: first-step rows have 4 cells (acid name, formula, Ka, Lewis). The second-step rows for hydrogen selenide, hydrogen sulfide and hydrogen telluride have 3 cells (text formula HSe⁻/HS⁻/HTe⁻, Ka, empty Lewis). All other later-step rows have 2 cells (image formula, Ka). In every row the Ka is the first cell whose text contains × 10. The <mo>×</mo> count is 66 for 33 values, which confirms the doubling.

  • Values the app needs, transcribed exactly as printed (Ka at 25 °C):

AcidSpecies (step)Ka as printedNumeric
aceticCH₃CO₂H1.8 × 10⁻⁵1.8e-5
formicHCO₂H1.8 × 10⁻⁴1.8e-4
carbonicH₂CO₃ (Ka1)4.3 × 10⁻⁷4.3e-7
carbonicHCO₃⁻ (Ka2)4.7 × 10⁻¹¹4.7e-11
phosphoricH₃PO₄ (Ka1)7.5 × 10⁻³7.5e-3
phosphoricH₂PO₄⁻ (Ka2)6.2 × 10⁻⁸6.2e-8
phosphoricHPO₄²⁻ (Ka3)4.2 × 10⁻¹³4.2e-13
  • Other acids in the table, if the app wants more choices (as printed): arsenic 5.5×10⁻³ / 1.7×10⁻⁷ / 3.0×10⁻¹²; arsenous 5.1×10⁻¹⁰; boric 5.4×10⁻¹⁰; hydrazoic 2.5×10⁻⁵; hydrocyanic 4.9×10⁻¹⁰; hydrofluoric 6.4×10⁻⁴; hydrogen peroxide 2.4×10⁻¹²; hydrogen selenide 1.29×10⁻⁴ / 1×10⁻¹²; hydrogen sulfate ion 1.2×10⁻²; hydrogen sulfide 8.9×10⁻⁸ / 1.0×10⁻¹⁹; hydrogen telluride 2.3×10⁻³ / 1.6×10⁻¹¹; hypobromous 2.8×10⁻⁹; hypochlorous 2.9×10⁻⁸; isocyanic 2×10⁻⁴; nitrous 4.6×10⁻⁴; oxalic 6.0×10⁻² / 6.1×10⁻⁵; phosphorous 5×10⁻² / 2.0×10⁻⁷; sulfurous 1.6×10⁻² / 6.4×10⁻⁸. HCl is not in this table because it is a strong acid. Treat it as fully dissociated. No constant is needed.

  • Citation: Flowers, P., Theopold, K., Langley, R., & Robinson, W. R. (2019). Chemistry 2e, Appendix H: Ionization Constants of Weak Acids. OpenStax (CC BY-NC-SA 4.0). https://openstax.org/books/chemistry-2e/pages/h-ionization-constants-of-weak-acids

2. openstax_chem2e_appendixI_weak_bases.html
  • Contents: Appendix I, "Ionization Constants of Weak Bases". One <table> with header Base | Lewis Structure | Kb at 25 °C and 5 data rows. The formula appears only inside the Lewis-structure image.

  • Values, as printed: ammonia Kb = 1.8 × 10⁻⁵ (1.8e-5). Also: dimethylamine 5.9×10⁻⁴; methylamine 4.4×10⁻⁴; phenylamine (aniline) 4.3×10⁻¹⁰; trimethylamine 6.3×10⁻⁵.

  • NaOH is a strong base and is not listed. Treat it as fully dissociated.

  • Citation: Flowers, P., Theopold, K., Langley, R., & Robinson, W. R. (2019). Chemistry 2e, Appendix I: Ionization Constants of Weak Bases. OpenStax (CC BY-NC-SA 4.0). https://openstax.org/books/chemistry-2e/pages/i-ionization-constants-of-weak-bases

3. openstax_chem2e_14-7_acid-base_titrations.html
  • Contents: Section 14.7, "Acid-Base Titrations".

    • Table 14.2 (a worked-example titration table). Not used. Its values are intentionally not transcribed here (owner decision above).

    • Figure 14.18 (id="CNX_Chem_14_07_titration"), titration curves.

    • Figure 14.19 (id="CNX_Chem_14_07_indicators"), indicator color-change intervals. This is file 5.

    • Figure 14.20 (id="CNX_Chem_14_07_titration2"), indicator choice. This is file 6.

    • The text "Acid-Base Indicators".

  • Indicator pH ranges as stated in the text:

    • Phenolphthalein: "colorless … [H₃O⁺] greater than 5.0 × 10⁻⁹ M (pH < 8.3). In more basic solutions … (pH > 8.3), it is red or pink." Near the equivalence point: "A sharp color change from colorless to pink".

    • Methyl orange: HIn (nonionized) is red, In⁻ is yellow, and the mixture is orange. The book says "for most indicators this range is approximately pKa ± 1". (Verifier addition.) The same passage prints methyl orange's Ka = 4.0 × 10⁻⁴. pKa = 3.40 is DERIVED as −log10(4.0e-4). pKa ± 1 gives about 2.4–4.4. That agrees with the image-derived bar (3.2–4.5) and the Figure 14.20 alt text (3–4.2), not with the 4.2–6.3 in the Figure 14.19 alt text.

    • Litmus is named with a range in Figure 14.20 only.

  • Indicator ranges in Figure 14.19, the chart in file 5. The chart has no numeric labels on its bars. Its gridlines are at pH 0, 2, 4 … 12 and tick marks at every unit. Two sources are given below:

    • (a) The figure's alt text in the HTML, verbatim numbers. In the alt text, each indicator label (in curly quotes) comes before its bar description. The alt text spells four names "Bromophenol blue", "Bromocresol green", "Bromocresol purple" and "Bromothymol blue". The table below uses the shorter Bromphenol/Bromcresol/Bromthymol spellings. Both refer to the same indicators.

    • (b) DERIVED: bar edges measured from the JPEG pixels. Method: PIL, grayscale threshold <240. The x axis is calibrated by least squares on the 7 detected vertical gridlines (x = 265.0 px → pH 0, …, 1178.5 px → pH 12; 76.17 px per pH unit). Estimated uncertainty is about ±0.05 pH. Values are rounded to 0.1.

IndicatorColors in figure (acid → base)(a) alt-text range(b) DERIVED from image
Crystal violetyellow → green → blue0 – 1.80.0 – 1.8
Cresol redred → yellow; 2nd: yellow → red1 – "just over 2"; ~7 – ~91.0 – 2.1; 7.0 – 8.8
Thymol bluered → yellow; 2nd: yellow → blue~1.2 – 2.8; 8 – ~9.11.2 – 2.8; 8.0 – 9.2
Erythrosin Bred2.2 – 3.62.2 – 3.6
2,4-Dinitrophenolcolorless → yellow2.6 – 42.6 – 4.0
Bromphenol blueyellow → blue3 – 4.53.0(5) – 4.6
Methyl orangered → yellow4.2 – 6.3 (alt text is WRONG, see note)3.2 – 4.5
Bromcresol greenyellow → blue3.8 – 5.43.7 – 5.4
Methyl redred/orange → yellow4.2 – 6.34.2 – 6.3(5)
Eriochrome Black Tred → blue5 – 6.55.0 – 6.5
Bromcresol purpleyellow → purple5.2 – 6.85.2 – 6.8
Alizarinyellow → red; 2nd: red → purple/blue5.7 – 7.2; 11 – 12.45.6 – 7.2; 11.0 – 12.4(5)
Bromthymol blueyellow → blue6 – 7.66.0(5) – 7.6
Phenol redyellow → red6.8 – 8.26.8 – 8.2(5)
m-Nitrophenolcolorless → yellow6.8 – 8.66.8 – 8.6(5)
o-Cresolphthaleincolorless → red8.3 – 9.88.3 – 9.8
Phenolphthaleincolorless → pink8 – 108.0 – 10.0
Thymolphthaleincolorless/light blue → blue9.3 – 10.59.3 – 10.6
Alizarin yellow Ryellow → red10 – 1210.0 – 12.0
  • Note on methyl orange: the alt text gives "4.2 … to its endpoint at a pH of 6.3", the same as methyl red. The image itself shows the methyl orange bar at about pH 3.2–4.5, and section text and Figure 14.20 agree with the image. Treat the alt text for methyl orange as a transcription error on the page. The book gives no numeric endpoints for the chart, so the image-derived values are the best reading of what the figure shows. Indicator ranges in Figure 14.20 (file 6: HCl and CH₃COOH curves with shaded bands):

    • Alt text, verbatim numbers: methyl orange "pH of 3 to 4.2", litmus "pH of 4.6 to 8", phenolphthalein "pH of 8.4 to 10".

    • DERIVED from the image, using the same pixel method with the y axis calibrated on the 15 tick marks (pH 0–14, 51.2 px per unit): methyl orange 3.0–4.3; litmus 4.6–8.0; phenolphthalein 8.4–10.0.

    • Parsing trap: in this alt text, the order is reversed from Figure 14.19. Each "pH of X to Y" comes before its label, e.g. "…pH of 3 to 4.2 … labeled 'Methyl orange pH range.'"

    • The figure shows only 3 indicators, so its phenolphthalein band (8.4–10) differs slightly from Figure 14.19 (8.0–10.0) and from the text (8.3). Pick one and cite it.

  • Table 14.2: transcription removed on 2026-09-13 by owner decision (see top of file). Do not re-create it.

  • Citation: Flowers, P., Theopold, K., Langley, R., & Robinson, W. R. (2019). Chemistry 2e, Section 14.7: Acid-Base Titrations. OpenStax (CC BY-NC-SA 4.0). https://openstax.org/books/chemistry-2e/pages/14-7-acid-base-titrations

4. openstax_chem2e_14-1_bronsted-lowry_acids_bases.html
  • Contents: Section 14.1, "Brønsted-Lowry Acids and Bases". It is saved only as the citable source for Kw.

  • Value, as printed: "at 25 °C, Kw has a value of 1.0 × 10⁻¹⁴". The same passage says "at 100 °C, the value for Kw is about 5.6 × 10⁻¹³".

  • Citation: Flowers, P., Theopold, K., Langley, R., & Robinson, W. R. (2019). Chemistry 2e, Section 14.1: Brønsted-Lowry Acids and Bases. OpenStax (CC BY-NC-SA 4.0). https://openstax.org/books/chemistry-2e/pages/14-1-bronsted-lowry-acids-and-bases

5. openstax_chem2e_fig14-19_indicator_ranges.jpg
  • Contents: The original full-resolution source image of Figure 14.19, "This chart illustrates the color change intervals for several acid-base indicators." It is a JPEG, 1300×1137 px, 200 dpi. The page itself serves a WebP rendition through /apps/image-cdn/v1/f=webp/…. This file is the archive original that URL points to. It has 19 indicator rows, and the pH axis runs 0–13. Colors are gradient bars. There are no numeric labels; see the table in section 3. "Eriochrome" carries the footnote "* Trademark Ciba-Geigy".

  • Citation: OpenStax, Chemistry 2e, Figure 14.19 (Section 14.7), CC BY-NC-SA 4.0. https://openstax.org/books/chemistry-2e/pages/14-7-acid-base-titrations

6. openstax_chem2e_fig14-20_titration_indicator_choice.jpg
  • Contents: The original image of Figure 14.20. It is a JPEG, 1300×872 px, 200 dpi.

    • Two titration curves: weak acid CH₃COOH (purple) and strong acid HCl (red), each titrated with 0.100 M NaOH over 0–50 mL.

    • Shaded bands for the methyl orange, litmus and phenolphthalein pH ranges.

  • Citation: OpenStax, Chemistry 2e, Figure 14.20 (Section 14.7), CC BY-NC-SA 4.0. https://openstax.org/books/chemistry-2e/pages/14-7-acid-base-titrations


Independent verification (2026-09-13)

  • All 6 URLs were re-fetched live (HTTP GET, same User-Agent, all HTTP 200). Every live response had the same sha256, byte size, ETag and Last-Modified as the saved file.

  • The saved tables were re-parsed with a separate parser. Appendix H has 1 table with 33 data rows and 22 acids. Appendix I has 1 table with 5 rows. Section 14.7 has 1 table. Every Ka, Kb and Kw value transcribed above matches the markup exactly.

  • License text was found in all four pages (by-nc-sa/4.0, no licenses/by/4.0), and the AI notice appears once per page. The metadata archiveVersion 20260604.144757 and contentVersion 3be4b60, "© Jul 8, 2026 OpenStax" and "Publication date: Feb 14, 2019" are confirmed.

  • The Figure 14.19 and 14.20 alt-text numbers match column (a), including the "around 7", "nearly 1.2" and "around 9.1" wording. measure_figs.py was re-run on the saved JPEGs and reproduced every column (b) value within rounding: methyl orange 3.21–4.51, phenolphthalein 8.04–9.97, and Figure 14.20 bands 3.00–4.27, 4.58–7.98 and 8.45–9.98.

  • Not checked against text: the "* Trademark Ciba-Geigy" footnote, which appears only in the image pixels.

Suggested one-line credit for the app's "Data & sources" panel

Ka, Kb and Kw values and indicator color-change pH ranges are transcribed from OpenStax, Chemistry 2e (Flowers, Theopold, Langley & Robinson, 2019): Appendices H and I and Sections 14.1 and 14.7. Licensed CC BY-NC-SA 4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/). Access for free at https://openstax.org/books/chemistry-2e/pages/1-introduction. Only numeric values were transcribed; no text, tables, figures or images from the book are reproduced.

How it was built

Written by ForgeCode 2.13.21 (forgecode.dev) on qwen/qwen3.8-27b, served by MindRouter, the University of Idaho's on-premises AI platform.

17harness rounds
175.4minutes of harness work (2.9 h)
3,328lines of code
187KB, single-file app

Rounds by kind

build1
feedback9
continue1
critic6

Lines of code per file

body html455
app css555
app js1,963
scripts355

Critic scores ✎ scored by the independent AI critic, 1–10

visual 8 out of 10
usefulness 9 out of 10
correctness 9 out of 10
interactivity 8 out of 10

Independent audits ✎ by the AI critic

Data honesty: checked against raw data Authorship audit: every app-code commit is a harness round

Automated checks

Console no console errors No network 0 external requests Phone layout 0 px overflow Canvases no empty canvases Brand header header present No key leak no key in source

Checked by critic-verify.

Data spot checks ✎ written by the AI critic

  • Constants table vs data/raw/SOURCES.md: acetic Ka 1.8e-5 vs '1.8 x 10^-5'; oxalic Ka2 6.1e-5 vs '6.1x10^-5'; dimethylamine Kb 5.9e-4 vs '5.9x10^-4'; Kw 1.0e-14 vs Section 14.1 '1.0 x 10^-14' - all match
  • Indicator table vs SOURCES.md image-derived column: thymol blue '1.2-2.8, 8.0-9.2' vs 1.2-2.8 / 8.0-9.2; methyl orange 3.2-4.5 vs 3.2-4.5 (alt-text error noted); litmus 4.6-8.0 vs Figure 14.20 derived 4.6-8.0 - match
  • Solver-check table (reviewer's own HCl/NaOH values, no textbook data) vs my independent exact quadratic: 6.25 mL 1.222 vs 1.2218; 24.95 mL 4.000 vs 3.9996; 26.50 mL 11.464 vs 11.4643 - match; app Max |solver - reference| = 0.000 over 13 volumes
  • Computed: 20.00 mL 0.150 M oxalic acid + 0.100 M NaOH: UI pH 1.679 / 2.876 / 8.395 at 15 / 30 / 60 mL vs independent charge-balance solver 1.6793 / 2.8760 / 8.3947 - match
  • Computed: 50.00 mL 0.050 M dimethylamine + 0.200 M HCl: UI pH 10.749 at 6.25 mL and 6.081 at 12.50 mL (equivalence) vs independent 10.7489 / 6.0812 - match
Show all 10 spot checks
  • Computed: 25.00 mL 0.100 M carbonic acid + 37.50 mL 0.100 M NaOH: UI 10.319 vs independent 10.3190; phosphoric half-equivalence 2.27 / eqv-1 4.70 vs 2.267 / 4.697; acetic equivalence 8.72 vs 8.7219 - match
  • Buffer: HF/fluoride 0.20/0.05 M, 250 mL + 5 mL 1.00 M NaOH: UI pH 2.798, H-H 2.784, beta 1.19e-1, dpH(+1 mL NaOH) +0.03 vs independent 2.7976 / 2.7836 / 1.191e-1 / +0.033 - match (before critic round 15 beta was 1.18e-1 from a formula missing 2.303 on the [H+]+[OH-] terms)
  • Buffer: acetate 0.10/0.10 M, 100 mL + 20 mL 1.00 M HCl: UI pH 1.079 vs independent 1.079 (was 1.000 before the added-volume fix); + 5 mL 1.00 M NaOH instead: UI 5.222, beta 8.23e-2 vs 5.222 / 8.226e-2 - match
  • Notebook/CSV: 25.00 mL 0.100 M HCl + 30 mL NaOH with thymol blue (pH 11.96, blue flask) now records indicator_color 'blue' (was 'yellow' before critic round 15)
  • Challenge scoring: formic 0.066 M, answer 0.067 M -> concentration 39/40 = 40 x (1 - 0.0152/0.5) = 38.8 rounded - matches the displayed breakdown
Build notes ✎ written by the AI reviewer

This follow-up applied the project owner's OpenStax decision of 2026-09-13. ForgeCode 2.13.21 (default-agent via MindRouter) did every code change in one feedback round, round 14. It took 110 s, exited 0, build check was OK, and there were 0 network errors. I never edited src/, scripts/, data/embed/ or index.html. The runner never printed TAMPER GUARD.

  • Confirmed before any change: data/embed/reference.json held all of Table 14.2 (19 rows, HCl and CH3CO2H) plus its citation.

  • scripts/make_data.py hard-coded TABLE_14_2 and wrote reference.json.

  • scripts/check_solver.py used Table 14.2 values as expected results in its acetic and HCl blocks and in the '0.100 M acetic -> 2.87' check.

  • In the Data & sources panel, src/body.html had a 'Reference titration (Table 14.2)' section with #ref-table and #solver-check. Its intro credited OpenStax for 'reference titration data'.

  • In src/app.js, renderSources() rendered the book table with solver/delta columns and a 'Max |solver - Table 14.2|' self-check, and listed the Table 14.2 citation.

  • No license-deed link existed, and no OpenStax logo, cover or image was present.

  • Everything else from OpenStax was Ka/Kb/Kw values and indicator ranges and colors, which the decision keeps.

  • Driver-authored files: SPEC.md: the Data section records the decision (commit fcbfd0a).

  • data/raw/SOURCES.md: the Table 14.2 transcription and the 8.72/7.00 equivalence labels are removed, an owner-decision note is added, and the suggested credit line is updated (commit 1b317cd), so the harness is no longer fed the book table.

  • FEEDBACK.md (5 items): it gave the reviewer's own exact charge-balance pH values for 25.00 mL of 0.100 M HCl titrated with 0.100 M NaOH. I computed them in Python (runs/titration-lab/followup/refcalc.py) at 13 volumes chosen mostly off the book's rows: 0, 6.25, 12.5, 18.75, 23, 24.75, 24.95, 25, 25.05, 26.5, 31.25, 37.5 and 50 mL.

  • FEEDBACK.md was deleted afterwards (commit 1cffebf).

  • Round 14 by the harness: Deleted reference.json and removed the Table 14.2 section from make_data.py.

  • Added data/embed/solver_check.json, generated by make_data.py and labelled as the reviewer's own calculation.

  • Replaced the panel with a 13-row solver-check table computed live with titrationPH().

  • Rewrote check_solver.py to read solver_check.json; it prints FAILURES: 0.

  • Rewrote the credit with the CC BY-NC-SA 4.0 license link, the book link, a changes note and a no-endorsement line, and put the same text in constants.json.

  • Renamed the indicator source entry.

  • Browser verification (verify_app.py into verify-r14, plus my own Playwright checks on port 18941, desktop and phone): 0 console errors and 0 external requests.

  • index.html contains 0 occurrences of 'Table 14.2', '8.72', 'ph_acetic' or 'TABLE_14_2'; the embedded JSON blocks are constants, indicators and solver_check.

  • The UI table matches my values exactly (delta 0.000). Bench readouts at 6.25, 24.95 and 25.05 mL equal my values.

  • The phone Data & sources tab has 0 px overflow.

  • Phosphoric auto-titration still finds 25.00/50.00 mL.

  • No further round was needed.

  • Caveats for a human: (1) Git history still contains Table 14.2 in earlier commits (reference.json, make_data.py, the old SOURCES.md, index.html). History was not rewritten.

  • (2) The six original OpenStax HTML/JPEG downloads are still outside the project, in runs/titration-lab/provenance/.

  • (3) critic.json is stale: its data_spot_checks and highlights still mention Table 14.2 and the 'textbook's Table 14.2 within 0.005' claim, so the gallery blurb/highlights should not reuse that line.

  • (4) buildlog.json data_sources were updated to the new state.

Round-by-round timeline

Every harness round, in order. The prompts and FEEDBACK.md were written by the AI reviewer; the app code was written by the harness. Expand a round to see the exact prompt and the FEEDBACK.md the harness received.

    1 build 21.3 min · 10 files changed, 5468 insertions(+), 3 deletions(-) Show prompt

    Prompt given to the harness:

    Read AGENTS.md, SPEC.md and BRAND.md in this directory. Then build the complete app they describe, step by step: first write a script in scripts/ that turns the constants transcribed in data/raw/SOURCES.md (Ka/Kb/Kw values, indicator pH ranges and colors, and the Table 14.2 reference titration data, with their OpenStax citations and links) into compact JSON in data/embed/ and run it (data/raw/ contains only SOURCES.md — use the numbers in its tables; do not download anything); then write src/body.html, src/app.css and src/app.js; run python3 build.py --check and fix any error it reports. Implement every required feature in SPEC.md with a polished, modern, responsive University of Idaho design. Do not stop until python3 build.py --check succeeds and every feature is implemented.
    

    No feedback file was sent in this round.

    4 continue 27.1 min · 3 files changed, 101 insertions(+), 148 deletions(-) Show prompt

    Prompt given to the harness:

    Read FEEDBACK.md in this directory — it lists problems a reviewer found when testing index.html in a real browser. Fix every item, keeping everything that already works, then run python3 build.py --check.
    

    FEEDBACK.md the harness received ✎ written by the AI reviewer:

    Reviewer feedback — round 2 build (tested in Chromium at 1440×900 and 390×844)

    • Good progress: The burette now stands over the flask.

    • The buffer and challenge dropdowns are filled.

    • Challenge scoring and history work.

    • The phone tab bar scrolls in one row.

    But the page now crashes on load, and the pH math is still wrong everywhere except after the equivalence point. Fix items 1 and 2 first, test them, then continue with the rest.

    1. Page crash on load: the Titration bench is dead. On opening index.html the console shows Uncaught ReferenceError: r is not defined at renderSources (called from init). It is in the new "solver vs. textbook" check table. A variable r is used outside the callback where it is defined, near the end of renderSources. Because init stops there, the bench never initializes:

      • The pH readout shows "–".

      • The Titration curve, First derivative and Second derivative cards are empty white boxes.

      • The "Analyte" and "Titrant" facts show "–".

      • Add drop does nothing: 0.00 mL, no points.

      • The lab notebook stays empty.

        Expected: no console errors, and a working bench at load with the exact curve drawn.

    2. The pH solver is still wrong. I called the app's own titrationPH in the browser: 25.00 mL analyte at 0.100 M, 0.100 M titrant.

    CaseApp givesCorrect (Table 14.2 / exact charge balance)
    0.100 M acetic acid, 0 mL NaOH1.932.87
    acetic, 5 / 12.5 / 20 / 24.9 / 25 / 30 mL0.00 at every volume4.14 / 4.74 / 5.35 / 7.14 / 8.72 / 11.96
    0.100 M HCl, 0 mL7.001.00
    HCl, 5 / 24.9 / 25 / 25.1 mL0.001.18 / 3.70 / 7.00 / 10.30
    phosphoric, 0 / 12.5 / 25 / 37.5 / 50 / 60 mL1.19 then 0.001.62 / 2.27 / 4.70 / 7.21 / 9.68 / 11.77
    carbonic, 0 / 25 / 50 mL2.46 then 0.003.68 / 8.35 / 11.41
    ammonia analyte with 0.100 M HCl, 0 / 12.5 / 25 mL(check)11.13 / 9.26 / 5.28
    Buffer builder: 0.10 M acetic + 0.10 M acetate, 100 mL3.2014.745 (H–H 4.745)
    • What goes wrong, as observed: (a) A pure weak acid solution comes out too acidic, so the ionization fractions still do not sum to 1. For an n-protic acid, the denominator is [H⁺]ⁿ + K₁[H⁺]ⁿ⁻¹ + K₁K₂[H⁺]ⁿ⁻² + … + K₁…Kₙ. The fraction of the species that has lost i protons is its own term divided by that same sum.

    • (b) HCl in the flask gives pH 7, so a strong-acid analyte is ignored. It looks like it is treated as a "weak acid with no Ka steps".

    • (c) As soon as any titrant is added, the result is pH 0.00. The conjugate base created by neutralization is given the acid's Ka list, but the base term reads a Kb that does not exist.

    • A simpler model avoids all of this and needs no neutralization bookkeeping: Keep the total concentration of each acid family. A family is the analyte acid, or the protonated form BH⁺ of a weak base, with Ka = Kw/Kb, diluted to the current total volume.

    • Keep the total [Na⁺] from NaOH, the total [Cl⁻] from HCl, and for a weak base the total [BH⁺]+[B], with the "extra" positive charge of the acid form.

    • Solve [H⁺] + [Na⁺] + Σ(positive species) = [OH⁻] + [Cl⁻] + Σ(anion charges) by bisection over pH −1…15.

    1. scripts/check_solver.py exists. Make the JS solver pass these table values within ±0.02, away from the exact equivalence volume. Also make the Data & sources "solver vs. textbook" table show them with small differences. Challenge mode math. It follows from item 2: after 350 drops the challenge pH is 0.00 at every point, the curve is a flat line at 0, and the flask never turns pink. So the challenge cannot be solved from the titration. The scoring UI itself (score chip, best score, history) is good; keep it.

    2. Challenge mini-charts are poorly scaled. In the derivative mini-plot the axis labels are about 20 px, much larger than the rest of the UI. The "peak 0.2 mL" label overlaps the y-axis labels. The challenge flask is still the old plain flask without a burette. Expected: the same 11–12 px tick labels as the main charts, and the same stand/burette/flask artwork as the bench, at a smaller size.

    3. The burette artwork has overlapping labels. On the bench, the "0.00 mL" reading text overlaps the burette tube and the "25" graduation number. The graduation numbers "0 / 25 / 50" and the "mL" label sit on top of the glass and the clamp. Expected: graduation numbers outside the tube on one side, and the live reading in a small pill beside the meniscus that does not collide with the numbers. Show the meniscus moving down as titrant is used.

    4. Buffer builder edge cases and labels.

      • The pair dropdown text is cut off: "Acetic acid / Acetic acid conjugat…". Use short names such as "Acetic acid / acetate (CH₃CO₂H / CH₃CO₂⁻)".

      • With 0.001 M acid + 0.001 M base + 5 mL of 1.00 M NaOH, the base is in large excess, yet the app still prints an H–H value (13.046) as if it were meaningful. Show "H–H not applicable — strong base in excess" instead.

      • Add a simple "add strong acid (HCl)" control next to the NaOH one.

      • Show the pH change a small addition causes: "adding 1.0 mL of 0.10 M NaOH changes the pH by +0.04; in pure water the change would be +3.0". Buffer capacity is then tangible.

      • At low concentrations the capacity chart is dominated by the water terms at pH 0 and 14, so the buffer peak at pKa is invisible. Mark the buffer's own contribution separately (e.g. shaded area), or clip the y-axis to the buffer region.

    5. Empty CSV export gives no feedback. With no notebook rows, clicking Export CSV does nothing. Expected: disable the button with a tooltip, or show a short message "No readings yet — run a titration first".

    6. Derivative cards layout. On desktop, the First and Second derivative cards still use only 2 of 3 grid columns, leaving an empty gap on the right. Make them share the full width equally, or put them side by side under the curve at full width.

    7. Bench right column. The fact list (Analyte, Titrant, Burette "50 mL · refills automatically") and the pH readout float in the middle of a mostly empty column. Once the bench works, check that the pH readout is large and near the flask. Also check that the facts show the actual analyte and titrant.

    • Keep everything that works now: the phone tab row

    • the stand/burette/flask layout

    • buffer and challenge dropdowns

    • challenge scoring and history

    • the sources tables

    • the no-external-requests rule

    11 critic 7.0 min · 7 files changed, 1188 insertions(+), 398 deletions(-) Show prompt

    Prompt given to the harness:

    Read FEEDBACK.md in this directory — it lists problems a reviewer found when testing index.html in a real browser. Fix every item, keeping everything that already works (the pH solver, endpoint detection and chart axes are correct — do not change the chemistry), then run python3 build.py --check.
    

    FEEDBACK.md the harness received ✎ written by the AI reviewer:

    Reviewer feedback (critic pass 1) — fix every item, then run python3 build.py --check

    The charge-balance solver, endpoint detection and chart axes are CORRECT now. Every pH I checked matches an independent calculation. Do not change the chemistry in titrationPH / solvePH / chargeResidual, and do not break the 390 px phone layout (no horizontal overflow) or introduce console errors.

    Items are in priority order.

    1. Burette drawing is geometrically wrong (bench #stand-svg and challenge #ch-flask-svg)
    • The liquid is drawn 30 SVG units (about 6 mL) above the scale. The graduations are drawn in init() at y = 60 + i*24.8, so 0 mL is at y=60 and 50 mL is at y=308. But updateBenchVisuals() and updateChallenge() position the liquid with y = 30 + (248 - h). Measured at 25.00 mL delivered: the tag says "25.00 mL", but the liquid top is at y=154 while the "25" tick is at y=184. The meniscus reads about 19 mL. At 0 mL the liquid sticks out above the top of the tube (tube top y=40, liquid top y=30). Fix: liquid bottom at y=308, meniscus at y = 60 + (reading/50)*248. The reading tag uses the same y.

    • The burette tip and stopcock are hidden inside the flask neck. The tube spans y 40–310 and the stopcock is at y=322, but the flask mouth is at y=250 and the neck runs to y=320. The yellow stopcock handle is invisible and the "50" label is covered by the flask. Fix: move the flask down (or raise and shorten the burette) so the stopcock, its handle and the tip sit clearly ABOVE the flask mouth with a visible gap. The drop should visibly fall from the tip into the mouth, and the handle should rotate as the stopcock slider moves.

    • The stand pole is directly behind the burette and shows through the flask. Put the pole to one side (e.g. x≈40) with a clamp arm reaching across to the burette, like a real ring stand.

    • Graduations are hard to read. Label every 10 mL (0, 10, 20, 30, 40, 50) with minor ticks every 1 or 2 mL, and keep the reading tag from covering the labels.

    2. Buffer builder is wrong when strong acid or base is in excess
    • Set 100 mL of 0.10 M acetic / 0.10 M acetate, then add 20 mL of 1.00 M HCl. The app shows: "pH (Henderson–Hasselbalch): n/a — strong base in excess". It is strong ACID in excess.

    • "ΔpH · 1 mL of 1 M NaOH = +2.46". The correct value is +0.05 (pH 1.000 → 1.05).

    Cause: in renderBuffer(), spAfter rebuilds the species without r.nClLeft (it passes strongAcid = 0) and without r.nNaLeft. Build spAfter from the same totals as bufferSpecies() plus the extra 0.001 mol NaOH (and the extra 1 mL of volume). The n/a message must say "strong acid in excess" or "strong base in excess", whichever is true.

    3. Indicators with two color changes only have their first range
    • SOURCES.md (Figure 14.19 table) lists second transitions: cresol red, yellow → red at 7.0–8.8

    • thymol blue, yellow → blue at 8.0–9.2

    • alizarin, red → purple/blue at 11.0–12.4

    The app ignores them. With thymol blue at pH 12 the flask is yellow (it should be blue), and the dropdown reads "Thymol blue (1.2–2.8)".

    • Fix: add the second transition (range and colors) in scripts/make_data.py, then regenerate data/embed/indicators.json. Use it everywhere the indicator appears: indicatorColor() (three-color interpolation)

    • the curve's shaded indicator band (draw both bands)

    • the dropdown label, e.g. "Thymol blue (1.2–2.8, 8.0–9.2)"

    • the notebook "Color" column

    • the sources indicator table

    4. Changing "Drop size" wipes the titration

    After 5 drops (0.50 mL), changing Drop size to 0.05 resets to 0.00 mL ("Fresh flask"). Drop size is not chemistry, so it must NOT call resetBench(). Only analyte, titrant, concentration and volume changes should reset.

    5. Event-listener leak on the charts (hover gets slower the longer the page is used)

    attachChartTooltip() runs on every redraw of #chart-curve, #chart-d1 and #chart-d2. Each call adds mousemove, mouseleave, touchstart, touchmove and touchend listeners, but clearSvg() only removes children. Measured after one auto-titration: #chart-curve had 274 mousemove listeners, growing by one per drop with no limit.

    Fix: attach the listeners ONCE per svg. Store the latest getTip function and tooltip nodes on the svg (e.g. svg._getTip), have the single permanent listener use them, and re-create the tooltip group at the end of each draw.

    6. The unknown challenge cannot really be solved, and it is tedious
    • The half-equivalence pH, which identifies the acid, cannot be read. The mini curve has no hover or touch readout and a 0–14 axis. Formic (half-eq pH 3.75), nitrous (3.35) and HF (3.21) look the same. Fix: give #ch-curve the same crosshair tooltip as the main chart (V and pH of the nearest recorded point). Also add a small "readings" list, or log challenge drops to the Lab notebook as a "Challenge" run.

    • Students have no pKa list to match against. Add a compact candidate list with pKa values to the "Your answer" card: acetic 4.74, formic 3.74, nitrous 3.34, hydrofluoric 3.19, hydrocyanic 9.31. Compute these from the embedded Ka values; don't hard-code them.

    • Reaching equivalence takes about 30 clicks of "Add 10 drops" (320 drops for a 0.114 M sample). Add "Add 1 mL" and "Add 5 mL" buttons. The drop count stays volume / 0.10 mL.

    • The score is not explained. Show a breakdown after submitting: identity x/40, concentration x/40 with the % error, endpoint x/10, efficiency x/10. Explain the rules under the answer card BEFORE the user starts. Going a few mL past equivalence to see the whole jump should not lose the endpoint points (allow up to 125 % of V_eq).

    • A blank answer scores 10/100. Submitting with no acid and no concentration gave 10/100 from the efficiency bonus. Require both fields (inline message if missing), and give the efficiency bonus only if the user actually reached the equivalence point. Before a sample is drawn, the answer card should look disabled and show a hint.

    7. Numeric inputs accept nonsense
    • Analyte concentration −1 → "Equivalence vol. −250.00 mL" and pH 10.60.

    • Concentration 0 → silently uses 0.100 M (the Analyte fact pill says 0.100 M) while the field still shows 0.

    Fix: clamp or validate every numeric input (bench, buffer, challenge) to its min/max. Show a short inline message under a field with an invalid value, and never compute with a value different from what the field shows.

    8. Buffer capacity chart: the interesting part is a tiny bump

    The y-axis goes to 1.2 because [H⁺] + [OH⁻] explode at pH 0 and 14, so the buffer peak at pKa (β ≈ 0.115) is a barely visible bump. That makes the caption "The peak sits at pH = pKa" hard to believe.

    • Fix: Limit the y-axis to about 2.5× the buffer peak (clip the curve to the plot area), or plot pH 2–12 only.

    • Shade the pKa ± 1 band and label the axes ("pH", "β").

    • Add a hover readout.

    • Give the card enough height that it doesn't leave a large empty area under the chart.

    9. Derivative charts are small and letterboxed

    On desktop the first- and second-derivative plots fill only about half their card width, and tick labels render at about 7–8 px. Make these SVGs fill the card width (a viewBox close to the card's aspect ratio, e.g. 600×260) with on-screen tick labels of at least 11 px. Do the same for the challenge mini charts.

    10. Layout polish of the first screen and the bench card
    • The bench barely shows above the fold. At 1440×900 the intro card takes the top ~430 px. Make the intro compact: a one-line lede, the 4 how-to steps as a single row of small numbered items, and smaller stat tiles.

    • The bench card has a large empty area with the pH readout floating far right. Put the big pH readout and the fact pills directly beside the flask, vertically centred on it. Put the stopcock controls (Add drop, Auto-titrate, Close) on one row.

    • Selects are truncated ("Sodium hydroxide (NaOH) — st…"). Shorten the option labels (e.g. "NaOH — strong base") or widen the Setup column.

    11. Small polish
    • Messages from other tabs stay on the bench status line (e.g. "Exported 933 reading(s) to titration-notebook.csv."). Show notebook messages inside the notebook panel instead.

    • Chart labels collide with the data. "Phenolphthalein 8.0–10.0" and "buffer region (pKa … ± 1)" are drawn underneath the curve points, and "zero 25.0 mL" in the 2nd-derivative chart sits on the spike. Draw labels last, on a small rounded white background, offset from lines and points.

    12 critic 3.4 min · 5 files changed, 264 insertions(+), 239 deletions(-) Show prompt

    Prompt given to the harness:

    Read FEEDBACK.md in this directory — it lists the few remaining problems a reviewer found when testing index.html in a real browser. Fix every item, keeping everything that already works (the pH solver, endpoint detection, chart axes, challenge and notebook are correct — do not change them), then run python3 build.py --check.
    

    FEEDBACK.md the harness received ✎ written by the AI reviewer:

    Reviewer feedback (critic pass 2) — fix every item, then run python3 build.py --check

    • Round 11 fixed nearly everything I reported. Re-tested and confirmed: Every pH value still matches an independent solver.

    • The burette liquid now lines up with its scale.

    • Buffer ΔpH is correct.

    • Two-range indicators work.

    • The listener leak is gone.

    • Inputs are clamped.

    • The challenge is solvable and scored with a breakdown.

    Keep all of that. Do not change the chemistry, the chart axes, the challenge scoring or the notebook. Only these remaining items:

    1. The burette tip and stopcock are still INSIDE the flask (bench #stand-svg and challenge #ch-flask-svg)
    • The flask geometry was never moved: the flask outline path and flask-clip start at the mouth, y=250, and the neck runs to y=320

    • the burette tube spans y 40–310

    • the stopcock is at translate(130,322)

    • the tip path spans y 334–352

    • the drop starts at cy=356

    So the lower 6 mL of the burette scale (the 40–50 labels), the stopcock with its yellow handle, and the tip are all drawn inside the flask neck and body. When the flask fills, the stopcock is hidden behind the liquid.

    • Fix (both SVGs): Increase the viewBox height from 470 to about 590.

    • Wrap every flask element in <g transform="translate(0,120)">: the outline path, the mouth line, the clipPath group with flask-liquid and flask-surface, and the white highlight path. The mouth then sits at y≈370, clearly below the tip at y=352. Keep the JS coordinates for the liquid (350/442) unchanged, because they are inside the translated group.

    • Move the stand base rect down to y≈572 and extend the pole to reach it.

    • Make the drop animation fall from the tip (cy 356) to the liquid surface inside the translated flask (≈ y 470 in outer coordinates), then ripple.

    • Check the result: the stopcock, its handle and the 40–50 mL labels must be fully visible above the flask mouth, with a visible gap between the tip and the mouth.

    • The stopcock handle should visibly rotate (e.g. 0° closed → 90° fully open) while the slider is open, and return to 0° on Close.

    2. Bench card: large empty area, controls wrap, readout values wrap
    • At 1440×900 the middle "2 · Bench" card has a big blank region at the top right. The pH readout and fact pills sit low in the right half, and "Close" wraps onto its own line under "Add drop / Auto-titrate" because .burette-controls is limited to 300 px under the stand. In .bench-stage, vertically centre the right column (.flask-wrap { align-self: center; }). Put the big pH readout at the top of that column, with the fact pills under it.

    • Move .burette-controls (stopcock slider, flow state, the three buttons, tip) to a full-width row under both columns (grid-column: 1 / -1, no 300 px max-width), so Add drop, Auto-titrate and Close sit on ONE row on desktop.

    • The "3 · Readouts" column (260 px) wraps values: "65.00 mL (1 refill)", "25.00 / 50.00 mL" and the labels "Equivalence vol." and "Endpoint (2nd deriv.)" all break awkwardly. Widen it to 300 px (shrink the middle column), and give .kv__row dd white-space: nowrap.

    3. The bench status line shows stale numbers

    On the phone, after 8 drops (0.80 mL, pH 3.33) the status line under the bench still said "Titrant added: 0.00 mL — pH –". flashStatus() writes a snapshot after 4 s, and later drops or stopcock flow never refresh it. Update the status text in addVolume() on every drop, unless a flash message is currently showing. In that case, write the live values when the flash timer ends.

    4. Phone titration chart: labels collide

    At 390 px the "buffer region (pKa 4.74 ± 1)" label overlaps the first data points and the indicator band label. On narrow screens, put the buffer-region label at the right end of its band (text-anchor end, x = plot right − 6), and keep the indicator label at the left.

    13 critic 0.5 min · 4 files changed, 37 insertions(+), 63 deletions(-) Show prompt

    Prompt given to the harness:

    Read FEEDBACK.md in this directory — it lists three small problems a reviewer found when testing index.html in a real browser. Fix exactly those three items and change nothing else, then run python3 build.py --check.
    

    FEEDBACK.md the harness received ✎ written by the AI reviewer:

    Reviewer feedback (critic pass 3) — three small fixes, then run python3 build.py --check

    • Round 12 looks good: The burette, stopcock and tip are now above the flask.

    • The stand is to the side.

    • The controls sit on one row.

    • Every pH value still matches an independent solver.

    Change NOTHING else. Only fix these three items:

    1. JavaScript error on every challenge drop (regression from round 12)

    challengeRipple() in src/app.js uses start inside step(), but the line const start = performance.now(); is missing. The bench version rippleFlask() has it. Every "Add drop / Add 10 drops / Add 1 mL / Add 5 mL" click in the Unknown challenge logs Uncaught ReferenceError: start is not defined. Fix: add const start = performance.now(); right before const step = ... in challengeRipple().

    2. Phone (390 px): stopcock label is cut off and the page scrolls sideways by 1 px

    On the Titration bench tab at 390×844, the label "STOPCOCK (FLOW RATE: CLOSED → DROPWISE → OPEN)" is clipped at "→ OPE", and document.documentElement.scrollWidth is 1 px wider than the window. Cause: .burette-controls .slider-label has flex: 0 0 auto in a wrapping flex row, so the long label can't shrink or wrap. Fix: in the phone media query, give .burette-controls .slider-label flex: 1 1 100%; white-space: normal; and give the range input flex: 1 1 100%. On desktop, allow the label to shrink too (min-width: 0). Also add min-width: 0 to .bench-stage > *, so no child can force horizontal overflow.

    3. Desktop: the Analyte fact pill wraps

    In the Bench card at 1440 px, "Analyte CH₃CO₂H · 0.100 M · 25.00 mL" wraps to a second line ("mL" on its own). Keep the value on one line: .fact__val { white-space: nowrap; } with a slightly smaller font (e.g. .8rem) for the fact pills.

    15 critic 14.6 min · 5 files changed, 237 insertions(+), 92 deletions(-) Show prompt

    Prompt given to the harness:

    Read FEEDBACK.md in this directory — it lists problems a reviewer found when testing index.html in a real browser, with exact repro steps and expected values. Fix every item, keeping everything that already works (solver, solver-check table, endpoint detection, challenge, notebook, CSV, sources credit). Then run python3 build.py --check and python3 scripts/check_solver.py.
    

    FEEDBACK.md the harness received ✎ written by the AI reviewer:

    Reviewer feedback (critic re-review, 2026-09-13)

    I tested index.html in Chromium at 1440×900 and 390×844. The solver is accurate: I matched 12 pH values against an independent charge-balance solver to 0.001, and the solver-check table matches. Fix the items below in priority order. Keep everything that works. That means the 13-row solver-check table (Δ 0.000), phosphoric auto-titration endpoints 25.00 / 50.00 mL, 0 console errors, 0 external requests and no horizontal overflow at 390 px. Do not add any OpenStax table, figure or worked-example data. The only OpenStax data allowed are the Ka/Kb/Kw values and indicator ranges already in data/embed/.

    Correctness
    1. Notebook and CSV record the wrong indicator color for two-transition indicators. In logReading() (src/app.js), indColor looks only at ph_low/ph_high. Use the existing indicatorColorName(ind, ph) instead. Repro: 25.00 mL 0.100 M HCl, 0.100 M NaOH, Thymol blue, drop size 1.00, add 30 drops. The flask is blue at pH 12.0, but the notebook row and the CSV indicator_color both say yellow. Expected: blue. Cresol red and alizarin have the same problem.

    2. Buffer capacity β formula is wrong. Both the "Buffer capacity β" readout (renderBuffer) and the chart (drawBufferCapacity) use 2.303·CaCb/(Ca+Cb) + [H+] + [OH-]. The factor 2.303 must multiply every term. Use the exact Van Slyke expression at the solved pH: β = 2.303 · ( [H+] + Kw/[H+] + C_T·Ka·[H+] / (Ka + [H+])² ), where C_T is the total acid-family concentration. Check: at pH 1.00 the water term alone is 2.303 × 0.100 = 0.230. Check values are in item 3, which also includes the added volume.

    3. Buffer builder ignores the volume of added NaOH/HCl. The total volume stays at the "Total volume" field. Use V_final = buffer volume + mL NaOH + mL HCl for the exact pH, β and the "+1 mL of 1 M NaOH" test (that test uses V_final + 1 mL). Relabel the field "Buffer volume before additions (mL)" and show "Final volume: … mL" in the Result card. Expected values with correct volume and β:

      • Acetic/acetate 0.10/0.10 M, 100 mL, + 20 mL 1.00 M HCl → pH 1.079 (the app now shows 1.000)

      • Acetic/acetate 0.10/0.10 M, 100 mL, + 5 mL 1.00 M NaOH → pH 5.222, β 8.23e-2

      • Hydrofluoric/fluoride 0.20/0.05 M, 250 mL, + 5 mL 1.00 M NaOH → pH 2.798, H–H 2.784, β 1.19e-1, ΔpH for +1 mL of 1 M NaOH +0.03

      • Acetic/acetate 0.10/0.10 M, 100 mL, no additions → pH 4.745, β 1.15e-1 (unchanged)

    4. Wrong conjugate-acid formulas for weak bases. The half-equivalence card builds formula + "H⁺" and prints "pKa of NH₃H⁺" and "pKa of (CH₃)₂NHH⁺". Use proper formulas from a small table: NH₄⁺, CH₃NH₃⁺, (CH₃)₂NH₂⁺, (CH₃)₃NH⁺, C₆H₅NH₃⁺. Print the numeric pKa = 14.00 − pKb, e.g. "pH 10.75 ≈ pKa of (CH₃)₂NH₂⁺ (10.77)". Expected pKa values: NH₄⁺ 9.26, CH₃NH₃⁺ 10.64, (CH₃)₂NH₂⁺ 10.77, (CH₃)₃NH⁺ 9.80, C₆H₅NH₃⁺ 4.63. Use "≈", not "=", for weak acids too: the half-equivalence pH only approximates pKa.

    Interaction
    1. The bench status line re-flashes on every drop after the endpoint. In detectEndpoints() the condition bench.v - bench.endpointVol > 0.01 is always true once past the endpoint, so each drop calls flashStatus("Endpoint detected …") again. The live "Titrant added: … mL — pH …" line never shows while you keep titrating, and the aria-live region repeats itself. Flash only when the endpoint is first found, or when its detected volume moves by more than 0.05 mL.

    2. The clamp message is never visible on the bench. Typing 10 into analyte Concentration silently becomes 5. clampNum shows the message, but the change handler then runs resetBench() → currentConfig() → clampNum() on the already-clamped value, and that call hides the message at once. Keep the message until the user edits that field again.

    Visual polish
    1. "pH" is displayed as "PH". CSS text-transform: uppercase turns pH into PH in several places:

      • the big bench readout ("PH 2.88") and the challenge readout

      • the notebook column header

      • the Data & sources table headers ("REFERENCE PH", "APP SOLVER PH", "PH RANGE")

      • the field labels Chemistry notation must always read pH. Remove the uppercase transform from those elements, or wrap "pH" in a span with text-transform: none.

    2. The setup form is misaligned at 1440 px. The "Concentration (M)" label wraps to two lines, so its input sits 19 px lower than the Volume input beside it. Keep both labels on one line, or align the inputs to the bottom of the row.

    3. Bench card details:

      • (a) The burette's major-tick labels (0, 10 … 50) are drawn over the tube's right edge; move them clear of the glass.

      • (b) The Analyte fact tile overflows with long formulas: "(CH₃)₂NH · 0.050 M · 50.00 mL" pokes past the tile border. Let the value wrap or shrink it.

      • (c) The bench card has a large empty area above the pH readout on desktop. Place the readout and fact tiles higher (vertically centered against the stand) so the card looks balanced.

    4. Buffer capacity chart text is too small on desktop. Tick labels render at about 8 px (a 600-unit viewBox in a 458 px card). Size the viewBox to the rendered width or raise the font sizes so tick labels and the "pKa" label are at least 11 px on screen.

    5. Phone (390 px):

      • (a) The intro "how to use" list wraps badly: the bold labels break ("Set / up", "Go / further") and the descriptions form a ragged second column. Stack each label above its description, or keep the label on one line.

      • (b) The derivative charts draw the "Keep titrating — the derivative peaks at the endpoint" placeholder on top of the curve once data exist. Show it only when there is no curve yet, or place it where it cannot overlap the data.

      • (c) On the main curve, the "buffer region (pKa … ± 1)" label sits on the equivalence line and the current-point marker. Move it clear of both.

    6. The analyte and titrant dropdowns show only formulas ("H₂C₂O₄ — weak acid"), so first-time students cannot tell which acid is which. Use "Oxalic acid (H₂C₂O₄)" style labels, and make sure they are not truncated at 1440 px or 390 px.

    When done, run python3 build.py --check, and also python3 scripts/check_solver.py (it must still print FAILURES: 0).

    16 critic 2.2 min · 5 files changed, 146 insertions(+), 151 deletions(-) Show prompt

    Prompt given to the harness:

    Read FEEDBACK.md in this directory — it lists six remaining layout problems (some are regressions from the last round) that a reviewer found in a real browser. Fix every item with small, targeted CSS/markup/JS changes; do not change the chemistry, solver, solver-check table or sources text, and keep every earlier fix. Then run python3 build.py --check and python3 scripts/check_solver.py.
    

    FEEDBACK.md the harness received ✎ written by the AI reviewer:

    Reviewer feedback: follow-up after round 15 (2026-09-13)

    • Round 15 fixed the chemistry and most of the layout. I checked it in a browser: notebook/CSV colors are right

    • β and final volume match my independent values

    • NH₄⁺ and (CH₃)₂NH₂⁺ are shown, with their pKa

    • the status line is right and the clamp message stays visible

    • no "PH" remains, and the inputs are aligned

    Do not change the chemistry, the solver, the solver-check table or the Data & sources text. Only the layout items below are left. Several are new regressions from round 15. Keep every fix from round 15.

    1. Dropdown text is truncated at 1440 px (regression). The selected option now reads "Acetic acid (CH₃CO₂H) — weak aci", "Sodium hydroxide (NaOH) — stron" and "Hydrochloric acid (HCl) — strong a". Drop the " — weak acid / strong base" suffix from the option text and keep "Acetic acid (CH₃CO₂H)". Show strong/weak next to the Analyte/Titrant facts or in the constants table instead. The longest option, "Aniline (phenylamine) (C₆H₅NH₂)", must be fully visible in the select at 1440 px and at 390 px. Use a smaller font in the select if needed.

    2. Buffer recipe card overflows (regression). The "Acid concentration (M)" / "Conjugate-base concentration (M)" row no longer fits: the label shows "CONJUGATE-BASE C…" and the right input extends past the card's right border at 1440 px. Give each field min-width: 0 so the inputs shrink within the card, and let labels wrap, or stack the two fields vertically.

    3. Burette labels still sit on the glass. With text-anchor: end at x=146, the labels "0 … 50" are drawn over the tube, whose right edge is x=142. They look struck through at both 1440 px and 390 px. Put them fully outside the tube: text-anchor: start at x ≥ 146. Move the live reading tag (#burette-tag, now at x=148) further right, e.g. x=162, so it does not collide with the labels. Check the challenge burette (#ch-burette-ticks) too.

    4. Bench card balance on desktop. (a) The bench card still has a large empty area between the "2 · Bench" heading and the pH readout: the readout starts about 250 px below the heading, level with the lower half of the burette. Align the readout column to the top of the stand (align-self: start with a small top margin) so the pH value sits beside the top of the burette. (b) The Analyte fact tile now wraps "mL" onto its own line ("25.00 / mL"). Put the label on one line and the value on a second line (stacked) in all three fact tiles, so no value ever splits.

    5. Phone intro "how to use" list (390 px). Each step now shows the number badge alone on a line, then the label, then a description that starts with a stray "—". Show the badge and the bold label on the same line, with the description below without the leading dash. Keep the desktop layout (one line per step) unchanged.

    6. Phone main curve (390 px). The "buffer region (pKa 4.74 ± 1)" label still crosses the red equivalence line at 25 mL, because the label is right-aligned and wider than the space right of the line. On narrow screens, place it at the left end of the band (below the indicator label), or shorten it to "buffer pKa ± 1". It must not overlap the equivalence line, the indicator label or the data.

    Then run python3 build.py --check and python3 scripts/check_solver.py (it must still print FAILURES: 0).

    17 critic 1.0 min · 3 files changed, 60 insertions(+), 32 deletions(-) Show prompt

    Prompt given to the harness:

    Read FEEDBACK.md in this directory — it lists two small layout problems a reviewer found in a real browser (one is a regression from the last round). Fix both with small, targeted CSS/markup/JS changes, change nothing else, and keep every earlier fix. Then run python3 build.py --check and python3 scripts/check_solver.py.
    

    FEEDBACK.md the harness received ✎ written by the AI reviewer:

    Reviewer feedback: two small layout fixes after round 16 (2026-09-13)

    Round 16 fixed the six layout items (I checked them in a browser). Two layout problems remain, one of them a regression. Change only what these two items need. Do not touch the chemistry, solver, readouts, notebook, challenge, buffer builder or sources text, and keep every earlier fix: select labels, burette labels, stacked fact tiles, bench readout position, buffer card fields, phone how-to layout.

    1. Desktop intro "how to use" list (regression from round 16). At 1440 px, steps 1 and 2 now share one line: "① Set up analyte, concentration, titrant, indicator. ② Titrate add drops (or drag the stopcock) and watch the flask and curve." Steps 3 and 4 are each on their own line. The list items flow inline. On desktop (wider than 720 px), each of the four steps must be on its own line: badge, bold label, then the description on the same line, with a clear gap (about 8 px) between the bold label and the description. Keep the phone layout from round 16 (badge + label on one line, description below) exactly as it is.

    2. Phone main curve label (390 px). "buffer pKa 4.74 ± 1" is drawn inside the plot at the left end of the buffer band. With acetic acid the teal data curve runs straight through the text at pH ≈ 4.5. On narrow screens (below 720 px), do not draw the buffer-region label or the indicator-band label inside the plot. Add them to the legend above the chart instead: one legend entry with a small violet swatch for "buffer region (pKa 4.74 ± 1)", and one with a pink gradient swatch for "Phenolphthalein 8.0–10.0" (use the current indicator). The shaded bands stay in the plot. Desktop keeps its in-plot labels unchanged.

    Then run python3 build.py --check and python3 scripts/check_solver.py (it must still print FAILURES: 0).

What the agent was asked

The full specification the harness received, rendered from the original Markdown.

Read the specification

Virtual Titration Lab — Acid–base titration and buffer simulator

Category: Education Who it is for: General chemistry students (CHEM 111/112), lab instructors, high-school outreach

What it should do

Run acid–base titrations as many times as you want: choose the analyte and titrant, add drops from an animated burette, watch the indicator change, and compare your curve with the exact equilibrium calculation.

Data

No external data; use textbook Ka/Kb values for common acids and bases (acetic, formic, carbonic, phosphoric (polyprotic), ammonia, HCl, NaOH) with a small cited constants table.

  • Data decision by the project owner (2026-09-13): OpenStax material is limited to physical constants. OpenStax Chemistry 2e is licensed CC BY-NC-SA 4.0. Every page says the book "may not be used in the training of large language models or otherwise be ingested into large language models or generative AI offerings without OpenStax's permission." So the app keeps only physical-constant facts transcribed from it: the Ka, Kb and Kw values

  • the indicator color-change pH ranges, with each indicator's acid and base colors

  • These are credited to OpenStax in the Data & sources panel with the required CC BY-NC-SA 4.0 attribution: the title (Chemistry 2e) and authors (Flowers, Theopold, Langley & Robinson, 2019)

  • a link to the book: https://openstax.org/books/chemistry-2e/pages/1-introduction

  • a link to the license: https://creativecommons.org/licenses/by-nc-sa/4.0/

  • a note that the values were transcribed and that no text, tables, figures or images from the book are reproduced

Everything else derived from OpenStax content is removed from the app, data/embed/ and scripts/. That includes the Table 14.2 worked-example titration data and any validation or comparison panel that reproduces it. The solver is validated instead against an independent, hand-calculable case: 25.00 mL of 0.100 M HCl titrated with 0.100 M NaOH. The reviewer calculated those reference pH values (exact charge balance), and the app labels them that way. The app uses no OpenStax logo, cover or branding.

Raw downloads (if any) are in data/raw/ with data/raw/SOURCES.md describing where each file came from. Transform what the app needs into compact JSON in data/embed/ (write a small script in scripts/ to do it, so the transformation is reproducible). Every dataset the app shows must be credited in a visible "Data & sources" section of the app, with links. If a dataset is illustrative/synthetic, label it clearly as such in the UI — never present made-up numbers as real University of Idaho figures.

Required features

  • Animated lab bench: burette with stopcock slider and drop button, flask whose color follows the chosen indicator's transition range

  • Exact pH computation (charge-balance solver, including polyprotic acids) and a live titration curve that grows point by point

  • Equivalence and half-equivalence detection with first- and second-derivative plots

  • Buffer builder: pick a weak acid/conjugate base, set concentrations, see buffer capacity and Henderson–Hasselbalch comparison

  • Unknown-sample challenge mode: identify the acid and its concentration from your own titration, with scoring

  • Lab-notebook table of readings with CSV export

Quality bar

This app is going into a University of Idaho gallery that shows faculty, staff and students what they can build with AI coding agents. It must look polished and modern (think a well-designed data product, not a homework demo), feel alive (smooth transitions, responsive hover/selection states, animated where it helps understanding), and do real, useful work — every control should change something meaningful. It must work well on a laptop and on a phone. Include a short "How to use" hint for first-time visitors and a helpful empty state wherever the user must supply data.

Known limitations

Reported by the independent critic. ✎ written by the AI critic

  • Carbonic acid's weak second equivalence (50 mL) is marked from stoichiometry but is not reported by the 2nd-derivative endpoint detector.
  • Desktop: for two-transition indicators such as thymol blue, the in-plot band label (e.g. 'Thymol blue 1.2-2.8') can sit on the data curve when the analyte starts near pH 2.
  • Stopcock flow at the mid setting is slow (0.14 mL/s); auto-titration takes about 15 s for a monoprotic acid and 29 s for phosphoric acid, and auto runs write many notebook rows (grouped and collapsible).
  • The challenge 'Submit answer' button is enabled before any drops are added (blank answers are blocked with an inline message).
  • Phone: the tab strip scrolls horizontally with no visual cue that more tabs are off-screen.
  • Judgement call kept: the Data & sources hint describes the methyl orange alt text on the OpenStax page as a 'known transcription error' (commentary about the source, not book content).
  • For a human: git history before round 14 still contains the removed Table 14.2 data, and the original OpenStax downloads remain in runs/titration-lab/provenance/ outside the project.