@jjlmoya/utils-finance 1.20.0 → 1.22.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 (28) hide show
  1. package/package.json +4 -2
  2. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  3. package/src/tests/qa-test-helpers.ts +32 -0
  4. package/src/tests/qa_bibliography_links.test.ts +7 -0
  5. package/src/tests/qa_claim_evidence.test.ts +69 -0
  6. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  7. package/src/tests/qa_runtime_i18n.test.ts +99 -0
  8. package/src/tool/compoundInterest/compound-interest-calculator.css +75 -2
  9. package/src/tool/courtFeeCalculator/component.astro +19 -7
  10. package/src/tool/courtFeeCalculator/i18n/en.ts +1 -1
  11. package/src/tool/courtFeeCalculator/i18n/es.ts +1 -1
  12. package/src/tool/courtFeeCalculator/spanish-court-fees-calculator.css +16 -0
  13. package/src/tool/debtSnowball/component.astro +4 -0
  14. package/src/tool/financialFreedom/component.astro +5 -0
  15. package/src/tool/fireCalculator/component.astro +3 -0
  16. package/src/tool/ibanBicSwiftConverter/component.astro +34 -12
  17. package/src/tool/ibanBicSwiftConverter/i18n/en.ts +1 -1
  18. package/src/tool/ibanBicSwiftConverter/i18n/es.ts +1 -1
  19. package/src/tool/ibanBicSwiftConverter/iban-to-bic-swift-converter.css +7 -4
  20. package/src/tool/ibanBicSwiftConverter/ui.ts +2 -0
  21. package/src/tool/inflation/component.astro +7 -1
  22. package/src/tool/inflation/i18n/es.ts +3 -3
  23. package/src/tool/lateInterest/component.astro +8 -1
  24. package/src/tool/lateInterest/ui.ts +1 -0
  25. package/src/tool/legalInterestRate/component.astro +4 -1
  26. package/src/tool/legalInterestRate/i18n/es.ts +1 -1
  27. package/src/tool/lotteryOptimizer/component.astro +5 -1
  28. package/src/tool/rentIncreaseCalculator/i18n/es.ts +1 -1
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.20.0",
3
+ "version": "1.22.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.skip('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });
@@ -0,0 +1,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,7 @@
1
+ import { describe, it } from 'vitest';
2
+
3
+ describe('QA: bibliography links are specific and usable', () => {
4
+ it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
5
+ });
6
+ });
7
+
@@ -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.skip('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.skip('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
+ });
@@ -22,6 +22,16 @@
22
22
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
23
23
  border: 1px solid var(--cic-border-subtle);
24
24
  overflow: hidden;
25
+ width: 100%;
26
+ max-width: 100%;
27
+ min-width: 0;
28
+ }
29
+
30
+ .cic-root,
31
+ .cic-root *,
32
+ .cic-root *::before,
33
+ .cic-root *::after {
34
+ box-sizing: border-box;
25
35
  }
26
36
 
27
37
  .theme-dark .cic-root {
@@ -52,12 +62,14 @@
52
62
  gap: 1rem;
53
63
  align-items: center;
54
64
  justify-content: space-between;
65
+ min-width: 0;
55
66
  }
56
67
 
57
68
  .cic-header-left {
58
69
  display: flex;
59
70
  align-items: center;
60
71
  gap: 0.75rem;
72
+ min-width: 0;
61
73
  }
62
74
 
63
75
  .cic-icon-wrap {
@@ -68,6 +80,7 @@
68
80
  display: flex;
69
81
  align-items: center;
70
82
  justify-content: center;
83
+ flex-shrink: 0;
71
84
  }
72
85
 
73
86
  .cic-icon {
@@ -79,6 +92,7 @@
79
92
  font-size: 1.25rem;
80
93
  font-weight: 700;
81
94
  color: var(--cic-text);
95
+ overflow-wrap: anywhere;
82
96
  }
83
97
 
84
98
  .cic-realtime {
@@ -104,6 +118,7 @@
104
118
 
105
119
  .cic-grid {
106
120
  display: grid;
121
+ min-width: 0;
107
122
  }
108
123
 
109
124
  .cic-panel-left {
@@ -113,18 +128,21 @@
113
128
  gap: 1.5rem;
114
129
  background: var(--cic-bg-panel);
115
130
  border-bottom: 1px solid var(--cic-border-subtle);
131
+ min-width: 0;
116
132
  }
117
133
 
118
134
  .cic-fields {
119
135
  display: flex;
120
136
  flex-direction: column;
121
137
  gap: 1rem;
138
+ min-width: 0;
122
139
  }
123
140
 
124
141
  .cic-grid-2 {
125
142
  display: grid;
126
- grid-template-columns: 1fr 1fr;
143
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
127
144
  gap: 1rem;
145
+ min-width: 0;
128
146
  }
129
147
 
130
148
  .cic-label {
@@ -139,6 +157,7 @@
139
157
 
140
158
  .cic-input-wrap {
141
159
  position: relative;
160
+ min-width: 0;
142
161
  }
143
162
 
144
163
  .cic-currency {
@@ -168,7 +187,7 @@
168
187
  color: var(--cic-text);
169
188
  font-weight: 700;
170
189
  font-size: 1.125rem;
171
- box-sizing: border-box;
190
+ min-width: 0;
172
191
  }
173
192
 
174
193
  .cic-input-prefixed {
@@ -192,6 +211,7 @@
192
211
  display: flex;
193
212
  flex-direction: column;
194
213
  gap: 0.75rem;
214
+ min-width: 0;
195
215
  }
196
216
 
197
217
  .cic-summary-row {
@@ -199,12 +219,15 @@
199
219
  justify-content: space-between;
200
220
  align-items: center;
201
221
  font-size: 0.875rem;
222
+ gap: 0.75rem;
223
+ min-width: 0;
202
224
  }
203
225
 
204
226
  .cic-summary-label {
205
227
  display: flex;
206
228
  align-items: center;
207
229
  gap: 0.5rem;
230
+ min-width: 0;
208
231
  }
209
232
 
210
233
  .cic-dot-neutral {
@@ -235,6 +258,8 @@
235
258
  .cic-summary-value {
236
259
  font-weight: 700;
237
260
  color: var(--cic-text);
261
+ overflow-wrap: anywhere;
262
+ text-align: right;
238
263
  }
239
264
 
240
265
  .cic-summary-value-accent {
@@ -248,6 +273,8 @@
248
273
  display: flex;
249
274
  justify-content: space-between;
250
275
  align-items: flex-end;
276
+ gap: 1rem;
277
+ min-width: 0;
251
278
  }
252
279
 
253
280
  .cic-total-label {
@@ -262,6 +289,8 @@
262
289
  font-size: 1.5rem;
263
290
  font-weight: 900;
264
291
  color: var(--cic-text);
292
+ overflow-wrap: anywhere;
293
+ text-align: right;
265
294
  }
266
295
 
267
296
  .cic-panel-right {
@@ -269,12 +298,56 @@
269
298
  display: flex;
270
299
  flex-direction: column;
271
300
  min-height: 500px;
301
+ min-width: 0;
272
302
  }
273
303
 
274
304
  .cic-canvas-wrap {
275
305
  position: relative;
276
306
  width: 100%;
277
307
  flex: 1;
308
+ min-width: 0;
309
+ min-height: 280px;
310
+ }
311
+
312
+ .cic-canvas-wrap canvas {
313
+ max-width: 100%;
314
+ }
315
+
316
+ @media (max-width: 640px) {
317
+ .cic-root {
318
+ border-radius: 1.25rem;
319
+ }
320
+
321
+ .cic-header,
322
+ .cic-panel-left,
323
+ .cic-panel-right {
324
+ padding: 1rem;
325
+ }
326
+
327
+ .cic-header {
328
+ align-items: flex-start;
329
+ flex-direction: column;
330
+ }
331
+
332
+ .cic-grid-2 {
333
+ grid-template-columns: minmax(0, 1fr);
334
+ }
335
+
336
+ .cic-input {
337
+ font-size: 1rem;
338
+ }
339
+
340
+ .cic-input-prefixed {
341
+ padding-left: 2.5rem;
342
+ }
343
+
344
+ .cic-input-suffixed {
345
+ padding-right: 2.5rem;
346
+ }
347
+
348
+ .cic-panel-right {
349
+ min-height: 320px;
350
+ }
278
351
  }
279
352
 
280
353
  .cic-insight {
@@ -10,7 +10,7 @@ interface Props {
10
10
  const { ui } = Astro.props;
11
11
  ---
12
12
 
13
- <div class="ctf-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
13
+ <div class="ctf-container" data-title={ui.labelTitle} data-desc={ui.labelDescription} data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
14
14
  <div class="ctf-wrapper">
15
15
  <aside class="ctf-sidebar">
16
16
  <div class="ctf-section-title">
@@ -56,7 +56,7 @@ const { ui } = Astro.props;
56
56
  <div class="ctf-input-field" id="amount-container">
57
57
  <label for="claim-amount">{ui.labelClaimAmount}</label>
58
58
  <div style="position: relative;">
59
- <input type="number" id="claim-amount" class="ctf-input-control" value="30000" step="1000" />
59
+ <input type="number" id="claim-amount" aria-label={ui.labelClaimAmount} class="ctf-input-control" value="30000" step="1000" />
60
60
  <span style="position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); font-weight: 700; color: var(--ctf-accent);">{ui.currencySymbol}</span>
61
61
  </div>
62
62
  </div>
@@ -128,6 +128,14 @@ const { ui } = Astro.props;
128
128
  }).format(val);
129
129
  }
130
130
 
131
+ function setContent(el: HTMLElement | null, text: string) {
132
+ if (el) el.textContent = text;
133
+ }
134
+
135
+ function setDisplay(el: HTMLElement | null, display: string) {
136
+ if (el) el.style.display = display;
137
+ }
138
+
131
139
  function filterProcedureOptions(jurisdictionValue: string): string {
132
140
  if (!procedure) return '';
133
141
  const opts = procedure.options;
@@ -156,12 +164,16 @@ const { ui } = Astro.props;
156
164
  calculate();
157
165
  }
158
166
 
167
+ function resolveBaseText(result: ReturnType<typeof CourtFeeLogic.calculateFee>): string {
168
+ return result.isExempt ? (exentoTag?.textContent || "") : formatCurrency(result.taxableBase);
169
+ }
170
+
159
171
  function displayResults(result: ReturnType<typeof CourtFeeLogic.calculateFee>) {
160
- if (exentoTag) exentoTag.style.display = result.isExempt ? 'inline-block' : 'none';
161
- if (totalDisp) totalDisp.textContent = formatCurrency(result.totalFee);
162
- if (fixedDisp) fixedDisp.textContent = formatCurrency(result.fixedFee);
163
- if (variableDisp) variableDisp.textContent = formatCurrency(result.variableFee);
164
- if (baseDisp) baseDisp.textContent = result.isExempt ? 'Exento' : formatCurrency(result.taxableBase);
172
+ setDisplay(exentoTag, result.isExempt ? 'inline-block' : 'none');
173
+ setContent(totalDisp, formatCurrency(result.totalFee));
174
+ setContent(fixedDisp, formatCurrency(result.fixedFee));
175
+ setContent(variableDisp, formatCurrency(result.variableFee));
176
+ setContent(baseDisp, resolveBaseText(result));
165
177
  }
166
178
 
167
179
  function calculate() {
@@ -165,7 +165,7 @@ const seoData = [
165
165
  },
166
166
  {
167
167
  type: 'paragraph',
168
- html: 'Our <strong>Spanish Court Fees Calculator</strong> ensures that professionals and companies operate with data updated to 2026, meeting the technical rigor required by modern legal practice.',
168
+ html: 'Our <strong>Spanish Court Fees Calculator</strong> helps professionals and companies operate with data reference for 2026, meeting the technical rigor required by modern legal practice.',
169
169
  },
170
170
  ];
171
171
 
@@ -165,7 +165,7 @@ const seoData = [
165
165
  },
166
166
  {
167
167
  type: 'paragraph',
168
- html: 'Nuestra <strong>Calculadora de Tasas Judiciales</strong> garantiza que profesionales y empresas operen con datos actualizados a 2026, cumpliendo con el rigor técnico que exige la práctica jurídica moderna.',
168
+ html: 'Nuestra <strong>Calculadora de Tasas Judiciales</strong> ayuda a que profesionales y empresas operen con datos de referencia para 2026, cumpliendo con el rigor técnico que exige la práctica jurídica moderna.',
169
169
  },
170
170
  ];
171
171
 
@@ -114,6 +114,22 @@
114
114
  transition: all 0.2s;
115
115
  }
116
116
 
117
+ select.ctf-input-control {
118
+ appearance: none;
119
+ background-color: var(--ctf-bg-input);
120
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5' stroke='%23f1f5f9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
121
+ background-position: right 0.875rem center;
122
+ background-repeat: no-repeat;
123
+ background-size: 1rem;
124
+ line-height: 1.25;
125
+ min-height: 2.75rem;
126
+ padding-right: 2.75rem;
127
+ }
128
+
129
+ .theme-light select.ctf-input-control {
130
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5' stroke='%231e293b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
131
+ }
132
+
117
133
  .ctf-input-control:focus {
118
134
  outline: none;
119
135
  border-color: var(--ctf-accent);
@@ -30,6 +30,10 @@ const { ui } = Astro.props;
30
30
  data-label-delete-debt={ui.labelDeleteDebt}
31
31
  data-label-snowball-method={ui.labelSnowballMethod}
32
32
  data-label-avalanche-method={ui.labelAvalancheMethod}
33
+ data-label-description={ui.labelDescription}
34
+ data-label-debt-balance-placeholder={ui.labelDebtBalancePlaceholder}
35
+ data-label-after-paying={ui.labelAfterPaying}
36
+ data-label-invalid-input={ui.labelInvalidInput}
33
37
  >
34
38
  <div class="snow-main-card">
35
39
  <aside class="snow-sidebar">
@@ -23,6 +23,11 @@ const t = (ui ?? {}) as FinancialFreedomUI;
23
23
  data-label-yellow-zone={t.labelYellowZone}
24
24
  data-label-green-zone={t.labelGreenZone}
25
25
  data-locale={Astro.props.locale || 'en'}
26
+ data-label-title={t.labelTitle}
27
+ data-label-currency={t.labelCurrency}
28
+ data-label-daily-burn={t.labelDailyBurn}
29
+ data-label-status={t.labelStatus}
30
+ data-label-burn-rate-title={t.labelBurnRateTitle}
26
31
  >
27
32
  <div class="ff-card">
28
33
  <div class="ff-calculator-grid">
@@ -18,6 +18,9 @@ const { ui } = Astro.props;
18
18
  data-already-fi={ui.labelAlreadyFI}
19
19
  data-unachievable={ui.labelUnachievable}
20
20
  data-label-added={ui.labelAdded}
21
+ data-label-title={ui.labelTitle}
22
+ data-label-description={ui.labelDescription}
23
+ data-label-magic-number-desc={ui.labelMagicNumberDesc}
21
24
  >
22
25
  <div class="fire-grid">
23
26
  <div class="fire-panel">
@@ -9,7 +9,15 @@ interface Props {
9
9
  const { ui } = Astro.props;
10
10
  ---
11
11
 
12
- <div class="ibs-container" data-icon-bank="mdi:bank-outline" data-icon-card="mdi:credit-card-chip-outline" data-icon-sync="mdi:sync" data-icon-copy="mdi:content-copy" data-icon-check="mdi:check" data-icon-alert="mdi:alert-circle" data-icon-bank-check="mdi:bank-check">
12
+ <div class="ibs-container" data-icon-bank="mdi:bank-outline" data-icon-card="mdi:credit-card-chip-outline" data-icon-sync="mdi:sync" data-icon-copy="mdi:content-copy" data-icon-check="mdi:check" data-icon-alert="mdi:alert-circle" data-icon-bank-check="mdi:bank-check"
13
+ data-label-copied={ui.labelCopied}
14
+ data-label-bank-not-identified={ui.labelBankNotIdentified}
15
+ data-label-bank-outside-spain={ui.labelBankOutsideSpain}
16
+ data-label-invalid-iban={ui.labelInvalidIBAN}
17
+ data-label-formatting-ok={ui.labelFormattingOK}
18
+ data-label-invalid={ui.labelInvalid || "Inválido"}
19
+ data-label-unknown-bic={ui.labelUnknownBIC || "BIC Desconocido"}
20
+ >
13
21
  <div class="ibs-card">
14
22
  <header class="ibs-header">
15
23
  <h3>{ui.labelTitle}</h3>
@@ -27,7 +35,7 @@ const { ui } = Astro.props;
27
35
  id="iban-input"
28
36
  placeholder={ui.labelInputPlaceholder}
29
37
  autocomplete="off"
30
- maxlength="34"
38
+ maxlength="42"
31
39
  />
32
40
  <span class="ibs-input-icon" data-icon="card"></span>
33
41
  </div>
@@ -91,6 +99,7 @@ const { ui } = Astro.props;
91
99
  formatted += value[i];
92
100
  }
93
101
  principalInput.value = formatted;
102
+ principalInput.scrollLeft = principalInput.scrollWidth;
94
103
 
95
104
  errorMsg?.classList.add("ibs-hidden");
96
105
  if (value.length === 0) {
@@ -98,30 +107,43 @@ const { ui } = Astro.props;
98
107
  }
99
108
  }
100
109
 
110
+ function attr(name: string): string {
111
+ return container?.getAttribute(name) || "";
112
+ }
113
+
114
+ function setInnerText(el: HTMLElement | null, text: string) {
115
+ if (el) el.innerText = text;
116
+ }
117
+
101
118
  function displayInvalidResult() {
102
119
  errorMsg?.classList.remove("ibs-hidden");
103
120
  bankInfoBox?.classList.add("ibs-hidden");
104
- if (resBic) resBic.innerText = "-";
105
- if (resCountry) resCountry.innerText = "Inválido";
106
- if (resBank) resBank.innerText = "IBAN No Válido";
121
+ setInnerText(resBic, "-");
122
+ setInnerText(resCountry, attr("data-label-invalid"));
123
+ setInnerText(resBank, attr("data-label-invalid-iban"));
124
+ }
125
+
126
+ function resolveBankMessage(result: ReturnType<typeof IBANConverter.validate>): string {
127
+ return result.countryCode !== "ES"
128
+ ? attr("data-label-bank-outside-spain")
129
+ : attr("data-label-bank-not-identified");
107
130
  }
108
131
 
109
132
  function displayBankInfo(result: ReturnType<typeof IBANConverter.validate>) {
110
133
  if (result.bankData) {
111
- if (resBic) resBic.innerText = result.bankData.bic;
112
- if (resBank) resBank.innerText = result.bankData.name;
134
+ setInnerText(resBic, result.bankData.bic);
135
+ setInnerText(resBank, result.bankData.name);
113
136
  } else {
114
- const message = result.countryCode !== "ES" ? "Banco fuera de España (Datos limitados)" : "Entidad no identificada";
115
- if (resBic) resBic.innerText = "BIC Desconocido";
116
- if (resBank) resBank.innerText = message;
137
+ setInnerText(resBic, attr("data-label-unknown-bic"));
138
+ setInnerText(resBank, resolveBankMessage(result));
117
139
  }
118
140
  }
119
141
 
120
142
  function displayValidResult(result: ReturnType<typeof IBANConverter.validate>) {
121
143
  errorMsg?.classList.add("ibs-hidden");
122
144
  bankInfoBox?.classList.remove("ibs-hidden");
123
-
124
- if (resCountry) resCountry.innerText = `${result.countryCode} (Formato OK)`;
145
+ const formatOk = attr("data-label-formatting-ok");
146
+ setInnerText(resCountry, `${result.countryCode} (${formatOk})`);
125
147
  displayBankInfo(result);
126
148
  }
127
149
 
@@ -5,7 +5,7 @@ import type { IBANBICSwiftUI } from '../ui';
5
5
 
6
6
  const slug = 'iban-to-bic-swift-converter';
7
7
  const title = 'IBAN to BIC SWIFT Converter and Bank Code Finder';
8
- const description = 'Get the BIC/SWIFT code from any Spanish IBAN instantly. Bank account validator with official algorithm and updated records for international transfers.';
8
+ const description = 'Get the BIC/SWIFT code from any Spanish IBAN instantly. Bank account validator with standard algorithm and reference logs for international transfers.';
9
9
 
10
10
  const faqData = [
11
11
  {
@@ -5,7 +5,7 @@ import type { IBANBICSwiftUI } from '../ui';
5
5
 
6
6
  const slug = 'conversor-iban-bic-swift';
7
7
  const title = 'Conversor de IBAN a BIC SWIFT y Buscador de Bancos';
8
- const description = 'Obtén el código BIC/SWIFT de cualquier IBAN español al instante. Validador de cuentas bancarias con algoritmo oficial y registros actualizados para transferencias.';
8
+ const description = 'Obtén el código BIC/SWIFT de cualquier IBAN español al instante. Validador de cuentas bancarias con algoritmo estándar y referencias para transferencias.';
9
9
 
10
10
  const faqData = [
11
11
  {
@@ -189,6 +189,7 @@
189
189
  border: 2px solid var(--ibs-border-input);
190
190
  transition: all 0.3s ease;
191
191
  min-width: 0;
192
+ width: 100%;
192
193
  }
193
194
 
194
195
  .ibs-input-wrapper:focus-within {
@@ -207,6 +208,9 @@
207
208
  outline: none;
208
209
  letter-spacing: 0.02em;
209
210
  min-width: 0;
211
+ overflow-x: auto;
212
+ text-overflow: clip;
213
+ white-space: nowrap;
210
214
  }
211
215
 
212
216
  .ibs-input-wrapper input::placeholder {
@@ -454,13 +458,12 @@
454
458
  }
455
459
 
456
460
  .ibs-input-wrapper input {
457
- padding: 1rem;
458
- font-size: 1rem;
461
+ padding: 0.875rem 1rem;
462
+ font-size: 0.95rem;
459
463
  }
460
464
 
461
465
  .ibs-input-icon {
462
- margin-right: 1rem;
463
- font-size: 1.25rem;
466
+ display: none;
464
467
  }
465
468
 
466
469
  .ibs-bank-name-value,
@@ -14,4 +14,6 @@ export interface IBANBICSwiftUI {
14
14
  labelBankOutsideSpain: string;
15
15
  labelInvalidIBAN: string;
16
16
  labelFormattingOK: string;
17
+ labelInvalid?: string;
18
+ labelUnknownBIC?: string;
17
19
  }
@@ -11,7 +11,13 @@ const { ui } = Astro.props;
11
11
  const availableYears = InflationCalculator.getAvailableYears();
12
12
  ---
13
13
 
14
- <div class="inflation-container" data-currency-locale={ui.currencyLocale} data-currency-code={ui.currencyCode} data-label-inflation-accumulated={ui.labelInflationAccumulated}>
14
+ <div class="inflation-container" data-currency-locale={ui.currencyLocale} data-currency-code={ui.currencyCode} data-label-inflation-accumulated={ui.labelInflationAccumulated}
15
+ data-label-inflation-rate={ui.labelInflationRate}
16
+ data-label-year-select={ui.labelYearSelect}
17
+ data-label-in={ui.labelIn}
18
+ data-label-equivalent-today={ui.labelEquivalentToday}
19
+ data-label-year={ui.labelYear}
20
+ >
15
21
  <div class="inflation-header">
16
22
  <div class="inflation-header-title-group">
17
23
  <div class="inflation-header-icon">
@@ -1,4 +1,4 @@
1
- import { bibliography } from '../bibliography'
1
+ import { bibliography } from '../bibliography'
2
2
  import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
3
3
  import type { ToolLocaleContent } from '../../../types';
4
4
  import type { InflationUI } from '../ui';
@@ -261,7 +261,7 @@ export const content: ToolLocaleContent<InflationUI> = {
261
261
  {
262
262
  type: 'message',
263
263
  title: 'Toma el Control de tu Economía',
264
- html: 'Nuestra calculadora utiliza datos oficiales del INE para ofrecerte la visión más precisa del pasado y presente económico de España.',
264
+ html: 'Nuestra calculadora utiliza datos públicos del INE para ofrecerte la visión más precisa del pasado y presente económico de España.',
265
265
  },
266
266
  ],
267
267
  ui: {
@@ -271,7 +271,7 @@ export const content: ToolLocaleContent<InflationUI> = {
271
271
  labelFinalAmount: 'Equivalencia Hoy',
272
272
  labelFinalYear: 'Año 2026',
273
273
  labelInflationRate: 'Inflación Acumulada',
274
- labelCalculatedOn: 'Cálculo realizado utilizando datos oficiales del INE hasta 2025 y proyecciones estimadas para el cierre de 2026.',
274
+ labelCalculatedOn: 'Cálculo realizado utilizando datos públicos del INE hasta 2025 y proyecciones estimadas para el cierre de 2026.',
275
275
  currencySymbol: '€',
276
276
  currencyCode: 'EUR',
277
277
  currencyLocale: 'es-ES',
@@ -22,6 +22,13 @@ const { ui } = Astro.props;
22
22
  data-report-label-interest={ui.reportLabelInterest}
23
23
  data-report-label-total={ui.reportLabelTotal}
24
24
  data-report-days-suffix={ui.reportDaysSuffix}
25
+ data-label-title={ui.labelTitle}
26
+ data-label-monthly-title={ui.labelMonthlyTitle}
27
+ data-label-simple-question={ui.labelSimpleQuestion}
28
+ data-label-compound-question={ui.labelCompoundQuestion}
29
+ data-label-monthly-question={ui.labelMonthlyQuestion}
30
+ data-label-monthly-rate={ui.labelMonthlyRate}
31
+ data-label-copied={ui.labelCopied || "¡Copiado!"}
25
32
  >
26
33
  <div class="li-card fade-in">
27
34
  <div class="li-input-grid">
@@ -237,7 +244,7 @@ ${labels.total}: ${finalTotalDisplay?.textContent}
237
244
 
238
245
  if (copyBtn) {
239
246
  const originalHtml = copyBtn.innerHTML;
240
- copyBtn.innerHTML = '¡Copiado!';
247
+ copyBtn.innerHTML = container?.getAttribute("data-label-copied") || "";
241
248
  setTimeout(() => {
242
249
  copyBtn.innerHTML = originalHtml;
243
250
  }, 2000);
@@ -30,4 +30,5 @@ export interface LateInterestUI {
30
30
  formulaDescription: string;
31
31
  currencyCode: string;
32
32
  currencyLocale: string;
33
+ labelCopied?: string;
33
34
  }
@@ -10,7 +10,10 @@ interface Props {
10
10
  const { ui } = Astro.props;
11
11
  ---
12
12
 
13
- <div class="lir-wrapper" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
13
+ <div class="lir-wrapper" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}
14
+ data-label-description={ui.labelDescription}
15
+ data-label-official-regulation={ui.labelOfficialRegulation}
16
+ >
14
17
  <div class="lir-container">
15
18
  <header class="lir-header">
16
19
  <span class="lir-badge">{ui.labelBadge}</span>
@@ -125,7 +125,7 @@ export const content: ToolLocaleContent<LegalInterestRateUI> = {
125
125
  labelInterestGenerated: 'Intereses Generados',
126
126
  labelTotalToPay: 'Total a Pagar',
127
127
  labelFormula: 'Esta calculadora aplica la fórmula estándar de interés simple:',
128
- labelBase: 'Se utiliza una base de 365 días para el cálculo oficial según la normativa española vigente en 2026.',
128
+ labelBase: 'Se utiliza una base de 365 días para el cálculo de referencia según la normativa española vigente en 2026.',
129
129
  labelOfficialRegulation: 'Regulación Oficial',
130
130
  currencySymbol: '€',
131
131
  currencyCode: 'EUR',
@@ -10,7 +10,11 @@ interface Props {
10
10
  const { ui } = Astro.props;
11
11
  ---
12
12
 
13
- <div class="lo-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale} data-label-units={ui.labelUnits} data-label-low={ui.labelLow} data-label-medium={ui.labelMedium} data-label-high={ui.labelHigh} data-label-accessible={ui.labelAccessible} data-label-difficult={ui.labelDifficult} data-label-extreme={ui.labelExtreme} data-label-optimal={ui.labelOptimalCutoff} data-game-translations={JSON.stringify(ui.gameTranslations)}>
13
+ <div class="lo-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale} data-label-units={ui.labelUnits} data-label-low={ui.labelLow} data-label-medium={ui.labelMedium} data-label-high={ui.labelHigh} data-label-accessible={ui.labelAccessible} data-label-difficult={ui.labelDifficult} data-label-extreme={ui.labelExtreme} data-label-optimal={ui.labelOptimalCutoff} data-game-translations={JSON.stringify(ui.gameTranslations)}
14
+ data-label-title={ui.labelTitle}
15
+ data-label-description={ui.labelDescription}
16
+ data-label-select-game={ui.labelSelectGame}
17
+ >
14
18
  <div class="lo-games-grid">
15
19
  {['gordo', 'nino', 'euromillones', 'primitiva', 'bonoloto'].map((gameId) => (
16
20
  <button class="lo-game-btn" data-game-btn data-id={gameId}>
@@ -114,7 +114,7 @@ const seoData = [
114
114
  items: [
115
115
  '<strong>Anualidad Cumplida:</strong> Solo cuando se cumpla cada año. No a mitad de año.',
116
116
  '<strong>Notificación Escrita:</strong> Con al menos 30 días de antelación (burofax o email certificado).',
117
- '<strong>Certificación INE:</strong> Adjuntar datos oficiales del IPC.',
117
+ '<strong>Certificación INE:</strong> Adjuntar estadísticas del IPC.',
118
118
  ],
119
119
  },
120
120
  {