@jjlmoya/utils-forensic-science 1.6.0 → 1.7.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.
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-forensic-science",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./data": "./src/data.ts",
10
- "./entries": "./src/entries.ts"
10
+ "./entries": "./src/entries.ts",
11
+ "./runtime/*": "./src/tool/*/index.ts",
12
+ "./category-seo": "./src/category/seo.astro"
11
13
  },
12
14
  "files": [
13
15
  "src",
@@ -10,7 +10,12 @@ interface Props {
10
10
  hasSidebar?: boolean;
11
11
  }
12
12
 
13
- const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Astro.props;
13
+ const {
14
+ title,
15
+ currentLocale = "es",
16
+ localeUrls = {},
17
+ hasSidebar = false,
18
+ } = Astro.props;
14
19
  ---
15
20
 
16
21
  <!doctype html>
@@ -78,6 +83,7 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
78
83
  transition:
79
84
  background-color 0.3s ease,
80
85
  color 0.3s ease;
86
+ font-family: Inter, sans-serif;
81
87
  }
82
88
 
83
89
  main {
@@ -114,4 +120,3 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
114
120
  }
115
121
  }
116
122
  </style>
117
-
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });
@@ -111,7 +111,7 @@ const { ui } = Astro.props;
111
111
  import { analyzeBloodstains } from './logic';
112
112
  import type { BloodstainInput, OriginEstimate } from './logic';
113
113
 
114
- const ui = JSON.parse(document.getElementById('bpa-ui')?.textContent || '{}');
114
+ const ui = JSON.parse((document.getElementById('bpa-ui') as HTMLElement | null)?.innerText || '{}');
115
115
  const surface = document.getElementById('bpa-surface') as HTMLCanvasElement | null;
116
116
  const rows = document.getElementById('bpa-rows');
117
117
  const threeHost = document.getElementById('bpa-three');
@@ -182,31 +182,6 @@ const { ui } = Astro.props;
182
182
  return stains.find((stain) => stain.id === selectedStainId) ?? stains[0];
183
183
  }
184
184
 
185
- function configureFocusRanges() {
186
- if (!focusWidthEl || !focusLengthEl) return;
187
- focusWidthEl.min = cmToInput(0.2).toString();
188
- focusWidthEl.max = cmToInput(3).toString();
189
- focusWidthEl.step = unit === 'metric' ? '0.1' : '0.05';
190
- focusLengthEl.min = cmToInput(0.5).toString();
191
- focusLengthEl.max = cmToInput(6).toString();
192
- focusLengthEl.step = unit === 'metric' ? '0.1' : '0.05';
193
- }
194
-
195
- function syncFocusPanel() {
196
- const stain = selectedStain();
197
- if (!stain) return;
198
- const color = palette[selectedIndex() % palette.length];
199
- if (focusNameEl) focusNameEl.textContent = `${ui.stainLabel} ${stain.id}`;
200
- if (focusSwatchEl) focusSwatchEl.style.background = color;
201
- configureFocusRanges();
202
- if (focusWidthEl) focusWidthEl.value = cmToInput(stain.widthCm).toString();
203
- if (focusLengthEl) focusLengthEl.value = cmToInput(stain.lengthCm).toString();
204
- if (focusRotationEl) focusRotationEl.value = stain.rotationDeg.toString();
205
- if (focusWidthValueEl) focusWidthValueEl.textContent = display(stain.widthCm);
206
- if (focusLengthValueEl) focusLengthValueEl.textContent = display(stain.lengthCm);
207
- if (focusRotationValueEl) focusRotationValueEl.textContent = `${stain.rotationDeg.toFixed(0)} ${ui.degree}`;
208
- }
209
-
210
185
  function canvasMetrics() {
211
186
  if (!surface) return null;
212
187
  const rect = surface.getBoundingClientRect();
@@ -239,6 +214,39 @@ const { ui } = Astro.props;
239
214
  };
240
215
  }
241
216
 
217
+ function resizeThree() {
218
+ if (!threeHost) return;
219
+ const rect = threeHost.getBoundingClientRect();
220
+ renderer.setSize(rect.width, rect.height, false);
221
+ camera.aspect = rect.width / Math.max(rect.height, 1);
222
+ camera.updateProjectionMatrix();
223
+ }
224
+
225
+ function configureFocusRanges() {
226
+ if (!focusWidthEl || !focusLengthEl) return;
227
+ focusWidthEl.min = cmToInput(0.2).toString();
228
+ focusWidthEl.max = cmToInput(3).toString();
229
+ focusWidthEl.step = unit === 'metric' ? '0.1' : '0.05';
230
+ focusLengthEl.min = cmToInput(0.5).toString();
231
+ focusLengthEl.max = cmToInput(6).toString();
232
+ focusLengthEl.step = unit === 'metric' ? '0.1' : '0.05';
233
+ }
234
+
235
+ function syncFocusPanel() {
236
+ const stain = selectedStain();
237
+ if (!stain) return;
238
+ const color = palette[selectedIndex() % palette.length];
239
+ if (focusNameEl) focusNameEl.textContent = `${ui.stainLabel} ${stain.id}`;
240
+ if (focusSwatchEl) focusSwatchEl.style.background = color;
241
+ configureFocusRanges();
242
+ if (focusWidthEl) focusWidthEl.value = cmToInput(stain.widthCm).toString();
243
+ if (focusLengthEl) focusLengthEl.value = cmToInput(stain.lengthCm).toString();
244
+ if (focusRotationEl) focusRotationEl.value = stain.rotationDeg.toString();
245
+ if (focusWidthValueEl) focusWidthValueEl.textContent = display(stain.widthCm);
246
+ if (focusLengthValueEl) focusLengthValueEl.textContent = display(stain.lengthCm);
247
+ if (focusRotationValueEl) focusRotationValueEl.textContent = `${stain.rotationDeg.toFixed(0)} ${ui.degree}`;
248
+ }
249
+
242
250
  function findStainAt(event: PointerEvent): BloodstainInput | null {
243
251
  const pointer = eventToSurface(event);
244
252
  if (!pointer) return null;
@@ -274,14 +282,6 @@ const { ui } = Astro.props;
274
282
  renderer.setAnimationLoop(() => renderer.render(scene, camera));
275
283
  }
276
284
 
277
- function resizeThree() {
278
- if (!threeHost) return;
279
- const rect = threeHost.getBoundingClientRect();
280
- renderer.setSize(rect.width, rect.height, false);
281
- camera.aspect = rect.width / Math.max(rect.height, 1);
282
- camera.updateProjectionMatrix();
283
- }
284
-
285
285
  function clearDynamic3d() {
286
286
  for (let index = group.children.length - 1; index >= 0; index--) {
287
287
  const child = group.children[index];
@@ -398,11 +398,11 @@ const { ui } = Astro.props;
398
398
  const row = document.createElement('tr');
399
399
  if (stain.id === selectedStainId) row.className = 'is-selected';
400
400
  row.innerHTML = `
401
- <td><input data-field="xCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.xCm).toFixed(1)}"></td>
402
- <td><input data-field="yCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.yCm).toFixed(1)}"></td>
403
- <td><input data-field="widthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.widthCm).toFixed(1)}"></td>
404
- <td><input data-field="lengthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.lengthCm).toFixed(1)}"></td>
405
- <td><input data-field="rotationDeg" data-id="${stain.id}" type="number" step="1" value="${stain.rotationDeg.toFixed(0)}"></td>
401
+ <td><input aria-label="${ui.x} ${stain.id}" data-field="xCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.xCm).toFixed(1)}"></td>
402
+ <td><input aria-label="${ui.y} ${stain.id}" data-field="yCm" data-id="${stain.id}" type="number" step="0.5" value="${cmToInput(stain.yCm).toFixed(1)}"></td>
403
+ <td><input aria-label="${ui.width} ${stain.id}" data-field="widthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.widthCm).toFixed(1)}"></td>
404
+ <td><input aria-label="${ui.length} ${stain.id}" data-field="lengthCm" data-id="${stain.id}" type="number" min="0.1" step="0.1" value="${cmToInput(stain.lengthCm).toFixed(1)}"></td>
405
+ <td><input aria-label="${ui.rotation} ${stain.id}" data-field="rotationDeg" data-id="${stain.id}" type="number" step="1" value="${stain.rotationDeg.toFixed(0)}"></td>
406
406
  <td><button type="button" class="bpa-icon-button" data-remove="${stain.id}" aria-label="${ui.remove} ${stain.id}">${deleteIconTemplate?.innerHTML || ui.remove}</button></td>
407
407
  `;
408
408
  rows.appendChild(row);
@@ -12,9 +12,9 @@ export function animateDrop(dropEl: HTMLElement | null, dishEl: HTMLElement | nu
12
12
  }
13
13
 
14
14
  dropEl.classList.remove('animating');
15
- void dropEl.offsetWidth;
16
-
17
- dropEl.classList.add('animating');
15
+ requestAnimationFrame(() => {
16
+ dropEl.classList.add('animating');
17
+ });
18
18
 
19
19
  setTimeout(() => {
20
20
  dropEl.classList.remove('animating');
@@ -24,7 +24,7 @@ const { ui } = Astro.props;
24
24
  <aside class="fiber-controls">
25
25
  <div class="fiber-field">
26
26
  <span>{ui.questionedSample}</span>
27
- <select id="fiber-left" class="fiber-native-select" aria-hidden="true" tabindex="-1">
27
+ <select id="fiber-left" class="fiber-native-select" aria-hidden="true" aria-label={ui.questionedSample} tabindex="-1">
28
28
  <option value="questionedCotton">{ui.questionedCotton}</option>
29
29
  </select>
30
30
  <div class="fiber-builder-head">
@@ -73,7 +73,7 @@ const { ui } = Astro.props;
73
73
 
74
74
  <div class="fiber-field">
75
75
  <span>{ui.knownSample}</span>
76
- <select id="fiber-right" class="fiber-native-select" aria-hidden="true" tabindex="-1">
76
+ <select id="fiber-right" class="fiber-native-select" aria-hidden="true" aria-label={ui.knownSample} tabindex="-1">
77
77
  <option value="suspectCotton">{ui.suspectCotton}</option>
78
78
  <option value="wool">{ui.wool}</option>
79
79
  <option value="polyester">{ui.polyester}</option>
@@ -249,8 +249,9 @@ class FiberComparisonView {
249
249
  if (!panel) return;
250
250
  panel.dataset.verdict = state;
251
251
  panel.classList.remove('is-updating');
252
- void panel.offsetWidth;
253
- panel.classList.add('is-updating');
252
+ requestAnimationFrame(() => {
253
+ panel.classList.add('is-updating');
254
+ });
254
255
  }
255
256
 
256
257
  private statusKey(verdictKey: Result['verdictKey']): string {
@@ -190,7 +190,7 @@ const { ui } = Astro.props;
190
190
  renderTable,
191
191
  } from './renderer';
192
192
 
193
- const ui = JSON.parse(document.getElementById('fp-ui')?.textContent || '{}');
193
+ const ui = JSON.parse((document.getElementById('fp-ui') as HTMLElement | null)?.innerText || '{}');
194
194
  const board = document.querySelector<HTMLElement>('[data-fingerprint-tool] .fp-board');
195
195
  const canvas = document.getElementById('fp-canvas') as HTMLCanvasElement | null;
196
196
  const context = canvas?.getContext('2d') ?? null;
@@ -238,10 +238,12 @@ const { ui } = Astro.props;
238
238
  if (!uploadedImage) return 1;
239
239
  return Math.max(2, Math.min(4, step));
240
240
  }
241
+ function getCanvasRect(): DOMRect | null { return canvas ? canvas.getBoundingClientRect() : null; }
241
242
  function updateMagnifier(): void {
242
243
  if (canvas && glass) {
244
+ const w = canvas.clientWidth, h = canvas.clientHeight;
243
245
  glass.style.backgroundImage = `url(${canvas.toDataURL('image/png')})`;
244
- glass.style.backgroundSize = `${canvas.clientWidth * 2.4}px ${canvas.clientHeight * 2.4}px`;
246
+ glass.style.backgroundSize = `${w * 2.4}px ${h * 2.4}px`;
245
247
  }
246
248
  }
247
249
  function render(): void {
@@ -311,14 +313,16 @@ const { ui } = Astro.props;
311
313
  canvas?.addEventListener('click', (event) => {
312
314
  if (!uploadedImage) return fileInput?.click();
313
315
  if (activeStep !== 3 || !canvas) return;
314
- const rect = canvas.getBoundingClientRect();
316
+ const rect = getCanvasRect();
317
+ if (!rect) return;
315
318
  const canvasX = ((event.clientX - rect.left) / rect.width) * canvas.width;
316
319
  const canvasY = ((event.clientY - rect.top) / rect.height) * canvas.height;
317
320
  handleCanvasClick(canvasX, canvasY);
318
321
  });
319
322
  canvas?.addEventListener('mousemove', (event) => {
320
323
  if (!glass || !uploadedImage || !canvas) return;
321
- const rect = canvas.getBoundingClientRect();
324
+ const rect = getCanvasRect();
325
+ if (!rect) return;
322
326
  const localX = event.clientX - rect.left;
323
327
  const localY = event.clientY - rect.top;
324
328
  glass.style.opacity = '1';
@@ -103,7 +103,7 @@ const regions = [
103
103
  <div class="forensic-sex-field-body">
104
104
  <div class="forensic-sex-control-side">
105
105
  <div class="forensic-sex-slider-container">
106
- <input type="range" id={`input-${field.id}`} name={field.id} min="0" max="5" value="0" class="forensic-sex-slider" />
106
+ <input type="range" id={`input-${field.id}`} name={field.id} min="0" max="5" value="0" class="forensic-sex-slider" aria-label={field.label} />
107
107
  <div class="forensic-sex-slider-labels">
108
108
  <span>Ø</span>
109
109
  <span>1</span>
@@ -61,7 +61,7 @@ const ancestries = [
61
61
  <label for="input-length-val" id="length-label-text">{ui.lengthLabel} ({ui.unitsCm})</label>
62
62
  <div class="length-input-container">
63
63
  <input type="number" id="input-length-val" class="selector-number" step="0.1" />
64
- <input type="range" id="input-length" class="selector-range" step="0.1" />
64
+ <input type="range" id="input-length" class="selector-range" step="0.1" aria-label={`${ui.lengthLabel} ${ui.unitsCm}`} />
65
65
  </div>
66
66
  </div>
67
67
 
@@ -16,7 +16,7 @@ const { ui } = Astro.props;
16
16
 
17
17
  <div class="widmark-field">
18
18
  <span>{ui.weight}</span>
19
- <input type="number" id="widmark-weight" class="widmark-input" min="30" max="250" value="80" />
19
+ <input type="number" id="widmark-weight" class="widmark-input" min="30" max="250" value="80" aria-label={ui.weight} />
20
20
  </div>
21
21
 
22
22
  <div class="widmark-field">
@@ -35,7 +35,7 @@ const { ui } = Astro.props;
35
35
 
36
36
  <div class="widmark-field">
37
37
  <span>{ui.hydration}</span>
38
- <select id="widmark-hydration" class="widmark-select">
38
+ <select id="widmark-hydration" class="widmark-select" aria-label={ui.hydration}>
39
39
  <option value="low">{ui.hydrationLow}</option>
40
40
  <option value="normal" selected>{ui.hydrationNormal}</option>
41
41
  <option value="high">{ui.hydrationHigh}</option>
@@ -44,7 +44,7 @@ const { ui } = Astro.props;
44
44
 
45
45
  <div class="widmark-field">
46
46
  <span>{ui.stomachState}</span>
47
- <select id="widmark-stomach" class="widmark-select">
47
+ <select id="widmark-stomach" class="widmark-select" aria-label={ui.stomachState}>
48
48
  <option value="empty">{ui.stomachEmpty}</option>
49
49
  <option value="light" selected>{ui.stomachLight}</option>
50
50
  <option value="full">{ui.stomachFull}</option>
@@ -64,15 +64,15 @@ const { ui } = Astro.props;
64
64
  <div class="widmark-drink-form-grid">
65
65
  <div class="widmark-field">
66
66
  <span>{ui.drinkVolume}</span>
67
- <input type="number" id="widmark-drink-vol" class="widmark-input" value="330" min="10" max="2000" />
67
+ <input type="number" id="widmark-drink-vol" class="widmark-input" value="330" min="10" max="2000" aria-label={ui.drinkVolume} />
68
68
  </div>
69
69
  <div class="widmark-field">
70
70
  <span>{ui.drinkAbv}</span>
71
- <input type="number" id="widmark-drink-abv" class="widmark-input" value="5" min="0.1" max="100" step="0.1" />
71
+ <input type="number" id="widmark-drink-abv" class="widmark-input" value="5" min="0.1" max="100" step="0.1" aria-label={ui.drinkAbv} />
72
72
  </div>
73
73
  <div class="widmark-field">
74
74
  <span>{ui.drinkTime}</span>
75
- <input type="number" id="widmark-drink-time" class="widmark-input" value="0" min="0" max="11.9" step="0.1" />
75
+ <input type="number" id="widmark-drink-time" class="widmark-input" value="0" min="0" max="11.9" step="0.1" aria-label={ui.drinkTime} />
76
76
  </div>
77
77
  <button type="button" id="widmark-add-drink" class="widmark-btn-primary">
78
78
  <span>{ui.addDrink}</span>