@jjlmoya/utils-aquarium 1.60.0 → 1.62.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 (72) hide show
  1. package/package.json +18 -5
  2. package/scripts/postbuild.mjs +17 -0
  3. package/scripts/postinstall.mjs +2 -4
  4. package/src/category/index.ts +2 -1
  5. package/src/components/ProductionBreadcrumb.astro +134 -0
  6. package/src/components/ProductionStructuredData.astro +46 -0
  7. package/src/components/ProductionWidget.astro +182 -0
  8. package/src/entries.ts +4 -1
  9. package/src/i18n/header-ui.ts +15 -0
  10. package/src/i18n/language-ui.ts +9 -0
  11. package/src/i18n/languages.ts +24 -0
  12. package/src/identity/brands.ts +5 -0
  13. package/src/index.ts +2 -0
  14. package/src/layouts/ProductionCategoryPage.astro +184 -0
  15. package/src/layouts/ProductionPage.astro +218 -0
  16. package/src/layouts/ProductionUtilityPage.astro +283 -0
  17. package/src/mfe/assets.ts +39 -0
  18. package/src/mfe/category-ui.ts +34 -0
  19. package/src/mfe/manifest.ts +45 -0
  20. package/src/mfe/routes.ts +50 -0
  21. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
  22. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
  23. package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
  24. package/src/pages/index.astro +1 -1
  25. package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
  26. package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
  27. package/src/pages/utilidades/[slug].astro +90 -0
  28. package/src/pages/utilidades/categorias/[category].astro +38 -0
  29. package/src/tests/i18n_coverage.test.ts +1 -1
  30. package/src/tests/mfe_cache_contract.test.ts +14 -0
  31. package/src/tests/mfe_manifest.test.ts +28 -0
  32. package/src/tests/registry_contract.test.ts +67 -0
  33. package/src/tests/tool_validation.test.ts +4 -3
  34. package/src/tool/aquariumEvaporationTopOffPlanner/aquarium-evaporation-top-off-planner.css +490 -0
  35. package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.astro +6 -0
  36. package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.ts +10 -0
  37. package/src/tool/aquariumEvaporationTopOffPlanner/component.astro +61 -0
  38. package/src/tool/aquariumEvaporationTopOffPlanner/controller.ts +232 -0
  39. package/src/tool/aquariumEvaporationTopOffPlanner/dom-views.ts +39 -0
  40. package/src/tool/aquariumEvaporationTopOffPlanner/entry.ts +27 -0
  41. package/src/tool/aquariumEvaporationTopOffPlanner/evaluator.ts +9 -0
  42. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/de.ts +3 -0
  43. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/en.ts +179 -0
  44. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/es.ts +3 -0
  45. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/fr.ts +3 -0
  46. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/id.ts +3 -0
  47. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/it.ts +3 -0
  48. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ja.ts +3 -0
  49. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ko.ts +3 -0
  50. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/nl.ts +3 -0
  51. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pl.ts +3 -0
  52. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pt.ts +3 -0
  53. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ru.ts +3 -0
  54. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/shared.ts +79 -0
  55. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/sv.ts +3 -0
  56. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/tr.ts +3 -0
  57. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/translations.ts +509 -0
  58. package/src/tool/aquariumEvaporationTopOffPlanner/i18n/zh.ts +3 -0
  59. package/src/tool/aquariumEvaporationTopOffPlanner/index.ts +11 -0
  60. package/src/tool/aquariumEvaporationTopOffPlanner/logic.test.ts +61 -0
  61. package/src/tool/aquariumEvaporationTopOffPlanner/logic.ts +102 -0
  62. package/src/tool/aquariumEvaporationTopOffPlanner/seo.astro +13 -0
  63. package/src/tool/aquariumEvaporationTopOffPlanner/storage.ts +28 -0
  64. package/src/tool/aquariumEvaporationTopOffPlanner/ui.ts +49 -0
  65. package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/zh.ts +2 -2
  66. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/fr.ts +1 -1
  67. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/it.ts +1 -1
  68. package/src/tools.ts +6 -0
  69. package/src/types.ts +4 -6
  70. package/src/worker.ts +27 -0
  71. package/src/pages/[locale]/[slug].astro +0 -65
  72. package/src/pages/[locale].astro +0 -69
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createUtilityManifestResponse } from "../mfe/manifest";
3
+
4
+ describe("MFE utility manifest", () => {
5
+ it("returns a versioned installable manifest for the current utility", async () => {
6
+ const response = createUtilityManifestResponse({
7
+ title: "Card draw odds calculator",
8
+ description: "Calculate card draw probabilities.",
9
+ startUrl: "/en/utilities/categories/aquarium/card-draw-odds-calculator/",
10
+ englishSlug: "card-draw-odds-calculator",
11
+ });
12
+ const manifest = await response.json() as {
13
+ name: string;
14
+ start_url: string;
15
+ scope: string;
16
+ icons: { src: string; sizes: string; type: string; purpose: string }[];
17
+ };
18
+
19
+ expect(response.headers.get("Content-Type")).toContain("application/manifest+json");
20
+ expect(response.headers.get("Cache-Control")).toContain("immutable");
21
+ expect(manifest.name).toBe("Card draw odds calculator");
22
+ expect(manifest.start_url).toBe(manifest.scope);
23
+ expect(manifest.icons[0]?.src).toContain("/_utilities/aquarium/images/card-draw-odds-calculator.webp?version=");
24
+ expect(manifest.icons[0]?.sizes).toBe("512x512");
25
+ expect(manifest.icons[0]?.type).toBe("image/webp");
26
+ expect(manifest.icons[0]?.purpose).toBe("any");
27
+ });
28
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import { ALL_ENTRIES } from '../entries';
6
+ import { ALL_TOOLS } from '../tools';
7
+ import type { ToolDefinition } from '../types';
8
+
9
+ function isToolDefinition(value: unknown): value is ToolDefinition {
10
+ if (!value || typeof value !== 'object') return false;
11
+
12
+ const candidate = value as Record<string, unknown>;
13
+ return Boolean(
14
+ candidate.entry &&
15
+ typeof candidate.entry === 'object' &&
16
+ typeof candidate.Component === 'function' &&
17
+ typeof candidate.SEOComponent === 'function' &&
18
+ typeof candidate.BibliographyComponent === 'function',
19
+ );
20
+ }
21
+
22
+ const toolRoot = join(process.cwd(), 'src', 'tool');
23
+ const toolDirectories = readdirSync(toolRoot, { withFileTypes: true })
24
+ .filter((entry) => entry.isDirectory() && existsSync(join(toolRoot, entry.name, 'index.ts')))
25
+ .map((entry) => entry.name)
26
+ .sort();
27
+
28
+ describe('Library registry contract', () => {
29
+ it('keeps ALL_ENTRIES and ALL_TOOLS synchronized by id', () => {
30
+ const entryIds = ALL_ENTRIES.map((entry) => entry.id);
31
+ const toolIds = ALL_TOOLS.map((tool) => tool.entry.id);
32
+
33
+ expect(new Set(entryIds).size).toBe(entryIds.length);
34
+ expect(new Set(toolIds).size).toBe(toolIds.length);
35
+ expect([...toolIds].sort()).toEqual([...entryIds].sort());
36
+ });
37
+
38
+ it('registers every tool runtime index in both public registries', async () => {
39
+ const failures: string[] = [];
40
+
41
+ for (const directory of toolDirectories) {
42
+ const runtimeModule = await import(
43
+ pathToFileURL(join(toolRoot, directory, 'index.ts')).href,
44
+ );
45
+ const definitions = [...new Set(Object.values(runtimeModule).filter(isToolDefinition))];
46
+
47
+ if (definitions.length !== 1) {
48
+ failures.push(`${directory}: expected exactly one ToolDefinition export, found ${definitions.length}`);
49
+ continue;
50
+ }
51
+
52
+ const [definition] = definitions;
53
+ if (!definition) {
54
+ failures.push(`${directory}: runtime definition is undefined`);
55
+ continue;
56
+ }
57
+ if (!ALL_TOOLS.some((tool) => tool.entry.id === definition.entry.id)) {
58
+ failures.push(`${directory}: runtime definition is missing from ALL_TOOLS`);
59
+ }
60
+ if (!ALL_ENTRIES.some((entry) => entry.id === definition.entry.id)) {
61
+ failures.push(`${directory}: runtime entry is missing from ALL_ENTRIES`);
62
+ }
63
+ }
64
+
65
+ expect(failures).toEqual([]);
66
+ });
67
+ });
@@ -3,13 +3,14 @@ import { ALL_TOOLS } from '../tools';
3
3
  import { aquariumCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
- it('registers exactly three aquarium production tools', () => {
7
- expect(ALL_TOOLS.length).toBe(3);
8
- expect(aquariumCategory.tools).toHaveLength(3);
6
+ it('registers exactly four aquarium production tools', () => {
7
+ expect(ALL_TOOLS.length).toBe(4);
8
+ expect(aquariumCategory.tools).toHaveLength(4);
9
9
  expect(aquariumCategory.tools.map((tool) => tool.id)).toEqual([
10
10
  'aquarium-tank-volume-water-change-calculator',
11
11
  'aquarium-filter-turnover-stocking-planner',
12
12
  'aquarium-lighting-photoperiod-schedule-planner',
13
+ 'aquarium-evaporation-top-off-planner',
13
14
  ]);
14
15
  });
15
16
  });
@@ -0,0 +1,490 @@
1
+ .n-evaporation {
2
+ --n-paper: #eef5f2;
3
+ --n-panel: #fff;
4
+ --n-ink: #12383a;
5
+ --n-muted: #607979;
6
+ --n-line: #c8dcda;
7
+ --n-teal: #147d79;
8
+ --n-teal-dark: #0b5655;
9
+ --n-water: #56b9b5;
10
+ --n-sand: #e7b75e;
11
+ --n-loss: #f3dfbb;
12
+ --n-good: #dcefe5;
13
+ --n-alert: #9a5d12;
14
+ --n-shadow: rgba(19, 62, 62, 0.12);
15
+
16
+ display: grid;
17
+ grid-template-columns: minmax(320px, 0.95fr) minmax(360px, 1.05fr);
18
+ max-width: 1080px;
19
+ margin: 0 auto;
20
+ overflow: hidden;
21
+ color: var(--n-ink);
22
+ background: var(--n-paper);
23
+ border: 1px solid var(--n-line);
24
+ border-radius: 24px;
25
+ box-shadow: 0 14px 36px var(--n-shadow);
26
+ }
27
+
28
+ .theme-dark .n-evaporation {
29
+ --n-paper: #111b1c;
30
+ --n-panel: #1c292a;
31
+ --n-ink: #edf5f2;
32
+ --n-muted: #afc7c4;
33
+ --n-line: #355355;
34
+ --n-teal: #72d4cb;
35
+ --n-teal-dark: #a2e9e0;
36
+ --n-water: #318e93;
37
+ --n-sand: #edc66c;
38
+ --n-loss: #493c29;
39
+ --n-good: #234239;
40
+ --n-alert: #f2bf68;
41
+ --n-shadow: rgba(0, 0, 0, 0.34);
42
+ }
43
+
44
+ .n-evaporation *,
45
+ .n-evaporation *::before,
46
+ .n-evaporation *::after {
47
+ box-sizing: border-box;
48
+ }
49
+
50
+ .n-evaporation h2,
51
+ .n-evaporation h3,
52
+ .n-evaporation p {
53
+ margin: 0;
54
+ }
55
+
56
+ .n-output,
57
+ .n-controls {
58
+ min-width: 0;
59
+ align-self: start;
60
+ padding: clamp(22px, 3vw, 34px);
61
+ }
62
+
63
+ .n-controls {
64
+ background: var(--n-panel);
65
+ border-right: 1px solid var(--n-line);
66
+ grid-column: 1;
67
+ grid-row: 1;
68
+ }
69
+
70
+ .n-output {
71
+ grid-column: 2;
72
+ grid-row: 1;
73
+ }
74
+
75
+ .n-output-head,
76
+ .n-controls-head,
77
+ .n-schedule-head {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ gap: 16px;
82
+ }
83
+
84
+ .n-main-result {
85
+ display: grid;
86
+ gap: 4px;
87
+ max-width: 420px;
88
+ color: var(--n-ink);
89
+ letter-spacing: -0.04em;
90
+ line-height: 1;
91
+ }
92
+
93
+ .n-answer-label {
94
+ display: block;
95
+ color: var(--n-muted);
96
+ font-size: 0.72rem;
97
+ font-weight: 800;
98
+ letter-spacing: 0.04em;
99
+ text-transform: uppercase;
100
+ }
101
+
102
+ .n-main-result strong {
103
+ color: var(--n-teal-dark);
104
+ font-size: clamp(2.25rem, 4vw, 3.25rem);
105
+ font-weight: 850;
106
+ }
107
+
108
+ .n-main-result span {
109
+ color: var(--n-ink);
110
+ font-size: clamp(1rem, 2vw, 1.35rem);
111
+ font-weight: 750;
112
+ letter-spacing: -0.02em;
113
+ }
114
+
115
+ .n-status {
116
+ flex: 0 0 auto;
117
+ width: 11px;
118
+ height: 11px;
119
+ overflow: hidden;
120
+ color: transparent;
121
+ background: var(--n-teal);
122
+ border-radius: 50%;
123
+ }
124
+
125
+ .n-scene {
126
+ margin-top: 22px;
127
+ padding: 8px;
128
+ background: var(--n-panel);
129
+ border: 1px solid var(--n-line);
130
+ border-radius: 18px;
131
+ }
132
+
133
+ .n-evaporation-scene {
134
+ display: block;
135
+ width: 100%;
136
+ height: 220px;
137
+ }
138
+
139
+ .n-tank-frame {
140
+ fill: var(--n-panel);
141
+ stroke: var(--n-teal-dark);
142
+ stroke-width: 3;
143
+ }
144
+
145
+ .n-water {
146
+ fill: var(--n-water);
147
+ }
148
+
149
+ .n-loss-zone {
150
+ fill: var(--n-loss);
151
+ opacity: 0.78;
152
+ }
153
+
154
+ .n-waterline,
155
+ .n-mark {
156
+ fill: none;
157
+ stroke: var(--n-sand);
158
+ stroke-width: 3;
159
+ }
160
+
161
+ .n-mark-label,
162
+ .n-scene-label,
163
+ .n-scene-caption {
164
+ fill: var(--n-muted);
165
+ text-anchor: middle;
166
+ }
167
+
168
+ .n-mark-label {
169
+ font-size: 13px;
170
+ text-anchor: end;
171
+ }
172
+
173
+ .n-scene-label {
174
+ fill: var(--n-teal-dark);
175
+ font-size: 21px;
176
+ font-weight: 850;
177
+ }
178
+
179
+ .n-scene-caption {
180
+ font-size: 13px;
181
+ }
182
+
183
+ .n-stat-grid {
184
+ display: grid;
185
+ grid-template-columns: repeat(4, minmax(0, 1fr));
186
+ gap: 1px;
187
+ margin-top: 18px;
188
+ overflow: hidden;
189
+ background: var(--n-line);
190
+ border: 1px solid var(--n-line);
191
+ border-radius: 12px;
192
+ }
193
+
194
+ .n-stat {
195
+ display: grid;
196
+ min-height: 70px;
197
+ gap: 6px;
198
+ padding: 12px;
199
+ background: var(--n-panel);
200
+ }
201
+
202
+ .n-stat-feature {
203
+ background: var(--n-good);
204
+ }
205
+
206
+ .n-stat span,
207
+ .n-schedule-head span {
208
+ color: var(--n-muted);
209
+ font-size: 0.68rem;
210
+ }
211
+
212
+ .n-stat strong {
213
+ align-self: end;
214
+ color: var(--n-ink);
215
+ font-size: 1rem;
216
+ line-height: 1;
217
+ }
218
+
219
+ .n-stat-feature strong {
220
+ color: var(--n-teal-dark);
221
+ font-size: 1.12rem;
222
+ }
223
+
224
+ .n-alert {
225
+ margin-top: 12px;
226
+ padding: 10px 12px;
227
+ color: var(--n-alert);
228
+ background: var(--n-loss);
229
+ border: 1px solid var(--n-sand);
230
+ border-radius: 9px;
231
+ font-size: 0.72rem;
232
+ font-weight: 750;
233
+ line-height: 1.35;
234
+ }
235
+
236
+ .n-alert[hidden],
237
+ .n-field[hidden],
238
+ .n-mini-toggle button[hidden] {
239
+ display: none;
240
+ }
241
+
242
+ .n-schedule-head {
243
+ align-items: baseline;
244
+ margin-top: 22px;
245
+ padding-bottom: 9px;
246
+ border-bottom: 1px solid var(--n-line);
247
+ }
248
+
249
+ .n-schedule-head h3,
250
+ .n-controls-head h2 {
251
+ color: var(--n-ink);
252
+ font-size: 0.92rem;
253
+ }
254
+
255
+ .n-calendar {
256
+ display: grid;
257
+ grid-template-columns: repeat(7, minmax(0, 1fr));
258
+ gap: 5px;
259
+ margin-top: 11px;
260
+ }
261
+
262
+ .n-calendar-item {
263
+ display: grid;
264
+ min-height: 48px;
265
+ gap: 4px;
266
+ padding: 7px 6px;
267
+ color: var(--n-muted);
268
+ background: var(--n-panel);
269
+ border: 1px solid var(--n-line);
270
+ border-radius: 8px;
271
+ font-size: 0.68rem;
272
+ }
273
+
274
+ .n-calendar-item span {
275
+ color: var(--n-ink);
276
+ font-weight: 850;
277
+ }
278
+
279
+ .n-calendar-item small {
280
+ font-size: 0.61rem;
281
+ }
282
+
283
+ .n-calendar-item strong {
284
+ color: var(--n-teal-dark);
285
+ font-size: 0.61rem;
286
+ }
287
+
288
+ .n-calendar-item.is-refill {
289
+ background: var(--n-good);
290
+ border-color: var(--n-teal);
291
+ }
292
+
293
+ .n-controls-head {
294
+ padding-bottom: 20px;
295
+ border-bottom: 1px solid var(--n-line);
296
+ }
297
+
298
+ .n-unit-toggle {
299
+ display: flex;
300
+ flex: 0 0 auto;
301
+ padding: 3px;
302
+ background: var(--n-paper);
303
+ border: 1px solid var(--n-line);
304
+ border-radius: 9px;
305
+ }
306
+
307
+ .n-unit-toggle button {
308
+ padding: 7px 8px;
309
+ color: var(--n-muted);
310
+ background: transparent;
311
+ border: 0;
312
+ border-radius: 6px;
313
+ cursor: pointer;
314
+ font: inherit;
315
+ font-size: 0.67rem;
316
+ font-weight: 800;
317
+ }
318
+
319
+ .n-unit-toggle button.is-active,
320
+ .n-unit-toggle button:hover {
321
+ color: var(--n-teal-dark);
322
+ background: var(--n-good);
323
+ }
324
+
325
+ .n-fields {
326
+ display: grid;
327
+ grid-template-columns: repeat(2, minmax(0, 1fr));
328
+ gap: 18px 12px;
329
+ padding-top: 22px;
330
+ }
331
+
332
+ .n-field {
333
+ display: grid;
334
+ gap: 8px;
335
+ }
336
+
337
+ .n-field-head {
338
+ display: flex;
339
+ align-items: center;
340
+ justify-content: space-between;
341
+ gap: 7px;
342
+ }
343
+
344
+ .n-field-head label,
345
+ .n-water-type > span {
346
+ color: var(--n-ink);
347
+ font-size: 0.72rem;
348
+ font-weight: 800;
349
+ line-height: 1.2;
350
+ }
351
+
352
+ .n-field > span {
353
+ color: var(--n-ink);
354
+ font-size: 0.72rem;
355
+ font-weight: 800;
356
+ line-height: 1.2;
357
+ }
358
+
359
+ .n-field small {
360
+ color: var(--n-muted);
361
+ font-size: 0.65rem;
362
+ font-weight: 600;
363
+ }
364
+
365
+ .n-field input {
366
+ width: 100%;
367
+ min-height: 43px;
368
+ padding: 8px 10px;
369
+ color: var(--n-ink);
370
+ background: var(--n-paper);
371
+ border: 1px solid var(--n-line);
372
+ border-radius: 8px;
373
+ font: inherit;
374
+ font-size: 0.86rem;
375
+ }
376
+
377
+ .n-mini-toggle {
378
+ display: inline-flex;
379
+ flex: 0 0 auto;
380
+ padding: 2px;
381
+ background: var(--n-paper);
382
+ border: 1px solid var(--n-line);
383
+ border-radius: 7px;
384
+ }
385
+
386
+ .n-mini-toggle button {
387
+ padding: 4px 5px;
388
+ color: var(--n-muted);
389
+ background: transparent;
390
+ border: 0;
391
+ border-radius: 5px;
392
+ cursor: pointer;
393
+ font: inherit;
394
+ font-size: 0.59rem;
395
+ font-weight: 800;
396
+ }
397
+
398
+ .n-mini-toggle button.is-active,
399
+ .n-mini-toggle button:hover {
400
+ color: var(--n-teal-dark);
401
+ background: var(--n-good);
402
+ }
403
+
404
+ .n-advanced {
405
+ margin-top: 22px;
406
+ padding-top: 17px;
407
+ border-top: 1px solid var(--n-line);
408
+ }
409
+
410
+ .n-advanced summary {
411
+ color: var(--n-teal-dark);
412
+ cursor: pointer;
413
+ font-size: 0.72rem;
414
+ font-weight: 850;
415
+ }
416
+
417
+ .n-advanced-grid {
418
+ display: grid;
419
+ gap: 18px;
420
+ padding-top: 17px;
421
+ }
422
+
423
+ .n-water-type {
424
+ display: grid;
425
+ gap: 8px;
426
+ }
427
+
428
+ .n-unit-toggle button:focus-visible,
429
+ .n-field input:focus-visible {
430
+ outline: 3px solid var(--n-sand);
431
+ outline-offset: 3px;
432
+ }
433
+
434
+ @media (max-width: 760px) {
435
+ .n-evaporation {
436
+ grid-template-columns: 1fr;
437
+ }
438
+
439
+ .n-output,
440
+ .n-controls {
441
+ grid-column: 1;
442
+ grid-row: auto;
443
+ }
444
+
445
+ .n-controls {
446
+ order: 1;
447
+ border-top: 1px solid var(--n-line);
448
+ border-left: 0;
449
+ border-right: 0;
450
+ }
451
+
452
+ .n-output {
453
+ order: 2;
454
+ }
455
+
456
+ }
457
+
458
+ @media (max-width: 480px) {
459
+ .n-output,
460
+ .n-controls {
461
+ padding: 20px 16px;
462
+ }
463
+
464
+ .n-output-head,
465
+ .n-controls-head {
466
+ align-items: flex-start;
467
+ flex-direction: column;
468
+ }
469
+
470
+ .n-status {
471
+ display: none;
472
+ }
473
+
474
+ .n-stat {
475
+ min-height: 66px;
476
+ padding: 9px;
477
+ }
478
+
479
+ .n-stat-grid {
480
+ grid-template-columns: repeat(2, minmax(0, 1fr));
481
+ }
482
+
483
+ .n-calendar {
484
+ gap: 4px;
485
+ }
486
+
487
+ .n-calendar-item {
488
+ padding: 6px 4px;
489
+ }
490
+ }
@@ -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,10 @@
1
+ export const bibliography = [
2
+ {
3
+ name: 'FAO, Generalidades sobre las necesidades de agua para un estanque piscícola',
4
+ url: 'https://www.fao.org/fishery/static/FAO_Training/FAO_Training/General/x6705s/x6705s01.htm',
5
+ },
6
+ {
7
+ name: 'University of Florida IFAS, Marine Aquarium Project Book',
8
+ url: 'https://ask.ifas.ufl.edu/publication/4H433',
9
+ },
10
+ ];
@@ -0,0 +1,61 @@
1
+ ---
2
+ import type { AquariumEvaporationTopOffPlannerUI } from './ui';
3
+ import './aquarium-evaporation-top-off-planner.css';
4
+
5
+ interface Props { ui: AquariumEvaporationTopOffPlannerUI; }
6
+
7
+ const { ui } = Astro.props;
8
+ ---
9
+
10
+ <div class="n-evaporation" data-tool-root data-ui={JSON.stringify(ui)}>
11
+ <section class="n-output" aria-labelledby="n-evaporation-results">
12
+ <div class="n-output-head">
13
+ <div class="n-answer">
14
+ <span class="n-answer-label">{ui.topOffLabel}</span>
15
+ <h2 id="n-evaporation-results" class="n-main-result"><strong data-output="main-amount">—</strong><span data-output="main-frequency">{ui.emptyState}</span></h2>
16
+ </div>
17
+ <span class="n-status" data-output="status">{ui.stableStatus}</span>
18
+ </div>
19
+
20
+ <div class="n-scene" data-output="scene" aria-live="polite"></div>
21
+
22
+ <div class="n-stat-grid" aria-live="polite">
23
+ <div class="n-stat n-stat-feature"><span>{ui.nextRefillLabel}</span><strong data-output="next-refill">—</strong></div>
24
+ <div class="n-stat"><span>{ui.dailyLossLabel}</span><strong data-output="daily-loss">—</strong></div>
25
+ <div class="n-stat"><span>{ui.levelAfterIntervalLabel}</span><strong data-output="level-after">—</strong></div>
26
+ <div class="n-stat"><span>{ui.reservoirDaysLabel}</span><strong data-output="reservoir-days">—</strong></div>
27
+ <div class="n-stat" data-salinity-result hidden><span>{ui.estimatedSalinityLabel}</span><strong data-output="salinity">—</strong></div>
28
+ </div>
29
+ <p class="n-alert" data-output="alert" role="status" hidden></p>
30
+ </section>
31
+
32
+ <section class="n-controls" aria-labelledby="n-evaporation-inputs">
33
+ <div class="n-controls-head">
34
+ <h2 id="n-evaporation-inputs">{ui.unitsLabel}</h2>
35
+ <div class="n-unit-toggle" role="group" aria-label={ui.unitsLabel}>
36
+ <button type="button" data-unit-choice="metric" class="is-active" aria-pressed="true">{ui.metricLabel}</button>
37
+ <button type="button" data-unit-choice="imperial" aria-pressed="false">{ui.imperialLabel}</button>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="n-fields">
42
+ <label class="n-field"><span>{ui.tankVolumeLabel} <small data-unit="tank">{ui.litreUnit}</small></span><input data-field="tankVolume" type="number" min="0.1" max="100000" step="0.1" inputmode="decimal" /></label>
43
+ <div class="n-field"><div class="n-field-head"><label for="n-evaporation-loss">{ui.measuredLossLabel} <small data-unit="loss">{ui.millilitreUnit}</small></label><div class="n-mini-toggle" role="group" aria-label={ui.lossUnitLabel}><button type="button" data-loss-unit-choice="millilitres" class="is-active" aria-pressed="true">{ui.millilitreUnit}</button><button type="button" data-loss-unit-choice="litres" aria-pressed="false">{ui.litreUnit}</button><button type="button" data-loss-unit-choice="fluidOunces" aria-pressed="false">{ui.fluidOunceUnit}</button></div></div><input id="n-evaporation-loss" data-field="measuredLoss" type="number" min="0.1" max="100000" step="0.1" inputmode="decimal" /></div>
44
+ <label class="n-field"><span>{ui.observationDaysLabel} <small>{ui.dayUnit}</small></span><input data-field="observationDays" type="number" min="1" max="90" step="1" inputmode="numeric" /></label>
45
+ <label class="n-field"><span>{ui.refillIntervalLabel} <small>{ui.dayUnit}</small></span><input data-field="refillIntervalDays" type="number" min="1" max="30" step="1" inputmode="numeric" /></label>
46
+ </div>
47
+
48
+ <details class="n-advanced"><summary>{ui.advancedLabel}</summary><div class="n-advanced-grid"><label class="n-field"><span>{ui.reservoirCapacityLabel} <small data-unit="reservoir">{ui.litreUnit}</small></span><input data-field="reservoirCapacity" type="number" min="0.1" max="100000" step="0.1" inputmode="decimal" /></label><div class="n-water-type"><span>{ui.waterTypeLabel}</span><div class="n-mini-toggle" role="group" aria-label={ui.waterTypeLabel}><button type="button" data-water-type-choice="freshwater" class="is-active" aria-pressed="true">{ui.freshwaterLabel}</button><button type="button" data-water-type-choice="marine" aria-pressed="false">{ui.marineLabel}</button></div></div><label class="n-field" data-marine-field hidden><span>{ui.startingSalinityLabel} <small>SG</small></span><input data-field="startingSpecificGravity" type="number" min="1" max="1.04" step="0.001" inputmode="decimal" /></label></div></details>
49
+ <div class="n-schedule-head"><h3>{ui.scheduleTitle}</h3><span>{ui.levelLabel}</span></div>
50
+ <div class="n-calendar" data-output="schedule" role="list" aria-label={ui.scheduleTitle}></div>
51
+ </section>
52
+ </div>
53
+
54
+ <script>
55
+ import { startEvaporationController } from './controller';
56
+
57
+ const root = document.querySelector<HTMLElement>('[data-tool-root]');
58
+ if (root?.dataset.ui) {
59
+ startEvaporationController({ root, ui: JSON.parse(root.dataset.ui) });
60
+ }
61
+ </script>