@jjlmoya/utils-aquarium 1.64.0 → 1.66.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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -1
  4. package/src/layouts/ProductionCategoryPage.astro +184 -184
  5. package/src/layouts/ProductionUtilityPage.astro +4 -4
  6. package/src/mfe/category-ui.ts +34 -34
  7. package/src/mfe/widget-height.ts +14 -0
  8. package/src/tests/qa-test-helpers.ts +32 -0
  9. package/src/tests/qa_bibliography_links.test.ts +48 -0
  10. package/src/tests/qa_claim_evidence.test.ts +69 -0
  11. package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
  12. package/src/tests/qa_runtime_i18n.test.ts +138 -0
  13. package/src/tests/tool_validation.test.ts +4 -3
  14. package/src/tool/aquariumSaltDosingCalculator/aquarium-salt-dosing-calculator.css +503 -0
  15. package/src/tool/aquariumSaltDosingCalculator/bibliography.astro +6 -0
  16. package/src/tool/aquariumSaltDosingCalculator/bibliography.ts +5 -0
  17. package/src/tool/aquariumSaltDosingCalculator/component.astro +36 -0
  18. package/src/tool/aquariumSaltDosingCalculator/controller.ts +104 -0
  19. package/src/tool/aquariumSaltDosingCalculator/dom-views.ts +74 -0
  20. package/src/tool/aquariumSaltDosingCalculator/entry.ts +13 -0
  21. package/src/tool/aquariumSaltDosingCalculator/i18n/de.ts +2 -0
  22. package/src/tool/aquariumSaltDosingCalculator/i18n/en.ts +2 -0
  23. package/src/tool/aquariumSaltDosingCalculator/i18n/es.ts +2 -0
  24. package/src/tool/aquariumSaltDosingCalculator/i18n/fr.ts +2 -0
  25. package/src/tool/aquariumSaltDosingCalculator/i18n/id.ts +2 -0
  26. package/src/tool/aquariumSaltDosingCalculator/i18n/it.ts +2 -0
  27. package/src/tool/aquariumSaltDosingCalculator/i18n/ja.ts +2 -0
  28. package/src/tool/aquariumSaltDosingCalculator/i18n/ko.ts +2 -0
  29. package/src/tool/aquariumSaltDosingCalculator/i18n/nl.ts +2 -0
  30. package/src/tool/aquariumSaltDosingCalculator/i18n/pl.ts +2 -0
  31. package/src/tool/aquariumSaltDosingCalculator/i18n/pt.ts +2 -0
  32. package/src/tool/aquariumSaltDosingCalculator/i18n/ru.ts +2 -0
  33. package/src/tool/aquariumSaltDosingCalculator/i18n/sv.ts +2 -0
  34. package/src/tool/aquariumSaltDosingCalculator/i18n/tr.ts +2 -0
  35. package/src/tool/aquariumSaltDosingCalculator/i18n/zh.ts +2 -0
  36. package/src/tool/aquariumSaltDosingCalculator/index.ts +11 -0
  37. package/src/tool/aquariumSaltDosingCalculator/localized-content.ts +151 -0
  38. package/src/tool/aquariumSaltDosingCalculator/logic.test.ts +46 -0
  39. package/src/tool/aquariumSaltDosingCalculator/logic.ts +85 -0
  40. package/src/tool/aquariumSaltDosingCalculator/seo.astro +11 -0
  41. package/src/tool/aquariumSaltDosingCalculator/storage.ts +19 -0
  42. package/src/tool/aquariumSaltDosingCalculator/ui.ts +47 -0
  43. package/src/tools.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-aquarium",
3
- "version": "1.64.0",
3
+ "version": "1.66.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -3,10 +3,11 @@ import { aquariumTankVolumeWaterChangeCalculator } from '../tool/aquariumTankVol
3
3
  import { aquariumFilterTurnoverStockingPlanner } from '../tool/aquariumFilterTurnoverStockingPlanner/entry';
4
4
  import { aquariumLightingPhotoperiodPlanner } from '../tool/aquariumLightingPhotoperiodPlanner/entry';
5
5
  import { aquariumEvaporationTopOffPlanner } from '../tool/aquariumEvaporationTopOffPlanner/entry';
6
+ import { aquariumSaltDosingCalculator } from '../tool/aquariumSaltDosingCalculator/entry';
6
7
 
7
8
  export const aquariumCategory: AquariumCategoryEntry = {
8
9
  icon: 'mdi:fishbowl-outline',
9
- tools: [aquariumTankVolumeWaterChangeCalculator, aquariumFilterTurnoverStockingPlanner, aquariumLightingPhotoperiodPlanner, aquariumEvaporationTopOffPlanner],
10
+ tools: [aquariumTankVolumeWaterChangeCalculator, aquariumFilterTurnoverStockingPlanner, aquariumLightingPhotoperiodPlanner, aquariumEvaporationTopOffPlanner, aquariumSaltDosingCalculator],
10
11
  i18n: {
11
12
  en: () => import('./i18n/en').then((m) => m.content),
12
13
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -12,5 +12,6 @@ import { aquariumTankVolumeWaterChangeCalculator } from './tool/aquariumTankVolu
12
12
  import { aquariumFilterTurnoverStockingPlanner } from './tool/aquariumFilterTurnoverStockingPlanner/entry';
13
13
  import { aquariumLightingPhotoperiodPlanner } from './tool/aquariumLightingPhotoperiodPlanner/entry';
14
14
  import { aquariumEvaporationTopOffPlanner } from './tool/aquariumEvaporationTopOffPlanner/entry';
15
+ import { aquariumSaltDosingCalculator } from './tool/aquariumSaltDosingCalculator/entry';
15
16
 
16
- export const ALL_ENTRIES = [aquariumTankVolumeWaterChangeCalculator, aquariumFilterTurnoverStockingPlanner, aquariumLightingPhotoperiodPlanner, aquariumEvaporationTopOffPlanner];
17
+ export const ALL_ENTRIES = [aquariumTankVolumeWaterChangeCalculator, aquariumFilterTurnoverStockingPlanner, aquariumLightingPhotoperiodPlanner, aquariumEvaporationTopOffPlanner, aquariumSaltDosingCalculator];
@@ -1,184 +1,184 @@
1
- ---
2
- import ProductionPage from "./ProductionPage.astro";
3
- import { Icon } from "astro-icon/components";
4
- import { SEORenderer } from "@jjlmoya/utils-shared";
5
- import ProductionBreadcrumb from "../components/ProductionBreadcrumb.astro";
6
- import type { KnownLocale, CategoryLocaleContent } from "../types";
7
- import { getCategoryPath } from "../mfe/routes";
8
- import { getCategoryUi } from "../mfe/category-ui";
9
-
10
- interface CategoryTool { id: string; icon: string; title: string; description: string; href: string; }
11
- interface Props { locale: KnownLocale; category: CategoryLocaleContent; tools: CategoryTool[]; alternates: { language: KnownLocale; url: string }[]; image?: string | undefined; }
12
- const { locale, category, tools, alternates, image } = Astro.props;
13
- const publicPath = getCategoryPath(locale, category.slug);
14
- const ui = getCategoryUi(locale);
15
- ---
16
-
17
- <ProductionPage title={category.title} description={category.description} {locale} {publicPath} {alternates} {image}>
18
- <div class="category-production">
19
- <div class="category-production-inner">
20
- <ProductionBreadcrumb locale={locale} currentTitle={category.title} />
21
- <header class="category-header-production">
22
- <h1>{category.title}</h1><p>{category.description}</p>
23
- </header>
24
- <div class="category-tools-grid">
25
- {tools.map((tool, index) => (
26
- <a href={tool.href} class="category-tool-card">
27
- <span class="category-tool-index">{String(index + 1).padStart(2, "0")}</span>
28
- <div class="category-tool-icon"><Icon name={tool.icon} /></div>
29
- <h2>{tool.title}</h2><p>{tool.description}</p>
30
- <span class="category-tool-cta">{ui.useTool} <span aria-hidden="true">→</span></span>
31
- </a>
32
- ))}
33
- </div>
34
- <section class="category-seo-production"><SEORenderer content={{ locale, sections: category.seo }} /></section>
35
- </div>
36
- </div>
37
- </ProductionPage>
38
-
39
- <style>
40
- .category-production {
41
- min-height: 100vh;
42
- overflow-x: clip;
43
- background: radial-gradient(ellipse at top, var(--primary-10), var(--bg-surface) 50%, var(--bg-main));
44
- }
45
-
46
- .category-production-inner {
47
- max-width: 1200px;
48
- margin: 0 auto;
49
- padding: var(--space-xl) var(--space-md) var(--space-4xl);
50
- }
51
-
52
- .category-header-production {
53
- margin-bottom: var(--space-xl);
54
- text-align: center;
55
- }
56
-
57
- .category-header-production h1 {
58
- margin: 0;
59
- color: var(--text-main);
60
- font-size: clamp(3.5rem, 9vw, 7rem);
61
- font-weight: 900;
62
- letter-spacing: -0.06em;
63
- line-height: 0.95;
64
- text-transform: lowercase;
65
- }
66
-
67
- .category-header-production p {
68
- max-width: 48rem;
69
- margin: 1rem auto 0;
70
- color: var(--text-muted);
71
- font-size: 1.25rem;
72
- line-height: 1.6;
73
- }
74
-
75
- .category-tools-grid {
76
- display: grid;
77
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
78
- gap: var(--space-lg);
79
- margin-bottom: var(--space-4xl);
80
- }
81
-
82
- .category-tool-card {
83
- position: relative;
84
- display: flex;
85
- overflow: hidden;
86
- min-height: 230px;
87
- flex-direction: column;
88
- padding: var(--space-lg);
89
- border: 1px solid color-mix(in srgb, var(--aquarium-accent) 18%, var(--border-base));
90
- border-radius: 1.25rem;
91
- background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--aquarium-accent) 4%, var(--bg-surface)));
92
- color: inherit;
93
- text-decoration: none;
94
- box-shadow: 0 1rem 2rem color-mix(in srgb, var(--shadow-base, #000) 35%, transparent);
95
- transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
96
- }
97
-
98
- .category-tool-card:hover,
99
- .category-tool-card:focus-visible {
100
- border-color: var(--aquarium-accent);
101
- box-shadow: 0 1.25rem 2.5rem color-mix(in srgb, var(--aquarium-accent) 18%, transparent);
102
- transform: translateY(-4px);
103
- }
104
-
105
- .category-tool-card:focus-visible {
106
- outline: 3px solid color-mix(in srgb, var(--aquarium-accent) 55%, transparent);
107
- outline-offset: 4px;
108
- }
109
-
110
- .category-tool-card::after {
111
- position: absolute;
112
- right: -2rem;
113
- bottom: -3rem;
114
- width: 9rem;
115
- height: 9rem;
116
- border-radius: 50%;
117
- background: color-mix(in srgb, var(--aquarium-accent) 10%, transparent);
118
- content: "";
119
- pointer-events: none;
120
- }
121
-
122
- .category-tool-icon {
123
- position: relative;
124
- z-index: 1;
125
- display: grid;
126
- width: 3.25rem;
127
- height: 3.25rem;
128
- place-items: center;
129
- margin-bottom: var(--space-md);
130
- border: 1px solid color-mix(in srgb, var(--aquarium-accent) 28%, transparent);
131
- border-radius: 1rem;
132
- background: color-mix(in srgb, var(--aquarium-accent) 12%, var(--bg-surface));
133
- color: var(--aquarium-accent);
134
- font-size: 1.6rem;
135
- }
136
-
137
- .category-tool-index {
138
- position: absolute;
139
- top: var(--space-md);
140
- right: var(--space-lg);
141
- color: var(--text-dimmed, var(--text-muted));
142
- font-family: ui-monospace, SFMono-Regular, monospace;
143
- font-size: 0.72rem;
144
- letter-spacing: 0.12em;
145
- }
146
-
147
- .category-tool-card h2,
148
- .category-tool-card p,
149
- .category-tool-cta {
150
- position: relative;
151
- z-index: 1;
152
- }
153
-
154
- .category-tool-card h2 {
155
- margin: 0 0 0.5rem;
156
- color: var(--text-main);
157
- font-size: 1.25rem;
158
- }
159
-
160
- .category-tool-card p {
161
- margin: 0;
162
- color: var(--text-muted);
163
- line-height: 1.55;
164
- }
165
-
166
- .category-tool-cta {
167
- margin-top: auto;
168
- padding-top: var(--space-md);
169
- color: var(--aquarium-accent);
170
- font-weight: 700;
171
- }
172
-
173
- .category-seo-production {
174
- max-width: 56rem;
175
- margin: 0 auto;
176
- font-size: 1.125rem;
177
- line-height: 1.75;
178
- }
179
-
180
- @media (width <= 768px) {
181
- .category-production-inner { padding: var(--space-lg) var(--space-sm) var(--space-3xl); }
182
- .category-tools-grid { grid-template-columns: 1fr; }
183
- }
184
- </style>
1
+ ---
2
+ import ProductionPage from "./ProductionPage.astro";
3
+ import { Icon } from "astro-icon/components";
4
+ import { SEORenderer } from "@jjlmoya/utils-shared";
5
+ import ProductionBreadcrumb from "../components/ProductionBreadcrumb.astro";
6
+ import type { KnownLocale, CategoryLocaleContent } from "../types";
7
+ import { getCategoryPath } from "../mfe/routes";
8
+ import { getCategoryUi } from "../mfe/category-ui";
9
+
10
+ interface CategoryTool { id: string; icon: string; title: string; description: string; href: string; }
11
+ interface Props { locale: KnownLocale; category: CategoryLocaleContent; tools: CategoryTool[]; alternates: { language: KnownLocale; url: string }[]; image?: string | undefined; }
12
+ const { locale, category, tools, alternates, image } = Astro.props;
13
+ const publicPath = getCategoryPath(locale, category.slug);
14
+ const ui = getCategoryUi(locale);
15
+ ---
16
+
17
+ <ProductionPage title={category.title} description={category.description} {locale} {publicPath} {alternates} {image}>
18
+ <div class="category-production">
19
+ <div class="category-production-inner">
20
+ <ProductionBreadcrumb locale={locale} currentTitle={category.title} />
21
+ <header class="category-header-production">
22
+ <h1>{category.title}</h1><p>{category.description}</p>
23
+ </header>
24
+ <div class="category-tools-grid">
25
+ {tools.map((tool, index) => (
26
+ <a href={tool.href} class="category-tool-card">
27
+ <span class="category-tool-index">{String(index + 1).padStart(2, "0")}</span>
28
+ <div class="category-tool-icon"><Icon name={tool.icon} /></div>
29
+ <h2>{tool.title}</h2><p>{tool.description}</p>
30
+ <span class="category-tool-cta">{ui.useTool} <span aria-hidden="true">→</span></span>
31
+ </a>
32
+ ))}
33
+ </div>
34
+ <section class="category-seo-production"><SEORenderer content={{ locale, sections: category.seo }} /></section>
35
+ </div>
36
+ </div>
37
+ </ProductionPage>
38
+
39
+ <style>
40
+ .category-production {
41
+ min-height: 100vh;
42
+ overflow-x: clip;
43
+ background: radial-gradient(ellipse at top, var(--primary-10), var(--bg-surface) 50%, var(--bg-main));
44
+ }
45
+
46
+ .category-production-inner {
47
+ max-width: 1200px;
48
+ margin: 0 auto;
49
+ padding: var(--space-xl) var(--space-md) var(--space-4xl);
50
+ }
51
+
52
+ .category-header-production {
53
+ margin-bottom: var(--space-xl);
54
+ text-align: center;
55
+ }
56
+
57
+ .category-header-production h1 {
58
+ margin: 0;
59
+ color: var(--text-main);
60
+ font-size: clamp(3.5rem, 9vw, 7rem);
61
+ font-weight: 900;
62
+ letter-spacing: -0.06em;
63
+ line-height: 0.95;
64
+ text-transform: lowercase;
65
+ }
66
+
67
+ .category-header-production p {
68
+ max-width: 48rem;
69
+ margin: 1rem auto 0;
70
+ color: var(--text-muted);
71
+ font-size: 1.25rem;
72
+ line-height: 1.6;
73
+ }
74
+
75
+ .category-tools-grid {
76
+ display: grid;
77
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
78
+ gap: var(--space-lg);
79
+ margin-bottom: var(--space-4xl);
80
+ }
81
+
82
+ .category-tool-card {
83
+ position: relative;
84
+ display: flex;
85
+ overflow: hidden;
86
+ min-height: 230px;
87
+ flex-direction: column;
88
+ padding: var(--space-lg);
89
+ border: 1px solid color-mix(in srgb, var(--aquarium-accent) 18%, var(--border-base));
90
+ border-radius: 1.25rem;
91
+ background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--aquarium-accent) 4%, var(--bg-surface)));
92
+ color: inherit;
93
+ text-decoration: none;
94
+ box-shadow: 0 1rem 2rem color-mix(in srgb, var(--shadow-base, #000) 35%, transparent);
95
+ transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
96
+ }
97
+
98
+ .category-tool-card:hover,
99
+ .category-tool-card:focus-visible {
100
+ border-color: var(--aquarium-accent);
101
+ box-shadow: 0 1.25rem 2.5rem color-mix(in srgb, var(--aquarium-accent) 18%, transparent);
102
+ transform: translateY(-4px);
103
+ }
104
+
105
+ .category-tool-card:focus-visible {
106
+ outline: 3px solid color-mix(in srgb, var(--aquarium-accent) 55%, transparent);
107
+ outline-offset: 4px;
108
+ }
109
+
110
+ .category-tool-card::after {
111
+ position: absolute;
112
+ right: -2rem;
113
+ bottom: -3rem;
114
+ width: 9rem;
115
+ height: 9rem;
116
+ border-radius: 50%;
117
+ background: color-mix(in srgb, var(--aquarium-accent) 10%, transparent);
118
+ content: "";
119
+ pointer-events: none;
120
+ }
121
+
122
+ .category-tool-icon {
123
+ position: relative;
124
+ z-index: 1;
125
+ display: grid;
126
+ width: 3.25rem;
127
+ height: 3.25rem;
128
+ place-items: center;
129
+ margin-bottom: var(--space-md);
130
+ border: 1px solid color-mix(in srgb, var(--aquarium-accent) 28%, transparent);
131
+ border-radius: 1rem;
132
+ background: color-mix(in srgb, var(--aquarium-accent) 12%, var(--bg-surface));
133
+ color: var(--aquarium-accent);
134
+ font-size: 1.6rem;
135
+ }
136
+
137
+ .category-tool-index {
138
+ position: absolute;
139
+ top: var(--space-md);
140
+ right: var(--space-lg);
141
+ color: var(--text-dimmed, var(--text-muted));
142
+ font-family: ui-monospace, SFMono-Regular, monospace;
143
+ font-size: 0.72rem;
144
+ letter-spacing: 0.12em;
145
+ }
146
+
147
+ .category-tool-card h2,
148
+ .category-tool-card p,
149
+ .category-tool-cta {
150
+ position: relative;
151
+ z-index: 1;
152
+ }
153
+
154
+ .category-tool-card h2 {
155
+ margin: 0 0 0.5rem;
156
+ color: var(--text-main);
157
+ font-size: 1.25rem;
158
+ }
159
+
160
+ .category-tool-card p {
161
+ margin: 0;
162
+ color: var(--text-muted);
163
+ line-height: 1.55;
164
+ }
165
+
166
+ .category-tool-cta {
167
+ margin-top: auto;
168
+ padding-top: var(--space-md);
169
+ color: var(--aquarium-accent);
170
+ font-weight: 700;
171
+ }
172
+
173
+ .category-seo-production {
174
+ max-width: 56rem;
175
+ margin: 0 auto;
176
+ font-size: 1.125rem;
177
+ line-height: 1.75;
178
+ }
179
+
180
+ @media (width <= 768px) {
181
+ .category-production-inner { padding: var(--space-lg) var(--space-sm) var(--space-3xl); }
182
+ .category-tools-grid { grid-template-columns: 1fr; }
183
+ }
184
+ </style>
@@ -45,6 +45,7 @@ const ui = getCategoryUi(locale);
45
45
  </ProductionPage>
46
46
 
47
47
  <script>
48
+ import { observeWidgetHeight } from "../mfe/widget-height";
48
49
  const isWidget = new URLSearchParams(window.location.search).get("widget") === "true";
49
50
  const page = document.querySelector<HTMLElement>(".utility-production");
50
51
  const container = document.querySelector<HTMLElement>(".utility-tool-production");
@@ -60,11 +61,10 @@ const ui = getCategoryUi(locale);
60
61
  document.querySelector('[data-zoom="reset"]')?.addEventListener("click", () => { zoom = 1; applyZoom(); localStorage.removeItem("gamebob-utility-zoom"); });
61
62
  if (isWidget) {
62
63
  document.body.classList.add("utility-widget-body");
63
- if (page) {
64
- page.classList.add("utility-widget-mode");
65
- }
64
+ page?.classList.add("utility-widget-mode");
66
65
  }
67
66
  applyZoom();
67
+ if (isWidget && container) observeWidgetHeight(container);
68
68
  </script>
69
69
 
70
70
  <style>
@@ -88,7 +88,7 @@ const ui = getCategoryUi(locale);
88
88
  margin-top: 0;
89
89
  }
90
90
 
91
- .utility-production.utility-widget-mode .utility-production-inner > :not(.utility-tool-production) {
91
+ :global(.utility-production.utility-widget-mode .utility-production-inner > :not(.utility-tool-production)) {
92
92
  display: none;
93
93
  }
94
94
 
@@ -1,34 +1,34 @@
1
- import type { KnownLocale } from '../types';
2
-
3
- export interface CategoryUi {
4
- useTool: string;
5
- openTool: string;
6
- relatedEyebrow: string;
7
- moreToolsIn: string;
8
- zoom: string;
9
- zoomControls: string;
10
- zoomOut: string;
11
- resetZoom: string;
12
- zoomIn: string;
13
- breadcrumb: string;
14
- }
15
-
16
- export const CATEGORY_UI: Record<KnownLocale, CategoryUi> = {
17
- es: { useTool: 'Usar herramienta', openTool: 'Abrir herramienta', relatedEyebrow: 'Sigue explorando', moreToolsIn: 'Más herramientas en', zoom: 'Zoom', zoomControls: 'Controles de zoom', zoomOut: 'Reducir zoom', resetZoom: 'Restablecer zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Migas de pan' },
18
- en: { useTool: 'Use tool', openTool: 'Open tool', relatedEyebrow: 'Keep exploring', moreToolsIn: 'More tools in', zoom: 'Zoom', zoomControls: 'Zoom controls', zoomOut: 'Zoom out', resetZoom: 'Reset zoom', zoomIn: 'Zoom in', breadcrumb: 'Breadcrumb' },
19
- fr: { useTool: "Utiliser l'outil", openTool: "Ouvrir l'outil", relatedEyebrow: 'Continuez à explorer', moreToolsIn: "Plus d'outils dans", zoom: 'Zoom', zoomControls: 'Commandes de zoom', zoomOut: 'Réduire le zoom', resetZoom: 'Réinitialiser le zoom', zoomIn: 'Augmenter le zoom', breadcrumb: 'Fil d’Ariane' },
20
- de: { useTool: 'Werkzeug verwenden', openTool: 'Werkzeug öffnen', relatedEyebrow: 'Mehr entdecken', moreToolsIn: 'Weitere Werkzeuge in', zoom: 'Zoom', zoomControls: 'Zoom-Steuerung', zoomOut: 'Verkleinern', resetZoom: 'Zoom zurücksetzen', zoomIn: 'Vergrößern', breadcrumb: 'Brotkrümelnavigation' },
21
- it: { useTool: 'Usa lo strumento', openTool: 'Apri lo strumento', relatedEyebrow: 'Continua a esplorare', moreToolsIn: 'Altri strumenti in', zoom: 'Zoom', zoomControls: 'Controlli zoom', zoomOut: 'Riduci zoom', resetZoom: 'Reimposta zoom', zoomIn: 'Aumenta zoom', breadcrumb: 'Breadcrumb' },
22
- pt: { useTool: 'Usar ferramenta', openTool: 'Abrir ferramenta', relatedEyebrow: 'Continue a explorar', moreToolsIn: 'Mais ferramentas em', zoom: 'Zoom', zoomControls: 'Controlos de zoom', zoomOut: 'Reduzir zoom', resetZoom: 'Repor zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Trilho de navegação' },
23
- nl: { useTool: 'Tool gebruiken', openTool: 'Tool openen', relatedEyebrow: 'Blijf ontdekken', moreToolsIn: 'Meer tools in', zoom: 'Zoom', zoomControls: 'Zoomknoppen', zoomOut: 'Uitzoomen', resetZoom: 'Zoom herstellen', zoomIn: 'Inzoomen', breadcrumb: 'Broodkruimelnavigatie' },
24
- sv: { useTool: 'Använd verktyget', openTool: 'Öppna verktyget', relatedEyebrow: 'Utforska mer', moreToolsIn: 'Fler verktyg i', zoom: 'Zoom', zoomControls: 'Zoomkontroller', zoomOut: 'Zooma ut', resetZoom: 'Återställ zoom', zoomIn: 'Zooma in', breadcrumb: 'Brödsmulenavigering' },
25
- pl: { useTool: 'Użyj narzędzia', openTool: 'Otwórz narzędzie', relatedEyebrow: 'Odkrywaj dalej', moreToolsIn: 'Więcej narzędzi w', zoom: 'Powiększenie', zoomControls: 'Sterowanie powiększeniem', zoomOut: 'Pomniejsz', resetZoom: 'Resetuj powiększenie', zoomIn: 'Powiększ', breadcrumb: 'Okruszki nawigacji' },
26
- id: { useTool: 'Gunakan alat', openTool: 'Buka alat', relatedEyebrow: 'Jelajahi lebih lanjut', moreToolsIn: 'Alat lainnya di', zoom: 'Zoom', zoomControls: 'Kontrol zoom', zoomOut: 'Perkecil', resetZoom: 'Atur ulang zoom', zoomIn: 'Perbesar', breadcrumb: 'Breadcrumb' },
27
- tr: { useTool: 'Aracı kullan', openTool: 'Aracı aç', relatedEyebrow: 'Keşfetmeye devam edin', moreToolsIn: 'Diğer araçlar:', zoom: 'Yakınlaştırma', zoomControls: 'Yakınlaştırma denetimleri', zoomOut: 'Uzaklaştır', resetZoom: 'Yakınlaştırmayı sıfırla', zoomIn: 'Yakınlaştır', breadcrumb: 'İçerik haritası' },
28
- ru: { useTool: 'Использовать инструмент', openTool: 'Открыть инструмент', relatedEyebrow: 'Продолжить изучение', moreToolsIn: 'Другие инструменты в', zoom: 'Масштаб', zoomControls: 'Управление масштабом', zoomOut: 'Уменьшить', resetZoom: 'Сбросить масштаб', zoomIn: 'Увеличить', breadcrumb: 'Навигационная цепочка' },
29
- ja: { useTool: 'ツールを使う', openTool: 'ツールを開く', relatedEyebrow: 'さらに探索', moreToolsIn: 'その他のツール:', zoom: 'ズーム', zoomControls: 'ズーム操作', zoomOut: '縮小', resetZoom: 'ズームをリセット', zoomIn: '拡大', breadcrumb: 'パンくずリスト' },
30
- ko: { useTool: '도구 사용', openTool: '도구 열기', relatedEyebrow: '더 탐색하기', moreToolsIn: '카테고리의 다른 도구:', zoom: '확대/축소', zoomControls: '확대/축소 컨트롤', zoomOut: '축소', resetZoom: '확대/축소 초기화', zoomIn: '확대', breadcrumb: '탐색 경로' },
31
- zh: { useTool: '使用工具', openTool: '打开工具', relatedEyebrow: '继续探索', moreToolsIn: '更多工具:', zoom: '缩放', zoomControls: '缩放控件', zoomOut: '缩小', resetZoom: '重置缩放', zoomIn: '放大', breadcrumb: '面包屑导航' },
32
- };
33
-
34
- export const getCategoryUi = (locale: KnownLocale): CategoryUi => CATEGORY_UI[locale];
1
+ import type { KnownLocale } from '../types';
2
+
3
+ export interface CategoryUi {
4
+ useTool: string;
5
+ openTool: string;
6
+ relatedEyebrow: string;
7
+ moreToolsIn: string;
8
+ zoom: string;
9
+ zoomControls: string;
10
+ zoomOut: string;
11
+ resetZoom: string;
12
+ zoomIn: string;
13
+ breadcrumb: string;
14
+ }
15
+
16
+ export const CATEGORY_UI: Record<KnownLocale, CategoryUi> = {
17
+ es: { useTool: 'Usar herramienta', openTool: 'Abrir herramienta', relatedEyebrow: 'Sigue explorando', moreToolsIn: 'Más herramientas en', zoom: 'Zoom', zoomControls: 'Controles de zoom', zoomOut: 'Reducir zoom', resetZoom: 'Restablecer zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Migas de pan' },
18
+ en: { useTool: 'Use tool', openTool: 'Open tool', relatedEyebrow: 'Keep exploring', moreToolsIn: 'More tools in', zoom: 'Zoom', zoomControls: 'Zoom controls', zoomOut: 'Zoom out', resetZoom: 'Reset zoom', zoomIn: 'Zoom in', breadcrumb: 'Breadcrumb' },
19
+ fr: { useTool: "Utiliser l'outil", openTool: "Ouvrir l'outil", relatedEyebrow: 'Continuez à explorer', moreToolsIn: "Plus d'outils dans", zoom: 'Zoom', zoomControls: 'Commandes de zoom', zoomOut: 'Réduire le zoom', resetZoom: 'Réinitialiser le zoom', zoomIn: 'Augmenter le zoom', breadcrumb: 'Fil d’Ariane' },
20
+ de: { useTool: 'Werkzeug verwenden', openTool: 'Werkzeug öffnen', relatedEyebrow: 'Mehr entdecken', moreToolsIn: 'Weitere Werkzeuge in', zoom: 'Zoom', zoomControls: 'Zoom-Steuerung', zoomOut: 'Verkleinern', resetZoom: 'Zoom zurücksetzen', zoomIn: 'Vergrößern', breadcrumb: 'Brotkrümelnavigation' },
21
+ it: { useTool: 'Usa lo strumento', openTool: 'Apri lo strumento', relatedEyebrow: 'Continua a esplorare', moreToolsIn: 'Altri strumenti in', zoom: 'Zoom', zoomControls: 'Controlli zoom', zoomOut: 'Riduci zoom', resetZoom: 'Reimposta zoom', zoomIn: 'Aumenta zoom', breadcrumb: 'Breadcrumb' },
22
+ pt: { useTool: 'Usar ferramenta', openTool: 'Abrir ferramenta', relatedEyebrow: 'Continue a explorar', moreToolsIn: 'Mais ferramentas em', zoom: 'Zoom', zoomControls: 'Controlos de zoom', zoomOut: 'Reduzir zoom', resetZoom: 'Repor zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Trilho de navegação' },
23
+ nl: { useTool: 'Tool gebruiken', openTool: 'Tool openen', relatedEyebrow: 'Blijf ontdekken', moreToolsIn: 'Meer tools in', zoom: 'Zoom', zoomControls: 'Zoomknoppen', zoomOut: 'Uitzoomen', resetZoom: 'Zoom herstellen', zoomIn: 'Inzoomen', breadcrumb: 'Broodkruimelnavigatie' },
24
+ sv: { useTool: 'Använd verktyget', openTool: 'Öppna verktyget', relatedEyebrow: 'Utforska mer', moreToolsIn: 'Fler verktyg i', zoom: 'Zoom', zoomControls: 'Zoomkontroller', zoomOut: 'Zooma ut', resetZoom: 'Återställ zoom', zoomIn: 'Zooma in', breadcrumb: 'Brödsmulenavigering' },
25
+ pl: { useTool: 'Użyj narzędzia', openTool: 'Otwórz narzędzie', relatedEyebrow: 'Odkrywaj dalej', moreToolsIn: 'Więcej narzędzi w', zoom: 'Powiększenie', zoomControls: 'Sterowanie powiększeniem', zoomOut: 'Pomniejsz', resetZoom: 'Resetuj powiększenie', zoomIn: 'Powiększ', breadcrumb: 'Okruszki nawigacji' },
26
+ id: { useTool: 'Gunakan alat', openTool: 'Buka alat', relatedEyebrow: 'Jelajahi lebih lanjut', moreToolsIn: 'Alat lainnya di', zoom: 'Zoom', zoomControls: 'Kontrol zoom', zoomOut: 'Perkecil', resetZoom: 'Atur ulang zoom', zoomIn: 'Perbesar', breadcrumb: 'Breadcrumb' },
27
+ tr: { useTool: 'Aracı kullan', openTool: 'Aracı aç', relatedEyebrow: 'Keşfetmeye devam edin', moreToolsIn: 'Diğer araçlar:', zoom: 'Yakınlaştırma', zoomControls: 'Yakınlaştırma denetimleri', zoomOut: 'Uzaklaştır', resetZoom: 'Yakınlaştırmayı sıfırla', zoomIn: 'Yakınlaştır', breadcrumb: 'İçerik haritası' },
28
+ ru: { useTool: 'Использовать инструмент', openTool: 'Открыть инструмент', relatedEyebrow: 'Продолжить изучение', moreToolsIn: 'Другие инструменты в', zoom: 'Масштаб', zoomControls: 'Управление масштабом', zoomOut: 'Уменьшить', resetZoom: 'Сбросить масштаб', zoomIn: 'Увеличить', breadcrumb: 'Навигационная цепочка' },
29
+ ja: { useTool: 'ツールを使う', openTool: 'ツールを開く', relatedEyebrow: 'さらに探索', moreToolsIn: 'その他のツール:', zoom: 'ズーム', zoomControls: 'ズーム操作', zoomOut: '縮小', resetZoom: 'ズームをリセット', zoomIn: '拡大', breadcrumb: 'パンくずリスト' },
30
+ ko: { useTool: '도구 사용', openTool: '도구 열기', relatedEyebrow: '더 탐색하기', moreToolsIn: '카테고리의 다른 도구:', zoom: '확대/축소', zoomControls: '확대/축소 컨트롤', zoomOut: '축소', resetZoom: '확대/축소 초기화', zoomIn: '확대', breadcrumb: '탐색 경로' },
31
+ zh: { useTool: '使用工具', openTool: '打开工具', relatedEyebrow: '继续探索', moreToolsIn: '更多工具:', zoom: '缩放', zoomControls: '缩放控件', zoomOut: '缩小', resetZoom: '重置缩放', zoomIn: '放大', breadcrumb: '面包屑导航' },
32
+ };
33
+
34
+ export const getCategoryUi = (locale: KnownLocale): CategoryUi => CATEGORY_UI[locale];
@@ -0,0 +1,14 @@
1
+ export function observeWidgetHeight(container: HTMLElement): void {
2
+ if (window.parent === window) return;
3
+
4
+ const pathSlug = window.location.pathname.split("/").filter(Boolean).pop() ?? "utility";
5
+ const widgetId = new URLSearchParams(window.location.search).get("id") ?? `jj-widget-${pathSlug}`;
6
+ const reportHeight = (height: number) => {
7
+ if (height > 50) {
8
+ window.parent.postMessage({ jjlmoyaHeight: Math.ceil(height), jjlmoyaId: widgetId }, "*");
9
+ }
10
+ };
11
+ const observer = new ResizeObserver(([entry]) => reportHeight(entry?.contentRect.height ?? 0));
12
+
13
+ observer.observe(container);
14
+ }
@@ -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,48 @@
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 validateEntry(tool: string, entry: { url: string }, seen: Set<string>): LinkFailure[] {
11
+ const failures: LinkFailure[] = [];
12
+ let url: URL;
13
+
14
+ try {
15
+ url = new URL(entry.url);
16
+ } catch {
17
+ return [{ tool, message: `invalid URL: ${entry.url}` }];
18
+ }
19
+
20
+ if (url.protocol !== 'https:') {
21
+ failures.push({ tool, message: `non-HTTPS URL: ${entry.url}` });
22
+ }
23
+ if (url.pathname === '/' && !url.search && !url.hash) {
24
+ failures.push({ tool, message: `generic homepage, cite the exact document: ${entry.url}` });
25
+ }
26
+ if (seen.has(url.href)) {
27
+ failures.push({ tool, message: `duplicate source URL: ${entry.url}` });
28
+ }
29
+ seen.add(url.href);
30
+ return failures;
31
+ }
32
+
33
+ async function validateTool(tool: (typeof ALL_TOOLS)[number]): Promise<LinkFailure[]> {
34
+ const loader = tool.entry.i18n.en;
35
+ if (!loader) return [{ tool: tool.entry.id, message: 'English locale loader is missing' }];
36
+
37
+ const content = (await loader()) as ToolLocaleContent;
38
+ const seen = new Set<string>();
39
+ return (content.bibliography ?? []).flatMap((entry) => validateEntry(tool.entry.id, entry, seen));
40
+ }
41
+
42
+ describe('QA: bibliography links are specific and usable', () => {
43
+ it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
44
+ const failures = (await Promise.all(ALL_TOOLS.map(validateTool))).flat();
45
+ const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
46
+ expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
47
+ });
48
+ });