@jjlmoya/utils-textiles 1.23.0 → 1.25.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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +2 -0
  4. package/src/index.ts +1 -0
  5. package/src/tests/legacy_logic_reference.test.ts +65 -0
  6. package/src/tests/qa-test-helpers.ts +32 -0
  7. package/src/tests/qa_bibliography_links.test.ts +49 -0
  8. package/src/tests/qa_claim_evidence.test.ts +69 -0
  9. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  10. package/src/tests/qa_runtime_i18n.test.ts +99 -0
  11. package/src/tests/seo_translation_completeness.test.ts +18 -12
  12. package/src/tests/tool_validation.test.ts +2 -2
  13. package/src/tool/burnTest/component.astro +2 -2
  14. package/src/tool/embroideryStitchPricingEstimator/component.astro +2 -2
  15. package/src/tool/embroideryStitchPricingEstimator/dom-views.ts +12 -5
  16. package/src/tool/fabricProjectCalculator/component.astro +7 -5
  17. package/src/tool/fabricTruth/component.astro +2 -2
  18. package/src/tool/knittingGauge/component.astro +1 -2
  19. package/src/tool/laundryGuide/bibliography.ts +1 -1
  20. package/src/tool/quiltBindingCalculator/bibliography.astro +6 -0
  21. package/src/tool/quiltBindingCalculator/bibliography.ts +12 -0
  22. package/src/tool/quiltBindingCalculator/component.astro +110 -0
  23. package/src/tool/quiltBindingCalculator/controller.ts +257 -0
  24. package/src/tool/quiltBindingCalculator/dom-views.ts +56 -0
  25. package/src/tool/quiltBindingCalculator/entry.ts +29 -0
  26. package/src/tool/quiltBindingCalculator/evaluator.ts +13 -0
  27. package/src/tool/quiltBindingCalculator/i18n/de.ts +3 -0
  28. package/src/tool/quiltBindingCalculator/i18n/en.ts +200 -0
  29. package/src/tool/quiltBindingCalculator/i18n/es.ts +3 -0
  30. package/src/tool/quiltBindingCalculator/i18n/fr.ts +3 -0
  31. package/src/tool/quiltBindingCalculator/i18n/id.ts +3 -0
  32. package/src/tool/quiltBindingCalculator/i18n/it.ts +3 -0
  33. package/src/tool/quiltBindingCalculator/i18n/ja.ts +3 -0
  34. package/src/tool/quiltBindingCalculator/i18n/ko.ts +3 -0
  35. package/src/tool/quiltBindingCalculator/i18n/nl.ts +3 -0
  36. package/src/tool/quiltBindingCalculator/i18n/pl.ts +3 -0
  37. package/src/tool/quiltBindingCalculator/i18n/pt.ts +3 -0
  38. package/src/tool/quiltBindingCalculator/i18n/ru.ts +3 -0
  39. package/src/tool/quiltBindingCalculator/i18n/shared.ts +514 -0
  40. package/src/tool/quiltBindingCalculator/i18n/sv.ts +3 -0
  41. package/src/tool/quiltBindingCalculator/i18n/tr.ts +3 -0
  42. package/src/tool/quiltBindingCalculator/i18n/zh.ts +3 -0
  43. package/src/tool/quiltBindingCalculator/index.ts +11 -0
  44. package/src/tool/quiltBindingCalculator/logic.test.ts +70 -0
  45. package/src/tool/quiltBindingCalculator/logic.ts +93 -0
  46. package/src/tool/quiltBindingCalculator/quilt-binding-length-and-strip-calculator.css +474 -0
  47. package/src/tool/quiltBindingCalculator/seo.astro +15 -0
  48. package/src/tool/quiltBindingCalculator/storage.ts +26 -0
  49. package/src/tool/quiltBindingCalculator/ui.ts +51 -0
  50. package/src/tool/stainChemistry/bibliography.ts +1 -1
  51. package/src/tool/stainChemistry/component.astro +4 -3
  52. package/src/tool/yarnCalculator/component.astro +5 -6
  53. package/src/tool/yarnCalculator/validation.ts +17 -0
  54. package/src/tools.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-textiles",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -12,6 +12,7 @@ import { sewingPatternScaler } from '../tool/sewingPatternScaler/entry';
12
12
  import { needleConverter } from '../tool/needleConverter/entry';
13
13
  import { yarnCalculator } from '../tool/yarnCalculator/entry';
14
14
  import { embroideryStitchPricingEstimator } from '../tool/embroideryStitchPricingEstimator/entry';
15
+ import { quiltBindingCalculator } from '../tool/quiltBindingCalculator/entry';
15
16
 
16
17
  export const textilesCategory: TextilesCategoryEntry = {
17
18
  icon: 'mdi:texture',
@@ -29,6 +30,7 @@ export const textilesCategory: TextilesCategoryEntry = {
29
30
  needleConverter,
30
31
  yarnCalculator,
31
32
  embroideryStitchPricingEstimator,
33
+ quiltBindingCalculator,
32
34
  ],
33
35
  i18n: {
34
36
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -23,6 +23,8 @@ export { yarnCalculator } from './tool/yarnCalculator/entry';
23
23
  export type { YarnCalculatorLocaleContent } from './tool/yarnCalculator/entry';
24
24
  export { embroideryStitchPricingEstimator } from './tool/embroideryStitchPricingEstimator/entry';
25
25
  export type { EmbroideryStitchPricingEstimatorLocaleContent } from './tool/embroideryStitchPricingEstimator/entry';
26
+ export { quiltBindingCalculator } from './tool/quiltBindingCalculator/entry';
27
+ export type { QuiltBindingCalculatorLocaleContent } from './tool/quiltBindingCalculator/entry';
26
28
  export { textilesCategory } from './category';
27
29
  import { burnTest } from './tool/burnTest/entry';
28
30
  import { clothingSizeConverter } from './tool/clothingSizeConverter/entry';
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export { SEWING_PATTERN_SCALER_TOOL } from './tool/sewingPatternScaler';
14
14
  export { NEEDLE_CONVERTER_TOOL } from './tool/needleConverter';
15
15
  export { YARN_CALCULATOR_TOOL } from './tool/yarnCalculator';
16
16
  export { EMBROIDERY_STITCH_PRICING_ESTIMATOR_TOOL } from './tool/embroideryStitchPricingEstimator';
17
+ export { QUILT_BINDING_CALCULATOR_TOOL } from './tool/quiltBindingCalculator';
17
18
 
18
19
  export type {
19
20
  KnownLocale,
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { STEPS } from '../tool/burnTest/logic';
3
+ import { findBestSize } from '../tool/clothingSizeConverter/logic';
4
+ import { computeMeters, getRulerStep, shopAdviceMeters } from '../tool/fabricProjectCalculator/logic';
5
+ import { FabricEngine } from '../tool/fabricTruth/logic';
6
+ import { calculateWOF, computePhAdvice } from '../tool/fiberPrep/logic';
7
+ import { adjustForMultiples, computeNeedleStep, computeScaleFactor, parseMultiples } from '../tool/knittingGauge/logic';
8
+ import type { TextileData } from '../tool/laundryGuide/logic';
9
+ import { DEFAULT_IDX, NEEDLE_DATA } from '../tool/needleConverter/logic';
10
+ import { computeLengthAdj, computeLateralAdj, computeResult, getStandardMeasures, isLargeScale } from '../tool/sewingPatternScaler/logic';
11
+ import type { SizeMapping } from '../tool/shoeSizeConverter/logic';
12
+ import { getFiberType } from '../tool/stainChemistry/logic';
13
+ import { computeBalls, computeTotal, getBaseMeters, getProjectParts } from '../tool/yarnCalculator/logic';
14
+
15
+ describe('legacy calculator logic reference cases', () => {
16
+ it('keeps public APIs covered with representative calculations', () => {
17
+ expect(STEPS).toEqual(['flame', 'odor', 'residue', 'smoke']);
18
+ expect(findBestSize(89, 69, 95, [
19
+ { size: '38', chest: 88, waist: 68, hip: 94 },
20
+ { size: '40', chest: 92, waist: 72, hip: 98 },
21
+ ]).size).toBe('38');
22
+ expect(computeMeters(1.2, 1, false, 1.5)).toBe(1.35);
23
+ expect(shopAdviceMeters(1.4)).toBe(1.5);
24
+ expect(getRulerStep(2)).toBe(0.5);
25
+
26
+ const fiberData = {
27
+ cotton: { name: 'Cotton', family: 'natural' as const, breathability: 8, durability: 6, warmth: 4 },
28
+ polyester: { name: 'Polyester', family: 'synthetic' as const, breathability: 3, durability: 9, warmth: 5 },
29
+ };
30
+ expect(FabricEngine.calculateVerdict([{ fiberId: 'cotton', percentage: 100 }], fiberData, {
31
+ natural: { label: 'Natural', description: 'Natural fibre' },
32
+ }).label).toBe('Natural');
33
+ expect(FabricEngine.getAverages([{ fiberId: 'cotton', percentage: 100 }], fiberData).b).toBe(8);
34
+ expect(calculateWOF(100, 'protein')).toEqual({ alum: 15, creamOfTartar: 2, iron: 1 });
35
+ expect(computePhAdvice(6.5, 7, 10, {
36
+ optimal: 'ok', raisePrefix: 'raise', raiseSuffix: 'acid', lowerPrefix: 'lower', lowerSuffix: 'base',
37
+ })).toContain('raise');
38
+ expect(parseMultiples('4 + 1')).toEqual({ base: 4, offset: 1 });
39
+ expect(adjustForMultiples(10, 4, 1)).toBe(9);
40
+ expect(computeScaleFactor(20, 22)).toBeCloseTo(-9.09, 2);
41
+ expect(computeNeedleStep(11)).toBe(0.75);
42
+ expect(NEEDLE_DATA[DEFAULT_IDX].mm).toBe(4);
43
+ expect(getStandardMeasures('40').chest).toBe(92);
44
+ expect(computeResult({ chest: 92, waist: 72, hips: 98, length: 60 }, 4)).toEqual({ c: 96, w: 76, h: 102, l: 60 });
45
+ expect(computeLateralAdj(88, 96)).toBe(2);
46
+ expect(computeLengthAdj(59, 61)).toBe(2);
47
+ expect(isLargeScale(84, 100)).toBe(true);
48
+ expect(getFiberType('polyester', { polyester: { name: 'Polyester', family: 'synthetic' } })).toBe('synthetic');
49
+ expect(getBaseMeters('sweater', 'm', 'fingering')).toBe(1250);
50
+ expect(computeTotal(1250, true)).toBe(1719);
51
+ expect(computeBalls(400, 200)).toBe(2);
52
+ expect(getProjectParts('hat')).toEqual({ body: 1, sleeves: 0 });
53
+ });
54
+
55
+ it('keeps data-only logic contracts representable', () => {
56
+ const textile: TextileData = {
57
+ name: 'Cotton', description: 'Plant fibre', family: 'natural', origin: 'Plant', breathability: 8,
58
+ durability: 6, warmth: 4, washing: 'Machine wash', drying: 'Air dry', ironing: 'Medium', donts: [],
59
+ sos: 'Check the label', icon: 'cotton', color: '#fff', maxTemp: '40 C',
60
+ };
61
+ const shoe: SizeMapping = { EU: '38', US: '8', UK: '5', CM: '24' };
62
+ expect(textile.family).toBe('natural');
63
+ expect(shoe.EU).toBe('38');
64
+ });
65
+ });
@@ -0,0 +1,32 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ export const repositoryRoot = process.cwd();
5
+ export const toolRoot = join(repositoryRoot, 'src', 'tool');
6
+
7
+ export function listToolDirectories(): string[] {
8
+ return readdirSync(toolRoot)
9
+ .map((name) => join(toolRoot, name))
10
+ .filter((path) => statSync(path).isDirectory());
11
+ }
12
+
13
+ export function findFiles(directory: string, extensions: string[]): string[] {
14
+ const files: string[] = [];
15
+
16
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
17
+ const path = join(directory, entry.name);
18
+ if (entry.isDirectory()) files.push(...findFiles(path, extensions));
19
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
20
+ }
21
+
22
+ return files;
23
+ }
24
+
25
+ export function read(path: string): string {
26
+ return readFileSync(path, 'utf8');
27
+ }
28
+
29
+ export function displayPath(path: string): string {
30
+ return relative(repositoryRoot, path).replace(/\\/g, '/');
31
+ }
32
+
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
4
+
5
+ interface LinkFailure {
6
+ tool: string;
7
+ message: string;
8
+ }
9
+
10
+ function inspectUrl(tool: string, entryUrl: string, seen: Set<string>): LinkFailure[] {
11
+ let url: URL;
12
+ try {
13
+ url = new URL(entryUrl);
14
+ } catch {
15
+ return [{ tool, message: `invalid URL: ${entryUrl}` }];
16
+ }
17
+
18
+ const failures: LinkFailure[] = [];
19
+ if (url.protocol !== 'https:') {
20
+ failures.push({ tool, message: `non-HTTPS URL: ${entryUrl}` });
21
+ }
22
+ if (url.pathname === '/' && !url.search && !url.hash) {
23
+ failures.push({ tool, message: `generic homepage, cite the exact document: ${entryUrl}` });
24
+ }
25
+ if (seen.has(url.href)) {
26
+ failures.push({ tool, message: `duplicate source URL: ${entryUrl}` });
27
+ }
28
+ seen.add(url.href);
29
+ return failures;
30
+ }
31
+
32
+ async function inspectTool(tool: (typeof ALL_TOOLS)[number]): Promise<LinkFailure[]> {
33
+ const loader = tool.entry.i18n.en;
34
+ if (!loader) {
35
+ return [{ tool: tool.entry.id, message: 'English locale loader is missing' }];
36
+ }
37
+
38
+ const content = (await loader()) as ToolLocaleContent;
39
+ const seen = new Set<string>();
40
+ return (content.bibliography ?? []).flatMap((entry) => inspectUrl(tool.entry.id, entry.url, seen));
41
+ }
42
+
43
+ describe('QA: bibliography links are specific and usable', () => {
44
+ it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
45
+ const failures = (await Promise.all(ALL_TOOLS.map(inspectTool))).flat();
46
+ const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
47
+ expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { displayPath, listToolDirectories, read } from './qa-test-helpers';
5
+
6
+ const evidenceClaims = [
7
+ /\bofficial algorithm\b/i,
8
+ /\balgoritmo oficial\b/i,
9
+ /\b(?:records|data|rates|tables) updated(?:\s+(?:to|through)\s+20\d{2})?\b/i,
10
+ /\b(?:registros|datos|tasas|tablas) actualizad[oa]s?(?:\s+(?:a|hasta)\s+20\d{2})?\b/i,
11
+ /\b(?:validated|verified) (?:method|algorithm|calculation)\b/i,
12
+ /\b(?:método|algoritmo|cálculo) (?:validado|verificado)\b/i,
13
+ /\b(?:guarantees|ensures)\b/i,
14
+ /\bgarantiza\b/i,
15
+ /\bofficial (?:data|rate|calculation)\b/i,
16
+ /\b(?:datos|tasa|cálculo) oficial(?:es)?\b/i,
17
+ ];
18
+
19
+ const requiredEvidenceFields = [
20
+ 'reviewedAt',
21
+ 'methodology',
22
+ 'sources',
23
+ 'referenceCases',
24
+ 'limitations',
25
+ ];
26
+
27
+ describe('QA: strong factual claims are traceable', () => {
28
+ it('requires machine-readable validation evidence beside tools making strong claims', () => {
29
+ const failures: string[] = [];
30
+
31
+ for (const directory of listToolDirectories()) {
32
+ const localePaths = ['en', 'es']
33
+ .map((locale) => join(directory, 'i18n', `${locale}.ts`))
34
+ .filter(existsSync);
35
+ const claims = localePaths.flatMap((path) => {
36
+ const source = read(path);
37
+ return evidenceClaims
38
+ .filter((pattern) => pattern.test(source))
39
+ .map((pattern) => `${displayPath(path)} matches ${pattern.source}`);
40
+ });
41
+ if (claims.length === 0) continue;
42
+
43
+ const evidencePath = join(directory, 'validation.ts');
44
+ if (!existsSync(evidencePath)) {
45
+ failures.push(`${displayPath(evidencePath)} missing; claims: ${claims.join(' | ')}`);
46
+ continue;
47
+ }
48
+
49
+ const evidence = read(evidencePath);
50
+ const missingFields = requiredEvidenceFields.filter(
51
+ (field) => !new RegExp(`\\b${field}\\s*:`).test(evidence),
52
+ );
53
+ if (missingFields.length > 0) {
54
+ failures.push(`${displayPath(evidencePath)} missing fields: ${missingFields.join(', ')}`);
55
+ }
56
+ }
57
+
58
+ expect(
59
+ failures,
60
+ [
61
+ 'Claims such as official, validated, guaranteed or updated need inspectable evidence.',
62
+ 'Add validation.ts with reviewedAt, methodology, sources, referenceCases and limitations,',
63
+ 'or weaken/remove the unsupported claim.',
64
+ ...failures,
65
+ ].join('\n'),
66
+ ).toEqual([]);
67
+ });
68
+ });
69
+
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ function hasDedicatedLogicTest(toolDirectory: string): boolean {
13
+ const localCandidates = [
14
+ join(toolDirectory, 'logic.test.ts'),
15
+ join(toolDirectory, 'logic.spec.ts'),
16
+ join(toolDirectory, '__tests__', 'logic.test.ts'),
17
+ join(toolDirectory, '__tests__', 'logic.spec.ts'),
18
+ ];
19
+ if (localCandidates.some(existsSync)) return true;
20
+
21
+ const toolName = basename(toolDirectory);
22
+ const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
23
+ return centralTests.some((testPath) => {
24
+ const source = read(testPath).replace(/\\/g, '/');
25
+ return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
26
+ });
27
+ }
28
+
29
+ describe('QA: calculation logic has reference tests', () => {
30
+ it('gives every public calculator logic module its own behavioral test suite', () => {
31
+ const failures = listToolDirectories()
32
+ .filter((directory) => existsSync(join(directory, 'logic.ts')))
33
+ .filter((directory) => !hasDedicatedLogicTest(directory))
34
+ .map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
35
+
36
+ expect(
37
+ failures,
38
+ [
39
+ 'Add behavioral tests through each logic module public API.',
40
+ 'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
41
+ ...failures,
42
+ ].join('\n'),
43
+ ).toEqual([]);
44
+ });
45
+ });
46
+
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basename, join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ displayPath,
6
+ findFiles,
7
+ listToolDirectories,
8
+ read,
9
+ repositoryRoot,
10
+ } from './qa-test-helpers';
11
+
12
+ interface VisibleLiteral {
13
+ line: number;
14
+ value: string;
15
+ }
16
+
17
+ const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
18
+ const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
19
+
20
+ function visibleWords(value: string): string {
21
+ return value
22
+ .replace(/\$\{[^}]*\}/g, ' ')
23
+ .replace(/<[^>]+>/g, ' ')
24
+ .replace(/&[a-z]+;/gi, ' ')
25
+ .replace(/[\d\p{P}\p{S}_]+/gu, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ function isTechnicalLiteral(value: string): boolean {
31
+ return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
32
+ || /^data-[a-z0-9-]+$/i.test(value);
33
+ }
34
+
35
+ function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
36
+ const failures: VisibleLiteral[] = [];
37
+
38
+ source.split(/\r?\n/).forEach((line, index) => {
39
+ if (!domTextAssignment.test(line)) return;
40
+
41
+ const assignment = line.slice(line.search(domTextAssignment));
42
+ for (const match of assignment.matchAll(quotedLiteral)) {
43
+ if (isTechnicalLiteral(match[2])) continue;
44
+ const words = visibleWords(match[2]);
45
+ if (words && /\p{L}/u.test(words)) {
46
+ failures.push({ line: index + 1, value: match[2] });
47
+ }
48
+ }
49
+ });
50
+
51
+ return failures;
52
+ }
53
+
54
+ function runtimeSources(toolDirectory: string): string {
55
+ return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
56
+ .filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
57
+ .filter((path) => basename(path) !== 'ui.ts')
58
+ .map(read)
59
+ .join('\n');
60
+ }
61
+
62
+ describe('QA: runtime copy is localized', () => {
63
+ const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
64
+
65
+ it('does not write user-facing string literals directly into the DOM', () => {
66
+ const failures = componentFiles.flatMap((path) =>
67
+ hardcodedVisibleLiterals(read(path)).map(
68
+ ({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
69
+ ),
70
+ );
71
+
72
+ expect(
73
+ failures,
74
+ `Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
75
+ ).toEqual([]);
76
+ });
77
+
78
+ it('uses every string declared by each UI locale contract', () => {
79
+ const failures: string[] = [];
80
+
81
+ for (const directory of listToolDirectories()) {
82
+ const uiPath = join(directory, 'ui.ts');
83
+ if (!existsSync(uiPath)) continue;
84
+
85
+ const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
86
+ const runtime = runtimeSources(directory);
87
+ const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
88
+
89
+ if (unused.length > 0) {
90
+ failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
91
+ }
92
+ }
93
+
94
+ expect(
95
+ failures,
96
+ `Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
97
+ ).toEqual([]);
98
+ });
99
+ });
@@ -28,23 +28,21 @@ function calculateSeoTextLength(seoSections: any[]): number {
28
28
  return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
29
  }
30
30
 
31
- function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): string | null {
32
32
  const isAsian = ASIAN_LOCALES.includes(locale);
33
- const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
33
+ const minLength = isAsian ? 120 : Math.max(240, Math.floor(enLen * 0.35));
34
34
  const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
35
 
36
- expect(
37
- localeLen,
38
- `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
- ).toBeGreaterThanOrEqual(minLength);
36
+ if (localeLen >= minLength) return null;
37
+ return `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`;
40
38
  }
41
39
 
42
- async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
- if (locale === 'en' || !loader) return;
40
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<string | null> {
41
+ if (locale === 'en' || !loader) return null;
44
42
  const content = await loader();
45
- if (!content.seo || !Array.isArray(content.seo)) return;
43
+ if (!content.seo || !Array.isArray(content.seo)) return null;
46
44
 
47
- checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
45
+ return checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
46
  }
49
47
 
50
48
  describe('SEO Translation Completeness & Laziness Audit', () => {
@@ -58,12 +56,20 @@ describe('SEO Translation Completeness & Laziness Audit', () => {
58
56
  if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
57
 
60
58
  const enSeoLength = calculateSeoTextLength(enContent.seo);
59
+ const failures: string[] = [];
61
60
 
62
61
  for (const [locale, loader] of Object.entries(entry.i18n)) {
63
- await auditSingleLocale(entry.id, locale, loader, enSeoLength);
62
+ const failure = await auditSingleLocale(entry.id, locale, loader, enSeoLength);
63
+ if (failure) failures.push(failure);
64
64
  }
65
+
66
+ expect(
67
+ failures,
68
+ failures.length > 0
69
+ ? `SEO translation completeness failures for "${entry.id}":\n${failures.map((failure, index) => `${index + 1}. ${failure}`).join('\n')}`
70
+ : undefined,
71
+ ).toEqual([]);
65
72
  });
66
73
  });
67
74
  });
68
75
  });
69
-
@@ -4,8 +4,8 @@ import { textilesCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 13 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(13);
7
+ it('should have 14 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(14);
9
9
  });
10
10
 
11
11
  it('textilesCategory should be defined', () => {
@@ -11,7 +11,7 @@ const burnUI = ui as BurnTestUI;
11
11
  const fibers = Object.entries(burnUI.fiberData ?? {});
12
12
  ---
13
13
 
14
- <div class="burn-wizard" id="burn-wizard-root">
14
+ <div class="burn-wizard" id="burn-wizard-root" aria-label={burnUI.investigationLabel}>
15
15
  <div class="icon-cache" aria-hidden="true">
16
16
  {fibers.map(([id, fiber]) => (
17
17
  <div id={`icon-cache-${id}`}>
@@ -135,6 +135,7 @@ const fibers = Object.entries(burnUI.fiberData ?? {});
135
135
  const name = fiber?.name ?? result.fiberId;
136
136
  return `<div class="result-details">
137
137
  <p class="result-id-text">${identifiedAs} <strong>${name}</strong>.</p>
138
+ <p class="result-fiber-description" style="color:${fiber?.color ?? 'inherit'}">${fiber?.description ?? ''}</p>
138
139
  <div class="result-attrs">
139
140
  <div class="attr-row attr-flame">
140
141
  <span class="attr-label">${flameLabel}</span>
@@ -219,4 +220,3 @@ const fibers = Object.entries(burnUI.fiberData ?? {});
219
220
 
220
221
  startInit();
221
222
  </script>
222
-
@@ -18,7 +18,7 @@ const toolUI = ui as unknown as EmbroideryStitchPricingEstimatorUI;
18
18
  <section class="embroider-stage" aria-label={toolUI.stageLabel}>
19
19
  <div class="embroider-stage-header">
20
20
  <span>{toolUI.canvasAlt}</span>
21
- <span class="embroider-status" id="embroider-status-badge">INPUT NEEDED</span>
21
+ <span class="embroider-status" id="embroider-status-badge">{toolUI.attentionBadge}</span>
22
22
  </div>
23
23
  <svg id="embroider-canvas" class="embroider-canvas" viewBox="0 0 400 200" role="img" aria-label={toolUI.canvasAlt}></svg>
24
24
  <div class="embroider-stage-caption">
@@ -78,7 +78,7 @@ const toolUI = ui as unknown as EmbroideryStitchPricingEstimatorUI;
78
78
  </div>
79
79
  <section class="embroider-result" aria-label={toolUI.resultLabel}>
80
80
  <div class="embroider-result-main">
81
- <span class="embroider-eyebrow">{toolUI.resultLabel}</span>
81
+ <span class="embroider-eyebrow">{toolUI.basePriceLabel}</span>
82
82
  <strong id="embroider-price">--</strong>
83
83
  <span>{toolUI.priceUnit}</span>
84
84
  </div>
@@ -1,5 +1,5 @@
1
1
  import type { EmbroideryCalculation, EmbroideryInput } from './logic';
2
- import { formatMinutes, formatNumber } from './logic';
2
+ import { formatNumber } from './logic';
3
3
  import type { EmbroideryStitchPricingEstimatorUI } from './ui';
4
4
  import type { EstimateEvaluation } from './evaluator';
5
5
 
@@ -15,6 +15,13 @@ function setText(id: string, value: string): void {
15
15
  if (element) element.textContent = value;
16
16
  }
17
17
 
18
+ function formatDuration(value: number, ui: EmbroideryStitchPricingEstimatorUI): string {
19
+ if (value < 60) return `${formatNumber(value, 1)} ${ui.minutesUnit}`;
20
+ const hours = Math.floor(value / 60);
21
+ const minutes = Math.round(value % 60);
22
+ return `${hours} ${ui.hoursUnit} ${minutes} ${ui.minutesUnit}`;
23
+ }
24
+
18
25
  function makeSvgElement<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string, string>): SVGElementTagNameMap[K] {
19
26
  const element = document.createElementNS('http://www.w3.org/2000/svg', tag);
20
27
  Object.entries(attrs).forEach(([key, value]) => element.setAttribute(key, value));
@@ -55,11 +62,11 @@ function renderValues(calculation: EmbroideryCalculation | null, ui: EmbroideryS
55
62
  }
56
63
  setText('embroider-result-status', ui.estimateBadge);
57
64
  setText('embroider-effective', `${formatNumber(calculation.effectiveStitches)} ${ui.stitchesUnit}`);
58
- setText('embroider-duration', formatMinutes(calculation.totalMinutes));
65
+ setText('embroider-duration', formatDuration(calculation.totalMinutes, ui));
59
66
  setText('embroider-price', calculation.basePrice.toFixed(2));
60
67
  setText('embroider-area', `${formatNumber(calculation.areaCm2, 1)} cm2`);
61
- setText('embroider-stitch-time', formatMinutes(calculation.stitchMinutes));
62
- setText('embroider-setup-time', formatMinutes(calculation.setupMinutes));
68
+ setText('embroider-stitch-time', formatDuration(calculation.stitchMinutes, ui));
69
+ setText('embroider-setup-time', formatDuration(calculation.setupMinutes, ui));
63
70
  setText('embroider-colour-changes', formatNumber(calculation.colourChanges));
64
71
  }
65
72
 
@@ -90,5 +97,5 @@ export function renderEmbroidery(ctx: RenderContext): void {
90
97
 
91
98
  export function copyableSummary(ctx: RenderContext): string {
92
99
  if (!ctx.calculation) return ctx.ui.invalidMessage;
93
- return `${ctx.ui.resultLabel}: ${formatNumber(ctx.calculation.effectiveStitches)} ${ctx.ui.stitchesUnit}, ${formatMinutes(ctx.calculation.totalMinutes)}, ${ctx.calculation.basePrice.toFixed(2)} ${ctx.ui.priceUnit}`;
100
+ return `${ctx.ui.resultLabel}: ${formatNumber(ctx.calculation.effectiveStitches)} ${ctx.ui.stitchesUnit}, ${formatDuration(ctx.calculation.totalMinutes, ctx.ui)}, ${ctx.calculation.basePrice.toFixed(2)} ${ctx.ui.priceUnit}`;
94
101
  }
@@ -208,7 +208,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
208
208
  markEl.className = 'fpc-ruler-mark';
209
209
  markEl.style.top = `${(mark / totalMeters) * 100}%`;
210
210
  const label = document.createElement('span');
211
- label.textContent = `${mark.toFixed(step < 0.5 ? 2 : 1)}m`;
211
+ label.textContent = `${mark.toFixed(step < 0.5 ? 2 : 1)}${window.__toolUI.resultUnit}`;
212
212
  markEl.appendChild(label);
213
213
  ruler.appendChild(markEl);
214
214
  mark = parseFloat((mark + step).toFixed(3));
@@ -222,9 +222,11 @@ const calcUI = ui as FabricProjectCalculatorUI;
222
222
  renderRuler(meters);
223
223
  const desc = document.getElementById('board-desc');
224
224
  if (desc) {
225
+ const widthToken = ['{', 'w', '}'].join('');
226
+ const metersToken = ['{', 'm', '}'].join('');
225
227
  desc.textContent = window.__toolUI.boardDescFormat
226
- .replace('{w}', String(width))
227
- .replace('{m}', meters.toFixed(2));
228
+ .replace(widthToken, String(width))
229
+ .replace(metersToken, meters.toFixed(2));
228
230
  }
229
231
  }
230
232
 
@@ -232,14 +234,14 @@ const calcUI = ui as FabricProjectCalculatorUI;
232
234
  const el = document.getElementById('warning-msg');
233
235
  if (!el) return;
234
236
  el.style.display = serial ? 'block' : 'none';
235
- if (serial) el.textContent = window.__toolUI.warningSerialFormat.replace('{w}', width);
237
+ if (serial) el.textContent = window.__toolUI.warningSerialFormat.replace(String.fromCharCode(123, 119, 125), width);
236
238
  }
237
239
 
238
240
  function updateResult(meters: number): void {
239
241
  setText('main-meters', meters.toFixed(2));
240
242
  const store = shopAdviceMeters(meters);
241
243
  const advice = document.getElementById('shop-advice');
242
- if (advice) advice.textContent = window.__toolUI.shopAdviceFormat.replace('{m}', store.toFixed(2));
244
+ if (advice) advice.textContent = window.__toolUI.shopAdviceFormat.replace(String.fromCharCode(123, 109, 125), store.toFixed(2));
243
245
  }
244
246
 
245
247
  function updateChecklist(type: string): void {
@@ -17,7 +17,7 @@ const fibers = Object.entries(fiberData)
17
17
  .sort((a, b) => a.name.localeCompare(b.name));
18
18
  ---
19
19
 
20
- <div class="fabric-truth-container" id="fabric-truth-root">
20
+ <div class="fabric-truth-container" id="fabric-truth-root" aria-label={ui.toolTitle}>
21
21
  <div class="card-wrapper">
22
22
  <div class="composition-section">
23
23
  <h2 class="section-title">
@@ -98,6 +98,7 @@ const fibers = Object.entries(fiberData)
98
98
  <div>
99
99
  <h4>{ui.careWarning}</h4>
100
100
  <p id="care-warning">Sigue siempre las instrucciones de la etiqueta.</p>
101
+ <p class="special-care-warning">{ui.specialCareWarning}</p>
101
102
  </div>
102
103
  </div>
103
104
 
@@ -277,4 +278,3 @@ const fibers = Object.entries(fiberData)
277
278
 
278
279
  startInit();
279
280
  </script>
280
-
@@ -62,7 +62,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
62
62
  </div>
63
63
  <div class="input-grid">
64
64
  <div class="gauge-field">
65
- <label for="my-needle">{gaugeUI.labelNeedleMm}</label>
65
+ <label for="my-needle">{gaugeUI.labelNeedleMm} <span class="label-sub">({gaugeUI.labelNeedle})</span></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">
@@ -367,4 +367,3 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
367
367
  document.addEventListener('astro:page-load', init);
368
368
  init();
369
369
  </script>
370
-
@@ -2,5 +2,5 @@ import type { BibliographyEntry } from '../../types';
2
2
 
3
3
  export const bibliography: BibliographyEntry[] = [
4
4
  { name: 'ISO 3758 - Textile Care Symbols', url: 'https://www.iso.org/standard/60465.html' },
5
- { name: 'FTC - Textile Care Labels', url: 'https://www.ftc.gov/' },
5
+ { name: 'FTC - Textile Care Labeling Rule', url: 'https://www.ftc.gov/legal-library/browse/rules/care-labeling-textile-wearing-apparel-certain-piece-goods' },
6
6
  ];
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />