@jjlmoya/utils-hardware 1.17.0 → 1.19.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 (98) hide show
  1. package/package.json +1 -1
  2. package/src/category/i18n/de.ts +4 -4
  3. package/src/category/i18n/id.ts +3 -3
  4. package/src/category/i18n/it.ts +3 -3
  5. package/src/category/i18n/nl.ts +3 -3
  6. package/src/category/i18n/pl.ts +4 -4
  7. package/src/category/i18n/pt.ts +3 -3
  8. package/src/category/i18n/ru.ts +3 -3
  9. package/src/category/i18n/sv.ts +3 -3
  10. package/src/category/i18n/tr.ts +3 -3
  11. package/src/category/i18n/zh.ts +3 -3
  12. package/src/category/index.ts +2 -1
  13. package/src/entries.ts +4 -1
  14. package/src/index.ts +1 -0
  15. package/src/layouts/PreviewLayout.astro +1 -0
  16. package/src/tests/diacritics_density.test.ts +118 -0
  17. package/src/tests/inverted_punctuation.test.ts +84 -0
  18. package/src/tests/locale_completeness.test.ts +2 -2
  19. package/src/tests/no_en_dash.test.ts +70 -0
  20. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  21. package/src/tests/script_density.test.ts +94 -0
  22. package/src/tests/tool_validation.test.ts +2 -2
  23. package/src/tool/batteryHealthEstimator/component.astro +3 -3
  24. package/src/tool/batteryHealthEstimator/i18n/fr.ts +4 -4
  25. package/src/tool/batteryHealthEstimator/i18n/pl.ts +1 -1
  26. package/src/tool/colorAccuracyTest/color-accuracy-test.css +118 -5
  27. package/src/tool/colorAccuracyTest/i18n/de.ts +7 -7
  28. package/src/tool/colorAccuracyTest/i18n/en.ts +3 -3
  29. package/src/tool/colorAccuracyTest/i18n/es.ts +2 -2
  30. package/src/tool/colorAccuracyTest/i18n/fr.ts +9 -9
  31. package/src/tool/colorAccuracyTest/i18n/id.ts +3 -3
  32. package/src/tool/colorAccuracyTest/i18n/it.ts +3 -3
  33. package/src/tool/colorAccuracyTest/i18n/ja.ts +1 -1
  34. package/src/tool/colorAccuracyTest/i18n/ko.ts +2 -2
  35. package/src/tool/colorAccuracyTest/i18n/nl.ts +3 -3
  36. package/src/tool/colorAccuracyTest/i18n/pl.ts +5 -5
  37. package/src/tool/colorAccuracyTest/i18n/pt.ts +3 -3
  38. package/src/tool/colorAccuracyTest/i18n/ru.ts +15 -15
  39. package/src/tool/colorAccuracyTest/i18n/sv.ts +3 -3
  40. package/src/tool/colorAccuracyTest/i18n/tr.ts +2 -2
  41. package/src/tool/colorAccuracyTest/i18n/zh.ts +7 -7
  42. package/src/tool/deadPixelTest/i18n/ru.ts +6 -6
  43. package/src/tool/deadPixelTest/i18n/sv.ts +1 -1
  44. package/src/tool/deadPixelTest/i18n/zh.ts +5 -5
  45. package/src/tool/gamepadTest/component.astro +4 -3
  46. package/src/tool/gamepadTest/gamepad-test.css +171 -3
  47. package/src/tool/gamepadTest/i18n/es.ts +4 -4
  48. package/src/tool/gamepadTest/i18n/ru.ts +1 -1
  49. package/src/tool/gamepadTest/i18n/zh.ts +1 -1
  50. package/src/tool/gamepadVibrationTester/component.astro +3 -3
  51. package/src/tool/gamepadVibrationTester/i18n/es.ts +1 -1
  52. package/src/tool/gamepadVibrationTester/i18n/fr.ts +2 -2
  53. package/src/tool/keyboardTest/component.astro +6 -1
  54. package/src/tool/keyboardTest/keyboard-test.css +115 -2
  55. package/src/tool/mouseDoubleClickTest/bibliography.astro +14 -0
  56. package/src/tool/mouseDoubleClickTest/bibliography.ts +16 -0
  57. package/src/tool/mouseDoubleClickTest/component.astro +135 -0
  58. package/src/tool/mouseDoubleClickTest/entry.ts +29 -0
  59. package/src/tool/mouseDoubleClickTest/i18n/de.ts +274 -0
  60. package/src/tool/mouseDoubleClickTest/i18n/en.ts +274 -0
  61. package/src/tool/mouseDoubleClickTest/i18n/es.ts +274 -0
  62. package/src/tool/mouseDoubleClickTest/i18n/fr.ts +274 -0
  63. package/src/tool/mouseDoubleClickTest/i18n/id.ts +285 -0
  64. package/src/tool/mouseDoubleClickTest/i18n/it.ts +274 -0
  65. package/src/tool/mouseDoubleClickTest/i18n/ja.ts +274 -0
  66. package/src/tool/mouseDoubleClickTest/i18n/ko.ts +274 -0
  67. package/src/tool/mouseDoubleClickTest/i18n/nl.ts +274 -0
  68. package/src/tool/mouseDoubleClickTest/i18n/pl.ts +274 -0
  69. package/src/tool/mouseDoubleClickTest/i18n/pt.ts +274 -0
  70. package/src/tool/mouseDoubleClickTest/i18n/ru.ts +274 -0
  71. package/src/tool/mouseDoubleClickTest/i18n/sv.ts +274 -0
  72. package/src/tool/mouseDoubleClickTest/i18n/tr.ts +274 -0
  73. package/src/tool/mouseDoubleClickTest/i18n/zh.ts +274 -0
  74. package/src/tool/mouseDoubleClickTest/index.ts +9 -0
  75. package/src/tool/mouseDoubleClickTest/logic.ts +258 -0
  76. package/src/tool/mouseDoubleClickTest/mouse-double-click-test.css +488 -0
  77. package/src/tool/mouseDoubleClickTest/seo.astro +15 -0
  78. package/src/tool/mouseDoubleClickTest/ui.ts +26 -0
  79. package/src/tool/mousePollingTest/i18n/fr.ts +3 -3
  80. package/src/tool/mousePollingTest/i18n/pl.ts +1 -1
  81. package/src/tool/mousePollingTest/i18n/ru.ts +1 -1
  82. package/src/tool/mousePollingTest/i18n/zh.ts +1 -1
  83. package/src/tool/mousePollingTest/logic/RatonManager.ts +6 -6
  84. package/src/tool/refreshRateDetector/i18n/de.ts +3 -3
  85. package/src/tool/refreshRateDetector/i18n/en.ts +3 -3
  86. package/src/tool/refreshRateDetector/i18n/fr.ts +4 -4
  87. package/src/tool/refreshRateDetector/i18n/id.ts +3 -3
  88. package/src/tool/refreshRateDetector/i18n/pl.ts +2 -2
  89. package/src/tool/refreshRateDetector/i18n/pt.ts +3 -3
  90. package/src/tool/refreshRateDetector/i18n/sv.ts +3 -3
  91. package/src/tool/refreshRateDetector/i18n/tr.ts +2 -2
  92. package/src/tool/refreshRateDetector/i18n/zh.ts +2 -2
  93. package/src/tool/toneGenerator/component.astro +7 -7
  94. package/src/tool/toneGenerator/i18n/fr.ts +2 -2
  95. package/src/tool/toneGenerator/i18n/pl.ts +1 -1
  96. package/src/tool/toneGenerator/i18n/ru.ts +2 -2
  97. package/src/tool/toneGenerator/i18n/zh.ts +3 -3
  98. package/src/tools.ts +2 -2
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function getFiles(dir: string): string[] {
6
+ const results: string[] = [];
7
+ if (!fs.existsSync(dir)) {
8
+ return results;
9
+ }
10
+ const list = fs.readdirSync(dir);
11
+ for (const file of list) {
12
+ const fullPath = path.join(dir, file);
13
+ const stat = fs.statSync(fullPath);
14
+ if (stat && stat.isDirectory()) {
15
+ if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
16
+ results.push(...getFiles(fullPath));
17
+ }
18
+ } else {
19
+ results.push(fullPath);
20
+ }
21
+ }
22
+ return results;
23
+ }
24
+
25
+ function isContentFile(filePath: string): boolean {
26
+ return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
27
+ }
28
+
29
+ const srcDir = path.join(process.cwd(), 'src');
30
+ const scriptsDir = path.join(process.cwd(), 'scripts');
31
+ const filesToTest = [
32
+ ...getFiles(srcDir).filter(isContentFile),
33
+ ...getFiles(scriptsDir).filter(isContentFile),
34
+ ];
35
+
36
+ const aiTypographyGarbage = [
37
+ '\u2013',
38
+ '\u2014',
39
+ '\u2026',
40
+ '\u201C',
41
+ '\u201D',
42
+ '\u2018',
43
+ '\u2019',
44
+ '\u00AB',
45
+ '\u00BB',
46
+ '\u200B',
47
+ '\u201E',
48
+ ];
49
+
50
+ describe('Typography Garbage Character Validation', () => {
51
+ filesToTest.forEach((filePath) => {
52
+ const relativePath = path.relative(process.cwd(), filePath);
53
+ it(`should not contain typography garbage characters in ${relativePath}`, () => {
54
+ const content = fs.readFileSync(filePath, 'utf-8');
55
+ const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
56
+ expect(hasAiPatterns).toBe(false);
57
+ });
58
+
59
+ it(`should not contain space before colon in ${relativePath}`, () => {
60
+ const content = fs.readFileSync(filePath, 'utf-8');
61
+ const spaceBeforeColon = / : /.test(content);
62
+ expect(spaceBeforeColon).toBe(false);
63
+ });
64
+
65
+ it(`should not contain double hyphen in ${relativePath}`, () => {
66
+ const content = fs.readFileSync(filePath, 'utf-8');
67
+ expect(content).not.toContain('--');
68
+ });
69
+ });
70
+ });
@@ -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
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type ScriptLocale = keyof typeof SCRIPT_RULES;
5
+
6
+ const SCRIPT_RULES = {
7
+ ja: {
8
+ language: 'Japanese',
9
+ scriptName: 'kana/kanji',
10
+ scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
11
+ minScriptRatio: 0.45,
12
+ },
13
+ ko: {
14
+ language: 'Korean',
15
+ scriptName: 'hangul',
16
+ scriptCharacters: /\p{Script=Hangul}/gu,
17
+ minScriptRatio: 0.55,
18
+ },
19
+ ru: {
20
+ language: 'Russian',
21
+ scriptName: 'cyrillic',
22
+ scriptCharacters: /\p{Script=Cyrillic}/gu,
23
+ minScriptRatio: 0.65,
24
+ },
25
+ zh: {
26
+ language: 'Chinese',
27
+ scriptName: 'han',
28
+ scriptCharacters: /\p{Script=Han}/gu,
29
+ minScriptRatio: 0.45,
30
+ },
31
+ } as const;
32
+
33
+ const LETTERS = /\p{L}/gu;
34
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
35
+
36
+ function collectStrings(value: unknown): string[] {
37
+ if (typeof value === 'string') return [value];
38
+ if (!value || typeof value !== 'object') return [];
39
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
40
+ return Object.values(value).flatMap(collectStrings);
41
+ }
42
+
43
+ function normalizeText(value: unknown): string {
44
+ return collectStrings(value).join(' ').normalize('NFC');
45
+ }
46
+
47
+ function translatableContent(content: Record<string, unknown>) {
48
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
49
+ }
50
+
51
+ function letterCount(text: string): number {
52
+ return text.match(LETTERS)?.length ?? 0;
53
+ }
54
+
55
+ function scriptCount(text: string, locale: ScriptLocale): number {
56
+ return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
57
+ }
58
+
59
+ function scriptRatio(text: string, locale: ScriptLocale): number {
60
+ const letters = letterCount(text);
61
+ if (letters === 0) return 0;
62
+ return scriptCount(text, locale) / letters;
63
+ }
64
+
65
+ describe('Native script density validation', () => {
66
+ ALL_TOOLS.forEach((tool) => {
67
+ describe(`Tool: ${tool.entry.id}`, () => {
68
+ Object.keys(SCRIPT_RULES).forEach((locale) => {
69
+ it(`${locale} keeps most translated text in its native script`, async () => {
70
+ const typedLocale = locale as ScriptLocale;
71
+ const loader = tool.entry.i18n[typedLocale];
72
+ if (!loader) return;
73
+
74
+ const content = await loader();
75
+ const rule = SCRIPT_RULES[typedLocale];
76
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
77
+ const letters = letterCount(text);
78
+ const matches = scriptCount(text, typedLocale);
79
+ const ratio = scriptRatio(text, typedLocale);
80
+
81
+ expect(
82
+ ratio,
83
+ [
84
+ `Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
85
+ `The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
86
+ `Most translatable content should be written in ${rule.scriptName} script.`,
87
+ 'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
88
+ ].join(' '),
89
+ ).toBeGreaterThanOrEqual(rule.minScriptRatio);
90
+ });
91
+ });
92
+ });
93
+ });
94
+ });
@@ -4,8 +4,8 @@ import { hardwareCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 9 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(9);
7
+ it('should have 10 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(10);
9
9
  });
10
10
 
11
11
  it('hardwareCategory should be defined', () => {
@@ -37,7 +37,7 @@ const S = JSON.stringify({
37
37
  <div class="bh-sliders">
38
38
  <div class="bh-slider-group">
39
39
  <div class="bh-slider-header">
40
- <label class="bh-slider-label">{t.voltageLabel}</label>
40
+ <label for="bh-v" class="bh-slider-label">{t.voltageLabel}</label>
41
41
  <span id="bh-v-val" class="bh-slider-val">3.70V</span>
42
42
  </div>
43
43
  <input type="range" id="bh-v" class="bh-slider" min="3.0" max="4.2" step="0.01" value="3.70" />
@@ -46,7 +46,7 @@ const S = JSON.stringify({
46
46
 
47
47
  <div class="bh-slider-group">
48
48
  <div class="bh-slider-header">
49
- <label class="bh-slider-label">{t.cyclesLabel}</label>
49
+ <label for="bh-c" class="bh-slider-label">{t.cyclesLabel}</label>
50
50
  <span id="bh-c-val" class="bh-slider-val">50</span>
51
51
  </div>
52
52
  <input type="range" id="bh-c" class="bh-slider" min="0" max="1000" step="1" value="50" />
@@ -54,7 +54,7 @@ const S = JSON.stringify({
54
54
 
55
55
  <div class="bh-slider-group">
56
56
  <div class="bh-slider-header">
57
- <label class="bh-slider-label">{t.tempLabel}</label>
57
+ <label for="bh-t" class="bh-slider-label">{t.tempLabel}</label>
58
58
  <span id="bh-t-val" class="bh-slider-val">25°C</span>
59
59
  </div>
60
60
  <input type="range" id="bh-t" class="bh-slider" min="0" max="60" step="1" value="25" />
@@ -93,7 +93,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
93
93
  schemas: [faqSchema, howToSchema, appSchema],
94
94
  bibliography,
95
95
  seo: [
96
- { type: 'title', text: 'La chimie du temps : pourquoi les batteries lithium meurent', level: 2 },
96
+ { type: 'title', text: 'La chimie du temps: pourquoi les batteries lithium meurent', level: 2 },
97
97
  {
98
98
  type: 'paragraph',
99
99
  html: "Une batterie lithium-ion n'est pas une boîte d'énergie statique, mais un écosystème chimique dynamique en dégradation constante depuis sa fabrication. Chaque cycle de charge et décharge, chaque variation de température et chaque minute à des tensions extrêmes contribue à la formation de sous-produits qui entravent le flux d'ions.",
@@ -103,7 +103,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
103
103
  type: 'paragraph',
104
104
  html: "<strong>Couche SEI :</strong> l'interface électrolyte solide croît avec le temps, consomme du lithium actif et augmente la résistance interne. <strong>Oxydation de l'électrolyte :</strong> des tensions supérieures à 4,1V accélèrent l'oxydation et peuvent gonfler la batterie. <strong>Lithium Plating :</strong> charger à basse température dépose du lithium sous forme métallique, créant des dendrites qui peuvent percer le séparateur.",
105
105
  },
106
- { type: 'title', text: "Le mythe des 100% : pourquoi charger toute la nuit est une erreur", level: 3 },
106
+ { type: 'title', text: "Le mythe des 100%: pourquoi charger toute la nuit est une erreur", level: 3 },
107
107
  {
108
108
  type: 'paragraph',
109
109
  html: "Pour un ion lithium, être à 100% de charge (4,2V) est un état de haute tension. Les recherches montrent que la durée de vie double ou triple si l'on maintient l'appareil entre <strong>20% et 80%</strong>. De plus, pour chaque hausse de 10°C au-dessus de 25°C, la vitesse de dégradation chimique double approximativement.",
@@ -111,7 +111,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
111
111
  { type: 'title', text: 'Protocole de survie énergétique', level: 3 },
112
112
  {
113
113
  type: 'paragraph',
114
- html: "Ne chargez jamais une batterie en dessous de 0°C : le lithium se dépose sur l'anode, causant des dommages permanents. La charge rapide génère de la chaleur localisée et du stress mécanique ; utilisez-la uniquement en cas de stricte nécessité. Pour un stockage prolongé, conservez la batterie à 50% dans un endroit frais.",
114
+ html: "Ne chargez jamais une batterie en dessous de 0°C: le lithium se dépose sur l'anode, causant des dommages permanents. La charge rapide génère de la chaleur localisée et du stress mécanique ; utilisez-la uniquement en cas de stricte nécessité. Pour un stockage prolongé, conservez la batterie à 50% dans un endroit frais.",
115
115
  },
116
116
  ],
117
117
  ui: {
@@ -122,7 +122,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
122
122
  voltageLabel: 'Tension Actuelle',
123
123
  cyclesLabel: 'Cycles de Charge',
124
124
  tempLabel: 'Température',
125
- voltageHint: 'Plage nominale : 3,0V (vide) à 4,2V (plein).',
125
+ voltageHint: 'Plage nominale: 3,0V (vide) à 4,2V (plein).',
126
126
  labelUsefulLife: 'Durée de Vie',
127
127
  yearsPrefix: 'Est.',
128
128
  yearsSuffix: 'ans',
@@ -106,7 +106,7 @@ export const content: ToolLocaleContent<EstimadorSaludBateriaUI> = {
106
106
  { type: 'title', text: 'Mit 100%: dlaczego ładowanie przez noc to błąd', level: 3 },
107
107
  {
108
108
  type: 'paragraph',
109
- html: 'Dla jonu litu stan naładowania 100% (4.2V) jest stanem wysokiego stresu. Badania pokazują, że żywotność cyklu wzrasta dwu- lub trzykrotnie przy utrzymywaniu urządzenia w zakresie <strong>20% 80%</strong>. Ponadto na każde 10°C powyżej 25°C tempo degradacji chemicznej wzrasta około dwukrotnie.',
109
+ html: 'Dla jonu litu stan naładowania 100% (4.2V) jest stanem wysokiego stresu. Badania pokazują, że żywotność cyklu wzrasta dwu- lub trzykrotnie przy utrzymywaniu urządzenia w zakresie <strong>20% - 80%</strong>. Ponadto na każde 10°C powyżej 25°C tempo degradacji chemicznej wzrasta około dwukrotnie.',
110
110
  },
111
111
  { type: 'title', text: 'Protokół przetrwania energii', level: 3 },
112
112
  {
@@ -20,6 +20,13 @@
20
20
  margin: 0 auto;
21
21
  padding: 1rem;
22
22
  color: var(--sc-text);
23
+ overflow-x: clip;
24
+ }
25
+
26
+ .sc-wrapper *,
27
+ .sc-wrapper *::before,
28
+ .sc-wrapper *::after {
29
+ box-sizing: border-box;
23
30
  }
24
31
 
25
32
  .theme-dark .sc-wrapper {
@@ -44,6 +51,7 @@
44
51
  transition: all 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
45
52
  position: relative;
46
53
  overflow: hidden;
54
+ max-width: 100%;
47
55
  }
48
56
 
49
57
  .sc-dashboard::before {
@@ -68,13 +76,18 @@
68
76
  }
69
77
 
70
78
  @media (max-width: 768px) {
79
+ .sc-wrapper {
80
+ padding: 0;
81
+ }
82
+
71
83
  .sc-grid {
72
84
  grid-template-columns: 1fr;
73
- gap: 2rem;
85
+ gap: 1.25rem;
74
86
  }
75
87
 
76
88
  .sc-dashboard {
77
- padding: 1.5rem;
89
+ border-radius: 0.875rem;
90
+ padding: 1rem;
78
91
  }
79
92
  }
80
93
 
@@ -160,6 +173,7 @@
160
173
  padding: 0.5rem;
161
174
  border-radius: 0.875rem;
162
175
  border: 1px solid var(--sc-border);
176
+ min-width: 0;
163
177
  }
164
178
 
165
179
  .sc-gamut-btn {
@@ -173,6 +187,7 @@
173
187
  background: transparent;
174
188
  color: var(--sc-text-muted);
175
189
  position: relative;
190
+ min-width: 0;
176
191
  }
177
192
 
178
193
  .sc-gamut-btn.sc-gamut-active {
@@ -220,6 +235,7 @@
220
235
  display: flex;
221
236
  gap: 1rem;
222
237
  animation: fade-in-up 0.8s ease-out 0.4s both;
238
+ width: 100%;
223
239
  }
224
240
 
225
241
  .sc-btn {
@@ -236,6 +252,7 @@
236
252
  gap: 0.5rem;
237
253
  position: relative;
238
254
  overflow: hidden;
255
+ min-width: 0;
239
256
  }
240
257
 
241
258
  .sc-btn::before {
@@ -292,6 +309,7 @@
292
309
  color: var(--sc-text-light);
293
310
  margin-top: 1.5rem;
294
311
  animation: fade-in-up 0.8s ease-out 0.5s both;
312
+ flex-wrap: wrap;
295
313
  }
296
314
 
297
315
  .sc-shortcut {
@@ -315,12 +333,12 @@
315
333
  justify-content: center;
316
334
  align-items: center;
317
335
  animation: fade-in-right 0.8s ease-out 0.3s both;
336
+ min-width: 0;
318
337
  }
319
338
 
320
339
  .sc-preview-canvas {
321
340
  width: 100%;
322
341
  aspect-ratio: 1;
323
- max-width: 350px;
324
342
  border-radius: 1rem;
325
343
  background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 50%, #1e1b4b 100%);
326
344
  border: 2px solid var(--sc-border);
@@ -331,6 +349,7 @@
331
349
  flex-direction: column;
332
350
  justify-content: center;
333
351
  align-items: center;
352
+ max-width: min(350px, 100%);
334
353
  }
335
354
 
336
355
  .sc-preview-gradient {
@@ -362,6 +381,8 @@
362
381
  letter-spacing: 0.05em;
363
382
  text-transform: uppercase;
364
383
  z-index: 10;
384
+ width: calc(100% - 2rem);
385
+ text-align: center;
365
386
  }
366
387
 
367
388
  #test-overlay {
@@ -402,6 +423,8 @@
402
423
  z-index: 1001;
403
424
  backdrop-filter: blur(10px);
404
425
  animation: slide-up 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
426
+ max-height: 55vh;
427
+ overflow-y: auto;
405
428
  }
406
429
 
407
430
  @media (min-width: 768px) {
@@ -419,11 +442,24 @@
419
442
  @keyframes slide-up {
420
443
  from {
421
444
  opacity: 0;
422
- transform: translateX(-50%) translateY(20px);
445
+ transform: translateY(20px);
423
446
  }
424
447
  to {
425
448
  opacity: 1;
426
- transform: translateX(-50%) translateY(0);
449
+ transform: translateY(0);
450
+ }
451
+ }
452
+
453
+ @media (min-width: 768px) {
454
+ @keyframes slide-up {
455
+ from {
456
+ opacity: 0;
457
+ transform: translateX(-50%) translateY(20px);
458
+ }
459
+ to {
460
+ opacity: 1;
461
+ transform: translateX(-50%) translateY(0);
462
+ }
427
463
  }
428
464
  }
429
465
 
@@ -675,6 +711,7 @@
675
711
  display: flex;
676
712
  gap: 0.5rem;
677
713
  margin-top: 0.75rem;
714
+ width: 100%;
678
715
  }
679
716
 
680
717
  @media (min-width: 768px) {
@@ -695,6 +732,7 @@
695
732
  font-weight: 600;
696
733
  cursor: pointer;
697
734
  transition: all 0.2s ease;
735
+ min-width: 0;
698
736
  }
699
737
 
700
738
  @media (min-width: 768px) {
@@ -726,3 +764,78 @@
726
764
  .sc-action-secondary:hover {
727
765
  background: rgba(255, 255, 255, 0.15);
728
766
  }
767
+
768
+ @media (max-width: 640px) {
769
+ .sc-content {
770
+ min-width: 0;
771
+ }
772
+
773
+ .sc-gamut-toggle {
774
+ gap: 0.375rem;
775
+ padding: 0.375rem;
776
+ }
777
+
778
+ .sc-gamut-btn {
779
+ padding: 0.65rem 0.5rem;
780
+ font-size: 0.8rem;
781
+ }
782
+
783
+ .sc-buttons {
784
+ display: grid;
785
+ grid-template-columns: 1fr;
786
+ }
787
+
788
+ .sc-btn {
789
+ width: 100%;
790
+ padding: 0.875rem 1rem;
791
+ }
792
+
793
+ .sc-shortcuts {
794
+ gap: 0.5rem;
795
+ font-size: 0.75rem;
796
+ }
797
+
798
+ .sc-shortcuts > span {
799
+ display: none;
800
+ }
801
+
802
+ .sc-shortcut {
803
+ flex: 1 1 8rem;
804
+ }
805
+
806
+ .sc-preview-canvas {
807
+ max-width: min(18rem, 100%);
808
+ }
809
+
810
+ .sc-preview-label {
811
+ bottom: 0.75rem;
812
+ font-size: 0.7rem;
813
+ }
814
+
815
+ .sc-controls {
816
+ padding: 3.75rem 0.75rem 0.75rem;
817
+ max-height: 70vh;
818
+ }
819
+
820
+ .sc-close-test {
821
+ top: 0.75rem;
822
+ right: 0.75rem;
823
+ width: 2.5rem;
824
+ height: 2.5rem;
825
+ font-size: 1.6rem;
826
+ }
827
+
828
+ .sc-test-description {
829
+ margin-top: 0;
830
+ font-size: 0.82rem;
831
+ }
832
+
833
+ .sc-test-actions {
834
+ display: grid;
835
+ grid-template-columns: 1fr 1fr;
836
+ }
837
+
838
+ .sc-action-btn {
839
+ padding: 0.7rem 0.5rem;
840
+ }
841
+ }