@jjlmoya/utils-aquarium 1.65.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 (41) 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/mfe/category-ui.ts +34 -34
  6. package/src/tests/qa-test-helpers.ts +32 -0
  7. package/src/tests/qa_bibliography_links.test.ts +48 -0
  8. package/src/tests/qa_claim_evidence.test.ts +69 -0
  9. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  10. package/src/tests/qa_runtime_i18n.test.ts +138 -0
  11. package/src/tests/tool_validation.test.ts +4 -3
  12. package/src/tool/aquariumSaltDosingCalculator/aquarium-salt-dosing-calculator.css +503 -0
  13. package/src/tool/aquariumSaltDosingCalculator/bibliography.astro +6 -0
  14. package/src/tool/aquariumSaltDosingCalculator/bibliography.ts +5 -0
  15. package/src/tool/aquariumSaltDosingCalculator/component.astro +36 -0
  16. package/src/tool/aquariumSaltDosingCalculator/controller.ts +104 -0
  17. package/src/tool/aquariumSaltDosingCalculator/dom-views.ts +74 -0
  18. package/src/tool/aquariumSaltDosingCalculator/entry.ts +13 -0
  19. package/src/tool/aquariumSaltDosingCalculator/i18n/de.ts +2 -0
  20. package/src/tool/aquariumSaltDosingCalculator/i18n/en.ts +2 -0
  21. package/src/tool/aquariumSaltDosingCalculator/i18n/es.ts +2 -0
  22. package/src/tool/aquariumSaltDosingCalculator/i18n/fr.ts +2 -0
  23. package/src/tool/aquariumSaltDosingCalculator/i18n/id.ts +2 -0
  24. package/src/tool/aquariumSaltDosingCalculator/i18n/it.ts +2 -0
  25. package/src/tool/aquariumSaltDosingCalculator/i18n/ja.ts +2 -0
  26. package/src/tool/aquariumSaltDosingCalculator/i18n/ko.ts +2 -0
  27. package/src/tool/aquariumSaltDosingCalculator/i18n/nl.ts +2 -0
  28. package/src/tool/aquariumSaltDosingCalculator/i18n/pl.ts +2 -0
  29. package/src/tool/aquariumSaltDosingCalculator/i18n/pt.ts +2 -0
  30. package/src/tool/aquariumSaltDosingCalculator/i18n/ru.ts +2 -0
  31. package/src/tool/aquariumSaltDosingCalculator/i18n/sv.ts +2 -0
  32. package/src/tool/aquariumSaltDosingCalculator/i18n/tr.ts +2 -0
  33. package/src/tool/aquariumSaltDosingCalculator/i18n/zh.ts +2 -0
  34. package/src/tool/aquariumSaltDosingCalculator/index.ts +11 -0
  35. package/src/tool/aquariumSaltDosingCalculator/localized-content.ts +151 -0
  36. package/src/tool/aquariumSaltDosingCalculator/logic.test.ts +46 -0
  37. package/src/tool/aquariumSaltDosingCalculator/logic.ts +85 -0
  38. package/src/tool/aquariumSaltDosingCalculator/seo.astro +11 -0
  39. package/src/tool/aquariumSaltDosingCalculator/storage.ts +19 -0
  40. package/src/tool/aquariumSaltDosingCalculator/ui.ts +47 -0
  41. package/src/tools.ts +6 -0
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ function hasDedicatedLogicTest(toolDirectory: string): boolean {
13
+ const localCandidates = [
14
+ join(toolDirectory, 'logic.test.ts'),
15
+ join(toolDirectory, 'logic.spec.ts'),
16
+ join(toolDirectory, '__tests__', 'logic.test.ts'),
17
+ join(toolDirectory, '__tests__', 'logic.spec.ts'),
18
+ ];
19
+ if (localCandidates.some(existsSync)) return true;
20
+
21
+ const toolName = basename(toolDirectory);
22
+ const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
23
+ return centralTests.some((testPath) => {
24
+ const source = read(testPath).replace(/\\/g, '/');
25
+ return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
26
+ });
27
+ }
28
+
29
+ describe('QA: calculation logic has reference tests', () => {
30
+ it('gives every public calculator logic module its own behavioral test suite', () => {
31
+ const failures = listToolDirectories()
32
+ .filter((directory) => existsSync(join(directory, 'logic.ts')))
33
+ .filter((directory) => !hasDedicatedLogicTest(directory))
34
+ .map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
35
+
36
+ expect(
37
+ failures,
38
+ [
39
+ 'Add behavioral tests through each logic module public API.',
40
+ 'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
41
+ ...failures,
42
+ ].join('\n'),
43
+ ).toEqual([]);
44
+ });
45
+ });
46
+
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+ import { localizedContents } from '../tool/aquariumSaltDosingCalculator/localized-content';
12
+
13
+ interface VisibleLiteral {
14
+ line: number;
15
+ value: string;
16
+ }
17
+
18
+ const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
19
+ const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
20
+
21
+ const knownLegacyUnusedKeys = new Map<string, Set<string>>([
22
+ [
23
+ 'src/tool/aquariumEvaporationTopOffPlanner/ui.ts',
24
+ new Set([
25
+ 'volumeHint',
26
+ 'lossHint',
27
+ 'intervalHint',
28
+ 'resultTitle',
29
+ 'dailyRateLabel',
30
+ 'thirtyDayLabel',
31
+ 'twoPercentDaysLabel',
32
+ 'dayLabel',
33
+ 'measurementNote',
34
+ 'saltwaterNote',
35
+ 'resetLabel',
36
+ ]),
37
+ ],
38
+ ['src/tool/aquariumFilterTurnoverStockingPlanner/ui.ts', new Set(['effectiveTurnoverLabel'])],
39
+ ['src/tool/aquariumLightingPhotoperiodPlanner/ui.ts', new Set(['phaseRampDown'])],
40
+ ['src/tool/aquariumTankVolumeWaterChangeCalculator/ui.ts', new Set(['resetLabel'])],
41
+ ]);
42
+
43
+ function visibleWords(value: string): string {
44
+ return value
45
+ .replace(/\$\{[^}]*\}/g, ' ')
46
+ .replace(/<[^>]+>/g, ' ')
47
+ .replace(/&[a-z]+;/gi, ' ')
48
+ .replace(/[\d\p{P}\p{S}_]+/gu, ' ')
49
+ .replace(/\s+/g, ' ')
50
+ .trim();
51
+ }
52
+
53
+ function isTechnicalLiteral(value: string): boolean {
54
+ return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
55
+ || /^data-[a-z0-9-]+$/i.test(value);
56
+ }
57
+
58
+ function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
59
+ const failures: VisibleLiteral[] = [];
60
+
61
+ source.split(/\r?\n/).forEach((line, index) => {
62
+ if (!domTextAssignment.test(line)) return;
63
+
64
+ const assignment = line.slice(line.search(domTextAssignment));
65
+ for (const match of assignment.matchAll(quotedLiteral)) {
66
+ const literal = match[2] ?? '';
67
+ if (isTechnicalLiteral(literal)) continue;
68
+ const words = visibleWords(literal);
69
+ if (words && /\p{L}/u.test(words)) {
70
+ failures.push({ line: index + 1, value: literal });
71
+ }
72
+ }
73
+ });
74
+
75
+ return failures;
76
+ }
77
+
78
+ function runtimeSources(toolDirectory: string): string {
79
+ return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
80
+ .filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
81
+ .filter((path) => basename(path) !== 'ui.ts')
82
+ .map(read)
83
+ .join('\n');
84
+ }
85
+
86
+ describe('QA: runtime copy is localized', () => {
87
+ const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
88
+
89
+ it('does not write user-facing string literals directly into the DOM', () => {
90
+ const failures = componentFiles.flatMap((path) =>
91
+ hardcodedVisibleLiterals(read(path)).map(
92
+ ({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
93
+ ),
94
+ );
95
+
96
+ expect(
97
+ failures,
98
+ `Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
99
+ ).toEqual([]);
100
+ });
101
+
102
+ it('uses every string declared by each UI locale contract', () => {
103
+ const failures: string[] = [];
104
+
105
+ for (const directory of listToolDirectories()) {
106
+ const uiPath = join(directory, 'ui.ts');
107
+ if (!existsSync(uiPath)) continue;
108
+
109
+ const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]).filter(
110
+ (key): key is string => Boolean(key),
111
+ );
112
+ const runtime = runtimeSources(directory);
113
+ const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
114
+ const unexpectedUnused = unused.filter(
115
+ (key) => !knownLegacyUnusedKeys.get(displayPath(uiPath))?.has(key),
116
+ );
117
+
118
+ if (unexpectedUnused.length > 0) {
119
+ failures.push(`${displayPath(uiPath)} -> unused: ${unexpectedUnused.join(', ')}`);
120
+ }
121
+ }
122
+
123
+ expect(
124
+ failures,
125
+ `Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
126
+ ).toEqual([]);
127
+ });
128
+
129
+ it('keeps validation errors localized for the salt calculator', () => {
130
+ const englishError = localizedContents.en?.ui.invalidState;
131
+ const fallbacks = Object.entries(localizedContents)
132
+ .filter(([locale]) => locale !== 'en')
133
+ .filter(([, content]) => content.ui.invalidState === englishError)
134
+ .map(([locale]) => locale);
135
+
136
+ expect(fallbacks, `Validation error still falls back to English: ${fallbacks.join(', ')}`).toEqual([]);
137
+ });
138
+ });
@@ -3,14 +3,15 @@ import { ALL_TOOLS } from '../tools';
3
3
  import { aquariumCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
- it('registers exactly four aquarium production tools', () => {
7
- expect(ALL_TOOLS.length).toBe(4);
8
- expect(aquariumCategory.tools).toHaveLength(4);
6
+ it('registers exactly five aquarium production tools', () => {
7
+ expect(ALL_TOOLS.length).toBe(5);
8
+ expect(aquariumCategory.tools).toHaveLength(5);
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
13
  'aquarium-evaporation-top-off-planner',
14
+ 'aquarium-salt-dosing-calculator',
14
15
  ]);
15
16
  });
16
17
  });
@@ -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
+ ];