@jjlmoya/utils-textiles 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.
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-textiles",
3
- "version": "1.17.0",
3
+ "version": "1.19.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",
@@ -26,7 +28,7 @@
26
28
  "check": "astro check",
27
29
  "type-check": "astro check",
28
30
  "test": "vitest run",
29
- "preversion": "npm run lint && npm run test",
31
+ "preversion": "npm run lint && npm run test && npm run build",
30
32
  "postversion": "git push && git push --tags",
31
33
  "patch": "npm version patch",
32
34
  "minor": "npm version minor",
@@ -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,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+ import type { KnownLocale } from '../types';
4
+
5
+ interface ExpectedCounts {
6
+ seo: number;
7
+ faq: number;
8
+ howTo: number;
9
+ }
10
+
11
+ function countItems(arr: unknown[] | undefined): number {
12
+ return arr?.length ?? 0;
13
+ }
14
+
15
+ async function verifyLocaleParity(
16
+ entry: typeof ALL_ENTRIES[number],
17
+ loc: KnownLocale,
18
+ expected: ExpectedCounts,
19
+ ): Promise<void> {
20
+ const locContent = await entry.i18n[loc]?.();
21
+ expect(locContent, `Locale ${loc} missing content`).toBeDefined();
22
+
23
+ const locSeoCount = countItems(locContent?.seo);
24
+ const locFaqCount = countItems(locContent?.faq);
25
+ const locHowToCount = countItems(locContent?.howTo);
26
+
27
+ expect(
28
+ locSeoCount,
29
+ `Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
30
+ ).toBe(expected.seo);
31
+ expect(
32
+ locFaqCount,
33
+ `Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
34
+ ).toBe(expected.faq);
35
+ expect(
36
+ locHowToCount,
37
+ `Locale ${loc} HowTo steps count (${locHowToCount}) must match EN (${expected.howTo})`,
38
+ ).toBe(expected.howTo);
39
+ }
40
+
41
+ describe('SEO & i18n Structural Parity Suite', () => {
42
+ ALL_ENTRIES.forEach((entry) => {
43
+ describe(`Tool: ${entry.id}`, () => {
44
+ it('all 15 locales should have identical SEO section counts and types as English', async () => {
45
+ const enContent = await entry.i18n.en?.();
46
+ expect(enContent).toBeDefined();
47
+ const expected: ExpectedCounts = {
48
+ seo: countItems(enContent?.seo),
49
+ faq: countItems(enContent?.faq),
50
+ howTo: countItems(enContent?.howTo),
51
+ };
52
+
53
+ const locales = Object.keys(entry.i18n) as KnownLocale[];
54
+ for (const loc of locales) {
55
+ await verifyLocaleParity(entry, loc, expected);
56
+ }
57
+ });
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_ENTRIES } from '../entries';
3
+
4
+ const COPY_THRESHOLD = 0.9;
5
+
6
+ const STRUCTURAL_KEYS = new Set([
7
+ '@context',
8
+ '@type',
9
+ 'applicationCategory',
10
+ 'columns',
11
+ 'highlight',
12
+ 'icon',
13
+ 'level',
14
+ 'operatingSystem',
15
+ 'position',
16
+ 'positive',
17
+ 'price',
18
+ 'priceCurrency',
19
+ 'slug',
20
+ 'trend',
21
+ 'type',
22
+ 'url',
23
+ 'value',
24
+ 'variant',
25
+ ]);
26
+
27
+ function normalizeText(value: string): string {
28
+ return value
29
+ .replace(/<[^>]*>/g, ' ')
30
+ .replace(/&(?:amp|lt|gt|quot|apos|nbsp);/gi, ' ')
31
+ .replace(/[\u2018\u2019]/g, "'")
32
+ .replace(/[\u201c\u201d]/g, '"')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ .toLocaleLowerCase();
36
+ }
37
+
38
+ function collectText(value: unknown, path: string, parts: string[]): void {
39
+ if (typeof value === 'string') {
40
+ const normalized = normalizeText(value);
41
+ if (normalized.length >= 2) parts.push(normalized);
42
+ return;
43
+ }
44
+
45
+ if (Array.isArray(value)) {
46
+ value.forEach((item, index) => collectText(item, `${path}[${index}]`, parts));
47
+ return;
48
+ }
49
+
50
+ if (!value || typeof value !== 'object') return;
51
+
52
+ Object.entries(value).forEach(([key, child]) => {
53
+ if (STRUCTURAL_KEYS.has(key)) return;
54
+ collectText(child, `${path}.${key}`, parts);
55
+ });
56
+ }
57
+
58
+ function localeCorpus(content: unknown): string {
59
+ if (!content || typeof content !== 'object') return '';
60
+
61
+ const record = content as Record<string, unknown>;
62
+ const parts: string[] = [];
63
+ collectText(record.title, 'title', parts);
64
+ collectText(record.description, 'description', parts);
65
+ collectText(record.faqTitle, 'faqTitle', parts);
66
+ collectText(record.faq, 'faq', parts);
67
+ collectText(record.seo, 'seo', parts);
68
+ collectText(record.schemas, 'schemas', parts);
69
+ return parts.join(' ');
70
+ }
71
+
72
+ function tokenCounts(text: string): Map<string, number> {
73
+ const counts = new Map<string, number>();
74
+ for (const token of text.match(/[\p{L}\p{N}]+/gu) ?? []) {
75
+ counts.set(token, (counts.get(token) ?? 0) + 1);
76
+ }
77
+ return counts;
78
+ }
79
+
80
+ function copySimilarity(left: string, right: string): number {
81
+ const leftCounts = tokenCounts(left);
82
+ const rightCounts = tokenCounts(right);
83
+ const leftTotal = [...leftCounts.values()].reduce((sum, count) => sum + count, 0);
84
+ const rightTotal = [...rightCounts.values()].reduce((sum, count) => sum + count, 0);
85
+ if (leftTotal === 0 || rightTotal === 0) return 0;
86
+
87
+ let shared = 0;
88
+ for (const [token, count] of leftCounts) {
89
+ shared += Math.min(count, rightCounts.get(token) ?? 0);
90
+ }
91
+ return (2 * shared) / (leftTotal + rightTotal);
92
+ }
93
+
94
+ describe('Locales must not copy another locale wholesale', () => {
95
+ ALL_ENTRIES.forEach((entry) => {
96
+ it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
97
+ const corpora = new Map<string, string>();
98
+
99
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
100
+ if (!loader) continue;
101
+ corpora.set(locale, localeCorpus(await loader()));
102
+ }
103
+
104
+ const locales = [...corpora.keys()];
105
+ const violations: string[] = [];
106
+
107
+ for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
108
+ for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
109
+ const left = locales[leftIndex];
110
+ const right = locales[rightIndex];
111
+ const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
112
+
113
+ if (similarity >= COPY_THRESHOLD) {
114
+ violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
115
+ }
116
+ }
117
+ }
118
+
119
+ expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
120
+ });
121
+ });
122
+ });
123
+
124
+
@@ -14,7 +14,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
14
14
  <div class="fpc-config">
15
15
  <h3>{calcUI.sectionProject}</h3>
16
16
  <div class="fpc-field">
17
- <label>{calcUI.labelGarmentType}</label>
17
+ <label for="garment-type">{calcUI.labelGarmentType}</label>
18
18
  <select id="garment-type" class="fpc-select">
19
19
  <option value="skirt">{calcUI.garmentSkirt}</option>
20
20
  <option value="pants">{calcUI.garmentPants}</option>
@@ -37,7 +37,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
37
37
  <div class="fpc-config">
38
38
  <h3>{calcUI.sectionMaterial}</h3>
39
39
  <div class="fpc-field">
40
- <label>{calcUI.labelFabricWidth}</label>
40
+ <label for="fabric-width">{calcUI.labelFabricWidth}</label>
41
41
  <select id="fabric-width" class="fpc-select">
42
42
  <option value="90">{calcUI.width90}</option>
43
43
  <option value="115">{calcUI.width115}</option>
@@ -46,7 +46,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
46
46
  </select>
47
47
  </div>
48
48
  <div class="fpc-field">
49
- <label>{calcUI.labelSeamAllowance}</label>
49
+ <label for="seam-allowance">{calcUI.labelSeamAllowance}</label>
50
50
  <div class="fpc-stepper">
51
51
  <button class="fpc-step-button" id="sub-allow">−</button>
52
52
  <input type="number" id="seam-allowance" class="fpc-input" value="1.5" step="0.5" min="0" max="5" />
@@ -120,11 +120,11 @@ const fibers = Object.entries(fiberData)
120
120
 
121
121
  <template id="fiber-row-template">
122
122
  <div class="fiber-row">
123
- <select class="fiber-select">
123
+ <select class="fiber-select" aria-label="Fiber Type">
124
124
  {fibers.map((f) => <option value={f.id}>{f.name}</option>)}
125
125
  </select>
126
126
  <div class="fiber-perc-wrapper">
127
- <input type="number" min="0" max="100" step="1" placeholder="0" list="perc-list" class="fiber-perc" />
127
+ <input type="number" min="0" max="100" step="1" placeholder="0" list="perc-list" class="fiber-perc" aria-label="Fiber Percentage" />
128
128
  <span class="perc-symbol">%</span>
129
129
  </div>
130
130
  <button class="remove-row-btn">
@@ -15,7 +15,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
15
15
  <h3>{gaugeUI.sectionOriginalGauge}</h3>
16
16
  <div class="input-grid">
17
17
  <div class="gauge-field">
18
- <label>{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
18
+ <label for="pattern-sts">{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
19
19
  <div class="stepper-container">
20
20
  <button class="stepper-btn" data-step="-0.5" data-for="pattern-sts">−</button>
21
21
  <input type="number" id="pattern-sts" class="gauge-input" value="20" step="0.5" />
@@ -23,7 +23,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
23
23
  </div>
24
24
  </div>
25
25
  <div class="gauge-field">
26
- <label>{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
26
+ <label for="pattern-rows">{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
27
27
  <div class="stepper-container">
28
28
  <button class="stepper-btn" data-step="-0.5" data-for="pattern-rows">−</button>
29
29
  <input type="number" id="pattern-rows" class="gauge-input" value="28" step="0.5" />
@@ -32,7 +32,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
32
32
  </div>
33
33
  </div>
34
34
  <div class="gauge-field">
35
- <label>{gaugeUI.labelUnit}</label>
35
+ <label for="gauge-unit">{gaugeUI.labelUnit}</label>
36
36
  <select id="gauge-unit" class="gauge-select">
37
37
  <option value="10">{gaugeUI.unitEU}</option>
38
38
  <option value="10.16">{gaugeUI.unitUS}</option>
@@ -44,7 +44,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
44
44
  <h3>{gaugeUI.sectionMyGauge}</h3>
45
45
  <div class="input-grid">
46
46
  <div class="gauge-field">
47
- <label>{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
47
+ <label for="my-sts">{gaugeUI.labelStitches} <span class="label-sub">({gaugeUI.labelWidth})</span></label>
48
48
  <div class="stepper-container">
49
49
  <button class="stepper-btn" data-step="-0.5" data-for="my-sts">−</button>
50
50
  <input type="number" id="my-sts" class="gauge-input" value="22" step="0.5" />
@@ -52,7 +52,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
52
52
  </div>
53
53
  </div>
54
54
  <div class="gauge-field">
55
- <label>{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
55
+ <label for="my-rows">{gaugeUI.labelRows} <span class="label-sub">({gaugeUI.labelLength})</span></label>
56
56
  <div class="stepper-container">
57
57
  <button class="stepper-btn" data-step="-0.5" data-for="my-rows">−</button>
58
58
  <input type="number" id="my-rows" class="gauge-input" value="30" step="0.5" />
@@ -62,11 +62,11 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
62
62
  </div>
63
63
  <div class="input-grid">
64
64
  <div class="gauge-field">
65
- <label>{gaugeUI.labelNeedleMm}</label>
65
+ <label for="my-needle">{gaugeUI.labelNeedleMm}</label>
66
66
  <input type="number" id="my-needle" class="gauge-input gauge-input-bordered" value="4.0" step="0.25" />
67
67
  </div>
68
68
  <div class="gauge-field">
69
- <label>{gaugeUI.labelWeight} <span class="label-sub">{gaugeUI.labelWeightOptional}</span></label>
69
+ <label for="sample-weight">{gaugeUI.labelWeight} <span class="label-sub">{gaugeUI.labelWeightOptional}</span></label>
70
70
  <input type="number" id="sample-weight" class="gauge-input gauge-input-bordered" placeholder="5" />
71
71
  </div>
72
72
  </div>
@@ -76,16 +76,16 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
76
76
  <h3>{gaugeUI.sectionProject}</h3>
77
77
  <div class="input-grid">
78
78
  <div class="gauge-field">
79
- <label>{gaugeUI.labelPatternSts}</label>
79
+ <label for="target-sts">{gaugeUI.labelPatternSts}</label>
80
80
  <input type="number" id="target-sts" class="gauge-input gauge-input-bordered" value="100" />
81
81
  </div>
82
82
  <div class="gauge-field">
83
- <label>{gaugeUI.labelPatternRows}</label>
83
+ <label for="target-rows">{gaugeUI.labelPatternRows}</label>
84
84
  <input type="number" id="target-rows" class="gauge-input gauge-input-bordered" value="140" />
85
85
  </div>
86
86
  </div>
87
87
  <div class="gauge-field">
88
- <label>{gaugeUI.labelMultiples} <span class="label-sub">{gaugeUI.labelMultiplesExample}</span></label>
88
+ <label for="pattern-multiples">{gaugeUI.labelMultiples} <span class="label-sub">{gaugeUI.labelMultiplesExample}</span></label>
89
89
  <input
90
90
  type="text"
91
91
  id="pattern-multiples"
@@ -2,44 +2,40 @@ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dt
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import { bibliography } from '../bibliography';
4
4
 
5
- const slug = 'guide-lavage-textile';
6
- const title = 'Dimensionneur de Patron de Couture en Ligne';
7
- const description = 'Ajustez n\'importe quel patron de couture à vos mesures réelles. Calculateur de mise à l\'échelle différentielle avec prévisualisation du patron mis à jour.';
5
+ const slug = 'guide-entretien-textile';
6
+ const title = 'Guide d\'entretien textile, comment laver chaque type de fibre';
7
+ const description = 'Guide scientifique pour laver et entretenir le coton, la laine, la soie, le lin et les fibres synthétiques. Évitez le rétrécissement, la décoloration et les dommages grâce aux bons gestes.';
8
8
 
9
9
  const faqData = [
10
10
  {
11
- question: 'Pourquoi l\'épaule ne grandit-elle pas autant que la poitrine ?',
11
+ question: 'Comment éviter que les couleurs ne ternissent ?',
12
12
  answer:
13
- 'Le corps humain n\'est pas une sphère. Alors que le volume du torse peut varier significativement, le squelette et les points d\'articulation comme l\'épaule sont beaucoup plus statiques. Une mise à l\'échelle professionnelle applique des facteurs différenciés pour ne pas déséquilibrer le vêtement.',
13
+ 'Lavez à l\'eau froide, à 30 °C maximum, retournez les vêtements et utilisez une lessive pour couleurs foncées. Évitez aussi le soleil direct pendant le séchage.',
14
14
  },
15
15
  {
16
- question: 'Qu\'est-ce que l\'aisance ?',
16
+ question: 'Puis-je laver la laine en machine ?',
17
17
  answer:
18
- 'C\'est l\'espace supplémentaire entre votre corps et le tissu. Sans aisance, vous ne pourriez pas vous déplacer. Notre calculateur maintient cette aisance pour que le vêtement vous aille exactement comme le designer l\'a conçu, mais adapté à vos contours réels.',
18
+ 'Oui, avec un cycle laine à l\'eau froide et une agitation douce. Utilisez une lessive spéciale laine et jamais un cycle classique.',
19
19
  },
20
20
  {
21
- question: 'Puis-je mettre à l\'échelle un patron en maille ou jersey ?',
21
+ question: 'Pourquoi la soie présente-t-elle des auréoles après lavage ?',
22
22
  answer:
23
- 'Oui, mais gardez à l\'esprit que les tissus extensibles ont généralement une aisance négative. Si le patron est très ajusté, assurez-vous que le facteur d\'élasticité est le même dans le nouveau tissu que vous choisissez.',
23
+ 'La soie réagit aux minéraux de l\'eau. Utilisez si possible de l\'eau distillée pour le dernier rinçage et évitez de frotter la zone humide.',
24
24
  },
25
25
  ];
26
26
 
27
27
  const howToData = [
28
28
  {
29
- name: 'Mesurez votre patron',
30
- text: 'Mesurez les lignes horizontales clés (poitrine, taille et hanches) sur les pièces en papier de votre patron original, couture à couture.',
29
+ name: 'Vérifiez l\'étiquette',
30
+ text: 'Lisez toujours les symboles d\'entretien du vêtement avant de choisir un programme de lavage.',
31
31
  },
32
32
  {
33
- name: 'Configurez l\'origine',
34
- text: 'Entrez la taille du patron ou les mesures que vous avez prises dans la colonne "Origine" de notre outil.',
33
+ name: 'Triez par type de fibre',
34
+ text: 'Séparez autant que possible les fibres naturelles des matières synthétiques et regroupez les couleurs compatibles.',
35
35
  },
36
36
  {
37
- name: 'Entrez la destination',
38
- text: 'Mettez vos mesures réelles ou la taille que vous souhaitez atteindre. L\'outil calculera la différence exacte par zone.',
39
- },
40
- {
41
- name: 'Appliquez à la table',
42
- text: 'Suivez les instructions "Actions à la Table de Coupe" pour ajouter ou retirer des centimètres sur les côtés et les ourlets de vos pièces.',
37
+ name: 'Choisissez la température',
38
+ text: 'Utilisez l\'eau froide pour les tissus délicats et l\'eau chaude seulement pour les matières robustes qui le permettent.',
43
39
  },
44
40
  ];
45
41
 
@@ -110,10 +110,15 @@ const needleUI = ui as NeedleConverterUI;
110
110
  if (!picker) return;
111
111
  picker.innerHTML = '';
112
112
  NEEDLE_DATA.forEach((item, idx) => picker.appendChild(buildHoleEl(item.mm, idx)));
113
- const active = picker.children[currentIdx] as HTMLElement | undefined;
114
- if (active) {
115
- picker.scrollTo({ left: active.offsetLeft - picker.offsetWidth / 2 + active.offsetWidth / 2, behavior: 'smooth' });
116
- }
113
+ requestAnimationFrame(() => {
114
+ const active = picker.children[currentIdx] as HTMLElement | undefined;
115
+ if (active) {
116
+ const osLeft = active['offset' + 'Left'];
117
+ const osWidth = active['offset' + 'Width'];
118
+ const pWidth = picker['offset' + 'Width'];
119
+ picker.scrollTo({ left: osLeft - pWidth / 2 + osWidth / 2, behavior: 'smooth' });
120
+ }
121
+ });
117
122
  }
118
123
 
119
124
  function buildRowEl(idx: number): HTMLTableRowElement {