@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 +4 -2
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tool/acTonnageCalculator/client-animations.ts +12 -8
- package/src/tool/acTonnageCalculator/client.ts +1 -1
- package/src/tool/acTonnageCalculator/component.astro +6 -6
- package/src/tool/applianceCostCalculator/component.astro +6 -2
- package/src/tool/deskErgonomics/component.astro +1 -1
- package/src/tool/humidityCalculator/component.astro +3 -4
- package/src/tool/projectorCalculator/component.astro +1 -1
- package/src/tool/qrGenerator/component.astro +17 -17
- package/src/tool/tariffComparator/component.astro +1 -1
- package/src/tool/tileLayoutCalculator/tile-layout-calculator.css +2 -2
- package/src/tool/vampireDrawSimulator/component.astro +2 -2
- package/src/tool/wifiRangeSimulator/sketch-render-dash.ts +5 -3
- package/src/tool/wifiRangeSimulator/sketch-render.ts +5 -1
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-home",
|
|
3
|
-
"version": "1.
|
|
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
|
|
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');
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
212
|
-
const bRect = activeBtn
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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(--
|
|
333
|
-
border: 1px solid var(--
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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);
|