@jjlmoya/utils-pets 1.18.0 → 1.19.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/data.ts +2 -0
  4. package/src/entries.ts +4 -1
  5. package/src/layouts/PreviewLayout.astro +2 -0
  6. package/src/pages/[locale]/[slug].astro +14 -5
  7. package/src/tests/diacritics_density.test.ts +1 -1
  8. package/src/tests/faq_count.test.ts +1 -1
  9. package/src/tests/inverted_punctuation.test.ts +1 -1
  10. package/src/tests/locale_completeness.test.ts +1 -1
  11. package/src/tests/script_density.test.ts +1 -1
  12. package/src/tests/title_quality.test.ts +1 -0
  13. package/src/tests/tool_validation.test.ts +4 -4
  14. package/src/tests/translation_copy.test.ts +14 -15
  15. package/src/tool/petGestation/i18n/en.ts +1 -0
  16. package/src/tool/petGestation/i18n/es.ts +1 -0
  17. package/src/tool/petToxicity/bibliography.astro +6 -0
  18. package/src/tool/petToxicity/bibliography.ts +16 -0
  19. package/src/tool/petToxicity/component.astro +53 -0
  20. package/src/tool/petToxicity/controller.ts +117 -0
  21. package/src/tool/petToxicity/dom-views.ts +61 -0
  22. package/src/tool/petToxicity/entry.ts +33 -0
  23. package/src/tool/petToxicity/evaluator.ts +23 -0
  24. package/src/tool/petToxicity/i18n/de.ts +181 -0
  25. package/src/tool/petToxicity/i18n/en.ts +175 -0
  26. package/src/tool/petToxicity/i18n/es.ts +181 -0
  27. package/src/tool/petToxicity/i18n/fr.ts +181 -0
  28. package/src/tool/petToxicity/i18n/id.ts +182 -0
  29. package/src/tool/petToxicity/i18n/it.ts +181 -0
  30. package/src/tool/petToxicity/i18n/ja.ts +181 -0
  31. package/src/tool/petToxicity/i18n/ko.ts +181 -0
  32. package/src/tool/petToxicity/i18n/nl.ts +182 -0
  33. package/src/tool/petToxicity/i18n/pl.ts +182 -0
  34. package/src/tool/petToxicity/i18n/pt.ts +181 -0
  35. package/src/tool/petToxicity/i18n/ru.ts +182 -0
  36. package/src/tool/petToxicity/i18n/sv.ts +182 -0
  37. package/src/tool/petToxicity/i18n/tr.ts +182 -0
  38. package/src/tool/petToxicity/i18n/zh.ts +181 -0
  39. package/src/tool/petToxicity/index.ts +11 -0
  40. package/src/tool/petToxicity/logic.test.ts +32 -0
  41. package/src/tool/petToxicity/logic.ts +192 -0
  42. package/src/tool/petToxicity/pet-food-toxicity-checker.css +450 -0
  43. package/src/tool/petToxicity/seo.astro +15 -0
  44. package/src/tool/petToxicity/storage.ts +37 -0
  45. package/src/tool/petToxicity/ui.ts +33 -0
  46. 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.18.0",
3
+ "version": "1.19.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -2,10 +2,11 @@ import type { PetCategoryEntry } from '../types';
2
2
  import { petAge } from '../tool/petAge/entry';
3
3
  import { petRation } from '../tool/petRation/entry';
4
4
  import { petGestation } from '../tool/petGestation/entry';
5
+ import { petToxicity } from '../tool/petToxicity/entry';
5
6
 
6
7
  export const petsCategory: PetCategoryEntry = {
7
8
  icon: 'mdi:paw',
8
- tools: [petAge, petRation, petGestation],
9
+ tools: [petAge, petRation, petGestation, petToxicity],
9
10
  i18n: {
10
11
  en: () => import('./i18n/en').then((m) => m.content),
11
12
  es: () => import('./i18n/es').then((m) => m.content),
package/src/data.ts CHANGED
@@ -2,10 +2,12 @@ export { petsCategory } from './category';
2
2
  export { petAge } from './tool/petAge';
3
3
  export { petRation } from './tool/petRation';
4
4
  export { petGestation } from './tool/petGestation';
5
+ export { petToxicity } from './tool/petToxicity';
5
6
 
6
7
  export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge';
7
8
  export type { PetRationUI, PetRationLocaleContent } from './tool/petRation';
8
9
  export type { PetGestationUI, PetGestationLocaleContent } from './tool/petGestation';
10
+ export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity';
9
11
 
10
12
  export type {
11
13
  KnownLocale,
package/src/entries.ts CHANGED
@@ -2,8 +2,11 @@ export { petAge } from './tool/petAge/entry';
2
2
  export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge/entry';
3
3
  export { petRation } from './tool/petRation/entry';
4
4
  export type { PetRationUI, PetRationLocaleContent } from './tool/petRation/entry';
5
+ export { petToxicity } from './tool/petToxicity/entry';
6
+ export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity/entry';
5
7
  export { petsCategory } from './category';
6
8
  import { petAge } from './tool/petAge/entry';
7
9
  import { petRation } from './tool/petRation/entry';
8
10
  import { petGestation } from './tool/petGestation/entry';
9
- export const ALL_ENTRIES = [petAge, petRation, petGestation];
11
+ import { petToxicity } from './tool/petToxicity/entry';
12
+ export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity];
@@ -87,7 +87,9 @@ const {
87
87
  }
88
88
 
89
89
  main {
90
+ min-width: 0;
90
91
  padding: 0 2rem;
92
+ width: 100%;
91
93
  }
92
94
 
93
95
  .page-wrapper {
@@ -15,7 +15,8 @@ export async function getStaticPaths() {
15
15
  const paths = [];
16
16
 
17
17
  for (const { entry, Component: lazyComp } of ALL_TOOLS) {
18
- const { default: Component } = await lazyComp();
18
+ const componentLoader = lazyComp as () => Promise<{ default: ToolComponent }>;
19
+ const { default: Component } = await componentLoader();
19
20
  const localeEntries = Object.entries(entry.i18n) as [
20
21
  KnownLocale,
21
22
  () => Promise<ToolLocaleContent>,
@@ -71,7 +72,7 @@ interface NavItem {
71
72
  }
72
73
 
73
74
  interface Props {
74
- Component: unknown;
75
+ Component: ToolComponent;
75
76
  locale: KnownLocale;
76
77
  content: ToolLocaleContent;
77
78
  localeUrls: Partial<Record<KnownLocale, string>>;
@@ -79,11 +80,14 @@ interface Props {
79
80
  englishSlug: string;
80
81
  }
81
82
 
82
- const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props;
83
+ type ToolComponent = (props: { ui: Record<string, string> }) => unknown;
84
+
85
+ const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as Props;
83
86
 
84
87
  const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
85
88
  const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
86
- const toolCss = cssKey ? await cssFiles[cssKey]() as string : "";
89
+ const cssLoader = cssKey ? cssFiles[cssKey] : undefined;
90
+ const toolCss = cssLoader ? await cssLoader() as string : "";
87
91
 
88
92
  const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
89
93
 
@@ -132,7 +136,7 @@ const titleBase = words.slice(1).join(" ") || "";
132
136
  </section>
133
137
 
134
138
  <section class="section-faq">
135
- <FAQSection items={content.faq} inLanguage={locale} title={content.faqTitle} />
139
+ <FAQSection items={content.faq} />
136
140
  </section>
137
141
 
138
142
  <section class="section-bibliography">
@@ -146,17 +150,22 @@ const titleBase = words.slice(1).join(" ") || "";
146
150
  display: flex;
147
151
  flex-direction: column;
148
152
  gap: 2rem;
153
+ min-width: 0;
154
+ width: 100%;
149
155
  }
150
156
 
151
157
  .section-tool {
152
158
  max-width: 1200px;
153
159
  margin: 0 auto;
160
+ min-width: 0;
154
161
  width: 100%;
155
162
  }
156
163
 
157
164
  .section-seo,
158
165
  .section-faq,
159
166
  .section-bibliography {
167
+ min-width: 0;
168
+ width: 100%;
160
169
  padding-top: 2rem;
161
170
  border-top: 1px solid var(--border-color);
162
171
  }
@@ -96,7 +96,7 @@ describe('Diacritics density validation', () => {
96
96
  if (!loader) return;
97
97
 
98
98
  const content = await loader();
99
- const text = normalizeText(translatableContent(content as Record<string, unknown>));
99
+ const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
100
100
  const rule = DIACRITIC_RULES[typedLocale];
101
101
  const letters = letterCount(text);
102
102
  const matches = diacriticCount(text, typedLocale);
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import * as DATA from '../data';
3
3
 
4
- const TOOLS = [DATA.petAge, DATA.petRation, DATA.petGestation];
4
+ const TOOLS = [DATA.petAge, DATA.petRation, DATA.petGestation, DATA.petToxicity];
5
5
 
6
6
  describe('FAQ Content Validation', () => {
7
7
  TOOLS.forEach((entry) => {
@@ -60,7 +60,7 @@ describe('Inverted punctuation validation', () => {
60
60
 
61
61
  const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
62
62
  const content = await loader();
63
- const strings = translatableStrings(content as Record<string, unknown>);
63
+ const strings = translatableStrings(content as unknown as Record<string, unknown>);
64
64
  const missingQuestions = strings.flatMap((text) =>
65
65
  findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
66
66
  );
@@ -26,6 +26,6 @@ describe('Locale Completeness Validation', () => {
26
26
  });
27
27
 
28
28
  it('all tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(3);
29
+ expect(ALL_TOOLS.length).toBe(4);
30
30
  });
31
31
  });
@@ -73,7 +73,7 @@ describe('Native script density validation', () => {
73
73
 
74
74
  const content = await loader();
75
75
  const rule = SCRIPT_RULES[typedLocale];
76
- const text = normalizeText(translatableContent(content as Record<string, unknown>));
76
+ const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
77
77
  const letters = letterCount(text);
78
78
  const matches = scriptCount(text, typedLocale);
79
79
  const ratio = scriptRatio(text, typedLocale);
@@ -41,6 +41,7 @@ describe('Project Titles - Separator Validation', () => {
41
41
  let match;
42
42
  while ((match = pattern.exec(content)) !== null) {
43
43
  const title = match[1];
44
+ if (!title) continue;
44
45
  if (title.includes('|') || title.includes('-')) {
45
46
  findings.push(title);
46
47
  }
@@ -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'];
74
+ const validSlugs = ['calculadora-edad-mascotas', 'calculadora-racion-diaria-mascotas', 'calculadora-gestacion-mascotas', 'buscador-alimentos-toxicos-perros-gatos'];
75
75
  expect(validSlugs).toContain(content.slug);
76
76
  }
77
77
  });
@@ -96,12 +96,12 @@ describe('Tool Validation Suite', () => {
96
96
  });
97
97
 
98
98
  describe('Library Registration', () => {
99
- it('should have 3 tools in ALL_TOOLS', () => {
100
- expect(ALL_TOOLS.length).toBe(3);
99
+ it('should have 4 tools in ALL_TOOLS', () => {
100
+ expect(ALL_TOOLS.length).toBe(4);
101
101
  });
102
102
 
103
103
  it('should have all tools in petsCategory', () => {
104
- expect(petsCategory.tools.length).toBe(3);
104
+ expect(petsCategory.tools.length).toBe(4);
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);
@@ -91,6 +91,19 @@ function copySimilarity(left: string, right: string): number {
91
91
  return (2 * shared) / (leftTotal + rightTotal);
92
92
  }
93
93
 
94
+ function findViolations(locales: string[], corpora: Map<string, string>): string[] {
95
+ const violations: string[] = [];
96
+ for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
97
+ for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
98
+ const left = locales[leftIndex] ?? '';
99
+ const right = locales[rightIndex] ?? '';
100
+ const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
101
+ if (similarity >= COPY_THRESHOLD) violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
102
+ }
103
+ }
104
+ return violations;
105
+ }
106
+
94
107
  describe('Locales must not copy another locale wholesale', () => {
95
108
  ALL_ENTRIES.forEach((entry) => {
96
109
  it(`${entry.id} is not at least ${COPY_THRESHOLD * 100}% identical to another locale`, async () => {
@@ -102,23 +115,9 @@ describe('Locales must not copy another locale wholesale', () => {
102
115
  }
103
116
 
104
117
  const locales = [...corpora.keys()];
105
- const violations: string[] = [];
106
-
107
- for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
108
- for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
109
- const left = locales[leftIndex];
110
- const right = locales[rightIndex];
111
- const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
112
-
113
- if (similarity >= COPY_THRESHOLD) {
114
- violations.push(`${left} ↔ ${right}: ${(similarity * 100).toFixed(1)}%`);
115
- }
116
- }
117
- }
118
+ const violations = findViolations(locales, corpora);
118
119
 
119
120
  expect(violations, `Locale copy threshold exceeded in ${entry.id}`).toEqual([]);
120
121
  });
121
122
  });
122
123
  });
123
-
124
-
@@ -1,6 +1,7 @@
1
1
  import { bibliography } from '../bibliography';
2
2
  import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
3
3
  import type { PetGestationLocaleContent } from '../index';
4
+ import type { PetGestationUI } from '../ui';
4
5
 
5
6
  const slug = 'pet-gestation-calculator';
6
7
  const title = 'Pet Gestation Calculator';
@@ -1,6 +1,7 @@
1
1
  import { bibliography } from '../bibliography';
2
2
  import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
3
3
  import type { PetGestationLocaleContent } from '../index';
4
+ import type { PetGestationUI } from '../ui';
4
5
 
5
6
  const slug = 'calculadora-gestacion-mascotas';
6
7
  const title = 'Calculadora de gestación de mascotas';
@@ -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,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'ASPCA Poison Control: People Foods to Avoid Feeding Your Pets',
6
+ url: 'https://www.aspca.org/pet-care/aspca-poison-control/people-foods-avoid-feeding-your-pets',
7
+ },
8
+ {
9
+ name: 'FDA: Potentially Dangerous Items for Your Pet',
10
+ url: 'https://www.fda.gov/animal-veterinary/animal-health-literacy/potentially-dangerous-items-your-pet',
11
+ },
12
+ {
13
+ name: 'Merck Veterinary Manual: Garlic and Onion Toxicosis in Animals',
14
+ url: 'https://www.merckvetmanual.com/toxicology/food-hazards/garlic-and-onion-allium-spp-toxicosis-in-animals',
15
+ },
16
+ ];
@@ -0,0 +1,53 @@
1
+ ---
2
+ import './pet-food-toxicity-checker.css';
3
+ import { getFoodEvaluation } from './logic';
4
+ import { renderFoodMenu, renderResult, renderScene, renderSpeciesMenu } from './dom-views';
5
+ import { petToxicity } from './entry';
6
+
7
+ const content = await petToxicity.i18n.en?.();
8
+ if (!content) return null;
9
+ const { ui } = content;
10
+ const defaultSpecies = 'dog';
11
+ const defaultFood = 'chocolate';
12
+ const evaluation = getFoodEvaluation(defaultSpecies, defaultFood);
13
+ const config = { ui, defaultSpecies, defaultFood };
14
+ ---
15
+
16
+ <section class="pet-toxicity" data-pet-toxicity-root aria-label={content.title}>
17
+ <div class="pet-toxicity-journey">
18
+ <span class="pet-toxicity-journey-mark" aria-hidden="true">!</span>
19
+ <p>{ui.journeyHint}</p>
20
+ </div>
21
+ <div class="pet-toxicity-workbench">
22
+ <div class="pet-toxicity-controls">
23
+ <div class="pet-toxicity-control">
24
+ <span class="pet-toxicity-label">{ui.speciesLabel}</span>
25
+ <div class="pet-toxicity-select">
26
+ <button class="pet-toxicity-select-trigger" type="button" data-menu-trigger data-species-trigger aria-haspopup="listbox" aria-expanded="false"><span data-trigger-value>{ui.speciesDog}</span><span class="pet-toxicity-chevron" aria-hidden="true"></span></button>
27
+ <div class="pet-toxicity-menu" data-menu data-species-menu role="listbox" hidden>{renderSpeciesMenu(defaultSpecies, ui)}</div>
28
+ </div>
29
+ </div>
30
+ <div class="pet-toxicity-control">
31
+ <span class="pet-toxicity-label">{ui.foodLabel}</span>
32
+ <div class="pet-toxicity-select">
33
+ <button class="pet-toxicity-select-trigger" type="button" data-menu-trigger data-food-trigger aria-haspopup="listbox" aria-expanded="false"><span data-trigger-value>{getFoodEvaluation(defaultSpecies, defaultFood)?.food.name ?? ui.foodPlaceholder}</span><span class="pet-toxicity-chevron" aria-hidden="true"></span></button>
34
+ <div class="pet-toxicity-menu" data-menu data-food-menu role="listbox" hidden>{renderFoodMenu([{ id: 'chocolate', name: 'Chocolate and caffeine', detail: 'Chocolate, coffee, tea, energy drinks' }], defaultFood)}</div>
35
+ </div>
36
+ <small class="pet-toxicity-hint">{ui.foodMenuHint}</small>
37
+ </div>
38
+ </div>
39
+ <div class="pet-toxicity-visual" data-scene>{renderScene(defaultSpecies, evaluation, ui)}</div>
40
+ </div>
41
+ <div class="pet-toxicity-result" data-result aria-live="polite">{renderResult(evaluation, ui)}</div>
42
+ <div class="pet-toxicity-emergency"><strong>{ui.emergencyTitle}</strong><span>{ui.emergencyText}</span></div>
43
+ <div class="pet-toxicity-sources"><span class="pet-toxicity-label">{ui.sourceLabel}</span><p>{ui.sourceText}</p></div>
44
+ </section>
45
+
46
+ <script is:inline type="application/json" data-pet-toxicity-config set:html={JSON.stringify(config)}></script>
47
+ <script>
48
+ import { mountPetToxicity } from './controller';
49
+
50
+ const root = document.querySelector<HTMLElement>('[data-pet-toxicity-root]');
51
+ const configElement = document.querySelector<HTMLScriptElement>('[data-pet-toxicity-config]');
52
+ if (root && configElement?.textContent) mountPetToxicity(root, JSON.parse(configElement.textContent));
53
+ </script>
@@ -0,0 +1,117 @@
1
+ import { getFoodEvaluation, getFoodOptions } from './logic';
2
+ import type { FoodId, PetSpecies } from './logic';
3
+ import { renderFoodMenu, renderResult, renderScene, renderSpeciesMenu } from './dom-views';
4
+ import { loadSavedState, saveState } from './storage';
5
+ import type { PetToxicityUI } from './ui';
6
+
7
+ interface MountPayload {
8
+ ui: PetToxicityUI;
9
+ defaultSpecies: PetSpecies;
10
+ defaultFood: FoodId;
11
+ }
12
+
13
+ interface ToolState {
14
+ species: PetSpecies;
15
+ foodId: FoodId;
16
+ }
17
+
18
+ function findElement<T extends Element>(root: HTMLElement, selector: string): T {
19
+ const element = root.querySelector<T>(selector);
20
+ if (!element) throw new Error(`Missing pet toxicity element: ${selector}`);
21
+ return element;
22
+ }
23
+
24
+ function closeMenus(root: HTMLElement): void {
25
+ root.querySelectorAll<HTMLElement>('[data-menu]').forEach((menu) => { menu.hidden = true; });
26
+ root.querySelectorAll<HTMLElement>('[data-menu-trigger]').forEach((trigger) => { trigger.setAttribute('aria-expanded', 'false'); });
27
+ }
28
+
29
+ function toggleMenu(root: HTMLElement, menu: HTMLElement, trigger: HTMLElement): void {
30
+ const shouldOpen = menu.hidden;
31
+ closeMenus(root);
32
+ menu.hidden = !shouldOpen;
33
+ trigger.setAttribute('aria-expanded', String(shouldOpen));
34
+ }
35
+
36
+ function labelForSpecies(species: PetSpecies, ui: PetToxicityUI): string {
37
+ return species === 'dog' ? ui.speciesDog : ui.speciesCat;
38
+ }
39
+
40
+ function setupKeyboard(root: HTMLElement): void {
41
+ root.querySelectorAll<HTMLElement>('[data-menu-trigger]').forEach((trigger) => {
42
+ trigger.addEventListener('keydown', (event) => {
43
+ if (event.key === 'Enter' || event.key === ' ') {
44
+ event.preventDefault();
45
+ trigger.click();
46
+ }
47
+ if (event.key === 'Escape') closeMenus(root);
48
+ });
49
+ });
50
+ }
51
+
52
+ interface ViewElements {
53
+ speciesTrigger: HTMLButtonElement;
54
+ speciesMenu: HTMLElement;
55
+ foodTrigger: HTMLButtonElement;
56
+ foodMenu: HTMLElement;
57
+ scene: HTMLElement;
58
+ result: HTMLElement;
59
+ }
60
+
61
+ function renderState(elements: ViewElements, state: ToolState, payload: MountPayload): void {
62
+ const options = getFoodOptions(state.species);
63
+ if (!options.some((option) => option.id === state.foodId)) state.foodId = options[0]?.id ?? payload.defaultFood;
64
+ const evaluation = getFoodEvaluation(state.species, state.foodId);
65
+ const selectedFood = options.find((option) => option.id === state.foodId);
66
+ elements.speciesTrigger.querySelector('[data-trigger-value]')!.textContent = labelForSpecies(state.species, payload.ui);
67
+ elements.foodTrigger.querySelector('[data-trigger-value]')!.textContent = selectedFood?.name ?? payload.ui.foodPlaceholder;
68
+ elements.speciesMenu.innerHTML = renderSpeciesMenu(state.species, payload.ui);
69
+ elements.foodMenu.innerHTML = renderFoodMenu(options, state.foodId);
70
+ elements.scene.innerHTML = renderScene(state.species, evaluation, payload.ui);
71
+ elements.result.innerHTML = renderResult(evaluation, payload.ui);
72
+ saveState(state);
73
+ }
74
+
75
+ function bindSelection(root: HTMLElement, elements: ViewElements, state: ToolState, render: () => void): void {
76
+ elements.speciesTrigger.addEventListener('click', () => toggleMenu(root, elements.speciesMenu, elements.speciesTrigger));
77
+ elements.foodTrigger.addEventListener('click', () => toggleMenu(root, elements.foodMenu, elements.foodTrigger));
78
+ root.addEventListener('click', (event) => {
79
+ const target = event.target as HTMLElement;
80
+ const speciesOption = target.closest<HTMLElement>('[data-species-id]');
81
+ const foodOption = target.closest<HTMLElement>('[data-food-id]');
82
+ if (speciesOption) selectSpecies(speciesOption, root, state, render);
83
+ if (foodOption) selectFood(foodOption, root, state, render);
84
+ });
85
+ document.addEventListener('click', (event) => {
86
+ if (!root.contains(event.target as Node)) closeMenus(root);
87
+ });
88
+ }
89
+
90
+ function selectSpecies(option: HTMLElement, root: HTMLElement, state: ToolState, render: () => void): void {
91
+ state.species = option.dataset.speciesId as PetSpecies;
92
+ closeMenus(root);
93
+ render();
94
+ }
95
+
96
+ function selectFood(option: HTMLElement, root: HTMLElement, state: ToolState, render: () => void): void {
97
+ state.foodId = option.dataset.foodId as FoodId;
98
+ closeMenus(root);
99
+ render();
100
+ }
101
+
102
+ export function mountPetToxicity(root: HTMLElement, payload: MountPayload): void {
103
+ const saved = loadSavedState();
104
+ const state: ToolState = { species: saved?.species ?? payload.defaultSpecies, foodId: saved?.foodId ?? payload.defaultFood };
105
+ const elements: ViewElements = {
106
+ speciesTrigger: findElement<HTMLButtonElement>(root, '[data-species-trigger]'),
107
+ speciesMenu: findElement<HTMLElement>(root, '[data-species-menu]'),
108
+ foodTrigger: findElement<HTMLButtonElement>(root, '[data-food-trigger]'),
109
+ foodMenu: findElement<HTMLElement>(root, '[data-food-menu]'),
110
+ scene: findElement<HTMLElement>(root, '[data-scene]'),
111
+ result: findElement<HTMLElement>(root, '[data-result]'),
112
+ };
113
+ const render = () => renderState(elements, state, payload);
114
+ bindSelection(root, elements, state, render);
115
+ setupKeyboard(root);
116
+ render();
117
+ }
@@ -0,0 +1,61 @@
1
+ import { getRiskPresentation } from './evaluator';
2
+ import type { RiskTone } from './evaluator';
3
+ import type { FoodEvaluation, FoodOption, PetSpecies } from './logic';
4
+ import type { PetToxicityUI } from './ui';
5
+
6
+ function escapeHtml(value: string): string {
7
+ return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ?? character);
8
+ }
9
+
10
+ function selectedAttribute(selected: boolean): string {
11
+ return selected ? ' aria-selected="true" data-selected="true"' : ' aria-selected="false"';
12
+ }
13
+
14
+ export function renderSpeciesMenu(species: PetSpecies, ui: PetToxicityUI): string {
15
+ const options = [
16
+ { id: 'dog', name: ui.speciesDog, detail: ui.speciesDogMeta },
17
+ { id: 'cat', name: ui.speciesCat, detail: ui.speciesCatMeta },
18
+ ];
19
+ return options.map((option) => `<button class="pet-toxicity-menu-option" type="button" role="option" data-species-id="${option.id}"${selectedAttribute(option.id === species)}><span>${escapeHtml(option.name)}</span><small>${escapeHtml(option.detail)}</small></button>`).join('');
20
+ }
21
+
22
+ export function renderFoodMenu(options: FoodOption[], selectedId: string): string {
23
+ return options.map((option) => `<button class="pet-toxicity-menu-option" type="button" role="option" data-food-id="${option.id}"${selectedAttribute(option.id === selectedId)}><span>${escapeHtml(option.name)}</span><small>${escapeHtml(option.detail)}</small></button>`).join('');
24
+ }
25
+
26
+ function renderMarkers(evaluation: FoodEvaluation | null): string {
27
+ const tone = evaluation?.level ?? 'empty';
28
+ const markerClass = evaluation ? `pet-toxicity-marker pet-toxicity-marker-${tone}` : 'pet-toxicity-marker pet-toxicity-marker-empty';
29
+ return [0, 1, 2, 3, 4].map((index) => `<circle class="${markerClass}" cx="${74 + index * 47}" cy="${82 + (index % 2) * 34}" r="${evaluation ? 7 + index : 5}" />`).join('');
30
+ }
31
+
32
+ export function renderScene(species: PetSpecies, evaluation: FoodEvaluation | null, ui: PetToxicityUI): string {
33
+ const speciesName = species === 'dog' ? ui.speciesDog : ui.speciesCat;
34
+ const selectedName = evaluation?.food.name ?? ui.sceneReady;
35
+ const stateText = evaluation ? ui.sceneSelected : ui.resultEmpty;
36
+ return `<div class="pet-toxicity-scene-card"><div class="pet-toxicity-scene-copy"><span class="pet-toxicity-eyebrow">${escapeHtml(ui.sceneLabel)}</span><strong>${escapeHtml(selectedName)}</strong><span>${escapeHtml(speciesName)} · ${escapeHtml(stateText)}</span></div><svg class="pet-toxicity-scene" viewBox="0 0 320 180" role="img" aria-label="${escapeHtml(selectedName)} risk scene"><rect class="pet-toxicity-scene-paper" x="14" y="14" width="292" height="152" rx="18" /><path class="pet-toxicity-scene-line" d="M38 122 C80 46 124 146 166 66 S246 50 282 118" /><path class="pet-toxicity-scene-line-thin" d="M42 138 C92 84 132 138 174 98 S240 82 280 136" />${renderMarkers(evaluation)}<circle class="pet-toxicity-scene-seal" cx="258" cy="52" r="20" /><path class="pet-toxicity-scene-seal-mark" d="M258 43v12" /><circle class="pet-toxicity-scene-seal-dot" cx="258" cy="62" r="1.7" /></svg></div>`;
37
+ }
38
+
39
+ function renderSignIcon(index: number): string {
40
+ const paths = [
41
+ '<circle cx="8" cy="8" r="6" /><path d="M8 4.5v4" /><circle cx="8" cy="11.5" r=".7" />',
42
+ '<path d="M1.5 9h2.5l1.5-4 2.5 6 1.6-3h2.3" />',
43
+ '<path d="m8 1.5 6 11H2l6-11Z" /><path d="M8 5v3" /><circle cx="8" cy="10.5" r=".7" />',
44
+ ];
45
+ return `<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">${paths[index % paths.length]}</svg>`;
46
+ }
47
+
48
+ function renderList(items: string[]): string {
49
+ return `<ul class="pet-toxicity-sign-list">${items.map((item, index) => `<li><span class="pet-toxicity-sign-icon">${renderSignIcon(index)}</span><span>${escapeHtml(item)}</span></li>`).join('')}</ul>`;
50
+ }
51
+
52
+ function renderRiskMeter(tone: RiskTone, label: string): string {
53
+ const activeCount = { critical: 3, high: 2, caution: 1, unknown: 0 }[tone];
54
+ return `<span class="pet-toxicity-risk-meter" role="img" aria-label="${escapeHtml(label)}"><span class="${activeCount >= 1 ? 'is-active' : ''}"></span><span class="${activeCount >= 2 ? 'is-active' : ''}"></span><span class="${activeCount >= 3 ? 'is-active' : ''}"></span></span>`;
55
+ }
56
+
57
+ export function renderResult(evaluation: FoodEvaluation | null, ui: PetToxicityUI): string {
58
+ if (!evaluation) return `<div class="pet-toxicity-result-empty">${escapeHtml(ui.resultEmpty)}</div>`;
59
+ const risk = getRiskPresentation(evaluation.level, ui);
60
+ return `<article class="pet-toxicity-result-card pet-toxicity-result-card-${risk.tone}"><div class="pet-toxicity-result-head"><span class="pet-toxicity-eyebrow">${escapeHtml(ui.resultEyebrow)}</span><span class="pet-toxicity-risk-status"><span class="pet-toxicity-risk pet-toxicity-risk-${risk.tone}">${escapeHtml(risk.label)}</span>${renderRiskMeter(risk.tone, `${ui.riskLabel}: ${risk.label}`)}</span></div><h3>${escapeHtml(evaluation.food.name)}</h3><p class="pet-toxicity-summary">${escapeHtml(evaluation.summary)}</p><div class="pet-toxicity-result-grid"><div><span class="pet-toxicity-label">${escapeHtml(ui.whyLabel)}</span><p>${escapeHtml(evaluation.why)}</p></div><div><span class="pet-toxicity-label">${escapeHtml(ui.signsLabel)}</span>${renderList(evaluation.signs)}</div><div class="pet-toxicity-action"><span class="pet-toxicity-label">${escapeHtml(ui.actionLabel)}</span><p>${escapeHtml(evaluation.action)}</p></div></div><div class="pet-toxicity-callout"><strong>${escapeHtml(ui.callVetLabel)}</strong><span>${escapeHtml(ui.callVetText)}</span></div></article>`;
61
+ }
@@ -0,0 +1,33 @@
1
+ import type { PetToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { PetToxicityUI } from './ui';
3
+
4
+ export type { PetToxicityUI } from './ui';
5
+
6
+ export type PetToxicityLocaleContent = ToolLocaleContent<PetToxicityUI>;
7
+
8
+ export const petToxicity: PetToolEntry<PetToxicityUI> = {
9
+ id: 'pet-toxicity',
10
+ icons: {
11
+ bg: 'mdi:paw',
12
+ fg: 'mdi:alert-octagon',
13
+ },
14
+ i18n: {
15
+ de: () => import('./i18n/de').then((module) => module.content),
16
+ en: () => import('./i18n/en').then((module) => module.content),
17
+ es: () => import('./i18n/es').then((module) => module.content),
18
+ fr: () => import('./i18n/fr').then((module) => module.content),
19
+ id: () => import('./i18n/id').then((module) => module.content),
20
+ it: () => import('./i18n/it').then((module) => module.content),
21
+ ja: () => import('./i18n/ja').then((module) => module.content),
22
+ ko: () => import('./i18n/ko').then((module) => module.content),
23
+ nl: () => import('./i18n/nl').then((module) => module.content),
24
+ pl: () => import('./i18n/pl').then((module) => module.content),
25
+ pt: () => import('./i18n/pt').then((module) => module.content),
26
+ ru: () => import('./i18n/ru').then((module) => module.content),
27
+ sv: () => import('./i18n/sv').then((module) => module.content),
28
+ tr: () => import('./i18n/tr').then((module) => module.content),
29
+ zh: () => import('./i18n/zh').then((module) => module.content),
30
+ },
31
+ };
32
+
33
+ export { bibliography } from './bibliography';
@@ -0,0 +1,23 @@
1
+ import type { PetToxicityUI } from './ui';
2
+ import type { FoodEvaluation, RiskLevel } from './logic';
3
+
4
+ export type RiskTone = 'critical' | 'high' | 'caution' | 'unknown';
5
+
6
+ export interface RiskPresentation {
7
+ label: string;
8
+ tone: RiskTone;
9
+ }
10
+
11
+ export function getRiskPresentation(level: RiskLevel, ui: PetToxicityUI): RiskPresentation {
12
+ const labels: Record<RiskLevel, string> = {
13
+ critical: ui.riskCritical,
14
+ high: ui.riskHigh,
15
+ caution: ui.riskCaution,
16
+ unknown: ui.riskUnknown,
17
+ };
18
+ return { label: labels[level], tone: level };
19
+ }
20
+
21
+ export function getResultTitle(evaluation: FoodEvaluation | null, ui: PetToxicityUI): string {
22
+ return evaluation ? evaluation.food.name : ui.resultEmpty;
23
+ }