@jjlmoya/utils-aquarium 1.0.0 → 1.52.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 (28) hide show
  1. package/package.json +13 -4
  2. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  3. package/src/tests/i18n_coverage.test.ts +1 -1
  4. package/src/tests/seo_wellformed_export.test.ts +65 -0
  5. package/src/tool/aquariumTankVolumeWaterChangeCalculator/aquarium-tank-volume-water-change-calculator.css +107 -56
  6. package/src/tool/aquariumTankVolumeWaterChangeCalculator/bibliography.ts +6 -6
  7. package/src/tool/aquariumTankVolumeWaterChangeCalculator/component.astro +24 -39
  8. package/src/tool/aquariumTankVolumeWaterChangeCalculator/controller.ts +90 -39
  9. package/src/tool/aquariumTankVolumeWaterChangeCalculator/dom-views.ts +19 -11
  10. package/src/tool/aquariumTankVolumeWaterChangeCalculator/entry.ts +14 -0
  11. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/de.ts +34 -0
  12. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/en.ts +8 -8
  13. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/es.ts +34 -0
  14. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/fr.ts +34 -0
  15. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/id.ts +34 -0
  16. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/it.ts +34 -0
  17. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/ja.ts +34 -0
  18. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/ko.ts +34 -0
  19. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/nl.ts +34 -0
  20. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/pl.ts +34 -0
  21. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/pt.ts +34 -0
  22. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/ru.ts +34 -0
  23. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/sv.ts +34 -0
  24. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/tr.ts +34 -0
  25. package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/zh.ts +34 -0
  26. package/src/tool/aquariumTankVolumeWaterChangeCalculator/localized-content.ts +60 -0
  27. package/src/tool/aquariumTankVolumeWaterChangeCalculator/logic.test.ts +10 -0
  28. package/src/tool/aquariumTankVolumeWaterChangeCalculator/ui.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-aquarium",
3
- "version": "1.0.0",
3
+ "version": "1.52.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -11,8 +11,13 @@
11
11
  "./runtime/*": "./src/tool/*/index.ts",
12
12
  "./category-seo": "./src/category/seo.astro"
13
13
  },
14
- "files": ["src", "scripts"],
15
- "publishConfig": {"access": "public"},
14
+ "files": [
15
+ "src",
16
+ "scripts"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
16
21
  "scripts": {
17
22
  "dev": "astro dev",
18
23
  "start": "astro dev",
@@ -29,7 +34,11 @@
29
34
  "major": "npm version major",
30
35
  "postinstall": "node scripts/postinstall.mjs"
31
36
  },
32
- "lint-staged": {"*.{ts,tsx,astro}": ["eslint --fix"]},
37
+ "lint-staged": {
38
+ "*.{ts,tsx,astro}": [
39
+ "eslint --fix"
40
+ ]
41
+ },
33
42
  "dependencies": {
34
43
  "@iconify-json/mdi": "^1.2.3",
35
44
  "@jjlmoya/prompagate": "^1.1.0",
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findBibliographyAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findBibliographyAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'bibliography.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const bibliographyFiles = findBibliographyAstroFiles(toolDir);
24
+
25
+ describe('Bibliography Component Wellformed Export', () => {
26
+ it('found tool bibliography.astro components', () => {
27
+ expect(bibliographyFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ bibliographyFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSharedComponent =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('Bibliography') || content.includes('SharedBibliography'));
39
+
40
+ expect(
41
+ usesSharedComponent,
42
+ `File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
43
+ ).toBe(true);
44
+ });
45
+ });
46
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { ALL_TOOLS } from '../tools';
3
3
 
4
- const EXPECTED_LOCALES = ['en'];
4
+ const EXPECTED_LOCALES = ['de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'];
5
5
 
6
6
  describe('I18n Coverage Validation', () => {
7
7
  ALL_TOOLS.forEach(({ entry }) => {
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findSeoAstroFiles(dir: string): string[] {
6
+ const files: string[] = [];
7
+ if (!existsSync(dir)) return files;
8
+ const entries = readdirSync(dir, { withFileTypes: true });
9
+
10
+ for (const entry of entries) {
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...findSeoAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'seo.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const seoFiles = findSeoAstroFiles(toolDir);
24
+
25
+ describe('SEO Component Wellformed Export', () => {
26
+ it('found tool seo.astro components', () => {
27
+ expect(seoFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ seoFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSeoRenderer =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('SEORenderer') || content.includes('SEOArticle'));
39
+
40
+ const acquiresDynamicContent =
41
+ content.includes('.i18n') ||
42
+ content.includes('loader') ||
43
+ content.includes('await loader') ||
44
+ content.includes('.seo');
45
+
46
+ const isBrokenPattern =
47
+ /sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
48
+
49
+ expect(
50
+ usesSeoRenderer,
51
+ `File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
52
+ ).toBe(true);
53
+
54
+ expect(
55
+ isBrokenPattern,
56
+ `File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
57
+ ).toBe(false);
58
+
59
+ expect(
60
+ acquiresDynamicContent,
61
+ `File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
62
+ ).toBe(true);
63
+ });
64
+ });
65
+ });
@@ -10,7 +10,7 @@
10
10
  --n-accent: #d96f4f;
11
11
  --n-accent-ink: #fff;
12
12
  --n-error: #a33632;
13
- --n-shadow: rgba(23, 49, 59, 0.12);
13
+ --n-shadow: #dbe6e2;
14
14
  }
15
15
 
16
16
  .theme-dark {
@@ -25,7 +25,7 @@
25
25
  --n-accent: #ef9474;
26
26
  --n-accent-ink: #251512;
27
27
  --n-error: #ff9b91;
28
- --n-shadow: rgba(0, 0, 0, 0.28);
28
+ --n-shadow: #101a1c;
29
29
  }
30
30
 
31
31
  .atvwc-root {
@@ -41,7 +41,6 @@
41
41
  padding: clamp(1rem, 3vw, 2rem);
42
42
  }
43
43
 
44
- .atvwc-heading,
45
44
  .atvwc-panel-topline,
46
45
  .atvwc-result-heading,
47
46
  .atvwc-footnotes {
@@ -51,6 +50,7 @@
51
50
  justify-content: space-between;
52
51
  }
53
52
 
53
+ .atvwc-step,
54
54
  .atvwc-eyebrow,
55
55
  .atvwc-subtitle,
56
56
  .atvwc-help,
@@ -62,12 +62,20 @@
62
62
  margin: 0;
63
63
  }
64
64
 
65
+ .atvwc-step,
65
66
  .atvwc-eyebrow {
66
67
  font-size: 1.15rem;
67
68
  font-weight: 800;
68
69
  letter-spacing: .02em;
69
70
  }
70
71
 
72
+ .atvwc-step {
73
+ color: var(--n-water);
74
+ font-size: .75rem;
75
+ letter-spacing: .12em;
76
+ text-transform: uppercase;
77
+ }
78
+
71
79
  .atvwc-subtitle,
72
80
  .atvwc-help,
73
81
  .atvwc-result-hint,
@@ -92,23 +100,17 @@
92
100
 
93
101
  .atvwc-unit-control button,
94
102
  .atvwc-shape,
95
- .atvwc-reset,
96
- .atvwc-calculate {
103
+ .atvwc-reset {
104
+ background: var(--n-card);
97
105
  border: 1px solid var(--n-line);
98
106
  border-radius: .7rem;
107
+ color: var(--n-ink);
99
108
  cursor: pointer;
100
109
  font: inherit;
101
110
  min-height: 2.6rem;
102
111
  padding: .55rem .8rem;
103
112
  }
104
113
 
105
- .atvwc-unit-control button,
106
- .atvwc-shape,
107
- .atvwc-reset {
108
- background: var(--n-card);
109
- color: var(--n-ink);
110
- }
111
-
112
114
  .atvwc-unit-control button[aria-pressed="true"],
113
115
  .atvwc-shape.is-active {
114
116
  background: var(--n-water-soft);
@@ -119,16 +121,13 @@
119
121
 
120
122
  .atvwc-layout {
121
123
  display: grid;
122
- gap: 1.25rem;
124
+ gap: 0;
123
125
  grid-template-columns: minmax(0, 1.1fr) minmax(18rem, .9fr);
124
- margin-top: 1.5rem;
126
+ margin-top: 0;
125
127
  }
126
128
 
127
129
  .atvwc-panel {
128
- background: var(--n-card);
129
- border: 1px solid var(--n-line);
130
- border-radius: 1.1rem;
131
- padding: clamp(1rem, 2vw, 1.5rem);
130
+ padding: .25rem 0;
132
131
  }
133
132
 
134
133
  .atvwc-panel h2 {
@@ -136,6 +135,16 @@
136
135
  margin: 0;
137
136
  }
138
137
 
138
+ .atvwc-form-panel {
139
+ border-right: 1px solid var(--n-line);
140
+ padding-right: clamp(1rem, 3vw, 2rem);
141
+ }
142
+
143
+ .atvwc-visual-panel {
144
+ min-height: 0;
145
+ padding-left: clamp(1rem, 3vw, 2rem);
146
+ }
147
+
139
148
  .atvwc-reset {
140
149
  min-height: 2.25rem;
141
150
  padding: .35rem .65rem;
@@ -186,29 +195,31 @@
186
195
  .atvwc-field input:focus-visible,
187
196
  .atvwc-unit-control button:focus-visible,
188
197
  .atvwc-shape:focus-visible,
189
- .atvwc-reset:focus-visible,
190
- .atvwc-calculate:focus-visible {
198
+ .atvwc-reset:focus-visible {
191
199
  outline: 3px solid var(--n-focus);
192
200
  outline-offset: 2px;
193
201
  }
194
202
 
195
- .atvwc-calculate {
196
- background: var(--n-accent);
197
- border-color: var(--n-accent);
198
- color: var(--n-accent-ink);
199
- font-weight: 800;
200
- margin-top: 1.25rem;
201
- width: 100%;
202
- }
203
-
204
203
  .atvwc-bow-note {
205
204
  display: none;
206
205
  }
207
206
 
208
- .atvwc-visual-panel {
207
+ .atvwc-root[data-shape="bow"] .atvwc-bow-note {
208
+ display: block;
209
+ }
210
+
211
+ .atvwc-scene-header {
212
+ align-items: center;
209
213
  display: flex;
210
- flex-direction: column;
211
- min-height: 100%;
214
+ justify-content: space-between;
215
+ }
216
+
217
+ .atvwc-scene-kicker {
218
+ color: var(--n-muted);
219
+ font-size: .82rem;
220
+ font-weight: 800;
221
+ letter-spacing: .08em;
222
+ text-transform: uppercase;
212
223
  }
213
224
 
214
225
  .atvwc-tank-stage {
@@ -234,6 +245,17 @@
234
245
  width: 10rem;
235
246
  }
236
247
 
248
+ .atvwc-root[data-shape="cylindrical"] .atvwc-tank {
249
+ border-radius: 50%;
250
+ height: 7rem;
251
+ width: 7rem;
252
+ }
253
+
254
+ .atvwc-root[data-shape="bow"] .atvwc-tank {
255
+ border-radius: .8rem 2.8rem 2.8rem .8rem;
256
+ transform: perspective(14rem) rotateY(-8deg);
257
+ }
258
+
237
259
  .atvwc-water,
238
260
  .atvwc-gravel {
239
261
  bottom: 0;
@@ -244,7 +266,7 @@
244
266
 
245
267
  .atvwc-water {
246
268
  background: var(--n-water);
247
- height: 70%;
269
+ height: var(--water-level, 70%);
248
270
  opacity: .82;
249
271
  transition: height .25s ease;
250
272
  }
@@ -266,23 +288,6 @@
266
288
  width: 84%;
267
289
  }
268
290
 
269
- .atvwc-fish {
270
- color: var(--n-accent);
271
- font-size: 1.5rem;
272
- position: absolute;
273
- }
274
-
275
- .atvwc-fish-one {
276
- left: 20%;
277
- top: 32%;
278
- }
279
-
280
- .atvwc-fish-two {
281
- right: 19%;
282
- top: 54%;
283
- transform: scaleX(-1);
284
- }
285
-
286
291
  .atvwc-result-heading {
287
292
  align-items: center;
288
293
  margin-top: 1.25rem;
@@ -294,6 +299,13 @@
294
299
  margin: .25rem 0 0;
295
300
  }
296
301
 
302
+ .atvwc-result-heading p {
303
+ color: var(--n-accent);
304
+ font-size: .95rem;
305
+ font-weight: 800;
306
+ margin: .4rem 0 0;
307
+ }
308
+
297
309
  .atvwc-scale {
298
310
  background: var(--n-water-soft);
299
311
  border-radius: 999px;
@@ -337,28 +349,59 @@
337
349
  .atvwc-footnotes {
338
350
  flex-wrap: wrap;
339
351
  gap: .6rem 1rem;
340
- margin-top: auto;
341
352
  padding-top: 1rem;
342
353
  }
343
354
 
355
+ .atvwc-stage-note {
356
+ align-items: flex-start;
357
+ bottom: 1rem;
358
+ display: grid;
359
+ gap: .1rem;
360
+ left: 1rem;
361
+ position: absolute;
362
+ }
363
+
364
+ .atvwc-stage-note strong {
365
+ color: var(--n-ink);
366
+ font-size: .95rem;
367
+ }
368
+
369
+ .atvwc-stage-note span,
370
+ .atvwc-level-mark {
371
+ color: var(--n-muted);
372
+ font-size: .72rem;
373
+ }
374
+
375
+ .atvwc-level-mark {
376
+ position: absolute;
377
+ right: 1rem;
378
+ }
379
+
380
+ .atvwc-level-mark-top {
381
+ top: 1rem;
382
+ }
383
+
384
+ .atvwc-level-mark-bottom {
385
+ bottom: 1rem;
386
+ }
387
+
344
388
  .atvwc-error {
345
389
  color: var(--n-error);
346
390
  font-weight: 700;
347
391
  margin-top: .75rem;
348
392
  }
349
393
 
350
- .atvwc-root[data-state="ready"] .atvwc-empty,
351
- .atvwc-root[data-state="error"] .atvwc-empty {
394
+ .atvwc-root.has-results .atvwc-empty,
395
+ .atvwc-root.has-error .atvwc-empty {
352
396
  display: none;
353
397
  }
354
398
 
355
- .atvwc-root[data-state="error"] .atvwc-result-card strong,
356
- .atvwc-root[data-state="error"] .atvwc-footnotes strong {
399
+ .atvwc-root.has-error .atvwc-result-card strong,
400
+ .atvwc-root.has-error .atvwc-footnotes strong {
357
401
  color: var(--n-muted);
358
402
  }
359
403
 
360
404
  @media (max-width: 760px) {
361
- .atvwc-heading,
362
405
  .atvwc-layout {
363
406
  display: block;
364
407
  }
@@ -369,7 +412,15 @@
369
412
  }
370
413
 
371
414
  .atvwc-visual-panel {
415
+ border-top: 1px solid var(--n-line);
372
416
  margin-top: 1.25rem;
417
+ padding-left: 0;
418
+ padding-top: 1.25rem;
419
+ }
420
+
421
+ .atvwc-form-panel {
422
+ border-right: 0;
423
+ padding-right: 0;
373
424
  }
374
425
 
375
426
  .atvwc-input-grid {
@@ -1,14 +1,14 @@
1
1
  export const bibliography = [
2
2
  {
3
- name: 'Centro Español de Metrología, Sistema Internacional de Unidades',
4
- url: 'https://www.cem.es/sites/default/files/2021-01/SistemaInternacionalUnidades.pdf',
3
+ name: 'Merck Veterinary Manual, Formulae to Calculate Tank Volumes',
4
+ url: 'https://www.merckvetmanual.com/multimedia/table/formulae-to-calculate-tank-volumes',
5
5
  },
6
6
  {
7
- name: 'U.S. Geological Survey, Water Density',
8
- url: 'https://www.usgs.gov/water-science-school/science/water-density',
7
+ name: 'FAO, Determinación de los volúmenes de agua',
8
+ url: 'https://www.fao.org/fishery/static/FAO_Training/FAO_Training/General/x6709s/x6709s15.htm',
9
9
  },
10
10
  {
11
- name: 'OpenStax, Measuring Volume',
12
- url: 'https://openstax.org/books/contemporary-mathematics/pages/9-3-measuring-volume',
11
+ name: 'U.S. Geological Survey, Water Density',
12
+ url: 'https://www.usgs.gov/water-science-school/science/water-density',
13
13
  },
14
14
  ];
@@ -6,23 +6,18 @@ const t = (ui ?? {}) as AquariumTankVolumeWaterChangeCalculatorUI;
6
6
  ---
7
7
 
8
8
  <div id="aquarium-tank-volume-water-change-calculator" class="atvwc-root" data-ui={JSON.stringify(t)}>
9
- <div class="atvwc-heading">
10
- <div>
11
- <p class="atvwc-eyebrow">{t.toolTitle}</p>
12
- <p class="atvwc-subtitle">{t.toolSubtitle}</p>
13
- </div>
14
- <div class="atvwc-unit-control" role="group" aria-label={t.unitLabel}>
15
- <span class="atvwc-control-label">{t.unitLabel}</span>
16
- <button type="button" data-unit-choice="metric" aria-pressed="true">{t.metricLabel}</button>
17
- <button type="button" data-unit-choice="imperial" aria-pressed="false">{t.imperialLabel}</button>
18
- </div>
19
- </div>
20
-
21
9
  <div class="atvwc-layout">
22
10
  <section class="atvwc-panel atvwc-form-panel" aria-labelledby="atvwc-dimensions-title">
23
11
  <div class="atvwc-panel-topline">
24
- <h2 id="atvwc-dimensions-title">{t.dimensionsTitle}</h2>
25
- <button type="button" class="atvwc-reset" data-action="reset">{t.resetLabel}</button>
12
+ <div>
13
+ <p class="atvwc-step">{t.configureLabel}</p>
14
+ <h2 id="atvwc-dimensions-title">{t.dimensionsTitle}</h2>
15
+ </div>
16
+ <div class="atvwc-unit-control" role="group" aria-label={t.unitLabel}>
17
+ <span class="atvwc-control-label">{t.unitLabel}</span>
18
+ <button type="button" data-unit-choice="metric" aria-pressed="true">{t.metricLabel}</button>
19
+ <button type="button" data-unit-choice="imperial" aria-pressed="false">{t.imperialLabel}</button>
20
+ </div>
26
21
  </div>
27
22
 
28
23
  <fieldset class="atvwc-fieldset">
@@ -35,43 +30,33 @@ const t = (ui ?? {}) as AquariumTankVolumeWaterChangeCalculatorUI;
35
30
  <p class="atvwc-help">{t.shapeHint}</p>
36
31
  </fieldset>
37
32
 
38
- <div class="atvwc-input-grid" data-shape-fields="rectangular">
39
- <label class="atvwc-field"><span>{t.lengthLabel} <span data-length-unit></span></span><input data-input="lengthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
40
- <label class="atvwc-field"><span>{t.widthLabel} <span data-length-unit></span></span><input data-input="widthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
41
- <label class="atvwc-field"><span>{t.heightLabel} <span data-length-unit></span></span><input data-input="heightCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
42
- </div>
43
-
44
- <div class="atvwc-input-grid" data-shape-fields="cylindrical" hidden>
45
- <label class="atvwc-field"><span>{t.diameterLabel} <span data-length-unit></span></span><input data-input="diameterCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
46
- <label class="atvwc-field"><span>{t.heightLabel} <span data-length-unit></span></span><input data-input="heightCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
33
+ <div class="atvwc-input-grid" data-dynamic-fields>
34
+ <label class="atvwc-field" data-field="lengthCm"><span>{t.lengthLabel} <span data-length-unit>{t.lengthUnitMetric}</span></span><input data-input="lengthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
35
+ <label class="atvwc-field" data-field="widthCm"><span>{t.widthLabel} <span data-length-unit>{t.lengthUnitMetric}</span></span><input data-input="widthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
36
+ <label class="atvwc-field" data-field="heightCm"><span>{t.heightLabel} <span data-length-unit>{t.lengthUnitMetric}</span></span><input data-input="heightCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
47
37
  </div>
48
38
 
49
- <div class="atvwc-input-grid" data-shape-fields="bow" hidden>
50
- <label class="atvwc-field"><span>{t.lengthLabel} <span data-length-unit></span></span><input data-input="lengthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
51
- <label class="atvwc-field"><span>{t.widthLabel} <span data-length-unit></span></span><input data-input="widthCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
52
- <label class="atvwc-field"><span>{t.heightLabel} <span data-length-unit></span></span><input data-input="heightCm" type="number" min="0.1" max="500" step="0.1" inputmode="decimal" /></label>
53
- <label class="atvwc-field"><span>{t.bowDepthLabel} <span data-length-unit></span></span><input data-input="bowDepthCm" type="number" min="0" max="500" step="0.1" inputmode="decimal" /></label>
54
- </div>
55
-
56
- <div class="atvwc-input-grid">
57
- <label class="atvwc-field"><span>{t.substrateLabel} <span data-length-unit></span></span><input data-input="substrateDepthCm" type="number" min="0" max="500" step="0.1" inputmode="decimal" /></label>
39
+ <div class="atvwc-input-grid atvwc-common-fields">
40
+ <label class="atvwc-field"><span>{t.substrateLabel} <span data-length-unit>{t.lengthUnitMetric}</span></span><input data-input="substrateDepthCm" type="number" min="0" max="500" step="0.1" inputmode="decimal" /></label>
58
41
  <label class="atvwc-field"><span>{t.changeLabel} %</span><input data-input="changePercent" type="number" min="0" max="100" step="1" inputmode="decimal" /></label>
59
42
  </div>
60
43
 
61
44
  <p class="atvwc-help">{t.substrateHint}</p>
62
45
  <p class="atvwc-help atvwc-bow-note">{t.bowHint}</p>
63
- <button type="button" class="atvwc-calculate" data-action="calculate">{t.calculateLabel}</button>
64
46
  </section>
65
47
 
66
48
  <section class="atvwc-panel atvwc-visual-panel" aria-labelledby="atvwc-results-title">
67
- <div class="atvwc-tank-stage" aria-hidden="true">
68
- <div class="atvwc-tank"><div class="atvwc-water" data-water></div><div class="atvwc-gravel"></div><div class="atvwc-glass-line"></div></div>
69
- <div class="atvwc-fish atvwc-fish-one">◈</div>
70
- <div class="atvwc-fish atvwc-fish-two">◈</div>
49
+ <div class="atvwc-scene-header">
50
+ <span class="atvwc-scene-kicker">{t.netVolumeLabel}</span>
51
+ <span class="atvwc-scale" data-result="scale"></span>
52
+ </div>
53
+ <div class="atvwc-tank-stage" aria-label={t.resultHint}>
54
+ <div class="atvwc-tank" aria-hidden="true"><div class="atvwc-water" data-water></div><div class="atvwc-gravel"></div><div class="atvwc-glass-line"></div><span class="atvwc-level-mark atvwc-level-mark-top">100%</span><span class="atvwc-level-mark atvwc-level-mark-bottom">0%</span></div>
55
+ <div class="atvwc-stage-note"><strong data-result="stage-volume">-</strong><span>{t.netVolumeLabel}</span></div>
71
56
  </div>
72
57
  <div class="atvwc-result-heading">
73
- <div><p class="atvwc-eyebrow">{t.netVolumeLabel}</p><h2 id="atvwc-results-title" data-result="net">{t.emptyState}</h2></div>
74
- <span class="atvwc-scale" data-result="scale"></span>
58
+ <h2 id="atvwc-results-title" data-result="net">{t.emptyState}</h2>
59
+ <p data-result="change-summary"></p>
75
60
  </div>
76
61
  <p class="atvwc-result-hint">{t.resultHint}</p>
77
62
  <p class="atvwc-error" data-error role="alert"></p>