@jjlmoya/utils-pets 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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/i18n_coverage.test.ts +5 -2
  6. package/src/tests/locale_completeness.test.ts +1 -1
  7. package/src/tests/qa-test-helpers.ts +32 -0
  8. package/src/tests/qa_bibliography_links.test.ts +40 -0
  9. package/src/tests/qa_claim_evidence.test.ts +69 -0
  10. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  11. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  12. package/src/tests/tool_validation.test.ts +3 -3
  13. package/src/tool/petCarrierCrateSizePlanner/bibliography.astro +6 -0
  14. package/src/tool/petCarrierCrateSizePlanner/bibliography.ts +12 -0
  15. package/src/tool/petCarrierCrateSizePlanner/component.astro +89 -0
  16. package/src/tool/petCarrierCrateSizePlanner/controller.ts +128 -0
  17. package/src/tool/petCarrierCrateSizePlanner/dom-views.ts +96 -0
  18. package/src/tool/petCarrierCrateSizePlanner/entry.ts +27 -0
  19. package/src/tool/petCarrierCrateSizePlanner/evaluator.ts +14 -0
  20. package/src/tool/petCarrierCrateSizePlanner/i18n/de.ts +171 -0
  21. package/src/tool/petCarrierCrateSizePlanner/i18n/en.ts +171 -0
  22. package/src/tool/petCarrierCrateSizePlanner/i18n/es.ts +171 -0
  23. package/src/tool/petCarrierCrateSizePlanner/i18n/fr.ts +171 -0
  24. package/src/tool/petCarrierCrateSizePlanner/i18n/id.ts +171 -0
  25. package/src/tool/petCarrierCrateSizePlanner/i18n/it.ts +171 -0
  26. package/src/tool/petCarrierCrateSizePlanner/i18n/ja.ts +171 -0
  27. package/src/tool/petCarrierCrateSizePlanner/i18n/ko.ts +171 -0
  28. package/src/tool/petCarrierCrateSizePlanner/i18n/nl.ts +171 -0
  29. package/src/tool/petCarrierCrateSizePlanner/i18n/pl.ts +171 -0
  30. package/src/tool/petCarrierCrateSizePlanner/i18n/pt.ts +171 -0
  31. package/src/tool/petCarrierCrateSizePlanner/i18n/ru.ts +171 -0
  32. package/src/tool/petCarrierCrateSizePlanner/i18n/sv.ts +171 -0
  33. package/src/tool/petCarrierCrateSizePlanner/i18n/tr.ts +171 -0
  34. package/src/tool/petCarrierCrateSizePlanner/i18n/zh.ts +171 -0
  35. package/src/tool/petCarrierCrateSizePlanner/index.ts +12 -0
  36. package/src/tool/petCarrierCrateSizePlanner/logic.test.ts +49 -0
  37. package/src/tool/petCarrierCrateSizePlanner/logic.ts +77 -0
  38. package/src/tool/petCarrierCrateSizePlanner/pet-carrier-crate-size-planner.css +639 -0
  39. package/src/tool/petCarrierCrateSizePlanner/seo.astro +14 -0
  40. package/src/tool/petCarrierCrateSizePlanner/storage.ts +31 -0
  41. package/src/tool/petCarrierCrateSizePlanner/ui.ts +59 -0
  42. package/src/tools.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-pets",
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",
@@ -4,10 +4,11 @@ import { petRation } from '../tool/petRation/entry';
4
4
  import { petGestation } from '../tool/petGestation/entry';
5
5
  import { petToxicity } from '../tool/petToxicity/entry';
6
6
  import { petWaterIntake } from '../tool/petWaterIntake/entry';
7
+ import { petCarrierCrateSizePlanner } from '../tool/petCarrierCrateSizePlanner/entry';
7
8
 
8
9
  export const petsCategory: PetCategoryEntry = {
9
10
  icon: 'mdi:paw',
10
- tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake],
11
+ tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner],
11
12
  i18n: {
12
13
  en: () => import('./i18n/en').then((m) => m.content),
13
14
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -6,10 +6,13 @@ export { petToxicity } from './tool/petToxicity/entry';
6
6
  export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity/entry';
7
7
  export { petWaterIntake } from './tool/petWaterIntake/entry';
8
8
  export type { PetWaterIntakeUI, PetWaterIntakeLocaleContent } from './tool/petWaterIntake/entry';
9
+ export { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
10
+ export type { PetCarrierCrateSizePlannerUI, PetCarrierCrateSizePlannerLocaleContent } from './tool/petCarrierCrateSizePlanner/entry';
9
11
  export { petsCategory } from './category';
10
12
  import { petAge } from './tool/petAge/entry';
11
13
  import { petRation } from './tool/petRation/entry';
12
14
  import { petGestation } from './tool/petGestation/entry';
13
15
  import { petToxicity } from './tool/petToxicity/entry';
14
16
  import { petWaterIntake } from './tool/petWaterIntake/entry';
15
- export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake];
17
+ import { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
18
+ export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner];
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { petAge, PET_AGE_TOOL } from './tool/petAge';
2
2
  export { petRation, PET_RATION_TOOL } from './tool/petRation';
3
3
  export { petGestation, PET_GESTATION_TOOL } from './tool/petGestation';
4
+ export { petCarrierCrateSizePlanner, PET_CARRIER_CRATE_SIZE_PLANNER_TOOL } from './tool/petCarrierCrateSizePlanner';
4
5
 
5
6
  export { petsCategory } from './category';
6
7
  export const PetsCategorySEO = () => import('./category/seo.astro').then((m) => m.default);
@@ -4,6 +4,7 @@ import { ALL_TOOLS } from '../tools';
4
4
  const EXPECTED_LOCALES = [
5
5
  'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh'
6
6
  ];
7
+ const ENGLISH_FIRST_TOOLS = new Set(['pet-carrier-crate-size-planner']);
7
8
 
8
9
  describe('I18n Coverage Validation', () => {
9
10
  it('all tools should be registered', () => {
@@ -12,9 +13,11 @@ describe('I18n Coverage Validation', () => {
12
13
 
13
14
  ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
14
15
  describe(`Tool: ${entry.id}`, () => {
16
+ const expectedLocales = ENGLISH_FIRST_TOOLS.has(entry.id) ? ['en'] : EXPECTED_LOCALES;
17
+
15
18
  it('should have all 15 required locales', () => {
16
19
  const registeredLocales = Object.keys(entry.i18n);
17
- EXPECTED_LOCALES.forEach((locale) => {
20
+ expectedLocales.forEach((locale) => {
18
21
  expect(
19
22
  registeredLocales,
20
23
  `Tool "${entry.id}" is missing locale "${locale}"`,
@@ -23,7 +26,7 @@ describe('I18n Coverage Validation', () => {
23
26
  });
24
27
 
25
28
  it('all locale loaders should be functions', () => {
26
- EXPECTED_LOCALES.forEach((locale) => {
29
+ expectedLocales.forEach((locale) => {
27
30
  const loader = entry.i18n[locale as keyof typeof entry.i18n];
28
31
  expect(
29
32
  typeof loader,
@@ -26,6 +26,6 @@ describe('Locale Completeness Validation', () => {
26
26
  });
27
27
 
28
28
  it('all tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(5);
29
+ expect(ALL_TOOLS.length).toBe(6);
30
30
  });
31
31
  });
@@ -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,40 @@
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 checkBibliographyEntry(tool: string, entry: { url: string }, seen: Set<string>): LinkFailure[] {
11
+ const failures: LinkFailure[] = [];
12
+ let url: URL;
13
+ try {
14
+ url = new URL(entry.url);
15
+ } catch {
16
+ return [{ tool, message: `invalid URL: ${entry.url}` }];
17
+ }
18
+
19
+ if (url.protocol !== 'https:') failures.push({ tool, message: `non-HTTPS URL: ${entry.url}` });
20
+ if (url.pathname === '/' && !url.search && !url.hash) failures.push({ tool, message: `generic homepage, cite the exact document: ${entry.url}` });
21
+ if (seen.has(url.href)) failures.push({ tool, message: `duplicate source URL: ${entry.url}` });
22
+ seen.add(url.href);
23
+ return failures;
24
+ }
25
+
26
+ async function checkToolBibliography(tool: (typeof ALL_TOOLS)[number]): Promise<LinkFailure[]> {
27
+ const loader = tool.entry.i18n.en;
28
+ if (!loader) return [{ tool: tool.entry.id, message: 'English locale loader is missing' }];
29
+ const content = (await loader()) as ToolLocaleContent;
30
+ const seen = new Set<string>();
31
+ return (content.bibliography ?? []).flatMap((entry) => checkBibliographyEntry(tool.entry.id, entry, seen));
32
+ }
33
+
34
+ describe('QA: bibliography links are specific and usable', () => {
35
+ it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
36
+ const failures = (await Promise.all(ALL_TOOLS.map(checkToolBibliography))).flat();
37
+ const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
38
+ expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
39
+ });
40
+ });
@@ -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,100 @@
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
+ const value = match[2];
44
+ if (!value || isTechnicalLiteral(value)) continue;
45
+ const words = visibleWords(value);
46
+ if (words && /\p{L}/u.test(words)) {
47
+ failures.push({ line: index + 1, value });
48
+ }
49
+ }
50
+ });
51
+
52
+ return failures;
53
+ }
54
+
55
+ function runtimeSources(toolDirectory: string): string {
56
+ return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
57
+ .filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
58
+ .filter((path) => basename(path) !== 'ui.ts')
59
+ .map(read)
60
+ .join('\n');
61
+ }
62
+
63
+ describe('QA: runtime copy is localized', () => {
64
+ const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
65
+
66
+ it('does not write user-facing string literals directly into the DOM', () => {
67
+ const failures = componentFiles.flatMap((path) =>
68
+ hardcodedVisibleLiterals(read(path)).map(
69
+ ({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
70
+ ),
71
+ );
72
+
73
+ expect(
74
+ failures,
75
+ `Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
76
+ ).toEqual([]);
77
+ });
78
+
79
+ it('uses every string declared by each UI locale contract', () => {
80
+ const failures: string[] = [];
81
+
82
+ for (const directory of listToolDirectories()) {
83
+ const uiPath = join(directory, 'ui.ts');
84
+ if (!existsSync(uiPath)) continue;
85
+
86
+ const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
87
+ const runtime = runtimeSources(directory);
88
+ const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
89
+
90
+ if (unused.length > 0) {
91
+ failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
92
+ }
93
+ }
94
+
95
+ expect(
96
+ failures,
97
+ `Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
98
+ ).toEqual([]);
99
+ });
100
+ });
@@ -71,7 +71,7 @@ describe('Tool Validation Suite', () => {
71
71
  expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
72
72
 
73
73
  if (locale === 'es') {
74
- const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos', 'calculadora-agua-diaria-perros-gatos'];
74
+ const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos', 'calculadora-agua-diaria-perros-gatos', 'planificador-dimensiones-transportin-mascotas'];
75
75
  expect(validSlugs).toContain(content.slug);
76
76
  }
77
77
  });
@@ -97,11 +97,11 @@ describe('Tool Validation Suite', () => {
97
97
 
98
98
  describe('Library Registration', () => {
99
99
  it('should have 5 tools in ALL_TOOLS', () => {
100
- expect(ALL_TOOLS.length).toBe(5);
100
+ expect(ALL_TOOLS.length).toBe(6);
101
101
  });
102
102
 
103
103
  it('should have all tools in petsCategory', () => {
104
- expect(petsCategory.tools.length).toBe(5);
104
+ expect(petsCategory.tools.length).toBe(6);
105
105
  ALL_TOOLS.forEach(({ entry }) => {
106
106
  const exists = petsCategory.tools.some((t: any) => t.id === entry.id);
107
107
  expect(exists).toBe(true);
@@ -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} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'International Air Transport Association: Traveler\'s Pet Corner',
6
+ url: 'https://www.iata.org/en/programs/cargo/live-animals/pets/',
7
+ },
8
+ {
9
+ name: 'USDA APHIS: Preparing Pets for Air Travel',
10
+ url: 'https://www.aphis.usda.gov/pet-travel/pets-on-planes/preparing-pets-air-travel',
11
+ },
12
+ ];
@@ -0,0 +1,89 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { PetCarrierCrateSizePlannerUI } from './index';
4
+
5
+ interface Props {
6
+ ui: PetCarrierCrateSizePlannerUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div class="pet-carrier-crate-size-planner" data-carrier-root>
13
+ <script is:inline type="application/json" data-carrier-ui set:html={JSON.stringify(ui)}></script>
14
+ <header class="carrier-hero">
15
+ <div class="carrier-hero-copy">
16
+ <p class="carrier-kicker">{ui.heroEyebrow}</p>
17
+ <p class="carrier-journey">{ui.journeyHint}</p>
18
+ </div>
19
+ <div class="unit-switch" role="group" aria-label={ui.unitLegend}>
20
+ <span>{ui.unitLegend}</span>
21
+ <button type="button" data-unit="metric" aria-pressed="true">{ui.metricUnit}</button>
22
+ <button type="button" data-unit="imperial" aria-pressed="false">{ui.imperialUnit}</button>
23
+ </div>
24
+ </header>
25
+ <div class="carrier-workbench">
26
+ <div class="carrier-controls">
27
+ <div class="carrier-panel-heading">
28
+ <span class="carrier-step">01</span>
29
+ <div><p>{ui.speciesStep}</p><h2>{ui.speciesLegend}</h2></div>
30
+ </div>
31
+ <fieldset>
32
+ <legend>{ui.speciesLegend}</legend>
33
+ <div class="choice-row">
34
+ <button type="button" data-species="cat" aria-pressed="true"><Icon name="mdi:cat" /><span>{ui.speciesCat}</span></button>
35
+ <button type="button" data-species="dog" aria-pressed="false"><Icon name="mdi:dog-side" /><span>{ui.speciesDog}</span></button>
36
+ </div>
37
+ </fieldset>
38
+ <fieldset>
39
+ <legend>{ui.modeLegend}</legend>
40
+ <div class="choice-row mode-row">
41
+ <button type="button" data-mode="car" aria-pressed="true"><Icon name="mdi:car-side" /><span>{ui.modeCar}</span></button>
42
+ <button type="button" data-mode="air" aria-pressed="false"><Icon name="mdi:airplane" /><span>{ui.modeAir}</span></button>
43
+ </div>
44
+ </fieldset>
45
+ <fieldset class="measurements">
46
+ <legend>{ui.measurementsLegend}</legend>
47
+ <label><span>{ui.noseTailLabel}<small>{ui.noseTailHint}</small></span><input data-field="noseToTail" type="number" min="1" step="0.1" inputmode="decimal" /><b data-length-unit>{ui.cmUnit}</b></label>
48
+ <label><span>{ui.elbowHeightLabel}</span><input data-field="elbowHeight" type="number" min="1" step="0.1" inputmode="decimal" /><b data-length-unit>{ui.cmUnit}</b></label>
49
+ <label><span>{ui.shoulderWidthLabel}</span><input data-field="shoulderWidth" type="number" min="1" step="0.1" inputmode="decimal" /><b data-length-unit>{ui.cmUnit}</b></label>
50
+ <label><span>{ui.standingHeightLabel}</span><input data-field="standingHeight" type="number" min="1" step="0.1" inputmode="decimal" /><b data-length-unit>{ui.cmUnit}</b></label>
51
+ <label><span>{ui.beddingLabel}</span><input data-field="bedding" type="number" min="0" step="0.1" inputmode="decimal" /><b data-length-unit>{ui.cmUnit}</b></label>
52
+ <label><span>{ui.weightLabel}</span><input data-field="weight" type="number" min="0.1" step="0.1" inputmode="decimal" /><b data-weight-unit>{ui.kgUnit}</b></label>
53
+ </fieldset>
54
+ <label class="snub-toggle"><input data-snub-nosed type="checkbox" /><span><strong>{ui.snubNosedLabel}</strong><small>{ui.snubNosedHint}</small></span></label>
55
+ <div class="preset-strip">
56
+ <span>{ui.presetLegend}</span>
57
+ <button type="button" data-preset="cat">{ui.presetCat}</button>
58
+ <button type="button" data-preset="smallDog">{ui.presetSmallDog}</button>
59
+ <button type="button" data-preset="mediumDog">{ui.presetMediumDog}</button>
60
+ <button type="button" data-preset="largeDog">{ui.presetLargeDog}</button>
61
+ </div>
62
+ <p class="carrier-error" data-error role="alert"></p>
63
+ </div>
64
+ <section class="carrier-result" data-result aria-live="polite">
65
+ <div class="result-heading"><div class="result-title-row"><div><p>{ui.resultEyebrow}</p><h2>{ui.resultTitle}</h2></div><span class="result-status" data-status></span></div><span class="result-status-detail" data-status-detail></span></div>
66
+ <div class="carrier-scene" data-scene aria-label={ui.blueprintLabel}></div>
67
+ <div class="dimension-ledger">
68
+ <p>{ui.resultDimensionLabel}</p>
69
+ <div class="dimension-primary"><div><span>{ui.lengthLabel}</span><strong data-length-result></strong></div><div><span>{ui.widthLabel}</span><strong data-width-result></strong></div><div><span>{ui.heightLabel}</span><strong data-height-result></strong></div></div>
70
+ <div class="dimension-context"><div><span>{ui.petWeightLabel}</span><strong data-weight-result></strong></div><div><span>{ui.journeyLabel}</span><strong data-mode-result></strong></div></div>
71
+ </div>
72
+ <div class="carrier-checklist">
73
+ <h3>{ui.checklistTitle}</h3>
74
+ <p><span>{ui.checkMark}</span>{ui.checklistStand}</p>
75
+ <p><span>{ui.checkMark}</span>{ui.checklistTurn}</p>
76
+ <p><span>{ui.checkMark}</span>{ui.checklistLie}</p>
77
+ <p><span>{ui.checkMark}</span>{ui.checklistAirline}</p>
78
+ </div>
79
+ </section>
80
+ </div>
81
+ <aside class="carrier-note"><strong>{ui.noteTitle}</strong><span>{ui.noteText}</span></aside>
82
+ <p class="carrier-method"><strong>{ui.methodTitle}</strong> {ui.methodText}</p>
83
+ </div>
84
+
85
+ <script>
86
+ import { initCarrierController } from './controller';
87
+ const root = document.querySelector<HTMLElement>('[data-carrier-root]');
88
+ if (root) initCarrierController(root);
89
+ </script>
@@ -0,0 +1,128 @@
1
+ import { calculateCarrier, convertLength, convertWeight, formatInputValue, type PetSpecies, type TravelMode, type UnitSystem } from './logic';
2
+ import { renderEvaluation, renderScene } from './dom-views';
3
+ import { loadCarrierState, saveCarrierState, type CarrierStorageState } from './storage';
4
+ import type { PetCarrierCrateSizePlannerUI } from './ui';
5
+
6
+ type LengthField = 'noseToTail' | 'elbowHeight' | 'shoulderWidth' | 'standingHeight' | 'bedding';
7
+ type ControllerState = CarrierStorageState;
8
+
9
+ const presets: Record<string, Omit<CarrierStorageState, 'unit' | 'mode' | 'snubNosed'>> = {
10
+ cat: { species: 'cat', noseToTail: 45, elbowHeight: 15, shoulderWidth: 16, standingHeight: 35, bedding: 2, weight: 5 },
11
+ smallDog: { species: 'dog', noseToTail: 45, elbowHeight: 16, shoulderWidth: 18, standingHeight: 38, bedding: 2, weight: 7 },
12
+ mediumDog: { species: 'dog', noseToTail: 70, elbowHeight: 30, shoulderWidth: 22, standingHeight: 55, bedding: 3, weight: 18 },
13
+ largeDog: { species: 'dog', noseToTail: 90, elbowHeight: 38, shoulderWidth: 28, standingHeight: 72, bedding: 4, weight: 32 },
14
+ };
15
+
16
+ function readUI(root: HTMLElement): PetCarrierCrateSizePlannerUI {
17
+ const script = root.querySelector<HTMLScriptElement>('[data-carrier-ui]');
18
+ return JSON.parse(script?.textContent || '{}') as PetCarrierCrateSizePlannerUI;
19
+ }
20
+
21
+ function defaultState(): ControllerState {
22
+ return {
23
+ species: 'cat',
24
+ mode: 'car',
25
+ unit: 'metric',
26
+ noseToTail: 45,
27
+ elbowHeight: 15,
28
+ shoulderWidth: 16,
29
+ standingHeight: 35,
30
+ bedding: 2,
31
+ weight: 5,
32
+ snubNosed: false,
33
+ };
34
+ }
35
+
36
+ function mergeState(stored: Partial<ControllerState>): ControllerState {
37
+ const cleanStored = { ...stored } as Partial<ControllerState> & { theme?: unknown };
38
+ delete cleanStored.theme;
39
+ return { ...defaultState(), ...cleanStored };
40
+ }
41
+
42
+ function setPressed(root: HTMLElement, attribute: string, value: string): void {
43
+ root.querySelectorAll<HTMLButtonElement>(`[data-${attribute}]`).forEach((button) => {
44
+ const active = button.dataset[attribute] === value;
45
+ button.classList.toggle('is-active', active);
46
+ button.setAttribute('aria-pressed', String(active));
47
+ });
48
+ }
49
+
50
+ function setInput(root: HTMLElement, field: string, value: number): void {
51
+ const input = root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
52
+ if (input) input.value = formatInputValue(value);
53
+ }
54
+
55
+ function syncStateToForm(root: HTMLElement, state: ControllerState, ui: PetCarrierCrateSizePlannerUI): void {
56
+ (Object.keys(state) as (keyof ControllerState)[]).forEach((field) => {
57
+ if (typeof state[field] === 'number') setInput(root, field, state[field] as number);
58
+ });
59
+ setPressed(root, 'species', state.species);
60
+ setPressed(root, 'mode', state.mode);
61
+ setPressed(root, 'unit', state.unit);
62
+ const snub = root.querySelector<HTMLInputElement>('[data-snub-nosed]');
63
+ if (snub) snub.checked = state.snubNosed;
64
+ root.querySelectorAll<HTMLElement>('[data-length-unit]').forEach((element) => { element.textContent = state.unit === 'metric' ? ui.cmUnit : ui.inchUnit; });
65
+ const weightUnit = root.querySelector<HTMLElement>('[data-weight-unit]');
66
+ if (weightUnit) weightUnit.textContent = state.unit === 'metric' ? ui.kgUnit : ui.lbUnit;
67
+ }
68
+
69
+ function readNumbers(root: HTMLElement, state: ControllerState): void {
70
+ const fields: LengthField[] = ['noseToTail', 'elbowHeight', 'shoulderWidth', 'standingHeight', 'bedding'];
71
+ fields.forEach((field) => { const input = root.querySelector<HTMLInputElement>(`[data-field="${field}"]`); if (input) state[field] = Number(input.value); });
72
+ const weight = root.querySelector<HTMLInputElement>('[data-field="weight"]');
73
+ if (weight) state.weight = Number(weight.value);
74
+ }
75
+
76
+ function render(root: HTMLElement, state: ControllerState, ui: PetCarrierCrateSizePlannerUI): void {
77
+ try {
78
+ readNumbers(root, state);
79
+ const result = calculateCarrier(state);
80
+ saveCarrierState(state);
81
+ const scene = root.querySelector<HTMLElement>('[data-scene]');
82
+ if (scene) renderScene(scene, result, ui);
83
+ renderEvaluation(root, result, ui);
84
+ const error = root.querySelector<HTMLElement>('[data-error]');
85
+ if (error) error.textContent = '';
86
+ } catch {
87
+ const error = root.querySelector<HTMLElement>('[data-error]');
88
+ if (error) error.textContent = ui.invalidInput;
89
+ }
90
+ }
91
+
92
+ function changeUnit(root: HTMLElement, state: ControllerState, ui: PetCarrierCrateSizePlannerUI, unit: UnitSystem): void {
93
+ if (state.unit === unit) return;
94
+ const fields: LengthField[] = ['noseToTail', 'elbowHeight', 'shoulderWidth', 'standingHeight', 'bedding'];
95
+ fields.forEach((field) => { state[field] = convertLength(state[field], state.unit, unit); });
96
+ state.weight = convertWeight(state.weight, state.unit, unit);
97
+ state.unit = unit;
98
+ syncStateToForm(root, state, ui);
99
+ render(root, state, ui);
100
+ }
101
+
102
+ function applyPreset(root: HTMLElement, state: ControllerState, ui: PetCarrierCrateSizePlannerUI, key: string): void {
103
+ const preset = presets[key];
104
+ if (!preset) return;
105
+ const currentUnit = state.unit;
106
+ const metric = { ...preset, unit: 'metric' as const, mode: state.mode, snubNosed: state.snubNosed };
107
+ Object.assign(state, metric, { unit: currentUnit });
108
+ if (currentUnit === 'imperial') {
109
+ const fields: LengthField[] = ['noseToTail', 'elbowHeight', 'shoulderWidth', 'standingHeight', 'bedding'];
110
+ fields.forEach((field) => { state[field] = convertLength(state[field], 'metric', 'imperial'); });
111
+ state.weight = convertWeight(state.weight, 'metric', 'imperial');
112
+ }
113
+ syncStateToForm(root, state, ui);
114
+ render(root, state, ui);
115
+ }
116
+
117
+ export function initCarrierController(root: HTMLElement): void {
118
+ const ui = readUI(root);
119
+ const state = mergeState(loadCarrierState());
120
+ syncStateToForm(root, state, ui);
121
+ root.querySelectorAll<HTMLButtonElement>('[data-species]').forEach((button) => button.addEventListener('click', () => { state.species = (button.dataset.species || 'cat') as PetSpecies; setPressed(root, 'species', state.species); render(root, state, ui); }));
122
+ root.querySelectorAll<HTMLButtonElement>('[data-mode]').forEach((button) => button.addEventListener('click', () => { state.mode = (button.dataset.mode || 'car') as TravelMode; setPressed(root, 'mode', state.mode); render(root, state, ui); }));
123
+ root.querySelectorAll<HTMLButtonElement>('[data-unit]').forEach((button) => button.addEventListener('click', () => changeUnit(root, state, ui, (button.dataset.unit || 'metric') as UnitSystem)));
124
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => applyPreset(root, state, ui, button.dataset.preset || 'cat')));
125
+ root.querySelectorAll<HTMLInputElement>('[data-field]').forEach((input) => input.addEventListener('input', () => render(root, state, ui)));
126
+ root.querySelector<HTMLInputElement>('[data-snub-nosed]')?.addEventListener('change', (event) => { state.snubNosed = (event.target as HTMLInputElement).checked; render(root, state, ui); });
127
+ render(root, state, ui);
128
+ }