@jjlmoya/utils-home 1.37.0 → 1.39.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-home",
3
- "version": "1.37.0",
3
+ "version": "1.39.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",
@@ -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
+ });
@@ -1,3 +1,7 @@
1
+ function getStrokeColor(el: HTMLElement | null): string {
2
+ return el ? getComputedStyle(el).stroke : '#0ea5e9';
3
+ }
4
+
1
5
  function getLabels(): Record<string, string> {
2
6
  const el = document.getElementById('ac-labels');
3
7
  if (!el) return {};
@@ -24,18 +28,11 @@ function roomLabel(btu: number): string {
24
28
  return labels.labelRoomHot ?? '';
25
29
  }
26
30
 
27
- export function updateMiniRoom(btu: number) {
28
- const svg = document.querySelector('.ac-mini-room svg');
29
- if (!svg) return;
30
- const txt = svg.querySelector('text');
31
- if (txt) txt.textContent = roomLabel(btu);
32
- }
33
-
34
31
  export function spawnRipple() {
35
32
  const wrap = document.querySelector('.ac-ring-wrap');
36
33
  if (!wrap) return;
37
34
  const el = document.getElementById('ac-ring-fill');
38
- const color = el ? getComputedStyle(el).stroke : '#0ea5e9';
35
+ const color = getStrokeColor(el as HTMLElement | null);
39
36
  const ripple = document.createElement('div');
40
37
  ripple.className = 'ac-ripple';
41
38
  ripple.style.left = '50%';
@@ -46,6 +43,13 @@ export function spawnRipple() {
46
43
  setTimeout(() => ripple.remove(), 800);
47
44
  }
48
45
 
46
+ export function updateMiniRoom(btu: number) {
47
+ const svg = document.querySelector('.ac-mini-room svg');
48
+ if (!svg) return;
49
+ const txt = svg.querySelector('text');
50
+ if (txt) txt.textContent = roomLabel(btu);
51
+ }
52
+
49
53
  export function pulseGlow() {
50
54
  const glow = document.getElementById('ac-ring-glow');
51
55
  if (!glow) return;
@@ -88,7 +88,7 @@ function renderResult(res: ReturnType<typeof calculateAcTonnage>, people: number
88
88
  updateMiniRoom(res.btu);
89
89
  spawnRipple();
90
90
  const card = el('ac-card');
91
- if (card) { card.classList.remove('ac-peak'); void card.offsetWidth; card.classList.add('ac-peak'); }
91
+ if (card) { card.classList.remove('ac-peak'); requestAnimationFrame(() => { card!.classList.add('ac-peak'); }); }
92
92
  }
93
93
 
94
94
  function update() {
@@ -57,7 +57,7 @@ function breakdownLabel(key: string): string {
57
57
  </div>
58
58
  <div class="ac-field">
59
59
  <div class="ac-field-top">
60
- <span class="ac-label">{aUI.labelRoomSize}</span>
60
+ <label class="ac-label" for="ac-area">{aUI.labelRoomSize}</label>
61
61
  <span class="ac-val" id="ac-area-num">{defaults.area}</span>
62
62
  </div>
63
63
  <input class="ac-slider" type="range" id="ac-area" min="5" max="120" step="1" value={defaults.area} />
@@ -68,28 +68,28 @@ function breakdownLabel(key: string): string {
68
68
  </div>
69
69
  <div class="ac-field">
70
70
  <div class="ac-field-top">
71
- <span class="ac-label">{aUI.labelCeilingHeight}</span>
71
+ <label class="ac-label" for="ac-height">{aUI.labelCeilingHeight}</label>
72
72
  <span class="ac-val" id="ac-height-num">{defaults.height}</span>
73
73
  </div>
74
74
  <input class="ac-slider" type="range" id="ac-height" min="2" max="6" step="0.1" value={defaults.height} />
75
75
  </div>
76
76
  <div class="ac-field">
77
77
  <div class="ac-field-top">
78
- <span class="ac-label">{aUI.labelPeople}</span>
78
+ <label class="ac-label" for="ac-people">{aUI.labelPeople}</label>
79
79
  <span class="ac-val" id="ac-people-num">{defaults.people}</span>
80
80
  </div>
81
81
  <input class="ac-slider" type="range" id="ac-people" min="0" max="20" step="1" value={defaults.people} />
82
82
  </div>
83
83
  <div class="ac-field">
84
84
  <div class="ac-field-top">
85
- <span class="ac-label">{aUI.labelHeatSources}</span>
85
+ <label class="ac-label" for="ac-heat">{aUI.labelHeatSources}</label>
86
86
  <span class="ac-val" id="ac-heat-num">{defaults.heat}</span>
87
87
  </div>
88
88
  <input class="ac-slider" type="range" id="ac-heat" min="0" max="20" step="1" value={defaults.heat} />
89
89
  </div>
90
90
  <div class="ac-row">
91
91
  <div class="ac-select-wrap">
92
- <span class="ac-label">{aUI.labelSunExposure}</span>
92
+ <label class="ac-label" for="ac-sun">{aUI.labelSunExposure}</label>
93
93
  <select class="ac-select" id="ac-sun">
94
94
  <option value="light">{aUI.sunLight}</option>
95
95
  <option value="medium" selected>{aUI.sunMedium}</option>
@@ -97,7 +97,7 @@ function breakdownLabel(key: string): string {
97
97
  </select>
98
98
  </div>
99
99
  <div class="ac-select-wrap">
100
- <span class="ac-label">{aUI.labelRoomType}</span>
100
+ <label class="ac-label" for="ac-room">{aUI.labelRoomType}</label>
101
101
  <select class="ac-select" id="ac-room">
102
102
  <option value="bedroom">{aUI.roomBedroom}</option>
103
103
  <option value="living" selected>{aUI.roomLiving}</option>
@@ -163,6 +163,10 @@ const PEAK = new Set([8, 9, 10, 11, 18, 19, 20, 21]);
163
163
  <script>
164
164
  import { calculateCycleCost, fmt2 } from './logic';
165
165
 
166
+ function getRect(e: HTMLElement): DOMRect {
167
+ return e.getBoundingClientRect();
168
+ }
169
+
166
170
  const RATES: Record<string, number> = { eur: 1, usd: 1.08, gbp: 0.85, jpy: 160 };
167
171
  const LS_KEY_CURRENCY = 'appliance-cost-currency';
168
172
  let currency: 'usd' | 'eur' | 'gbp' | 'jpy' = 'eur';
@@ -208,8 +212,8 @@ const PEAK = new Set([8, 9, 10, 11, 18, 19, 20, 21]);
208
212
  function moveIndicator(track: HTMLElement, activeBtn: HTMLElement) {
209
213
  const ind = el('apc-pill-indicator') as HTMLElement | null;
210
214
  if (!ind || !track || !activeBtn) return;
211
- const tRect = track.getBoundingClientRect();
212
- const bRect = activeBtn.getBoundingClientRect();
215
+ const tRect = getRect(track);
216
+ const bRect = getRect(activeBtn);
213
217
  ind.style.width = `${bRect.width}px`;
214
218
  ind.style.transform = `translateX(${bRect.left - tRect.left}px)`;
215
219
  }
@@ -27,7 +27,7 @@ const monPct = Math.min((initial.monitorHeight / maxVal) * 100, 100);
27
27
  </div>
28
28
  <div class="ergo-field">
29
29
  <div class="ergo-field-top">
30
- <span class="ergo-label">{eUI.labelHeight}</span>
30
+ <label class="ergo-label" for="ergo-height">{eUI.labelHeight}</label>
31
31
  <span class="ergo-val" id="ergo-height-val">{defaultHeight} {eUI.unitCm}</span>
32
32
  </div>
33
33
  <input type="range" class="ergo-slider" id="ergo-height" min="140" max="210" step="1" value={defaultHeight} />
@@ -89,12 +89,12 @@ const initial = calculateHumidity({ roomM2: DEF_ROOM, tempC: DEF_TEMP, currentRH
89
89
 
90
90
  <div class="hc-sliders">
91
91
  <div class="hc-slider-group">
92
- <label class="hc-slider-label">{hUI.labelTargetHumidity}</label>
92
+ <label class="hc-slider-label" for="hc-rh-t">{hUI.labelTargetHumidity}</label>
93
93
  <input type="range" id="hc-rh-t" class="hc-slider" min="0" max="100" value={DEF_TAR} />
94
94
  <span class="hc-slider-val" id="hc-rh-t-val">{DEF_TAR}{hUI.unitPercent}</span>
95
95
  </div>
96
96
  <div class="hc-slider-group">
97
- <label class="hc-slider-label">{hUI.labelCapacity}</label>
97
+ <label class="hc-slider-label" for="hc-cap">{hUI.labelCapacity}</label>
98
98
  <input type="range" id="hc-cap" class="hc-slider" min="0" max="100" value={DEF_CAP} />
99
99
  <span class="hc-slider-val" id="hc-cap-val">{DEF_CAP}{hUI.unitLitersDay}</span>
100
100
  </div>
@@ -205,8 +205,7 @@ const initial = calculateHumidity({ roomM2: DEF_ROOM, tempC: DEF_TEMP, currentRH
205
205
  const ring = el('hc-runtime-ring') as HTMLElement | null;
206
206
  if (ring) {
207
207
  ring.classList.remove('hc-pulse');
208
- void ring.offsetWidth;
209
- ring.classList.add('hc-pulse');
208
+ requestAnimationFrame(() => { ring!.classList.add('hc-pulse'); });
210
209
  }
211
210
  }
212
211
 
@@ -25,7 +25,7 @@ const initial = calculateProjection(100, 16 / 9, 1.5);
25
25
  <label class="proj-label" for="proj-diagonal">{pUI.labelDiagonal}</label>
26
26
  <span class="proj-unit">{pUI.labelDiagonalUnit}</span>
27
27
  </div>
28
- <input type="range" id="proj-diagonal-slider" min="30" max="300" value="100" step="1" class="proj-slider" />
28
+ <input type="range" id="proj-diagonal-slider" min="30" max="300" value="100" step="1" class="proj-slider" aria-label={pUI.labelDiagonal} />
29
29
  <div class="proj-number-row">
30
30
  <input type="number" id="proj-diagonal" value="100" min="30" max="300" class="proj-number-input" />
31
31
  <span class="proj-number-unit">"</span>
@@ -24,11 +24,11 @@ const qrUI = ui as QRGeneratorUI;
24
24
 
25
25
  <div id="form-wifi" class="qr-form">
26
26
  <div class="field-group">
27
- <label class="field-label">{qrUI.labelSsid}</label>
28
- <input type="text" name="ssid" class="qr-input field-input" placeholder={qrUI.placeholderSsid} />
27
+ <label class="field-label" for="qr-ssid">{qrUI.labelSsid}</label>
28
+ <input type="text" name="ssid" id="qr-ssid" class="qr-input field-input" placeholder={qrUI.placeholderSsid} />
29
29
  </div>
30
30
  <div class="field-group">
31
- <label class="field-label">{qrUI.labelPassword}</label>
31
+ <label class="field-label" for="wifi-password">{qrUI.labelPassword}</label>
32
32
  <div class="input-row">
33
33
  <input type="password" name="password" id="wifi-password" class="qr-input field-input" placeholder={qrUI.placeholderPassword} />
34
34
  <button type="button" id="toggle-password" class="eye-btn" aria-label="toggle password">
@@ -38,8 +38,8 @@ const qrUI = ui as QRGeneratorUI;
38
38
  </div>
39
39
  <div class="two-col">
40
40
  <div class="field-group">
41
- <label class="field-label">{qrUI.labelEncryption}</label>
42
- <select name="encryption" class="qr-input field-input">
41
+ <label class="field-label" for="qr-encryption">{qrUI.labelEncryption}</label>
42
+ <select name="encryption" id="qr-encryption" class="qr-input field-input">
43
43
  <option value="WPA">{qrUI.encWpa}</option>
44
44
  <option value="WEP">{qrUI.encWep}</option>
45
45
  <option value="nopass">{qrUI.encNone}</option>
@@ -56,33 +56,33 @@ const qrUI = ui as QRGeneratorUI;
56
56
 
57
57
  <div id="form-url" class="qr-form hidden">
58
58
  <div class="field-group">
59
- <label class="field-label">{qrUI.labelUrl}</label>
60
- <input type="url" name="url" class="qr-input field-input" placeholder={qrUI.placeholderUrl} />
59
+ <label class="field-label" for="qr-url">{qrUI.labelUrl}</label>
60
+ <input type="url" name="url" id="qr-url" class="qr-input field-input" placeholder={qrUI.placeholderUrl} />
61
61
  </div>
62
62
  </div>
63
63
 
64
64
  <div id="form-vcard" class="qr-form hidden">
65
65
  <div class="two-col">
66
66
  <div class="field-group">
67
- <label class="field-label">{qrUI.labelName}</label>
68
- <input type="text" name="name" class="qr-input field-input" />
67
+ <label class="field-label" for="qr-name">{qrUI.labelName}</label>
68
+ <input type="text" name="name" id="qr-name" class="qr-input field-input" />
69
69
  </div>
70
70
  <div class="field-group">
71
- <label class="field-label">{qrUI.labelSurname}</label>
72
- <input type="text" name="surname" class="qr-input field-input" />
71
+ <label class="field-label" for="qr-surname">{qrUI.labelSurname}</label>
72
+ <input type="text" name="surname" id="qr-surname" class="qr-input field-input" />
73
73
  </div>
74
74
  </div>
75
75
  <div class="field-group">
76
- <label class="field-label">{qrUI.labelPhone}</label>
77
- <input type="tel" name="phone" class="qr-input field-input" />
76
+ <label class="field-label" for="qr-phone">{qrUI.labelPhone}</label>
77
+ <input type="tel" name="phone" id="qr-phone" class="qr-input field-input" />
78
78
  </div>
79
79
  <div class="field-group">
80
- <label class="field-label">{qrUI.labelEmail}</label>
81
- <input type="email" name="email" class="qr-input field-input" />
80
+ <label class="field-label" for="qr-email">{qrUI.labelEmail}</label>
81
+ <input type="email" name="email" id="qr-email" class="qr-input field-input" />
82
82
  </div>
83
83
  <div class="field-group">
84
- <label class="field-label">{qrUI.labelOrg}</label>
85
- <input type="text" name="org" class="qr-input field-input" />
84
+ <label class="field-label" for="qr-org">{qrUI.labelOrg}</label>
85
+ <input type="text" name="org" id="qr-org" class="qr-input field-input" />
86
86
  </div>
87
87
  </div>
88
88
  </div>
@@ -151,7 +151,7 @@ function iBarH(val: number): number {
151
151
  <p class="tc-sim-desc">{tUI.labelSolarDesc}</p>
152
152
  </div>
153
153
  <div class="tc-sim-card">
154
- <p class="tc-sim-title">{tUI.labelShift}</p>
154
+ <label class="tc-sim-title" for="tc-shift">{tUI.labelShift}</label>
155
155
  <input type="range" id="tc-shift" min="0" max="50" step="5" value="0" class="tc-slider" />
156
156
  <p class="tc-sim-desc">{tUI.labelShiftDesc} <strong id="tc-shift-val">0%</strong></p>
157
157
  </div>
@@ -329,8 +329,8 @@
329
329
  gap: 10px;
330
330
  padding: 10px 16px;
331
331
  border-radius: 12px;
332
- background: var(--tile-bg-glow);
333
- border: 1px solid var(--tile-stroke);
332
+ background: var(--bg-surface);
333
+ border: 1px solid var(--border-color);
334
334
  width: 100%;
335
335
  max-width: 320px;
336
336
  }
@@ -69,7 +69,7 @@ const initial = calculateVampireDraw(DEFAULT_DEVICES.map((d) => ({ name: d.name,
69
69
  <div class="vamp-device-row" data-name={d.name} data-watts={d.watts}>
70
70
  <span class="vamp-device-name">{d.name}</span>
71
71
  <span class="vamp-device-watts">{d.watts} {vUI.unitWatts}</span>
72
- <input type="number" class="vamp-device-hours" value={d.hours} min="0" max="24" step="1" />
72
+ <input type="number" class="vamp-device-hours" value={d.hours} min="0" max="24" step="1" aria-label={vUI.labelHours} />
73
73
  <span class="vamp-device-watts">{vUI.unitHours}</span>
74
74
  <button class="vamp-remove-btn">{vUI.removeDevice}</button>
75
75
  </div>
@@ -223,7 +223,7 @@ const initial = calculateVampireDraw(DEFAULT_DEVICES.map((d) => ({ name: d.name,
223
223
  row.className = 'vamp-device-row';
224
224
  row.dataset.name = name;
225
225
  row.dataset.watts = String(watts);
226
- row.innerHTML = `<span class="vamp-device-name">${name}</span><span class="vamp-device-watts">${watts} ${unitW}</span><input type="number" class="vamp-device-hours" value="${hours}" min="0" max="24" step="1" /><span class="vamp-device-watts">${unitH}</span><button class="vamp-remove-btn">${removeLabel}</button>`;
226
+ row.innerHTML = `<span class="vamp-device-name">${name}</span><span class="vamp-device-watts">${watts} ${unitW}</span><input type="number" class="vamp-device-hours" value="${hours}" min="0" max="24" step="1" aria-label="hours" /><span class="vamp-device-watts">${unitH}</span><button class="vamp-remove-btn">${removeLabel}</button>`;
227
227
  list.appendChild(row);
228
228
  attachRowListeners(row, card);
229
229
  calculate(card);
@@ -163,8 +163,10 @@ export function updateDashboard(
163
163
  const ring = document.getElementById('sketch-ring');
164
164
  if (ring) {
165
165
  ring.classList.remove('sketch-peak-anim', 'sketch-danger-anim');
166
- void ring.offsetWidth;
167
- if (avgResult.verdict === 'perfect') ring.classList.add('sketch-peak-anim');
168
- if (avgResult.verdict === 'dead') ring.classList.add('sketch-danger-anim');
166
+ requestAnimationFrame(() => {
167
+ if (!ring) return;
168
+ if (avgResult.verdict === 'perfect') ring.classList.add('sketch-peak-anim');
169
+ if (avgResult.verdict === 'dead') ring.classList.add('sketch-danger-anim');
170
+ });
169
171
  }
170
172
  }
@@ -3,6 +3,10 @@ import { calculateSignalFromSketch, getVerdictColor, MATERIALS } from './logic';
3
3
  import { buildDeviceSVG } from './sketch-render-device';
4
4
  import { getUI } from './i18n-utils';
5
5
 
6
+ function getRect(el: HTMLElement): DOMRect {
7
+ return el.getBoundingClientRect();
8
+ }
9
+
6
10
  export function renderWalls(walls: Segment[]) {
7
11
  const layer = document.getElementById('sketch-walls-layer');
8
12
  if (!layer) return;
@@ -121,10 +125,10 @@ export function renderRays(router: Point, devices: PlacedDevice[], walls: Segmen
121
125
  export function spawnParticle(x: number, y: number, text: string) {
122
126
  const wrap = document.getElementById('sketch-canvas-wrap');
123
127
  if (!wrap) return;
128
+ const rect = getRect(wrap);
124
129
  const p = document.createElement('span');
125
130
  p.className = 'sketch-particle';
126
131
  p.textContent = text;
127
- const rect = wrap.getBoundingClientRect();
128
132
  p.style.left = `${x - rect.left}px`;
129
133
  p.style.top = `${y - rect.top}px`;
130
134
  wrap.appendChild(p);