@jjlmoya/utils-aquarium 1.64.0 → 1.66.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -1
  4. package/src/layouts/ProductionCategoryPage.astro +184 -184
  5. package/src/layouts/ProductionUtilityPage.astro +4 -4
  6. package/src/mfe/category-ui.ts +34 -34
  7. package/src/mfe/widget-height.ts +14 -0
  8. package/src/tests/qa-test-helpers.ts +32 -0
  9. package/src/tests/qa_bibliography_links.test.ts +48 -0
  10. package/src/tests/qa_claim_evidence.test.ts +69 -0
  11. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  12. package/src/tests/qa_runtime_i18n.test.ts +138 -0
  13. package/src/tests/tool_validation.test.ts +4 -3
  14. package/src/tool/aquariumSaltDosingCalculator/aquarium-salt-dosing-calculator.css +503 -0
  15. package/src/tool/aquariumSaltDosingCalculator/bibliography.astro +6 -0
  16. package/src/tool/aquariumSaltDosingCalculator/bibliography.ts +5 -0
  17. package/src/tool/aquariumSaltDosingCalculator/component.astro +36 -0
  18. package/src/tool/aquariumSaltDosingCalculator/controller.ts +104 -0
  19. package/src/tool/aquariumSaltDosingCalculator/dom-views.ts +74 -0
  20. package/src/tool/aquariumSaltDosingCalculator/entry.ts +13 -0
  21. package/src/tool/aquariumSaltDosingCalculator/i18n/de.ts +2 -0
  22. package/src/tool/aquariumSaltDosingCalculator/i18n/en.ts +2 -0
  23. package/src/tool/aquariumSaltDosingCalculator/i18n/es.ts +2 -0
  24. package/src/tool/aquariumSaltDosingCalculator/i18n/fr.ts +2 -0
  25. package/src/tool/aquariumSaltDosingCalculator/i18n/id.ts +2 -0
  26. package/src/tool/aquariumSaltDosingCalculator/i18n/it.ts +2 -0
  27. package/src/tool/aquariumSaltDosingCalculator/i18n/ja.ts +2 -0
  28. package/src/tool/aquariumSaltDosingCalculator/i18n/ko.ts +2 -0
  29. package/src/tool/aquariumSaltDosingCalculator/i18n/nl.ts +2 -0
  30. package/src/tool/aquariumSaltDosingCalculator/i18n/pl.ts +2 -0
  31. package/src/tool/aquariumSaltDosingCalculator/i18n/pt.ts +2 -0
  32. package/src/tool/aquariumSaltDosingCalculator/i18n/ru.ts +2 -0
  33. package/src/tool/aquariumSaltDosingCalculator/i18n/sv.ts +2 -0
  34. package/src/tool/aquariumSaltDosingCalculator/i18n/tr.ts +2 -0
  35. package/src/tool/aquariumSaltDosingCalculator/i18n/zh.ts +2 -0
  36. package/src/tool/aquariumSaltDosingCalculator/index.ts +11 -0
  37. package/src/tool/aquariumSaltDosingCalculator/localized-content.ts +151 -0
  38. package/src/tool/aquariumSaltDosingCalculator/logic.test.ts +46 -0
  39. package/src/tool/aquariumSaltDosingCalculator/logic.ts +85 -0
  40. package/src/tool/aquariumSaltDosingCalculator/seo.astro +11 -0
  41. package/src/tool/aquariumSaltDosingCalculator/storage.ts +19 -0
  42. package/src/tool/aquariumSaltDosingCalculator/ui.ts +47 -0
  43. package/src/tools.ts +6 -0
@@ -0,0 +1,503 @@
1
+ :root {
2
+ --assd-ink: #17343b;
3
+ --assd-muted: #5f777b;
4
+ --assd-paper: #f3f8f5;
5
+ --assd-surface: #fff;
6
+ --assd-line: #c8dbd5;
7
+ --assd-water: #23869b;
8
+ --assd-water-soft: #d7eff0;
9
+ --assd-salt: #fffdf3;
10
+ --assd-coral: #e27657;
11
+ --assd-shadow: #dbe9e3;
12
+ --assd-error: #a83a35;
13
+ }
14
+
15
+ .theme-dark {
16
+ --assd-ink: #edf8f4;
17
+ --assd-muted: #b4cbc7;
18
+ --assd-paper: #142b30;
19
+ --assd-surface: #203c41;
20
+ --assd-line: #4e6c6e;
21
+ --assd-water: #72c8d0;
22
+ --assd-water-soft: #244e57;
23
+ --assd-salt: #eee8cd;
24
+ --assd-coral: #f09a78;
25
+ --assd-shadow: #0e1d20;
26
+ --assd-error: #ffaaa0;
27
+ }
28
+
29
+ .assd-root {
30
+ background:
31
+ radial-gradient(circle at 88% 8%, color-mix(in srgb, var(--assd-water) 12%, transparent), transparent 31%),
32
+ linear-gradient(135deg, var(--assd-paper), color-mix(in srgb, var(--assd-water-soft) 28%, var(--assd-paper)));
33
+ border: 1px solid var(--assd-line);
34
+ border-radius: 1.75rem;
35
+ box-shadow: 0 1.5rem 3rem color-mix(in srgb, var(--assd-shadow) 82%, transparent);
36
+ color: var(--assd-ink);
37
+ display: grid;
38
+ gap: clamp(1.25rem, 3vw, 2.5rem);
39
+ grid-template-columns: minmax(0, 1.04fr) minmax(21rem, .96fr);
40
+ overflow: hidden;
41
+ padding: clamp(1rem, 3vw, 2.4rem);
42
+ }
43
+
44
+ #aquarium-salt-dosing-calculator [hidden] {
45
+ display: none;
46
+ }
47
+
48
+ .assd-result,
49
+ .assd-controls {
50
+ min-width: 0;
51
+ }
52
+
53
+ .assd-result {
54
+ background: color-mix(in srgb, var(--assd-surface) 78%, transparent);
55
+ border: 1px solid var(--assd-line);
56
+ border-radius: 1.35rem;
57
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 20%, transparent);
58
+ min-height: 100%;
59
+ padding: clamp(1.1rem, 2.5vw, 1.75rem);
60
+ }
61
+
62
+ .assd-result-head,
63
+ .assd-unit-row,
64
+ .assd-answer-label {
65
+ align-items: flex-start;
66
+ display: flex;
67
+ gap: .9rem;
68
+ justify-content: space-between;
69
+ }
70
+
71
+ .assd-kicker {
72
+ color: var(--assd-water);
73
+ font-size: .72rem;
74
+ font-weight: 900;
75
+ letter-spacing: .15em;
76
+ line-height: 1.2;
77
+ margin: 0 0 .45rem;
78
+ text-transform: uppercase;
79
+ }
80
+
81
+ .assd-root h2 {
82
+ font-size: clamp(1.25rem, 2.2vw, 1.65rem);
83
+ letter-spacing: -.035em;
84
+ line-height: 1.05;
85
+ margin: 0;
86
+ }
87
+
88
+ .assd-action {
89
+ align-self: flex-start;
90
+ background: var(--assd-water-soft);
91
+ border: 1px solid color-mix(in srgb, var(--assd-water) 30%, transparent);
92
+ border-radius: 99rem;
93
+ color: var(--assd-ink);
94
+ font-size: .76rem;
95
+ font-weight: 900;
96
+ line-height: 1.25;
97
+ max-width: 12rem;
98
+ padding: .5rem .75rem;
99
+ text-align: center;
100
+ }
101
+
102
+ .assd-scene {
103
+ align-items: end;
104
+ background: linear-gradient(145deg, color-mix(in srgb, var(--assd-water-soft) 70%, transparent), color-mix(in srgb, var(--assd-surface) 55%, transparent));
105
+ border: 1px solid color-mix(in srgb, var(--assd-water) 22%, var(--assd-line));
106
+ border-radius: 1rem;
107
+ display: flex;
108
+ height: 13.5rem;
109
+ justify-content: center;
110
+ margin: 1.35rem 0 1rem;
111
+ overflow: hidden;
112
+ position: relative;
113
+ }
114
+
115
+ .assd-scene::before,
116
+ .assd-scene::after {
117
+ border: 1px solid color-mix(in srgb, var(--assd-water) 22%, transparent);
118
+ border-radius: 50%;
119
+ content: '';
120
+ height: 15rem;
121
+ position: absolute;
122
+ right: -6rem;
123
+ top: -9rem;
124
+ width: 15rem;
125
+ }
126
+
127
+ .assd-scene::after {
128
+ height: 7rem;
129
+ left: -3rem;
130
+ right: auto;
131
+ top: auto;
132
+ width: 7rem;
133
+ }
134
+
135
+ .assd-water-vessel {
136
+ background: linear-gradient(180deg, color-mix(in srgb, var(--assd-surface) 60%, transparent), color-mix(in srgb, var(--assd-water-soft) 88%, transparent));
137
+ border: 3px solid var(--assd-ink);
138
+ border-radius: 1rem 1rem 2.3rem 2.3rem;
139
+ bottom: 1.45rem;
140
+ height: 9.6rem;
141
+ overflow: hidden;
142
+ position: absolute;
143
+ width: min(70%, 18rem);
144
+ }
145
+
146
+ .assd-water {
147
+ background: var(--assd-water);
148
+ bottom: 0;
149
+ height: 72%;
150
+ opacity: .72;
151
+ position: absolute;
152
+ width: 100%;
153
+ }
154
+
155
+ .assd-water::before,
156
+ .assd-water::after {
157
+ border: 2px solid color-mix(in srgb, white 75%, transparent);
158
+ border-color: color-mix(in srgb, white 75%, transparent) transparent transparent;
159
+ border-radius: 50%;
160
+ content: '';
161
+ height: 3rem;
162
+ left: 12%;
163
+ position: absolute;
164
+ top: .7rem;
165
+ transform: rotate(-8deg);
166
+ width: 75%;
167
+ }
168
+
169
+ .assd-water::after {
170
+ left: 18%;
171
+ top: 1.25rem;
172
+ transform: rotate(7deg);
173
+ }
174
+
175
+ .assd-salt-cloud {
176
+ background: var(--assd-salt);
177
+ border-radius: 45% 55% 50% 48%;
178
+ bottom: calc(var(--salt-fill, 20%) * .55);
179
+ box-shadow: 0 0 .4rem color-mix(in srgb, var(--assd-salt) 80%, transparent);
180
+ height: 2.2rem;
181
+ left: 40%;
182
+ opacity: .95;
183
+ position: absolute;
184
+ transform: rotate(-10deg);
185
+ width: 3.2rem;
186
+ }
187
+
188
+ .assd-scoop {
189
+ background: var(--assd-salt);
190
+ border: 2px solid var(--assd-ink);
191
+ border-radius: 60% 60% 35% 35%;
192
+ height: 2.8rem;
193
+ left: 56%;
194
+ position: absolute;
195
+ top: .65rem;
196
+ transform: rotate(25deg);
197
+ width: 4rem;
198
+ }
199
+
200
+ .assd-scoop::after {
201
+ background: var(--assd-water);
202
+ border: 2px solid var(--assd-ink);
203
+ border-radius: 99rem;
204
+ content: '';
205
+ height: .65rem;
206
+ position: absolute;
207
+ right: -3.4rem;
208
+ top: .9rem;
209
+ transform: rotate(18deg);
210
+ width: 3.6rem;
211
+ }
212
+
213
+ .assd-scoop span,
214
+ .assd-scoop span::before,
215
+ .assd-scoop span::after {
216
+ background: var(--assd-ink);
217
+ border-radius: 50%;
218
+ content: '';
219
+ height: .25rem;
220
+ position: absolute;
221
+ width: .25rem;
222
+ }
223
+
224
+ .assd-scoop span {
225
+ left: 1rem;
226
+ top: .8rem;
227
+ }
228
+
229
+ .assd-scoop span::before {
230
+ left: .7rem;
231
+ top: .4rem;
232
+ }
233
+
234
+ .assd-scoop span::after {
235
+ left: 1.5rem;
236
+ top: .15rem;
237
+ }
238
+
239
+ .assd-refractometer {
240
+ background: linear-gradient(90deg, var(--assd-ink), var(--assd-water));
241
+ border: 2px solid var(--assd-ink);
242
+ border-radius: .4rem 1rem 1rem .4rem;
243
+ bottom: .3rem;
244
+ height: 1.35rem;
245
+ position: absolute;
246
+ right: 6%;
247
+ transform: rotate(-18deg);
248
+ width: 7rem;
249
+ }
250
+
251
+ .assd-refractometer::before {
252
+ background: var(--assd-surface);
253
+ border: 2px solid var(--assd-ink);
254
+ border-radius: .3rem;
255
+ content: '';
256
+ height: 1.05rem;
257
+ left: .5rem;
258
+ position: absolute;
259
+ top: .05rem;
260
+ width: 2.1rem;
261
+ }
262
+
263
+ .assd-answer {
264
+ border-top: 1px solid var(--assd-line);
265
+ padding-top: 1rem;
266
+ }
267
+
268
+ .assd-answer-label {
269
+ align-items: center;
270
+ justify-content: flex-start;
271
+ }
272
+
273
+ .assd-answer-label > span:first-child,
274
+ .assd-unit-row > span,
275
+ .assd-presets > span {
276
+ color: var(--assd-muted);
277
+ font-size: .78rem;
278
+ font-weight: 800;
279
+ }
280
+
281
+ .assd-answer-rule {
282
+ background: color-mix(in srgb, var(--assd-water) 45%, var(--assd-line));
283
+ height: 1px;
284
+ width: 2.5rem;
285
+ }
286
+
287
+ .assd-answer strong {
288
+ display: block;
289
+ font-size: clamp(2.6rem, 6vw, 4.3rem);
290
+ letter-spacing: -.075em;
291
+ line-height: .95;
292
+ margin-top: .35rem;
293
+ overflow-wrap: anywhere;
294
+ }
295
+
296
+ .assd-answer p,
297
+ .assd-help,
298
+ .assd-note,
299
+ .assd-error {
300
+ color: var(--assd-muted);
301
+ font-size: .86rem;
302
+ line-height: 1.5;
303
+ margin: .65rem 0 0;
304
+ }
305
+
306
+ .assd-note {
307
+ background: color-mix(in srgb, var(--assd-water-soft) 72%, transparent);
308
+ border-left: 3px solid var(--assd-water);
309
+ border-radius: 0 .5rem .5rem 0;
310
+ padding: .7rem .8rem;
311
+ }
312
+
313
+ .assd-controls {
314
+ border-left: 1px solid var(--assd-line);
315
+ padding: .35rem 0 .35rem clamp(0rem, 1vw, .4rem);
316
+ }
317
+
318
+ .assd-controls-head {
319
+ display: grid;
320
+ gap: 1rem;
321
+ }
322
+
323
+ .assd-toggle,
324
+ .assd-preset-buttons {
325
+ display: flex;
326
+ flex-wrap: wrap;
327
+ gap: .45rem;
328
+ }
329
+
330
+ .assd-mode-toggle {
331
+ width: 100%;
332
+ }
333
+
334
+ .assd-mode-toggle button {
335
+ flex: 1 1 10rem;
336
+ }
337
+
338
+ .assd-toggle button,
339
+ .assd-preset-buttons button,
340
+ .assd-reset {
341
+ background: color-mix(in srgb, var(--assd-surface) 68%, transparent);
342
+ border: 1px solid var(--assd-line);
343
+ border-radius: .75rem;
344
+ color: var(--assd-ink);
345
+ cursor: pointer;
346
+ font: inherit;
347
+ min-height: 2.55rem;
348
+ padding: .55rem .8rem;
349
+ transition: background .2s ease, border-color .2s ease, transform .2s ease;
350
+ }
351
+
352
+ .assd-toggle button:hover,
353
+ .assd-preset-buttons button:hover,
354
+ .assd-reset:hover {
355
+ border-color: var(--assd-water);
356
+ transform: translateY(-1px);
357
+ }
358
+
359
+ .assd-toggle button.is-active,
360
+ .assd-preset-buttons button:focus-visible,
361
+ .assd-preset-buttons button:hover {
362
+ background: var(--assd-water-soft);
363
+ border-color: var(--assd-water);
364
+ font-weight: 900;
365
+ }
366
+
367
+ .assd-toggle button:focus-visible,
368
+ .assd-preset-buttons button:focus-visible,
369
+ .assd-reset:focus-visible,
370
+ .assd-field input:focus-visible {
371
+ outline: 3px solid var(--assd-water);
372
+ outline-offset: 2px;
373
+ }
374
+
375
+ .assd-unit-row {
376
+ border-bottom: 1px solid var(--assd-line);
377
+ border-top: 1px solid var(--assd-line);
378
+ margin-top: 1.4rem;
379
+ padding: .8rem 0;
380
+ }
381
+
382
+ .assd-unit-row > span {
383
+ align-self: center;
384
+ }
385
+
386
+ .assd-help {
387
+ background: color-mix(in srgb, var(--assd-water-soft) 42%, transparent);
388
+ border-radius: .65rem;
389
+ margin-top: 1rem;
390
+ padding: .7rem .8rem;
391
+ }
392
+
393
+ .assd-fields {
394
+ display: grid;
395
+ gap: 1rem;
396
+ grid-template-columns: repeat(2, minmax(0, 1fr));
397
+ margin-top: 1.15rem;
398
+ }
399
+
400
+ .assd-field {
401
+ display: grid;
402
+ gap: .4rem;
403
+ }
404
+
405
+ .assd-field > span {
406
+ display: flex;
407
+ font-size: .82rem;
408
+ font-weight: 900;
409
+ gap: .4rem;
410
+ justify-content: space-between;
411
+ line-height: 1.25;
412
+ }
413
+
414
+ .assd-field small {
415
+ color: var(--assd-muted);
416
+ font-size: .75rem;
417
+ font-weight: 700;
418
+ white-space: nowrap;
419
+ }
420
+
421
+ .assd-field input {
422
+ background: color-mix(in srgb, var(--assd-surface) 75%, transparent);
423
+ border: 1px solid var(--assd-line);
424
+ border-radius: .75rem;
425
+ box-sizing: border-box;
426
+ color: var(--assd-ink);
427
+ font: inherit;
428
+ font-size: 1rem;
429
+ font-weight: 700;
430
+ min-height: 3rem;
431
+ padding: .65rem .75rem;
432
+ width: 100%;
433
+ }
434
+
435
+ .assd-presets {
436
+ display: grid;
437
+ gap: .6rem;
438
+ margin-top: 1.15rem;
439
+ }
440
+
441
+ .assd-preset-buttons {
442
+ display: grid;
443
+ grid-template-columns: repeat(3, minmax(0, 1fr));
444
+ }
445
+
446
+ .assd-reset {
447
+ margin-top: 1.15rem;
448
+ }
449
+
450
+ .assd-error {
451
+ color: var(--assd-error);
452
+ display: none;
453
+ font-weight: 800;
454
+ }
455
+
456
+ .assd-root.has-error .assd-error {
457
+ display: block;
458
+ }
459
+
460
+ @media (max-width: 760px) {
461
+ .assd-root {
462
+ grid-template-columns: 1fr;
463
+ }
464
+
465
+ .assd-controls {
466
+ border-left: 0;
467
+ border-top: 1px solid var(--assd-line);
468
+ padding: 1.35rem 0 0;
469
+ }
470
+ }
471
+
472
+ @media (max-width: 520px) {
473
+ .assd-result-head {
474
+ flex-direction: column;
475
+ }
476
+
477
+ .assd-action {
478
+ max-width: none;
479
+ }
480
+
481
+ .assd-fields {
482
+ grid-template-columns: 1fr;
483
+ }
484
+
485
+ .assd-preset-buttons {
486
+ grid-template-columns: 1fr;
487
+ }
488
+
489
+ .assd-scene {
490
+ height: 11.5rem;
491
+ }
492
+
493
+ .assd-water-vessel {
494
+ height: 8.6rem;
495
+ }
496
+ }
497
+
498
+ @media (prefers-reduced-motion: reduce) {
499
+ *, *::before, *::after {
500
+ scroll-behavior: auto;
501
+ transition-duration: .01ms;
502
+ }
503
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,5 @@
1
+ export const bibliography = [
2
+ { name: 'NOAA Ocean Service, What is salinity?', url: 'https://oceanservice.noaa.gov/facts/salinity.html' },
3
+ { name: 'USGS Water Science School, Water density', url: 'https://www.usgs.gov/special-topics/water-science-school/science/water-density' },
4
+ { name: 'FAO, Manual de acuicultura en español', url: 'https://www.fao.org/4/x5685s/x5685s00.htm' },
5
+ ];
@@ -0,0 +1,36 @@
1
+ ---
2
+ import type { AquariumSaltDosingCalculatorUI } from './ui';
3
+ import './aquarium-salt-dosing-calculator.css';
4
+
5
+ const { ui } = Astro.props as { ui: AquariumSaltDosingCalculatorUI };
6
+ ---
7
+
8
+ <div id="aquarium-salt-dosing-calculator" class="assd-root" data-ui={JSON.stringify(ui)} data-mode="prepare">
9
+ <section class="assd-result" aria-labelledby="assd-result-title">
10
+ <div class="assd-result-head"><div><p class="assd-kicker">{ui.toolKicker}</p><h2 id="assd-result-title">{ui.resultTitle}</h2></div><span class="assd-action" data-output="action">{ui.emptyState}</span></div>
11
+ <div class="assd-scene" aria-hidden="true"><div class="assd-water-vessel"><div class="assd-water"></div><div class="assd-salt-cloud"></div></div><div class="assd-scoop"><span></span></div><div class="assd-refractometer"></div></div>
12
+ <div class="assd-answer"><div class="assd-answer-label"><span data-output="amount-label">{ui.amountLabel}</span><span class="assd-answer-rule"></span></div><strong data-output="amount">—</strong><p data-output="summary"></p></div>
13
+ <p class="assd-note">{ui.verificationNote}</p>
14
+ </section>
15
+
16
+ <section class="assd-controls" aria-labelledby="assd-configure-title">
17
+ <div class="assd-controls-head"><div><p class="assd-kicker">{ui.configureTitle}</p><h2 id="assd-configure-title">{ui.modeLabel}</h2></div><div class="assd-toggle assd-mode-toggle" role="group" aria-label={ui.modeLabel}><button type="button" data-mode-choice="prepare" class="is-active" aria-pressed="true">{ui.prepareMode}</button><button type="button" data-mode-choice="correct" aria-pressed="false">{ui.correctMode}</button></div></div>
18
+ <div class="assd-unit-row"><span>{ui.unitsLabel}</span><div class="assd-toggle" role="group" aria-label={ui.unitsLabel}><button type="button" data-unit-choice="metric" class="is-active" aria-pressed="true">{ui.metricLabel}</button><button type="button" data-unit-choice="imperial" aria-pressed="false">{ui.imperialLabel}</button></div></div>
19
+ <p class="assd-help" data-mode-only="prepare">{ui.prepareHint}</p><p class="assd-help" data-mode-only="correct" hidden>{ui.correctHint}</p>
20
+ <div class="assd-fields">
21
+ <label class="assd-field"><span>{ui.volumeLabel} <small data-volume-unit>{ui.metricVolumeUnit}</small></span><input data-field="volume" type="number" min="0.1" max="100000" step="0.1" inputmode="decimal" /></label>
22
+ <label class="assd-field" data-mode-only="correct" hidden><span>{ui.currentSalinityLabel} <small>{ui.salinityUnit}</small></span><input data-field="currentSalinity" type="number" min="0" max="60" step="0.1" inputmode="decimal" /></label>
23
+ <label class="assd-field"><span>{ui.targetSalinityLabel} <small>{ui.salinityUnit}</small></span><input data-field="targetSalinity" type="number" min="0" max="60" step="0.1" inputmode="decimal" /></label>
24
+ <label class="assd-field"><span>{ui.productRateLabel} <small data-rate-unit>{ui.metricRateUnit}</small></span><input data-field="productRate" type="number" min="0.01" max="200" step="0.1" inputmode="decimal" /></label>
25
+ </div>
26
+ <div class="assd-presets"><span>{ui.presetLabel}</span><div class="assd-preset-buttons"><button type="button" data-salinity-preset="10">{ui.brackishPreset}</button><button type="button" data-salinity-preset="35">{ui.marinePreset}</button><button type="button" data-salinity-preset="0">{ui.customPreset}</button></div></div>
27
+ <p class="assd-help">{ui.productRateHint}</p>
28
+ <button class="assd-reset" type="button" data-action="reset">{ui.resetLabel}</button>
29
+ <p class="assd-error" role="alert" data-error>{ui.invalidState}</p>
30
+ </section>
31
+ </div>
32
+
33
+ <script>
34
+ import { initAquariumSaltDosingCalculator } from './controller';
35
+ initAquariumSaltDosingCalculator();
36
+ </script>
@@ -0,0 +1,104 @@
1
+ import {
2
+ calculateSaltDosing,
3
+ gallonsToLitres,
4
+ ouncesPerGallonToGramsPerLitre,
5
+ litresToGallons,
6
+ type SaltDosingInput,
7
+ } from './logic';
8
+ import { defaultSaltDosingState, loadSaltDosingState, saveSaltDosingState, type SavedSaltDosingState } from './storage';
9
+ import { formatRateForDisplay, renderError, renderResults } from './dom-views';
10
+ import type { AquariumSaltDosingCalculatorUI } from './ui';
11
+
12
+ interface Context { root: HTMLElement; ui: AquariumSaltDosingCalculatorUI; state: SavedSaltDosingState; locale: string; }
13
+
14
+ function field(root: HTMLElement, name: string): HTMLInputElement | null { return root.querySelector<HTMLInputElement>('[data-field="' + name + '"]'); }
15
+ function readValue(root: HTMLElement, name: string): number { return Number.parseFloat(field(root, name)?.value ?? ''); }
16
+ function mode(root: HTMLElement): SaltDosingInput['mode'] { return root.dataset.mode === 'correct' ? 'correct' : 'prepare'; }
17
+
18
+ function readInput(context: Context): SaltDosingInput {
19
+ const unit = context.state.unit;
20
+ const rawVolume = readValue(context.root, 'volume');
21
+ const rawRate = readValue(context.root, 'productRate');
22
+ return {
23
+ mode: mode(context.root),
24
+ volumeLitres: unit === 'metric' ? rawVolume : gallonsToLitres(rawVolume),
25
+ currentSalinityPpt: readValue(context.root, 'currentSalinity'),
26
+ targetSalinityPpt: readValue(context.root, 'targetSalinity'),
27
+ productRateGramsPerLitre: unit === 'metric' ? rawRate : ouncesPerGallonToGramsPerLitre(rawRate),
28
+ };
29
+ }
30
+
31
+ function writeValue(context: Context, name: string, value: number): void {
32
+ const input = field(context.root, name);
33
+ if (!input) return;
34
+ if (name === 'volume') input.value = String(context.state.unit === 'metric' ? value : litresToGallons(value).toFixed(2));
35
+ else if (name === 'productRate') input.value = String(formatRateForDisplay(value, context.state.unit));
36
+ else input.value = String(value);
37
+ }
38
+
39
+ function writeState(context: Context): void {
40
+ context.root.dataset.mode = context.state.input.mode;
41
+ context.root.querySelectorAll<HTMLElement>('[data-mode-choice]').forEach((button) => {
42
+ const active = button.dataset.modeChoice === context.state.input.mode;
43
+ button.classList.toggle('is-active', active);
44
+ button.setAttribute('aria-pressed', String(active));
45
+ });
46
+ context.root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => {
47
+ const active = button.dataset.unitChoice === context.state.unit;
48
+ button.classList.toggle('is-active', active);
49
+ button.setAttribute('aria-pressed', String(active));
50
+ });
51
+ context.root.querySelectorAll<HTMLElement>('[data-mode-only="correct"]').forEach((element) => { element.hidden = context.state.input.mode !== 'correct'; });
52
+ context.root.querySelectorAll<HTMLElement>('[data-volume-unit]').forEach((element) => { element.textContent = context.state.unit === 'metric' ? context.ui.metricVolumeUnit : context.ui.imperialVolumeUnit; });
53
+ context.root.querySelectorAll<HTMLElement>('[data-rate-unit]').forEach((element) => { element.textContent = context.state.unit === 'metric' ? context.ui.metricRateUnit : context.ui.imperialRateUnit; });
54
+ writeValue(context, 'volume', context.state.input.volumeLitres);
55
+ writeValue(context, 'currentSalinity', context.state.input.currentSalinityPpt);
56
+ writeValue(context, 'targetSalinity', context.state.input.targetSalinityPpt);
57
+ writeValue(context, 'productRate', context.state.input.productRateGramsPerLitre);
58
+ }
59
+
60
+ function calculate(context: Context): void {
61
+ context.state.input = readInput(context);
62
+ saveSaltDosingState(context.state);
63
+ const result = calculateSaltDosing(context.state.input);
64
+ if (!result.ok) { renderError(context.root, context.ui.invalidState); return; }
65
+ renderResults(context.root, { result: result.value, unit: context.state.unit, locale: context.locale, ui: context.ui });
66
+ }
67
+
68
+ function bind(context: Context): void {
69
+ context.root.querySelectorAll<HTMLElement>('[data-mode-choice]').forEach((button) => button.addEventListener('click', () => {
70
+ const next = button.dataset.modeChoice;
71
+ if (next !== 'prepare' && next !== 'correct') return;
72
+ context.state.input = readInput(context);
73
+ context.state.input.mode = next;
74
+ writeState(context);
75
+ calculate(context);
76
+ }));
77
+ context.root.querySelectorAll<HTMLElement>('[data-unit-choice]').forEach((button) => button.addEventListener('click', () => {
78
+ const next = button.dataset.unitChoice;
79
+ if (next !== 'metric' && next !== 'imperial') return;
80
+ context.state.input = readInput(context);
81
+ context.state.unit = next;
82
+ writeState(context);
83
+ calculate(context);
84
+ }));
85
+ context.root.querySelectorAll<HTMLElement>('[data-salinity-preset]').forEach((button) => button.addEventListener('click', () => {
86
+ const value = Number.parseFloat(button.dataset.salinityPreset ?? '');
87
+ if (!Number.isFinite(value)) return;
88
+ const target = field(context.root, 'targetSalinity');
89
+ if (target) target.value = String(value);
90
+ calculate(context);
91
+ }));
92
+ context.root.addEventListener('input', () => calculate(context));
93
+ context.root.querySelector('[data-action="reset"]')?.addEventListener('click', () => { context.state = defaultSaltDosingState(); writeState(context); calculate(context); });
94
+ }
95
+
96
+ export function initAquariumSaltDosingCalculator(): void {
97
+ const root = document.querySelector<HTMLElement>('#aquarium-salt-dosing-calculator');
98
+ if (!root) return;
99
+ const ui = JSON.parse(root.dataset.ui ?? '{}') as AquariumSaltDosingCalculatorUI;
100
+ const context: Context = { root, ui, state: loadSaltDosingState() ?? defaultSaltDosingState(), locale: document.documentElement.lang || 'en' };
101
+ writeState(context);
102
+ bind(context);
103
+ calculate(context);
104
+ }