@jjlmoya/utils-textiles 1.16.0 → 1.18.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.16.0",
3
+ "version": "1.18.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
+ });
@@ -9,13 +9,13 @@ const { ui = {} } = Astro.props;
9
9
  const calcUI = ui as FabricProjectCalculatorUI;
10
10
  ---
11
11
 
12
- <div class="fabric-calculator-card" id="fabric-calc-app">
13
- <div class="fabric-sidebar">
14
- <div class="config-group">
12
+ <div class="fpc-root" id="fabric-calc-app">
13
+ <div class="fpc-controls">
14
+ <div class="fpc-config">
15
15
  <h3>{calcUI.sectionProject}</h3>
16
- <div class="fabric-field">
17
- <label>{calcUI.labelGarmentType}</label>
18
- <select id="garment-type" class="fabric-select">
16
+ <div class="fpc-field">
17
+ <label for="garment-type">{calcUI.labelGarmentType}</label>
18
+ <select id="garment-type" class="fpc-select">
19
19
  <option value="skirt">{calcUI.garmentSkirt}</option>
20
20
  <option value="pants">{calcUI.garmentPants}</option>
21
21
  <option value="dress">{calcUI.garmentDress}</option>
@@ -24,70 +24,70 @@ const calcUI = ui as FabricProjectCalculatorUI;
24
24
  <option value="tote">{calcUI.garmentTote}</option>
25
25
  </select>
26
26
  </div>
27
- <div class="fabric-field">
27
+ <div class="fpc-field">
28
28
  <label>{calcUI.labelSize}</label>
29
- <div class="preset-group" id="size-presets">
30
- <button class="preset-pill" data-val="xs_core">{calcUI.sizeXS}</button>
31
- <button class="preset-pill active" data-val="m_core">{calcUI.sizeM}</button>
32
- <button class="preset-pill" data-val="xl_core">{calcUI.sizeXL}</button>
29
+ <div class="fpc-size-options" id="size-presets">
30
+ <button class="fpc-size-option" data-val="xs_core">{calcUI.sizeXS}</button>
31
+ <button class="fpc-size-option active" data-val="m_core">{calcUI.sizeM}</button>
32
+ <button class="fpc-size-option" data-val="xl_core">{calcUI.sizeXL}</button>
33
33
  </div>
34
34
  </div>
35
35
  </div>
36
36
 
37
- <div class="config-group">
37
+ <div class="fpc-config">
38
38
  <h3>{calcUI.sectionMaterial}</h3>
39
- <div class="fabric-field">
40
- <label>{calcUI.labelFabricWidth}</label>
41
- <select id="fabric-width" class="fabric-select">
39
+ <div class="fpc-field">
40
+ <label for="fabric-width">{calcUI.labelFabricWidth}</label>
41
+ <select id="fabric-width" class="fpc-select">
42
42
  <option value="90">{calcUI.width90}</option>
43
43
  <option value="115">{calcUI.width115}</option>
44
44
  <option value="140" selected>{calcUI.width140}</option>
45
45
  <option value="150">{calcUI.width150}</option>
46
46
  </select>
47
47
  </div>
48
- <div class="fabric-field">
49
- <label>{calcUI.labelSeamAllowance}</label>
50
- <div class="shop-stepper">
51
- <button class="step-btn" id="sub-allow">−</button>
52
- <input type="number" id="seam-allowance" class="fabric-input" value="1.5" step="0.5" min="0" max="5" />
53
- <button class="step-btn" id="add-allow">+</button>
48
+ <div class="fpc-field">
49
+ <label for="seam-allowance">{calcUI.labelSeamAllowance}</label>
50
+ <div class="fpc-stepper">
51
+ <button class="fpc-step-button" id="sub-allow">−</button>
52
+ <input type="number" id="seam-allowance" class="fpc-input" value="1.5" step="0.5" min="0" max="5" />
53
+ <button class="fpc-step-button" id="add-allow">+</button>
54
54
  </div>
55
55
  </div>
56
56
  </div>
57
57
 
58
- <div class="btn-actions">
59
- <button id="btn-clear-f" class="f-btn f-seco">{calcUI.btnClear}</button>
60
- <button id="btn-share-f" class="f-btn f-prim">{calcUI.btnShare}</button>
58
+ <div class="fpc-actions">
59
+ <button id="btn-clear-f" class="fpc-action fpc-action-secondary">{calcUI.btnClear}</button>
60
+ <button id="btn-share-f" class="fpc-action fpc-action-primary">{calcUI.btnShare}</button>
61
61
  </div>
62
62
  </div>
63
63
 
64
- <div class="fabric-main">
65
- <div id="warning-msg" class="warning-banner"></div>
64
+ <div class="fpc-main">
65
+ <div id="warning-msg" class="fpc-warning"></div>
66
66
 
67
- <section class="magic-result-area">
68
- <span class="result-lbl">{calcUI.resultLabel}</span>
69
- <div class="val"><span id="main-meters">1.40</span><span class="unit">{calcUI.resultUnit}</span></div>
70
- <div class="advice-tag" id="shop-advice"></div>
67
+ <section class="fpc-result">
68
+ <span class="fpc-result-label">{calcUI.resultLabel}</span>
69
+ <div class="fpc-result-value"><span id="main-meters">1.40</span><span class="fpc-result-unit">{calcUI.resultUnit}</span></div>
70
+ <div class="fpc-advice" id="shop-advice"></div>
71
71
  </section>
72
72
 
73
- <div class="layout-container">
74
- <div class="canvas-wrapper">
75
- <div class="scheme-area">
76
- <div class="ruler" id="ruler-col"></div>
77
- <div id="fabric-board-box" class="fabric-board">
78
- <div class="board-empty-state">{calcUI.boardEmpty}</div>
73
+ <div class="fpc-layout">
74
+ <div class="fpc-canvas">
75
+ <div class="fpc-scheme">
76
+ <div class="fpc-ruler" id="ruler-col"></div>
77
+ <div id="fabric-board-box" class="fpc-board">
78
+ <div class="fpc-board-empty">{calcUI.boardEmpty}</div>
79
79
  </div>
80
80
  </div>
81
- <div class="scheme-info" id="board-desc"></div>
81
+ <div class="fpc-scheme-info" id="board-desc"></div>
82
82
  </div>
83
83
  </div>
84
84
 
85
- <section class="merch-section">
86
- <div class="merch-header">
87
- <h3 class="merch-title">{calcUI.merchTitle}</h3>
88
- <button id="copy-list-btn" class="copy-list-btn">{calcUI.btnCopyList}</button>
85
+ <section class="fpc-merch">
86
+ <div class="fpc-merch-header">
87
+ <h3 class="fpc-merch-title">{calcUI.merchTitle}</h3>
88
+ <button id="copy-list-btn" class="fpc-copy-button">{calcUI.btnCopyList}</button>
89
89
  </div>
90
- <ul id="check-list" class="merceria-checklist"></ul>
90
+ <ul id="check-list" class="fpc-checklist"></ul>
91
91
  </section>
92
92
  </div>
93
93
  </div>
@@ -157,17 +157,17 @@ const calcUI = ui as FabricProjectCalculatorUI;
157
157
 
158
158
  function buildPieceEl(nameKey: string, w: number, fold: boolean, col: number): HTMLDivElement {
159
159
  const el = document.createElement('div');
160
- el.className = `piece-block${fold ? ' piece-fold' : ''}`;
160
+ el.className = `fpc-piece${fold ? ' fpc-piece-fold' : ''}`;
161
161
  el.style.width = `${w * 100}%`;
162
162
  el.style.background = PIECE_COLORS[col];
163
163
  el.style.borderColor = PIECE_BORDERS[col];
164
164
  const nameEl = document.createElement('span');
165
- nameEl.className = 'piece-name';
165
+ nameEl.className = 'fpc-piece-name';
166
166
  nameEl.textContent = window.__toolUI.pieceNames[nameKey as PieceNameKey] || nameKey;
167
167
  el.appendChild(nameEl);
168
168
  if (fold) {
169
169
  const foldEl = document.createElement('div');
170
- foldEl.className = 'fold-indicator';
170
+ foldEl.className = 'fpc-fold-indicator';
171
171
  foldEl.textContent = window.__toolUI.foldLabel;
172
172
  el.appendChild(foldEl);
173
173
  }
@@ -176,7 +176,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
176
176
 
177
177
  function buildRowEl(row: PieceRow): HTMLDivElement {
178
178
  const rowEl = document.createElement('div');
179
- rowEl.className = 'piece-row';
179
+ rowEl.className = 'fpc-piece-row';
180
180
  rowEl.style.flex = `0 0 ${row.h * 100}%`;
181
181
  row.pieces.forEach((spec) => {
182
182
  rowEl.appendChild(buildPieceEl(spec.nameKey, spec.w, spec.fold ?? false, spec.col));
@@ -189,7 +189,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
189
189
  const rows = PIECE_ROWS[type];
190
190
  if (!rows?.length) {
191
191
  const empty = document.createElement('div');
192
- empty.className = 'board-empty-state';
192
+ empty.className = 'fpc-board-empty';
193
193
  empty.textContent = window.__toolUI.boardEmpty;
194
194
  board.appendChild(empty);
195
195
  return;
@@ -205,7 +205,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
205
205
  let mark = 0;
206
206
  while (mark <= totalMeters + 0.001) {
207
207
  const markEl = document.createElement('div');
208
- markEl.className = 'ruler-mark';
208
+ markEl.className = 'fpc-ruler-mark';
209
209
  markEl.style.top = `${(mark / totalMeters) * 100}%`;
210
210
  const label = document.createElement('span');
211
211
  label.textContent = `${mark.toFixed(step < 0.5 ? 2 : 1)}m`;
@@ -252,8 +252,8 @@ const calcUI = ui as FabricProjectCalculatorUI;
252
252
  checks.innerHTML = '';
253
253
  items.forEach((text) => {
254
254
  const li = document.createElement('li');
255
- li.className = 'merch-item';
256
- li.innerHTML = `<span class="merch-dot" aria-hidden="true">◆</span>${text}`;
255
+ li.className = 'fpc-merch-item';
256
+ li.innerHTML = `<span class="fpc-merch-dot" aria-hidden="true">◆</span>${text}`;
257
257
  checks.appendChild(li);
258
258
  });
259
259
  }
@@ -285,9 +285,9 @@ const calcUI = ui as FabricProjectCalculatorUI;
285
285
  }
286
286
 
287
287
  function handleSizeClick(e: Event): void {
288
- const btn = (e.target as HTMLElement).closest('.preset-pill');
288
+ const btn = (e.target as HTMLElement).closest('.fpc-size-option');
289
289
  if (!btn) return;
290
- document.getElementById('size-presets')?.querySelectorAll('.preset-pill').forEach((b) => {
290
+ document.getElementById('size-presets')?.querySelectorAll('.fpc-size-option').forEach((b) => {
291
291
  b.classList.remove('active');
292
292
  });
293
293
  btn.classList.add('active');
@@ -311,7 +311,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
311
311
  if (width) width.value = '140';
312
312
  if (allow) allow.value = '1.5';
313
313
  activeSize = 'm_core';
314
- document.getElementById('size-presets')?.querySelectorAll('.preset-pill').forEach((b) => {
314
+ document.getElementById('size-presets')?.querySelectorAll('.fpc-size-option').forEach((b) => {
315
315
  b.classList.toggle('active', b.getAttribute('data-val') === 'm_core');
316
316
  });
317
317
  calculate();
@@ -319,7 +319,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
319
319
 
320
320
  function handleCopyList(): void {
321
321
  const checks = document.getElementById('check-list');
322
- const lines = Array.from(checks?.querySelectorAll('.merch-item') || [])
322
+ const lines = Array.from(checks?.querySelectorAll('.fpc-merch-item') || [])
323
323
  .map((li) => '• ' + (li.textContent || '').replace('◆', '').trim());
324
324
  navigator.clipboard.writeText(lines.join('\n'));
325
325
  flashBtn('copy-list-btn', window.__toolUI.btnCopied);
@@ -340,7 +340,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
340
340
  function restoreSize(q: URLSearchParams): void {
341
341
  if (!q.has('s')) return;
342
342
  activeSize = q.get('s') || 'm_core';
343
- document.getElementById('size-presets')?.querySelectorAll('.preset-pill').forEach((b) => {
343
+ document.getElementById('size-presets')?.querySelectorAll('.fpc-size-option').forEach((b) => {
344
344
  b.classList.toggle('active', b.getAttribute('data-val') === activeSize);
345
345
  });
346
346
  }
@@ -1,92 +1,106 @@
1
- .fabric-calculator-card {
2
- --fabric-accent: #4f46e5;
3
- --fabric-accent-soft: rgba(79, 70, 229, 0.12);
4
- --fabric-panel: color-mix(in srgb, var(--bg-surface) 92%, var(--fabric-accent) 8%);
1
+ #fabric-calc-app {
2
+ --fpc-accent: #4f46e5;
3
+ --fpc-accent-soft: rgba(79, 70, 229, 0.12);
5
4
 
6
5
  width: min(100%, 1120px);
7
6
  margin: 1.5rem auto;
8
- padding: 1.25rem;
7
+ padding: 1rem;
9
8
  display: grid;
10
- grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
11
- gap: 1.25rem;
9
+ grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
10
+ gap: 1rem;
11
+ color: var(--text-main);
12
12
  background: var(--bg-surface);
13
13
  border: 1px solid var(--border-color);
14
- border-radius: 1.25rem;
15
- box-shadow: 0 24px 70px rgba(0, 0, 0, 0.14);
16
- color: var(--text-main);
14
+ border-radius: 1rem;
15
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.14);
16
+ box-sizing: border-box;
17
17
  overflow: visible;
18
+ isolation: isolate;
19
+ }
20
+
21
+ #fabric-calc-app *,
22
+ #fabric-calc-app *::before,
23
+ #fabric-calc-app *::after {
24
+ box-sizing: border-box;
18
25
  }
19
26
 
20
- .fabric-sidebar,
21
- .fabric-main,
22
- .magic-result-area,
23
- .canvas-wrapper,
24
- .merch-section {
27
+ #fabric-calc-app .fpc-controls,
28
+ #fabric-calc-app .fpc-main,
29
+ #fabric-calc-app .fpc-layout,
30
+ #fabric-calc-app .fpc-canvas,
31
+ #fabric-calc-app .fpc-result,
32
+ #fabric-calc-app .fpc-merch {
25
33
  min-width: 0;
34
+ max-width: 100%;
26
35
  }
27
36
 
28
- .fabric-sidebar {
29
- display: flex;
30
- flex-direction: column;
31
- gap: 1.25rem;
32
- padding: 1rem;
33
- background: var(--bg-page);
34
- border: 1px solid var(--border-color);
35
- border-radius: 1rem;
37
+ #fabric-calc-app .fpc-controls,
38
+ #fabric-calc-app .fpc-config,
39
+ #fabric-calc-app .fpc-field,
40
+ #fabric-calc-app .fpc-main,
41
+ #fabric-calc-app .fpc-checklist {
42
+ display: grid;
36
43
  }
37
44
 
38
- .config-group {
39
- display: flex;
40
- flex-direction: column;
45
+ #fabric-calc-app .fpc-controls,
46
+ #fabric-calc-app .fpc-main {
41
47
  gap: 1rem;
48
+ align-content: start;
49
+ }
50
+
51
+ #fabric-calc-app .fpc-config,
52
+ #fabric-calc-app .fpc-result,
53
+ #fabric-calc-app .fpc-canvas,
54
+ #fabric-calc-app .fpc-merch {
55
+ padding: 1rem;
56
+ background: var(--bg-page);
57
+ border: 1px solid var(--border-color);
58
+ border-radius: 0.85rem;
42
59
  }
43
60
 
44
- .config-group + .config-group {
45
- padding-top: 1rem;
46
- border-top: 1px solid var(--border-color);
61
+ #fabric-calc-app .fpc-config,
62
+ #fabric-calc-app .fpc-field {
63
+ gap: 0.85rem;
47
64
  }
48
65
 
49
- .config-group h3,
50
- .merch-title {
66
+ #fabric-calc-app .fpc-config h3,
67
+ #fabric-calc-app .fpc-merch-title {
51
68
  margin: 0;
69
+ color: var(--fpc-accent);
52
70
  font-size: 0.7rem;
53
71
  font-weight: 900;
54
72
  letter-spacing: 0.12em;
55
73
  text-transform: uppercase;
56
- color: var(--fabric-accent);
57
74
  }
58
75
 
59
- .fabric-field {
60
- display: flex;
61
- flex-direction: column;
62
- gap: 0.45rem;
76
+ #fabric-calc-app .fpc-field {
77
+ gap: 0.4rem;
63
78
  }
64
79
 
65
- .fabric-field label {
80
+ #fabric-calc-app .fpc-field label,
81
+ #fabric-calc-app .fpc-result-label {
82
+ color: var(--text-muted);
66
83
  font-size: 0.7rem;
67
- font-weight: 800;
84
+ font-weight: 850;
68
85
  letter-spacing: 0.06em;
69
86
  text-transform: uppercase;
70
- color: var(--text-muted);
71
87
  }
72
88
 
73
- .fabric-select,
74
- .fabric-input {
89
+ #fabric-calc-app .fpc-select,
90
+ #fabric-calc-app .fpc-input {
75
91
  width: 100%;
76
- min-width: 0;
77
- min-height: 46px;
92
+ min-height: 44px;
78
93
  color: var(--text-main);
79
94
  background-color: var(--bg-surface);
80
95
  border: 1.5px solid var(--border-color);
81
- border-radius: 0.75rem;
96
+ border-radius: 0.7rem;
82
97
  outline: none;
83
98
  }
84
99
 
85
- .fabric-select {
86
- padding: 0.75rem 2.5rem 0.75rem 0.9rem;
100
+ #fabric-calc-app .fpc-select {
101
+ padding: 0.75rem 2.4rem 0.75rem 0.85rem;
87
102
  font-size: 0.875rem;
88
103
  font-weight: 750;
89
- cursor: pointer;
90
104
  appearance: none;
91
105
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234F46E5' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
92
106
  background-repeat: no-repeat;
@@ -94,194 +108,159 @@
94
108
  background-size: 1rem;
95
109
  }
96
110
 
97
- .fabric-select:focus,
98
- .fabric-input:focus {
99
- border-color: var(--fabric-accent);
100
- box-shadow: 0 0 0 3px var(--fabric-accent-soft);
111
+ #fabric-calc-app .fpc-select:focus,
112
+ #fabric-calc-app .fpc-input:focus {
113
+ border-color: var(--fpc-accent);
114
+ box-shadow: 0 0 0 3px var(--fpc-accent-soft);
101
115
  }
102
116
 
103
- .preset-group {
117
+ #fabric-calc-app .fpc-size-options,
118
+ #fabric-calc-app .fpc-actions,
119
+ #fabric-calc-app .fpc-stepper,
120
+ #fabric-calc-app .fpc-scheme {
104
121
  display: grid;
122
+ }
123
+
124
+ #fabric-calc-app .fpc-size-options {
105
125
  grid-template-columns: repeat(3, minmax(0, 1fr));
106
126
  gap: 0.5rem;
107
127
  }
108
128
 
109
- .preset-pill {
110
- min-height: 42px;
111
- padding: 0.65rem 0.45rem;
112
- border: 1.5px solid rgba(79, 70, 229, 0.28);
113
- border-radius: 0.75rem;
114
- background: rgba(79, 70, 229, 0.07);
115
- color: var(--fabric-accent);
129
+ #fabric-calc-app .fpc-size-option,
130
+ #fabric-calc-app .fpc-action,
131
+ #fabric-calc-app .fpc-copy-button,
132
+ #fabric-calc-app .fpc-step-button {
133
+ border: 0;
134
+ cursor: pointer;
135
+ font: inherit;
136
+ }
137
+
138
+ #fabric-calc-app .fpc-size-option {
139
+ min-height: 40px;
140
+ padding: 0.6rem 0.4rem;
141
+ color: var(--fpc-accent);
142
+ background: rgba(79, 70, 229, 0.08);
143
+ border: 1px solid rgba(79, 70, 229, 0.28);
144
+ border-radius: 0.7rem;
116
145
  font-size: 0.72rem;
117
146
  font-weight: 900;
118
- line-height: 1.1;
119
- cursor: pointer;
120
147
  }
121
148
 
122
- .preset-pill.active {
149
+ #fabric-calc-app .fpc-size-option.active,
150
+ #fabric-calc-app .fpc-action-primary {
123
151
  color: #fff;
124
- background: var(--fabric-accent);
125
- border-color: var(--fabric-accent);
152
+ background: var(--fpc-accent);
126
153
  }
127
154
 
128
- .shop-stepper {
129
- width: 100%;
130
- display: grid;
131
- grid-template-columns: 46px minmax(0, 1fr) 46px;
132
- align-items: stretch;
155
+ #fabric-calc-app .fpc-stepper {
156
+ grid-template-columns: 44px minmax(0, 1fr) 44px;
133
157
  overflow: hidden;
134
158
  background: var(--bg-surface);
135
159
  border: 1.5px solid var(--border-color);
136
- border-radius: 0.75rem;
160
+ border-radius: 0.7rem;
137
161
  }
138
162
 
139
- .step-btn {
140
- min-height: 46px;
141
- border: 0;
163
+ #fabric-calc-app .fpc-step-button {
164
+ min-height: 44px;
165
+ color: var(--fpc-accent);
142
166
  background: transparent;
143
- color: var(--fabric-accent);
144
- font-size: 1.35rem;
145
- font-weight: 800;
146
- cursor: pointer;
147
- }
148
-
149
- .step-btn:hover {
150
- background: var(--fabric-accent);
151
- color: #fff;
167
+ font-size: 1.25rem;
168
+ font-weight: 900;
152
169
  }
153
170
 
154
- .fabric-input {
155
- min-height: 46px;
156
- border: 0;
157
- border-right: 1px solid var(--border-color);
158
- border-left: 1px solid var(--border-color);
171
+ #fabric-calc-app .fpc-input {
172
+ min-height: 44px;
173
+ border-block: 0;
159
174
  border-radius: 0;
160
175
  text-align: center;
161
176
  font-size: 1rem;
162
177
  font-weight: 850;
163
- box-shadow: none;
164
178
  }
165
179
 
166
- .btn-actions {
167
- display: grid;
180
+ #fabric-calc-app .fpc-actions {
168
181
  grid-template-columns: 1fr 1fr;
169
182
  gap: 0.75rem;
170
- margin-top: auto;
171
183
  }
172
184
 
173
- .f-btn,
174
- .copy-list-btn {
185
+ #fabric-calc-app .fpc-action {
175
186
  min-height: 44px;
176
- border: 0;
177
- border-radius: 0.75rem;
187
+ border-radius: 0.7rem;
178
188
  font-weight: 850;
179
- cursor: pointer;
180
189
  }
181
190
 
182
- .f-seco {
191
+ #fabric-calc-app .fpc-action-secondary {
183
192
  color: var(--text-main);
184
193
  background: var(--bg-muted);
185
194
  border: 1px solid var(--border-color);
186
195
  }
187
196
 
188
- .f-prim {
189
- color: #fff;
190
- background: var(--fabric-accent);
191
- }
192
-
193
- .fabric-main {
194
- display: grid;
197
+ #fabric-calc-app .fpc-main {
195
198
  grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr);
196
- gap: 1.25rem;
197
- align-content: start;
198
199
  }
199
200
 
200
- .warning-banner {
201
+ #fabric-calc-app .fpc-warning {
201
202
  grid-column: 1 / -1;
202
203
  display: none;
203
204
  padding: 0.85rem 1rem;
204
205
  color: #c2410c;
205
206
  background: #fff7ed;
206
207
  border: 1px solid #fed7aa;
207
- border-radius: 0.85rem;
208
+ border-radius: 0.75rem;
208
209
  font-size: 0.85rem;
209
210
  font-weight: 650;
210
211
  }
211
212
 
212
- .magic-result-area {
213
- padding: 1.5rem;
214
- background: var(--fabric-panel);
215
- border: 1px solid rgba(79, 70, 229, 0.16);
216
- border-radius: 1rem;
217
- }
218
-
219
- .result-lbl {
213
+ #fabric-calc-app .fpc-result-label {
220
214
  display: block;
221
- margin-bottom: 0.4rem;
222
- color: var(--text-muted);
223
- font-size: 0.68rem;
224
- font-weight: 900;
225
- letter-spacing: 0.12em;
226
- text-transform: uppercase;
215
+ margin-bottom: 0.35rem;
227
216
  }
228
217
 
229
- .magic-result-area .val {
218
+ #fabric-calc-app .fpc-result-value {
230
219
  display: flex;
231
220
  flex-wrap: wrap;
232
221
  align-items: baseline;
233
- gap: 0.4rem;
234
- color: var(--fabric-accent);
235
- font-size: 4.25rem;
222
+ gap: 0.35rem;
223
+ color: var(--fpc-accent);
224
+ font-size: clamp(2.8rem, 5vw, 4.4rem);
236
225
  font-weight: 950;
237
226
  line-height: 0.95;
238
227
  }
239
228
 
240
- .magic-result-area .unit {
229
+ #fabric-calc-app .fpc-result-unit {
241
230
  color: var(--text-muted);
242
- font-size: 1.35rem;
231
+ font-size: 1.25rem;
243
232
  font-weight: 900;
244
233
  }
245
234
 
246
- .advice-tag {
235
+ #fabric-calc-app .fpc-advice {
247
236
  display: inline-flex;
248
237
  max-width: 100%;
249
- margin-top: 1rem;
250
- padding: 0.65rem 0.9rem;
251
- color: var(--fabric-accent);
252
- background: rgba(79, 70, 229, 0.09);
238
+ margin-top: 0.9rem;
239
+ padding: 0.6rem 0.85rem;
240
+ color: var(--fpc-accent);
241
+ background: rgba(79, 70, 229, 0.08);
253
242
  border: 1px solid rgba(79, 70, 229, 0.22);
254
243
  border-radius: 999px;
255
- font-size: 0.9rem;
256
- font-weight: 850;
244
+ font-size: 0.88rem;
245
+ font-weight: 800;
257
246
  overflow-wrap: anywhere;
258
247
  }
259
248
 
260
- .layout-container {
261
- grid-row: span 2;
262
- }
263
-
264
- .canvas-wrapper {
265
- height: 100%;
266
- padding: 1rem;
267
- background: var(--bg-page);
268
- border: 1px solid var(--border-color);
269
- border-radius: 1rem;
249
+ #fabric-calc-app .fpc-scheme {
250
+ grid-template-columns: 38px minmax(0, 1fr);
251
+ gap: 0.55rem;
270
252
  }
271
253
 
272
- .scheme-area {
273
- display: grid;
274
- grid-template-columns: 38px minmax(0, 1fr);
275
- gap: 0.6rem;
276
- min-width: 0;
254
+ #fabric-calc-app .fpc-ruler,
255
+ #fabric-calc-app .fpc-board {
256
+ height: 260px;
277
257
  }
278
258
 
279
- .ruler {
259
+ #fabric-calc-app .fpc-ruler {
280
260
  position: relative;
281
- min-height: 260px;
282
261
  }
283
262
 
284
- .ruler-mark {
263
+ #fabric-calc-app .fpc-ruler-mark {
285
264
  position: absolute;
286
265
  right: 0;
287
266
  left: 0;
@@ -290,7 +269,7 @@
290
269
  transform: translateY(-50%);
291
270
  }
292
271
 
293
- .ruler-mark::before {
272
+ #fabric-calc-app .fpc-ruler-mark::before {
294
273
  content: '';
295
274
  width: 8px;
296
275
  height: 1.5px;
@@ -300,27 +279,27 @@
300
279
  flex-shrink: 0;
301
280
  }
302
281
 
303
- .ruler-mark span {
282
+ #fabric-calc-app .fpc-ruler-mark span {
304
283
  color: var(--text-muted);
305
284
  font-size: 0.58rem;
306
285
  font-weight: 750;
307
286
  white-space: nowrap;
308
287
  }
309
288
 
310
- .fabric-board {
289
+ #fabric-calc-app .fpc-board {
290
+ width: 100%;
311
291
  min-width: 0;
312
- height: 260px;
313
292
  padding: 6px 6px 22px;
314
293
  display: flex;
315
294
  flex-direction: column;
316
295
  gap: 4px;
317
296
  overflow: hidden;
318
297
  background: var(--bg-surface);
319
- border: 2px solid var(--fabric-accent);
298
+ border: 2px solid var(--fpc-accent);
320
299
  border-radius: 0.35rem;
321
300
  }
322
301
 
323
- .piece-row {
302
+ #fabric-calc-app .fpc-piece-row {
324
303
  display: flex;
325
304
  gap: 4px;
326
305
  align-items: stretch;
@@ -328,7 +307,7 @@
328
307
  flex-shrink: 0;
329
308
  }
330
309
 
331
- .piece-block {
310
+ #fabric-calc-app .fpc-piece {
332
311
  min-width: 0;
333
312
  padding: 4px 8px;
334
313
  display: flex;
@@ -339,7 +318,7 @@
339
318
  border-radius: 0.3rem;
340
319
  }
341
320
 
342
- .piece-name {
321
+ #fabric-calc-app .fpc-piece-name {
343
322
  width: 100%;
344
323
  overflow: hidden;
345
324
  color: var(--text-main);
@@ -350,11 +329,11 @@
350
329
  white-space: nowrap;
351
330
  }
352
331
 
353
- .piece-fold {
354
- border-left: 3px dashed rgba(79, 70, 229, 0.65);
332
+ #fabric-calc-app .fpc-piece-fold {
333
+ border-left: 3px dashed rgba(79, 70, 229, 0.68);
355
334
  }
356
335
 
357
- .fold-indicator {
336
+ #fabric-calc-app .fpc-fold-indicator {
358
337
  margin-top: 2px;
359
338
  color: rgba(79, 70, 229, 0.78);
360
339
  font-size: 0.48rem;
@@ -363,7 +342,7 @@
363
342
  text-transform: uppercase;
364
343
  }
365
344
 
366
- .board-empty-state {
345
+ #fabric-calc-app .fpc-board-empty {
367
346
  flex: 1;
368
347
  display: flex;
369
348
  align-items: center;
@@ -375,136 +354,90 @@
375
354
  text-align: center;
376
355
  }
377
356
 
378
- .scheme-info {
379
- margin-top: 0.6rem;
357
+ #fabric-calc-app .fpc-scheme-info {
358
+ margin-top: 0.55rem;
380
359
  color: var(--text-muted);
381
360
  font-size: 0.68rem;
382
361
  font-weight: 750;
383
362
  text-align: right;
384
363
  }
385
364
 
386
- .merch-section {
387
- padding: 1.25rem;
388
- background: var(--bg-page);
389
- border: 1px solid var(--border-color);
390
- border-radius: 1rem;
391
- }
392
-
393
- .merch-header {
365
+ #fabric-calc-app .fpc-merch-header {
394
366
  display: flex;
395
367
  align-items: center;
396
368
  justify-content: space-between;
397
369
  gap: 0.75rem;
398
- margin-bottom: 0.9rem;
370
+ margin-bottom: 0.85rem;
399
371
  }
400
372
 
401
- .copy-list-btn {
373
+ #fabric-calc-app .fpc-copy-button {
402
374
  min-height: 36px;
403
- padding: 0 0.85rem;
404
- color: var(--fabric-accent);
375
+ padding: 0 0.8rem;
376
+ color: var(--fpc-accent);
405
377
  background: rgba(79, 70, 229, 0.08);
406
378
  border: 1px solid rgba(79, 70, 229, 0.2);
379
+ border-radius: 0.65rem;
407
380
  font-size: 0.72rem;
381
+ font-weight: 850;
408
382
  }
409
383
 
410
- .merceria-checklist {
384
+ #fabric-calc-app .fpc-checklist {
411
385
  margin: 0;
412
386
  padding: 0;
413
- display: flex;
414
- flex-direction: column;
415
- gap: 0.55rem;
387
+ gap: 0.5rem;
416
388
  list-style: none;
417
389
  }
418
390
 
419
- .merch-item {
391
+ #fabric-calc-app .fpc-merch-item {
420
392
  display: flex;
421
393
  align-items: center;
422
- gap: 0.55rem;
394
+ gap: 0.5rem;
423
395
  color: var(--text-main);
424
396
  font-size: 0.875rem;
425
397
  font-weight: 650;
426
398
  }
427
399
 
428
- .merch-dot {
429
- color: var(--fabric-accent);
400
+ #fabric-calc-app .fpc-merch-dot {
401
+ color: var(--fpc-accent);
430
402
  font-size: 0.5rem;
431
403
  flex-shrink: 0;
432
- opacity: 0.7;
404
+ opacity: 0.72;
433
405
  }
434
406
 
435
407
  @media (max-width: 900px) {
436
- .fabric-calculator-card,
437
- .fabric-main {
408
+ #fabric-calc-app,
409
+ #fabric-calc-app .fpc-main {
438
410
  grid-template-columns: 1fr;
439
411
  }
440
412
 
441
- .layout-container {
442
- grid-row: auto;
443
- }
444
-
445
- .fabric-sidebar {
446
- display: grid;
413
+ #fabric-calc-app .fpc-controls {
447
414
  grid-template-columns: repeat(2, minmax(0, 1fr));
448
- align-items: start;
449
415
  }
450
416
 
451
- .btn-actions {
417
+ #fabric-calc-app .fpc-actions {
452
418
  grid-column: 1 / -1;
453
419
  }
454
420
  }
455
421
 
456
422
  @media (max-width: 600px) {
457
- .fabric-calculator-card {
458
- width: 100%;
423
+ #fabric-calc-app {
459
424
  margin: 0;
460
- padding: 1rem;
461
- gap: 1rem;
462
- border-radius: 1rem;
463
- }
464
-
465
- .fabric-sidebar {
466
- grid-template-columns: 1fr;
467
- padding: 0;
468
- background: transparent;
469
- border: 0;
470
- }
471
-
472
- .config-group {
473
- padding: 1rem;
474
- background: var(--bg-page);
475
- border: 1px solid var(--border-color);
476
- border-radius: 1rem;
477
- }
478
-
479
- .config-group + .config-group {
480
- padding-top: 1rem;
425
+ padding: 0.85rem;
426
+ border-radius: 0.85rem;
481
427
  }
482
428
 
483
- .preset-group {
429
+ #fabric-calc-app .fpc-controls,
430
+ #fabric-calc-app .fpc-actions,
431
+ #fabric-calc-app .fpc-size-options {
484
432
  grid-template-columns: 1fr;
485
433
  }
486
434
 
487
- .btn-actions {
488
- grid-template-columns: 1fr;
489
- }
490
-
491
- .magic-result-area,
492
- .canvas-wrapper,
493
- .merch-section {
494
- padding: 1rem;
495
- }
496
-
497
- .magic-result-area .val {
498
- font-size: 3rem;
499
- }
500
-
501
- .scheme-area {
435
+ #fabric-calc-app .fpc-scheme {
502
436
  grid-template-columns: 34px minmax(0, 1fr);
503
437
  }
504
438
 
505
- .ruler,
506
- .fabric-board {
439
+ #fabric-calc-app .fpc-ruler,
440
+ #fabric-calc-app .fpc-board {
507
441
  height: 220px;
508
- min-height: 220px;
509
442
  }
510
443
  }
@@ -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"
@@ -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 {
@@ -27,13 +27,13 @@ const SIZES = ['36', '38', '40', '42', '44', '46'];
27
27
  <div id="standard-grid">
28
28
  <div class="input-grid">
29
29
  <div class="input-group">
30
- <label class="label-tiny">{scalerUI.labelPatternSize}</label>
30
+ <label class="label-tiny" for="origin-size">{scalerUI.labelPatternSize}</label>
31
31
  <select id="origin-size" class="form-input form-select">
32
32
  {SIZES.map((s) => <option value={s} selected={s === '38'}>{scalerUI.sizePrefix} {s}</option>)}
33
33
  </select>
34
34
  </div>
35
35
  <div class="input-group">
36
- <label class="label-tiny">{scalerUI.labelTargetSize}</label>
36
+ <label class="label-tiny" for="target-size">{scalerUI.labelTargetSize}</label>
37
37
  <select id="target-size" class="form-input form-select">
38
38
  {SIZES.map((s) => <option value={s} selected={s === '42'}>{scalerUI.sizePrefix} {s}</option>)}
39
39
  </select>
@@ -44,22 +44,22 @@ const SIZES = ['36', '38', '40', '42', '44', '46'];
44
44
  <div id="custom-grid" class="hidden">
45
45
  <span class="label-tiny">{scalerUI.labelOriginMeasures}</span>
46
46
  <div class="input-grid">
47
- <div class="input-item"><label class="label-tiny">{scalerUI.labelChest}</label><input type="number" id="origin-chest" class="form-input" value="88" /></div>
48
- <div class="input-item"><label class="label-tiny">{scalerUI.labelWaist}</label><input type="number" id="origin-waist" class="form-input" value="68" /></div>
49
- <div class="input-item"><label class="label-tiny">{scalerUI.labelHips}</label><input type="number" id="origin-hips" class="form-input" value="94" /></div>
50
- <div class="input-item"><label class="label-tiny">{scalerUI.labelLength}</label><input type="number" id="origin-length" class="form-input" value="60" /></div>
47
+ <div class="input-item"><label class="label-tiny" for="origin-chest">{scalerUI.labelChest}</label><input type="number" id="origin-chest" class="form-input" value="88" /></div>
48
+ <div class="input-item"><label class="label-tiny" for="origin-waist">{scalerUI.labelWaist}</label><input type="number" id="origin-waist" class="form-input" value="68" /></div>
49
+ <div class="input-item"><label class="label-tiny" for="origin-hips">{scalerUI.labelHips}</label><input type="number" id="origin-hips" class="form-input" value="94" /></div>
50
+ <div class="input-item"><label class="label-tiny" for="origin-length">{scalerUI.labelLength}</label><input type="number" id="origin-length" class="form-input" value="60" /></div>
51
51
  </div>
52
52
  <span class="label-tiny target-lbl">{scalerUI.labelTargetMeasures}</span>
53
53
  <div class="input-grid">
54
- <div class="input-item"><label class="label-tiny">{scalerUI.labelChest}</label><input type="number" id="target-chest" class="form-input" value="96" /></div>
55
- <div class="input-item"><label class="label-tiny">{scalerUI.labelWaist}</label><input type="number" id="target-waist" class="form-input" value="76" /></div>
56
- <div class="input-item"><label class="label-tiny">{scalerUI.labelHips}</label><input type="number" id="target-hips" class="form-input" value="102" /></div>
57
- <div class="input-item"><label class="label-tiny">{scalerUI.labelLength}</label><input type="number" id="target-length" class="form-input" value="62" /></div>
54
+ <div class="input-item"><label class="label-tiny" for="target-chest">{scalerUI.labelChest}</label><input type="number" id="target-chest" class="form-input" value="96" /></div>
55
+ <div class="input-item"><label class="label-tiny" for="target-waist">{scalerUI.labelWaist}</label><input type="number" id="target-waist" class="form-input" value="76" /></div>
56
+ <div class="input-item"><label class="label-tiny" for="target-hips">{scalerUI.labelHips}</label><input type="number" id="target-hips" class="form-input" value="102" /></div>
57
+ <div class="input-item"><label class="label-tiny" for="target-length">{scalerUI.labelLength}</label><input type="number" id="target-length" class="form-input" value="62" /></div>
58
58
  </div>
59
59
  </div>
60
60
 
61
61
  <div class="ease-group">
62
- <label class="label-tiny">{scalerUI.labelEase}</label>
62
+ <label class="label-tiny" for="ease-val">{scalerUI.labelEase}</label>
63
63
  <input type="number" id="ease-val" class="form-input" value="4" />
64
64
  </div>
65
65
  </div>
@@ -28,7 +28,7 @@ const fibers = Object.entries(ui.fiberData)
28
28
  <div class="selectors-section">
29
29
  <div class="selectors-grid">
30
30
  <div class="selector-group">
31
- <label class="selector-label">
31
+ <label class="selector-label" for="fiber-select">
32
32
  <Icon name="mdi:tshirt-v-outline" class="label-icon" />
33
33
  {ui.fiberLabel}
34
34
  </label>
@@ -44,7 +44,7 @@ const fibers = Object.entries(ui.fiberData)
44
44
  </div>
45
45
 
46
46
  <div class="selector-group">
47
- <label class="selector-label">
47
+ <label class="selector-label" for="stain-select">
48
48
  <Icon name="mdi:shimmer" class="label-icon" />
49
49
  {ui.stainLabel}
50
50
  </label>
@@ -80,11 +80,11 @@ const initLabels = yarnUI.sizeLabels?.sweater ?? ['S', 'M', 'L', 'XL'];
80
80
  <div class="custom-input-box">
81
81
  <div class="ball-grid">
82
82
  <div>
83
- <label class="label-tiny">{yarnUI.labelGrams}</label>
83
+ <label class="label-tiny" for="ball-weight">{yarnUI.labelGrams}</label>
84
84
  <input type="number" id="ball-weight" class="custom-input" value="100" />
85
85
  </div>
86
86
  <div>
87
- <label class="label-tiny">{yarnUI.labelMeters}</label>
87
+ <label class="label-tiny" for="ball-meters">{yarnUI.labelMeters}</label>
88
88
  <input type="number" id="ball-meters" class="custom-input" value="200" />
89
89
  </div>
90
90
  </div>