@jjlmoya/utils-chrono 1.22.0 → 1.24.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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/layouts/PreviewLayout.astro +1 -1
  3. package/src/tests/diacritics_density.test.ts +118 -0
  4. package/src/tests/inverted_punctuation.test.ts +84 -0
  5. package/src/tests/script_density.test.ts +94 -0
  6. package/src/tool/gear-train-explorer/client.ts +13 -12
  7. package/src/tool/gear-train-explorer/component.astro +1 -1
  8. package/src/tool/gear-train-explorer/gear-train-explorer.css +49 -24
  9. package/src/tool/mainspring-finder/i18n/es.ts +6 -6
  10. package/src/tool/mainspring-finder/i18n/it.ts +1 -1
  11. package/src/tool/power-reserve-estimator/client.ts +13 -12
  12. package/src/tool/power-reserve-estimator/component.astro +1 -1
  13. package/src/tool/power-reserve-estimator/power-reserve-estimator.css +69 -53
  14. package/src/tool/quartz-battery-health/client.ts +20 -20
  15. package/src/tool/quartz-battery-health/component.astro +1 -1
  16. package/src/tool/quartz-battery-health/i18n/es.ts +1 -1
  17. package/src/tool/quartz-battery-health/i18n/fr.ts +1 -1
  18. package/src/tool/quartz-battery-health/i18n/pt.ts +1 -1
  19. package/src/tool/quartz-battery-health/quartz-battery-health.css +77 -59
  20. package/src/tool/sidereal-time-tracker/component.astro +1 -1
  21. package/src/tool/sidereal-time-tracker/sidereal-time-tracker.css +60 -1
  22. package/src/tool/tachymeter-calculator/i18n/es.ts +6 -6
  23. package/src/tool/tachymeter-calculator/i18n/fr.ts +1 -1
  24. package/src/tool/tachymeter-calculator/i18n/it.ts +1 -1
  25. package/src/tool/tachymeter-calculator/i18n/pl.ts +1 -1
  26. package/src/tool/tachymeter-calculator/i18n/pt.ts +1 -1
  27. package/src/tool/tachymeter-calculator/i18n/tr.ts +1 -1
  28. package/src/tool/telemeter-calculator/component.astro +1 -1
  29. package/src/tool/telemeter-calculator/i18n/es.ts +1 -1
  30. package/src/tool/telemeter-calculator/telemeter-calculator.css +51 -6
  31. package/src/tool/watch-savings-planner/i18n/es.ts +2 -2
  32. package/src/tool/watch-savings-planner/i18n/pl.ts +1 -1
  33. package/src/tool/wrist-presence-calculator/client.ts +1 -1
  34. package/src/tool/wrist-presence-calculator/component.astro +3 -3
  35. package/src/tool/wrist-presence-calculator/wrist-presence-calculator.css +30 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-chrono",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -78,7 +78,7 @@ const { title, currentLocale = "es", localeUrls = {}, hasSidebar = false } = Ast
78
78
  transition:
79
79
  background-color 0.3s ease,
80
80
  color 0.3s ease;
81
- }
81
+ font-family: Inter, sans-serif;}
82
82
 
83
83
  main {
84
84
  padding: 0 2rem;
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
5
+
6
+ const DIACRITIC_RULES = {
7
+ de: {
8
+ language: 'German',
9
+ expectedCharacters: 'ä ö ü ß',
10
+ characters: /[äöüÄÖÜß]/g,
11
+ minPerThousandLetters: 0.1,
12
+ },
13
+ es: {
14
+ language: 'Spanish',
15
+ expectedCharacters: 'á é í ó ú ü ñ',
16
+ characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
17
+ minPerThousandLetters: 0.1,
18
+ },
19
+ fr: {
20
+ language: 'French',
21
+ expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
22
+ characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
23
+ minPerThousandLetters: 0.1,
24
+ },
25
+ it: {
26
+ language: 'Italian',
27
+ expectedCharacters: 'à è é ì í î ò ó ù ú',
28
+ characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
29
+ minPerThousandLetters: 0.1,
30
+ },
31
+ pl: {
32
+ language: 'Polish',
33
+ expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
34
+ characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
35
+ minPerThousandLetters: 0.1,
36
+ },
37
+ pt: {
38
+ language: 'Portuguese',
39
+ expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
40
+ characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
41
+ minPerThousandLetters: 0.1,
42
+ },
43
+ sv: {
44
+ language: 'Swedish',
45
+ expectedCharacters: 'å ä ö',
46
+ characters: /[åäöÅÄÖ]/g,
47
+ minPerThousandLetters: 0.1,
48
+ },
49
+ tr: {
50
+ language: 'Turkish',
51
+ expectedCharacters: 'ç ğ ı İ ö ş ü',
52
+ characters: /[çğıöşüÇĞİÖŞÜ]/g,
53
+ minPerThousandLetters: 0.1,
54
+ },
55
+ } as const;
56
+
57
+ const LETTERS = /\p{L}/gu;
58
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
59
+
60
+ function collectStrings(value: unknown): string[] {
61
+ if (typeof value === 'string') return [value];
62
+ if (!value || typeof value !== 'object') return [];
63
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
64
+ return Object.values(value).flatMap(collectStrings);
65
+ }
66
+
67
+ function normalizeText(value: unknown): string {
68
+ return collectStrings(value).join(' ').normalize('NFC');
69
+ }
70
+
71
+ function translatableContent(content: Record<string, unknown>) {
72
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
73
+ }
74
+
75
+ function letterCount(text: string): number {
76
+ return text.match(LETTERS)?.length ?? 0;
77
+ }
78
+
79
+ function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
80
+ return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
81
+ }
82
+
83
+ function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
84
+ const letters = letterCount(text);
85
+ if (letters === 0) return 0;
86
+ return diacriticCount(text, locale) / letters * 1000;
87
+ }
88
+
89
+ describe('Diacritics density validation', () => {
90
+ ALL_TOOLS.forEach((tool) => {
91
+ describe(`Tool: ${tool.entry.id}`, () => {
92
+ Object.keys(DIACRITIC_RULES).forEach((locale) => {
93
+ it(`${locale} keeps the expected accent and special-letter set`, async () => {
94
+ const typedLocale = locale as LocaleWithDiacritics;
95
+ const loader = tool.entry.i18n[typedLocale];
96
+ if (!loader) return;
97
+
98
+ const content = await loader();
99
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
100
+ const rule = DIACRITIC_RULES[typedLocale];
101
+ const letters = letterCount(text);
102
+ const matches = diacriticCount(text, typedLocale);
103
+ const density = diacriticsPerThousandLetters(text, typedLocale);
104
+
105
+ expect(
106
+ density,
107
+ [
108
+ `Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
109
+ `The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
110
+ `This locale should include some of these characters: ${rule.expectedCharacters}.`,
111
+ 'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
112
+ ].join(' '),
113
+ ).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
114
+ });
115
+ });
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const INVERTED_PUNCTUATION_LOCALES = {
5
+ es: {
6
+ language: 'Spanish',
7
+ questionStart: '¿',
8
+ questionEnd: '?',
9
+ exclamationStart: '¡',
10
+ exclamationEnd: '!',
11
+ },
12
+ } as const;
13
+
14
+ type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
15
+
16
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
17
+
18
+ function collectStrings(value: unknown): string[] {
19
+ if (typeof value === 'string') return [value];
20
+ if (!value || typeof value !== 'object') return [];
21
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
22
+ return Object.values(value).flatMap(collectStrings);
23
+ }
24
+
25
+ function translatableStrings(content: Record<string, unknown>): string[] {
26
+ return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
27
+ }
28
+
29
+ function sentenceStart(text: string, endIndex: number): string {
30
+ const beforeMark = text.slice(0, endIndex).trimEnd();
31
+ const boundary = Math.max(
32
+ beforeMark.lastIndexOf('.'),
33
+ beforeMark.lastIndexOf(':'),
34
+ beforeMark.lastIndexOf(';'),
35
+ beforeMark.lastIndexOf('\n'),
36
+ );
37
+
38
+ return beforeMark.slice(boundary + 1).trimStart();
39
+ }
40
+
41
+ function findMissingInvertedMarks(
42
+ text: string,
43
+ startMark: string,
44
+ endMark: string,
45
+ ): string[] {
46
+ return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
47
+ .map((match) => sentenceStart(text, match.index ?? 0))
48
+ .filter((segment) => segment.length > 0 && !segment.includes(startMark))
49
+ .map((segment) => `${segment}${endMark}`);
50
+ }
51
+
52
+ describe('Inverted punctuation validation', () => {
53
+ ALL_TOOLS.forEach((tool) => {
54
+ describe(`Tool: ${tool.entry.id}`, () => {
55
+ Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
56
+ it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
57
+ const typedLocale = locale as InvertedPunctuationLocale;
58
+ const loader = tool.entry.i18n[typedLocale];
59
+ if (!loader) return;
60
+
61
+ const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
62
+ const content = await loader();
63
+ const strings = translatableStrings(content as Record<string, unknown>);
64
+ const missingQuestions = strings.flatMap((text) =>
65
+ findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
66
+ );
67
+ const missingExclamations = strings.flatMap((text) =>
68
+ findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
69
+ );
70
+ const failures = [...missingQuestions, ...missingExclamations];
71
+
72
+ expect(
73
+ failures,
74
+ [
75
+ `Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
76
+ `Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
77
+ `Examples: ${failures.slice(0, 5).join(' | ')}`,
78
+ ].join(' '),
79
+ ).toHaveLength(0);
80
+ });
81
+ });
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type ScriptLocale = keyof typeof SCRIPT_RULES;
5
+
6
+ const SCRIPT_RULES = {
7
+ ja: {
8
+ language: 'Japanese',
9
+ scriptName: 'kana/kanji',
10
+ scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
11
+ minScriptRatio: 0.45,
12
+ },
13
+ ko: {
14
+ language: 'Korean',
15
+ scriptName: 'hangul',
16
+ scriptCharacters: /\p{Script=Hangul}/gu,
17
+ minScriptRatio: 0.55,
18
+ },
19
+ ru: {
20
+ language: 'Russian',
21
+ scriptName: 'cyrillic',
22
+ scriptCharacters: /\p{Script=Cyrillic}/gu,
23
+ minScriptRatio: 0.65,
24
+ },
25
+ zh: {
26
+ language: 'Chinese',
27
+ scriptName: 'han',
28
+ scriptCharacters: /\p{Script=Han}/gu,
29
+ minScriptRatio: 0.45,
30
+ },
31
+ } as const;
32
+
33
+ const LETTERS = /\p{L}/gu;
34
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
35
+
36
+ function collectStrings(value: unknown): string[] {
37
+ if (typeof value === 'string') return [value];
38
+ if (!value || typeof value !== 'object') return [];
39
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
40
+ return Object.values(value).flatMap(collectStrings);
41
+ }
42
+
43
+ function normalizeText(value: unknown): string {
44
+ return collectStrings(value).join(' ').normalize('NFC');
45
+ }
46
+
47
+ function translatableContent(content: Record<string, unknown>) {
48
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
49
+ }
50
+
51
+ function letterCount(text: string): number {
52
+ return text.match(LETTERS)?.length ?? 0;
53
+ }
54
+
55
+ function scriptCount(text: string, locale: ScriptLocale): number {
56
+ return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
57
+ }
58
+
59
+ function scriptRatio(text: string, locale: ScriptLocale): number {
60
+ const letters = letterCount(text);
61
+ if (letters === 0) return 0;
62
+ return scriptCount(text, locale) / letters;
63
+ }
64
+
65
+ describe('Native script density validation', () => {
66
+ ALL_TOOLS.forEach((tool) => {
67
+ describe(`Tool: ${tool.entry.id}`, () => {
68
+ Object.keys(SCRIPT_RULES).forEach((locale) => {
69
+ it(`${locale} keeps most translated text in its native script`, async () => {
70
+ const typedLocale = locale as ScriptLocale;
71
+ const loader = tool.entry.i18n[typedLocale];
72
+ if (!loader) return;
73
+
74
+ const content = await loader();
75
+ const rule = SCRIPT_RULES[typedLocale];
76
+ const text = normalizeText(translatableContent(content as Record<string, unknown>));
77
+ const letters = letterCount(text);
78
+ const matches = scriptCount(text, typedLocale);
79
+ const ratio = scriptRatio(text, typedLocale);
80
+
81
+ expect(
82
+ ratio,
83
+ [
84
+ `Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
85
+ `The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
86
+ `Most translatable content should be written in ${rule.scriptName} script.`,
87
+ 'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
88
+ ].join(' '),
89
+ ).toBeGreaterThanOrEqual(rule.minScriptRatio);
90
+ });
91
+ });
92
+ });
93
+ });
94
+ });
@@ -1,5 +1,6 @@
1
1
  const canvas = document.getElementById('gear-canvas') as HTMLCanvasElement;
2
2
  const ctx = canvas.getContext('2d')!;
3
+ const root = canvas.closest('.gear-train-card') || document;
3
4
 
4
5
  import { MOVEMENTS } from './movements';
5
6
  import type { MovementDef } from './movements';
@@ -34,17 +35,17 @@ function resizeCanvas() {
34
35
 
35
36
  function updateStats() {
36
37
  const g = currentMov.gears;
37
- const setText = (id: string, v: string) => { const el = document.getElementById('rpm-' + id); if (el) el.textContent = v; };
38
+ const setText = (id: string, v: string) => { const el = root.querySelector('#rpm-' + id); if (el) el.textContent = v; };
38
39
  setText('barrel', (g[0].rpm * 60).toFixed(2) + '/h');
39
40
  setText('center', (g[1].rpm * 60).toFixed(1) + '/h');
40
41
  setText('third', (g[2].rpm * 60).toFixed(1) + '/h');
41
42
  setText('fourth', g[3].rpm.toFixed(1) + ' rpm');
42
43
  setText('escape', g[4].rpm.toFixed(0) + ' rpm');
43
- const bphEl = document.getElementById('bph-pallet');
44
+ const bphEl = root.querySelector('#bph-pallet');
44
45
  if (bphEl) bphEl.textContent = currentMov.pallet.bph.toString();
45
- const hzEl = document.getElementById('hz-balance');
46
+ const hzEl = root.querySelector('#hz-balance');
46
47
  if (hzEl) hzEl.textContent = currentMov.balance.hz.toString();
47
- const vphEl = document.getElementById('vph-balance');
48
+ const vphEl = root.querySelector('#vph-balance');
48
49
  if (vphEl) vphEl.textContent = currentMov.balance.vph.toString();
49
50
  }
50
51
 
@@ -53,7 +54,7 @@ function render() {
53
54
  setHovered(hoveredGear);
54
55
  drawScene(currentMov, { angles, palletPhase, balancePhase, highlight: highlightedGear, hover: hoveredGear });
55
56
  const step = highlightedGear !== null ? highlightedGear : -1;
56
- document.querySelectorAll('.flow-bar .step').forEach((el, idx) => {
57
+ root.querySelectorAll('.flow-bar .step').forEach((el, idx) => {
57
58
  el.classList.toggle('active', idx <= step);
58
59
  });
59
60
  }
@@ -83,7 +84,7 @@ function switchMovement(id: string) {
83
84
  balancePhase = 0;
84
85
  highlightedGear = null;
85
86
  updateStats();
86
- document.querySelectorAll('[data-mov]').forEach((b) => {
87
+ root.querySelectorAll('[data-mov]').forEach((b) => {
87
88
  b.classList.toggle('active', (b as HTMLElement).dataset.mov === id);
88
89
  });
89
90
  }
@@ -91,7 +92,7 @@ function switchMovement(id: string) {
91
92
  function setSpeed(mult: number) {
92
93
  speedMult = mult;
93
94
  paused = mult === 0;
94
- document.querySelectorAll('[data-spd]').forEach((b) => {
95
+ root.querySelectorAll('[data-spd]').forEach((b) => {
95
96
  const spd = parseFloat((b as HTMLElement).dataset.spd || '1');
96
97
  b.classList.toggle('active', spd === mult);
97
98
  });
@@ -118,20 +119,20 @@ function onCanvasMove(e: MouseEvent) {
118
119
  function highlightGear(idx: number | null) {
119
120
  hoveredGear = idx;
120
121
  highlightedGear = idx;
121
- document.querySelectorAll('.data-card').forEach((c) => c.classList.remove('highlighted'));
122
+ root.querySelectorAll('.data-card').forEach((c) => c.classList.remove('highlighted'));
122
123
  if (idx !== null && idx < 7) {
123
- document.querySelectorAll('.data-card')[idx]?.classList.add('highlighted');
124
+ root.querySelectorAll('.data-card')[idx]?.classList.add('highlighted');
124
125
  }
125
126
  }
126
127
 
127
128
  function initControls() {
128
- document.querySelectorAll('[data-mov]').forEach((b) => {
129
+ root.querySelectorAll('[data-mov]').forEach((b) => {
129
130
  b.addEventListener('click', () => switchMovement((b as HTMLElement).dataset.mov || '2824'));
130
131
  });
131
- document.querySelectorAll('[data-spd]').forEach((b) => {
132
+ root.querySelectorAll('[data-spd]').forEach((b) => {
132
133
  b.addEventListener('click', () => setSpeed(parseFloat((b as HTMLElement).dataset.spd || '1')));
133
134
  });
134
- document.querySelectorAll('.data-card').forEach((card, idx) => {
135
+ root.querySelectorAll('.data-card').forEach((card, idx) => {
135
136
  card.addEventListener('mouseenter', () => highlightGear(idx));
136
137
  card.addEventListener('mouseleave', () => highlightGear(null));
137
138
  });
@@ -10,7 +10,7 @@ const { ui } = Astro.props;
10
10
 
11
11
  <link href="./gear-train-explorer.css" rel="stylesheet" />
12
12
 
13
- <div class="tool-main-card" data-ui={JSON.stringify(ui)}>
13
+ <div class="gear-train-card" data-ui={JSON.stringify(ui)}>
14
14
  <GearPanel labels={ui} />
15
15
  </div>
16
16
 
@@ -1,4 +1,4 @@
1
- .tool-main-card {
1
+ .gear-train-card {
2
2
  background: var(--bg-surface);
3
3
  border: 1px solid var(--border-color);
4
4
  border-radius: 1.25rem;
@@ -10,32 +10,32 @@
10
10
  box-shadow: var(--shadow-base);
11
11
  }
12
12
 
13
- .canvas-wrapper {
13
+ .gear-train-card .canvas-wrapper {
14
14
  background: radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f1a 100%);
15
15
  border-radius: 0.75rem;
16
16
  overflow: hidden;
17
17
  border: 1px solid rgba(212, 175, 55, 0.15);
18
18
  }
19
19
 
20
- canvas#gear-canvas {
20
+ .gear-train-card canvas#gear-canvas {
21
21
  display: block;
22
22
  width: 100%;
23
23
  height: auto;
24
24
  }
25
25
 
26
- .controls-row {
26
+ .gear-train-card .controls-row {
27
27
  display: flex;
28
28
  flex-wrap: wrap;
29
29
  gap: 0.75rem;
30
30
  }
31
31
 
32
- .control-group {
32
+ .gear-train-card .control-group {
33
33
  display: flex;
34
34
  flex-direction: column;
35
35
  gap: 0.2rem;
36
36
  }
37
37
 
38
- .control-label {
38
+ .gear-train-card .control-label {
39
39
  font-size: 0.6rem;
40
40
  font-weight: 600;
41
41
  text-transform: uppercase;
@@ -44,12 +44,12 @@ canvas#gear-canvas {
44
44
  opacity: 0.5;
45
45
  }
46
46
 
47
- .control-group .btn-group {
47
+ .gear-train-card .control-group .btn-group {
48
48
  display: flex;
49
49
  gap: 0.25rem;
50
50
  }
51
51
 
52
- .control-group .btn-group button {
52
+ .gear-train-card .control-group .btn-group button {
53
53
  padding: 0.25rem 0.55rem;
54
54
  font-size: 0.7rem;
55
55
  font-weight: 500;
@@ -61,25 +61,25 @@ canvas#gear-canvas {
61
61
  transition: all 0.2s ease;
62
62
  }
63
63
 
64
- .control-group .btn-group button:hover {
64
+ .gear-train-card .control-group .btn-group button:hover {
65
65
  border-color: var(--accent);
66
66
  color: var(--accent);
67
67
  }
68
68
 
69
- .control-group .btn-group button.active {
69
+ .gear-train-card .control-group .btn-group button.active {
70
70
  background: color-mix(in srgb, var(--accent) 10%, var(--bg-page));
71
71
  border-color: var(--accent);
72
72
  color: var(--accent);
73
73
  box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 12%, transparent);
74
74
  }
75
75
 
76
- .data-grid {
76
+ .gear-train-card .data-grid {
77
77
  display: grid;
78
78
  grid-template-columns: repeat(auto-fill, minmax(95px, 1fr));
79
79
  gap: 0.35rem;
80
80
  }
81
81
 
82
- .data-card {
82
+ .gear-train-card .data-card {
83
83
  background: var(--bg-page);
84
84
  border: 1px solid var(--border-base);
85
85
  border-radius: 0.5rem;
@@ -88,17 +88,17 @@ canvas#gear-canvas {
88
88
  cursor: default;
89
89
  }
90
90
 
91
- .data-card:hover {
91
+ .gear-train-card .data-card:hover {
92
92
  border-color: var(--accent);
93
93
  }
94
94
 
95
- .data-card.highlighted {
95
+ .gear-train-card .data-card.highlighted {
96
96
  border-color: var(--accent);
97
97
  background: color-mix(in srgb, var(--accent) 6%, var(--bg-page));
98
98
  box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 8%, transparent);
99
99
  }
100
100
 
101
- .data-card .name {
101
+ .gear-train-card .data-card .name {
102
102
  font-size: 0.65rem;
103
103
  font-weight: 600;
104
104
  text-transform: uppercase;
@@ -107,33 +107,33 @@ canvas#gear-canvas {
107
107
  margin-bottom: 0.15rem;
108
108
  }
109
109
 
110
- .data-card .stats {
110
+ .gear-train-card .data-card .stats {
111
111
  display: flex;
112
112
  gap: 0.35rem;
113
113
  flex-wrap: wrap;
114
114
  }
115
115
 
116
- .data-card .stats span {
116
+ .gear-train-card .data-card .stats span {
117
117
  font-size: 0.65rem;
118
118
  color: var(--text-base);
119
119
  opacity: 0.75;
120
120
  }
121
121
 
122
- .data-card .stats .val {
122
+ .gear-train-card .data-card .stats .val {
123
123
  color: var(--accent);
124
124
  font-weight: 600;
125
125
  font-variant-numeric: tabular-nums;
126
126
  opacity: 1;
127
127
  }
128
128
 
129
- .flow-bar {
129
+ .gear-train-card .flow-bar {
130
130
  background: var(--bg-page);
131
131
  border: 1px solid var(--border-base);
132
132
  border-radius: 0.5rem;
133
133
  padding: 0.4rem 0.6rem;
134
134
  }
135
135
 
136
- .flow-bar .flow-label {
136
+ .gear-train-card .flow-bar .flow-label {
137
137
  font-size: 0.6rem;
138
138
  font-weight: 600;
139
139
  text-transform: uppercase;
@@ -143,14 +143,14 @@ canvas#gear-canvas {
143
143
  margin-bottom: 0.2rem;
144
144
  }
145
145
 
146
- .flow-bar .flow-path {
146
+ .gear-train-card .flow-bar .flow-path {
147
147
  display: flex;
148
148
  align-items: center;
149
149
  gap: 0.2rem;
150
150
  flex-wrap: wrap;
151
151
  }
152
152
 
153
- .flow-bar .flow-path .step {
153
+ .gear-train-card .flow-bar .flow-path .step {
154
154
  font-size: 0.65rem;
155
155
  color: var(--text-base);
156
156
  opacity: 0.4;
@@ -159,14 +159,39 @@ canvas#gear-canvas {
159
159
  transition: all 0.25s ease;
160
160
  }
161
161
 
162
- .flow-bar .flow-path .step.active {
162
+ .gear-train-card .flow-bar .flow-path .step.active {
163
163
  opacity: 1;
164
164
  color: var(--accent);
165
165
  background: color-mix(in srgb, var(--accent) 8%, var(--bg-page));
166
166
  }
167
167
 
168
- .flow-bar .flow-path .arrow {
168
+ .gear-train-card .flow-bar .flow-path .arrow {
169
169
  color: var(--text-base);
170
170
  opacity: 0.2;
171
171
  font-size: 0.7rem;
172
172
  }
173
+
174
+ @media (max-width: 520px) {
175
+ .gear-train-card {
176
+ padding: 0.875rem;
177
+ max-width: 100%;
178
+ overflow: hidden;
179
+ }
180
+
181
+ .gear-train-card .canvas-wrapper {
182
+ width: 100%;
183
+ }
184
+
185
+ .gear-train-card .control-group,
186
+ .gear-train-card .control-group .btn-group {
187
+ width: 100%;
188
+ }
189
+
190
+ .gear-train-card .control-group .btn-group {
191
+ flex-wrap: wrap;
192
+ }
193
+
194
+ .gear-train-card .control-group .btn-group button {
195
+ flex: 1 1 auto;
196
+ }
197
+ }
@@ -5,7 +5,7 @@ import { bibliography } from '../bibliography';
5
5
  export const content: ToolLocaleContent<MainspringFinderUI> = {
6
6
  slug: 'buscador-muelle-real',
7
7
  title: 'Buscador de Muelle Real',
8
- description: 'Calcule las dimensiones del muelle real a partir de las medidas del barrilete para movimientos de reloj antiguos.',
8
+ description: 'Calcule las dimensiones del muelle real a partir de las medidas del barrilete para movimientos de reloj antiguos con precisión, diámetro y árbol correctos.',
9
9
  ui: {
10
10
  title: 'Buscador de Muelle Real',
11
11
  barrelLabel: 'DI del Barrilete',
@@ -90,23 +90,23 @@ export const content: ToolLocaleContent<MainspringFinderUI> = {
90
90
  ],
91
91
  faq: [
92
92
  {
93
- question: 'Que precision tienen las dimensiones calculadas del muelle real?',
93
+ question: '¿Que precision tienen las dimensiones calculadas del muelle real?',
94
94
  answer: 'Los calculos se basan en formulas estandar de relojeria que asumen una geometria ideal del barrilete. Las dimensiones reales pueden variar hasta un 5-10 % debido a las tolerancias de fabricacion, las configuraciones del extremo del muelle (anclaje, ojal o extremo en T) y la aleacion especifica utilizada. Utilice siempre los valores calculados como un punto de referencia solido, pero compare con las hojas de datos del fabricante o los catalogos de proveedores comerciales antes de hacer su pedido.',
95
95
  },
96
96
  {
97
- question: 'Que sucede si no encuentro el tamano comercial exacto?',
97
+ question: '¿Que sucede si no encuentro el tamano comercial exacto?',
98
98
  answer: 'Cuando el tamano exacto no esta disponible, priorice coincidir exactamente con la altura del muelle, luego el grosor dentro de 0.005 mm. La longitud se puede ajustar seleccionando un muelle diferente de la misma familia de altura y grosor. Un muelle ligeramente mas largo funcionara si el barrilete tiene suficiente espacio, pero un muelle mas corto reducira la reserva de marcha.',
99
99
  },
100
100
  {
101
- question: 'Como mido las dimensiones del barrilete sin quitar el muelle real?',
101
+ question: '¿Como mido las dimensiones del barrilete sin quitar el muelle real?',
102
102
  answer: 'Si el barrilete aun contiene el muelle viejo, puede medir el diametro exterior del barrilete desde fuera (luego reste el grosor de la pared, tipicamente 0.2-0.4 mm) y la altura total (luego reste el grosor de la tapa). Para obtener los resultados mas precisos, retire el muelle viejo y limpie el barrilete antes de medir.',
103
103
  },
104
104
  {
105
- question: 'Cual es la diferencia entre un extremo de muelle de anclaje y uno de ojal?',
105
+ question: '¿Cual es la diferencia entre un extremo de muelle de anclaje y uno de ojal?',
106
106
  answer: 'Un extremo de anclaje (tambien llamado extremo en T) tiene una pequena lengueta en forma de T que se engancha en la pared del barrilete. La mayoria de los calibres suizos y japoneses modernos usan este tipo. Un extremo de ojal tiene un pequeno agujero que se ajusta sobre una clavija en el arbol. Esta herramienta calcula solo las dimensiones de la cinta; debe verificar que el tipo de extremo coincida con su barrilete antes de hacer su pedido.',
107
107
  },
108
108
  {
109
- question: 'Puedo usar esta herramienta para movimientos automaticos o cronografos?',
109
+ question: '¿Puedo usar esta herramienta para movimientos automaticos o cronografos?',
110
110
  answer: 'Si, pero tenga en cuenta que los movimientos automaticos a menudo tienen un numero mayor de vueltas (8-10) y pueden requerir un muelle ligeramente mas delgado para acomodar el modulo de cuerda adicional. Los movimientos de cronografo tipicamente necesitan muelles mas gruesos para accionar el mecanismo del cronografo. Ajuste el valor de Vueltas en consecuencia y verifique contra las especificaciones del fabricante.',
111
111
  },
112
112
  ],
@@ -5,7 +5,7 @@ import { bibliography } from '../bibliography';
5
5
  export const content: ToolLocaleContent<MainspringFinderUI> = {
6
6
  slug: 'trova-molla-reale',
7
7
  title: 'Trova Molla Reale',
8
- description: 'Calcola le dimensioni della molla reale a partire dalle misure del bariletto per movimenti di orologi vintage.',
8
+ description: 'Calcola le dimensioni della molla reale a partire dalle misure del bariletto per movimenti di orologi vintage; è utile per più verifiche di compatibilità.',
9
9
  ui: {
10
10
  title: 'Trova Molla Reale',
11
11
  barrelLabel: 'Diametro interno bariletto',