@jjlmoya/utils-health 1.1.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 (155) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +60 -0
  3. package/src/category/i18n/es.ts +60 -0
  4. package/src/category/i18n/fr.ts +60 -0
  5. package/src/category/index.ts +22 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +28 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +36 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +22 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/binauralTuner/bibliography.astro +14 -0
  25. package/src/tool/binauralTuner/component.astro +687 -0
  26. package/src/tool/binauralTuner/i18n/en.ts +187 -0
  27. package/src/tool/binauralTuner/i18n/es.ts +187 -0
  28. package/src/tool/binauralTuner/i18n/fr.ts +187 -0
  29. package/src/tool/binauralTuner/index.ts +27 -0
  30. package/src/tool/binauralTuner/seo.astro +14 -0
  31. package/src/tool/binauralTuner/ui.ts +18 -0
  32. package/src/tool/bloodUnitConverter/bibliography.astro +14 -0
  33. package/src/tool/bloodUnitConverter/component.astro +915 -0
  34. package/src/tool/bloodUnitConverter/i18n/en.ts +227 -0
  35. package/src/tool/bloodUnitConverter/i18n/es.ts +250 -0
  36. package/src/tool/bloodUnitConverter/i18n/fr.ts +218 -0
  37. package/src/tool/bloodUnitConverter/index.ts +27 -0
  38. package/src/tool/bloodUnitConverter/seo.astro +14 -0
  39. package/src/tool/bloodUnitConverter/ui.ts +38 -0
  40. package/src/tool/bmiCalculator/bibliography.astro +14 -0
  41. package/src/tool/bmiCalculator/component.astro +415 -0
  42. package/src/tool/bmiCalculator/i18n/en.ts +217 -0
  43. package/src/tool/bmiCalculator/i18n/es.ts +221 -0
  44. package/src/tool/bmiCalculator/i18n/fr.ts +217 -0
  45. package/src/tool/bmiCalculator/index.ts +27 -0
  46. package/src/tool/bmiCalculator/seo.astro +14 -0
  47. package/src/tool/bmiCalculator/ui.ts +21 -0
  48. package/src/tool/breathingVisualizer/bibliography.astro +14 -0
  49. package/src/tool/breathingVisualizer/component.astro +636 -0
  50. package/src/tool/breathingVisualizer/i18n/en.ts +206 -0
  51. package/src/tool/breathingVisualizer/i18n/es.ts +206 -0
  52. package/src/tool/breathingVisualizer/i18n/fr.ts +206 -0
  53. package/src/tool/breathingVisualizer/index.ts +27 -0
  54. package/src/tool/breathingVisualizer/seo.astro +14 -0
  55. package/src/tool/breathingVisualizer/ui.ts +31 -0
  56. package/src/tool/caffeineTracker/bibliography.astro +14 -0
  57. package/src/tool/caffeineTracker/component.astro +1210 -0
  58. package/src/tool/caffeineTracker/i18n/en.ts +198 -0
  59. package/src/tool/caffeineTracker/i18n/es.ts +198 -0
  60. package/src/tool/caffeineTracker/i18n/fr.ts +198 -0
  61. package/src/tool/caffeineTracker/index.ts +27 -0
  62. package/src/tool/caffeineTracker/logic.ts +31 -0
  63. package/src/tool/caffeineTracker/seo.astro +14 -0
  64. package/src/tool/caffeineTracker/ui.ts +36 -0
  65. package/src/tool/daltonismSimulator/bibliography.astro +14 -0
  66. package/src/tool/daltonismSimulator/component.astro +383 -0
  67. package/src/tool/daltonismSimulator/i18n/en.ts +188 -0
  68. package/src/tool/daltonismSimulator/i18n/es.ts +218 -0
  69. package/src/tool/daltonismSimulator/i18n/fr.ts +168 -0
  70. package/src/tool/daltonismSimulator/index.ts +27 -0
  71. package/src/tool/daltonismSimulator/seo.astro +14 -0
  72. package/src/tool/daltonismSimulator/ui.ts +20 -0
  73. package/src/tool/digestionStopwatch/bibliography.astro +14 -0
  74. package/src/tool/digestionStopwatch/component.astro +627 -0
  75. package/src/tool/digestionStopwatch/i18n/en.ts +173 -0
  76. package/src/tool/digestionStopwatch/i18n/es.ts +173 -0
  77. package/src/tool/digestionStopwatch/i18n/fr.ts +173 -0
  78. package/src/tool/digestionStopwatch/index.ts +27 -0
  79. package/src/tool/digestionStopwatch/logic.ts +63 -0
  80. package/src/tool/digestionStopwatch/seo.astro +14 -0
  81. package/src/tool/digestionStopwatch/ui.ts +20 -0
  82. package/src/tool/epworthSleepinessScale/bibliography.astro +14 -0
  83. package/src/tool/epworthSleepinessScale/component.astro +528 -0
  84. package/src/tool/epworthSleepinessScale/i18n/en.ts +217 -0
  85. package/src/tool/epworthSleepinessScale/i18n/es.ts +217 -0
  86. package/src/tool/epworthSleepinessScale/i18n/fr.ts +217 -0
  87. package/src/tool/epworthSleepinessScale/index.ts +27 -0
  88. package/src/tool/epworthSleepinessScale/seo.astro +14 -0
  89. package/src/tool/epworthSleepinessScale/ui.ts +27 -0
  90. package/src/tool/hydrationCalculator/bibliography.astro +14 -0
  91. package/src/tool/hydrationCalculator/component.astro +694 -0
  92. package/src/tool/hydrationCalculator/i18n/en.ts +217 -0
  93. package/src/tool/hydrationCalculator/i18n/es.ts +222 -0
  94. package/src/tool/hydrationCalculator/i18n/fr.ts +199 -0
  95. package/src/tool/hydrationCalculator/index.ts +27 -0
  96. package/src/tool/hydrationCalculator/seo.astro +14 -0
  97. package/src/tool/hydrationCalculator/ui.ts +28 -0
  98. package/src/tool/pelliRobsonTest/bibliography.astro +14 -0
  99. package/src/tool/pelliRobsonTest/component.astro +653 -0
  100. package/src/tool/pelliRobsonTest/i18n/en.ts +205 -0
  101. package/src/tool/pelliRobsonTest/i18n/es.ts +205 -0
  102. package/src/tool/pelliRobsonTest/i18n/fr.ts +205 -0
  103. package/src/tool/pelliRobsonTest/index.ts +27 -0
  104. package/src/tool/pelliRobsonTest/seo.astro +14 -0
  105. package/src/tool/pelliRobsonTest/ui.ts +21 -0
  106. package/src/tool/peripheralVisionTrainer/bibliography.astro +14 -0
  107. package/src/tool/peripheralVisionTrainer/component.astro +678 -0
  108. package/src/tool/peripheralVisionTrainer/i18n/en.ts +224 -0
  109. package/src/tool/peripheralVisionTrainer/i18n/es.ts +224 -0
  110. package/src/tool/peripheralVisionTrainer/i18n/fr.ts +211 -0
  111. package/src/tool/peripheralVisionTrainer/index.ts +27 -0
  112. package/src/tool/peripheralVisionTrainer/seo.astro +14 -0
  113. package/src/tool/peripheralVisionTrainer/ui.ts +26 -0
  114. package/src/tool/readingDistanceCalculator/bibliography.astro +14 -0
  115. package/src/tool/readingDistanceCalculator/component.astro +588 -0
  116. package/src/tool/readingDistanceCalculator/i18n/en.ts +202 -0
  117. package/src/tool/readingDistanceCalculator/i18n/es.ts +215 -0
  118. package/src/tool/readingDistanceCalculator/i18n/fr.ts +193 -0
  119. package/src/tool/readingDistanceCalculator/index.ts +31 -0
  120. package/src/tool/readingDistanceCalculator/seo.astro +14 -0
  121. package/src/tool/readingDistanceCalculator/ui.ts +18 -0
  122. package/src/tool/screenDecompressionTime/bibliography.astro +14 -0
  123. package/src/tool/screenDecompressionTime/component.astro +671 -0
  124. package/src/tool/screenDecompressionTime/i18n/en.ts +225 -0
  125. package/src/tool/screenDecompressionTime/i18n/es.ts +247 -0
  126. package/src/tool/screenDecompressionTime/i18n/fr.ts +225 -0
  127. package/src/tool/screenDecompressionTime/index.ts +27 -0
  128. package/src/tool/screenDecompressionTime/seo.astro +14 -0
  129. package/src/tool/screenDecompressionTime/ui.ts +32 -0
  130. package/src/tool/tinnitusReliever/bibliography.astro +14 -0
  131. package/src/tool/tinnitusReliever/component.astro +581 -0
  132. package/src/tool/tinnitusReliever/i18n/en.ts +161 -0
  133. package/src/tool/tinnitusReliever/i18n/es.ts +161 -0
  134. package/src/tool/tinnitusReliever/i18n/fr.ts +161 -0
  135. package/src/tool/tinnitusReliever/index.ts +27 -0
  136. package/src/tool/tinnitusReliever/seo.astro +14 -0
  137. package/src/tool/tinnitusReliever/ui.ts +9 -0
  138. package/src/tool/ubeCalculator/bibliography.astro +14 -0
  139. package/src/tool/ubeCalculator/component.astro +683 -0
  140. package/src/tool/ubeCalculator/i18n/en.ts +200 -0
  141. package/src/tool/ubeCalculator/i18n/es.ts +200 -0
  142. package/src/tool/ubeCalculator/i18n/fr.ts +196 -0
  143. package/src/tool/ubeCalculator/index.ts +27 -0
  144. package/src/tool/ubeCalculator/seo.astro +14 -0
  145. package/src/tool/ubeCalculator/ui.ts +26 -0
  146. package/src/tool/waterPurifier/bibliography.astro +14 -0
  147. package/src/tool/waterPurifier/component.astro +628 -0
  148. package/src/tool/waterPurifier/i18n/en.ts +167 -0
  149. package/src/tool/waterPurifier/i18n/es.ts +167 -0
  150. package/src/tool/waterPurifier/i18n/fr.ts +167 -0
  151. package/src/tool/waterPurifier/index.ts +27 -0
  152. package/src/tool/waterPurifier/seo.astro +14 -0
  153. package/src/tool/waterPurifier/ui.ts +18 -0
  154. package/src/tools.ts +19 -0
  155. package/src/types.ts +72 -0
@@ -0,0 +1,28 @@
1
+ export interface HydrationCalculatorUI extends Record<string, string> {
2
+ sectionWeight: string;
3
+ sectionClimate: string;
4
+ sectionActivity: string;
5
+ labelWeight: string;
6
+ labelTemp: string;
7
+ labelHum: string;
8
+ labelDuration: string;
9
+ weatherBtnTitle: string;
10
+ intensitySedentary: string;
11
+ intensityActive: string;
12
+ intensityAthlete: string;
13
+ resultUnit: string;
14
+ statRefill: string;
15
+ statRefillHelp: string;
16
+ statMix: string;
17
+ statUrine: string;
18
+ alertElectrolytes: string;
19
+ freqLow: string;
20
+ freqMed: string;
21
+ freqHigh: string;
22
+ mixWater: string;
23
+ mixWaterSalt: string;
24
+ mixWaterElectrolytes: string;
25
+ mixDetailLow: string;
26
+ mixDetailMed: string;
27
+ mixDetailHigh: string;
28
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography } from '@jjlmoya/utils-shared';
3
+ import { pelliRobsonTest } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await pelliRobsonTest.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <Bibliography items={content.bibliography} title={content.bibliographyTitle} />}
@@ -0,0 +1,653 @@
1
+ ---
2
+ import type { PelliRobsonTestUI } from './ui';
3
+
4
+ interface Props {
5
+ ui?: Partial<PelliRobsonTestUI>;
6
+ }
7
+
8
+ const ui = (Astro.props.ui ?? {}) as PelliRobsonTestUI;
9
+ ---
10
+
11
+ <div class="pr" data-ui={JSON.stringify(ui)}>
12
+
13
+ <div class="pr__view pr__view--intro" id="pr-intro">
14
+ <div class="pr__score-circle">
15
+ <span class="pr__stat-label" data-key="introSensLabel">Sensibilidad</span>
16
+ <span class="pr__score-title" data-key="introTestLabel">Test</span>
17
+ </div>
18
+ <h3 class="pr__intro-title" data-key="introTitle">¿Qué letra ves?</h3>
19
+ <p class="pr__intro-desc" data-key="introDescription">
20
+ Escribe la letra que aparezca en pantalla. Cuanto más tenue sea, mayor será tu sensibilidad al contraste.
21
+ </p>
22
+ <button class="pr__btn pr__btn--primary pr__btn--wide" id="pr-start" data-key="introBtnStart">
23
+ Comenzar Desafío
24
+ </button>
25
+ </div>
26
+
27
+
28
+ <div class="pr__view pr__view--test pr__view--hidden" id="pr-test">
29
+ <div class="pr__hud">
30
+ <div class="pr__stat-box">
31
+ <span class="pr__stat-label" data-key="hudLevelLabel">Nivel Actual</span>
32
+ <span class="pr__stat-number" id="pr-level">1 / 16</span>
33
+ </div>
34
+ <div class="pr__progress-track">
35
+ <div class="pr__progress-fill" id="pr-progress"></div>
36
+ </div>
37
+ <div class="pr__stat-box">
38
+ <span class="pr__stat-label" data-key="hudScoreLabel">LogCS Detectado</span>
39
+ <span class="pr__stat-number" id="pr-live-score">0.00</span>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="pr__screen">
44
+ <div class="pr__optotype" id="pr-optotype">D</div>
45
+ </div>
46
+
47
+ <div class="pr__input-group">
48
+ <input
49
+ type="text"
50
+ id="pr-input"
51
+ class="pr__letter-input"
52
+ maxlength="1"
53
+ autocomplete="off"
54
+ autofocus
55
+ data-key="inputPlaceholder"
56
+ placeholder="Escribe la letra..."
57
+ />
58
+ <button class="pr__btn pr__btn--ghost" id="pr-cant-see" data-key="btnCantSee">
59
+ Ya no distingo ninguna letra
60
+ </button>
61
+ </div>
62
+ </div>
63
+
64
+
65
+ <div class="pr__view pr__view--result pr__view--hidden" id="pr-result">
66
+ <div class="pr__score-circle">
67
+ <span class="pr__stat-label" data-key="resultScoreLabel">PUNTUACIÓN</span>
68
+ <span class="pr__score-title" id="pr-final-score">2.10</span>
69
+ </div>
70
+ <h3 class="pr__result-title" id="pr-result-title">¡Visión de Élite!</h3>
71
+ <p class="pr__result-text" id="pr-result-text">Tu visión es excelente.</p>
72
+ <button class="pr__btn pr__btn--primary pr__btn--wide" id="pr-restart" data-key="btnRestart">
73
+ Reiniciar Test
74
+ </button>
75
+ </div>
76
+ </div>
77
+
78
+ <style>
79
+ .pr {
80
+ --pr-bg: #f8fafc;
81
+ --pr-card-bg: #fff;
82
+ --pr-primary: #0ea5e9;
83
+ --pr-success: #10b981;
84
+ --pr-danger: #ef4444;
85
+ --pr-text: #0f172a;
86
+ --pr-muted: #94a3b8;
87
+ --pr-surface: #f1f5f9;
88
+ --pr-border: #e2e8f0;
89
+ --pr-shadow: 0 40px 100px -20px rgba(0, 0, 0, 0.1);
90
+ --pr-glow-primary: rgba(14, 165, 233, 0.3);
91
+
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 2rem;
95
+ padding: 1rem;
96
+ max-width: 1200px;
97
+ margin: 0 auto;
98
+ background: var(--pr-bg);
99
+ border-radius: 48px;
100
+ box-shadow: var(--pr-shadow);
101
+ position: relative;
102
+ overflow: hidden;
103
+ text-align: center;
104
+ }
105
+
106
+ .pr::before {
107
+ content: "A";
108
+ position: absolute;
109
+ top: -20px;
110
+ right: 10px;
111
+ font-size: 15rem;
112
+ font-weight: 900;
113
+ color: var(--pr-surface);
114
+ opacity: 0.5;
115
+ z-index: 0;
116
+ pointer-events: none;
117
+ }
118
+
119
+
120
+ .pr__view {
121
+ display: flex;
122
+ flex-direction: column;
123
+ align-items: center;
124
+ gap: 1.5rem;
125
+ padding: 3rem 2rem;
126
+ position: relative;
127
+ z-index: 1;
128
+ }
129
+
130
+ .pr__view--hidden {
131
+ display: none;
132
+ }
133
+
134
+ .pr__view--test {
135
+ gap: 2rem;
136
+ }
137
+
138
+ .pr__view--result {
139
+ animation: pr-slide-in 0.8s cubic-bezier(0.16, 1, 0.3, 1);
140
+ }
141
+
142
+ @keyframes pr-slide-in {
143
+ from { opacity: 0; transform: translateY(30px); }
144
+ to { opacity: 1; transform: translateY(0); }
145
+ }
146
+
147
+
148
+ .pr__score-circle {
149
+ width: 200px;
150
+ height: 200px;
151
+ border-radius: 50%;
152
+ border: 10px solid var(--pr-primary);
153
+ margin: 0 auto;
154
+ display: flex;
155
+ flex-direction: column;
156
+ justify-content: center;
157
+ align-items: center;
158
+ background: var(--pr-card-bg);
159
+ box-shadow: 0 0 50px var(--pr-glow-primary);
160
+ }
161
+
162
+ .pr__score-title {
163
+ font-size: 2.5rem;
164
+ font-weight: 950;
165
+ color: var(--pr-text);
166
+ line-height: 1.1;
167
+ }
168
+
169
+ .pr__stat-label {
170
+ font-size: 0.7rem;
171
+ font-weight: 800;
172
+ text-transform: uppercase;
173
+ letter-spacing: 2px;
174
+ color: var(--pr-muted);
175
+ }
176
+
177
+
178
+ .pr__intro-title {
179
+ font-size: 2.2rem;
180
+ font-weight: 950;
181
+ color: var(--pr-text);
182
+ margin: 0;
183
+ }
184
+
185
+ .pr__intro-desc {
186
+ max-width: 520px;
187
+ margin: 0 auto;
188
+ font-size: 1.1rem;
189
+ line-height: 1.6;
190
+ color: var(--pr-muted);
191
+ }
192
+
193
+
194
+ .pr__hud {
195
+ display: flex;
196
+ justify-content: space-between;
197
+ align-items: center;
198
+ width: 100%;
199
+ padding: 0 1rem;
200
+ gap: 1rem;
201
+ }
202
+
203
+ .pr__stat-box {
204
+ background: var(--pr-surface);
205
+ padding: 1rem 2rem;
206
+ border-radius: 20px;
207
+ display: flex;
208
+ flex-direction: column;
209
+ align-items: center;
210
+ gap: 0.25rem;
211
+ min-width: 120px;
212
+ }
213
+
214
+ .pr__stat-number {
215
+ font-size: 1.8rem;
216
+ font-weight: 900;
217
+ color: var(--pr-text);
218
+ line-height: 1;
219
+ }
220
+
221
+ .pr__progress-track {
222
+ flex: 1;
223
+ height: 12px;
224
+ background: var(--pr-surface);
225
+ border-radius: 100px;
226
+ overflow: hidden;
227
+ }
228
+
229
+ .pr__progress-fill {
230
+ height: 100%;
231
+ background: var(--pr-primary);
232
+ width: 0%;
233
+ transition: width 0.5s ease;
234
+ box-shadow: 0 0 15px var(--pr-glow-primary);
235
+ }
236
+
237
+
238
+ .pr__screen {
239
+ width: 100%;
240
+ background: #fff;
241
+ border-radius: 32px;
242
+ height: 400px;
243
+ display: flex;
244
+ justify-content: center;
245
+ align-items: center;
246
+ border: 1px solid var(--pr-border);
247
+ box-shadow: inset 0 4px 20px rgba(0, 0, 0, 0.02);
248
+ position: relative;
249
+ z-index: 1;
250
+ }
251
+
252
+ .pr__optotype {
253
+ font-size: clamp(6rem, 20vw, 12rem);
254
+ font-weight: 800;
255
+ color: #000;
256
+ line-height: 1;
257
+ transition: opacity 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.3s ease;
258
+ user-select: none;
259
+ letter-spacing: -0.05em;
260
+ }
261
+
262
+
263
+ .pr__input-group {
264
+ display: flex;
265
+ flex-direction: column;
266
+ gap: 0.75rem;
267
+ width: 100%;
268
+ max-width: 400px;
269
+ }
270
+
271
+ .pr__letter-input {
272
+ width: 100%;
273
+ box-sizing: border-box;
274
+ background: var(--pr-surface);
275
+ border: 4px solid transparent;
276
+ border-radius: 30px;
277
+ padding: 1.5rem 2rem;
278
+ font-size: 3rem;
279
+ font-weight: 900;
280
+ color: var(--pr-text);
281
+ text-align: center;
282
+ text-transform: uppercase;
283
+ transition: all 0.3s ease;
284
+ outline: none;
285
+ }
286
+
287
+ .pr__letter-input:focus {
288
+ background: var(--pr-card-bg);
289
+ border-color: var(--pr-primary);
290
+ box-shadow: 0 0 40px rgba(14, 165, 233, 0.15);
291
+ }
292
+
293
+ .pr__letter-input::placeholder {
294
+ color: var(--pr-muted);
295
+ font-size: 1.1rem;
296
+ text-transform: none;
297
+ font-weight: 600;
298
+ }
299
+
300
+ .pr__letter-input--correct {
301
+ animation: pr-flash-correct 0.6s ease-out;
302
+ border-color: var(--pr-success);
303
+ }
304
+
305
+ .pr__letter-input--error {
306
+ animation: pr-flash-error 0.4s ease-in-out;
307
+ border-color: var(--pr-danger);
308
+ }
309
+
310
+ @keyframes pr-flash-correct {
311
+ 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
312
+ 100% { box-shadow: 0 0 0 30px rgba(16, 185, 129, 0); }
313
+ }
314
+
315
+ @keyframes pr-flash-error {
316
+ 0% { transform: translateX(0); }
317
+ 25% { transform: translateX(-10px); }
318
+ 50% { transform: translateX(10px); }
319
+ 75% { transform: translateX(-10px); }
320
+ 100% { transform: translateX(0); }
321
+ }
322
+
323
+
324
+ .pr__btn {
325
+ border: none;
326
+ border-radius: 30px;
327
+ padding: 1.25rem 2.5rem;
328
+ font-size: 1.1rem;
329
+ font-weight: 800;
330
+ cursor: pointer;
331
+ transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
332
+ display: inline-flex;
333
+ align-items: center;
334
+ justify-content: center;
335
+ }
336
+
337
+ .pr__btn--primary {
338
+ background: var(--pr-primary);
339
+ color: #fff;
340
+ box-shadow: 0 15px 30px var(--pr-glow-primary);
341
+ }
342
+
343
+ .pr__btn--primary:hover {
344
+ transform: translateY(-4px);
345
+ box-shadow: 0 25px 40px var(--pr-glow-primary);
346
+ }
347
+
348
+ .pr__btn--wide {
349
+ max-width: 300px;
350
+ width: 100%;
351
+ }
352
+
353
+ .pr__btn--ghost {
354
+ background: transparent;
355
+ color: var(--pr-muted);
356
+ width: 100%;
357
+ margin-top: 0.25rem;
358
+ }
359
+
360
+ .pr__btn--ghost:hover {
361
+ color: var(--pr-danger);
362
+ }
363
+
364
+
365
+ .pr__result-title {
366
+ font-size: 2.8rem;
367
+ font-weight: 950;
368
+ color: var(--pr-text);
369
+ margin: 0;
370
+ }
371
+
372
+ .pr__result-text {
373
+ max-width: 580px;
374
+ margin: 0 auto;
375
+ font-size: 1.15rem;
376
+ line-height: 1.65;
377
+ color: var(--pr-muted);
378
+ }
379
+
380
+
381
+ :global(.theme-dark) .pr {
382
+ --pr-bg: #0c1120;
383
+ --pr-card-bg: #111827;
384
+ --pr-primary: #38bdf8;
385
+ --pr-success: #34d399;
386
+ --pr-danger: #f87171;
387
+ --pr-text: #f0f9ff;
388
+ --pr-muted: #94a3b8;
389
+ --pr-surface: #1e293b;
390
+ --pr-border: #334155;
391
+ --pr-shadow: 0 40px 100px -20px rgba(0, 0, 0, 0.6);
392
+ --pr-glow-primary: rgba(56, 189, 248, 0.25);
393
+ }
394
+
395
+ :global(.theme-dark) .pr::before {
396
+ color: #1e293b;
397
+ opacity: 0.4;
398
+ }
399
+
400
+
401
+
402
+
403
+ @media (max-width: 768px) {
404
+ .pr {
405
+ padding: 0.5rem;
406
+ border-radius: 1.25rem;
407
+ }
408
+
409
+ .pr::before {
410
+ font-size: 5rem;
411
+ top: -5px;
412
+ opacity: 0.3;
413
+ }
414
+
415
+ .pr__view {
416
+ padding: 1.25rem 0.75rem;
417
+ gap: 1rem;
418
+ }
419
+
420
+ .pr__score-circle {
421
+ width: 120px;
422
+ height: 120px;
423
+ border-width: 5px;
424
+ }
425
+
426
+ .pr__score-title {
427
+ font-size: 1.5rem;
428
+ }
429
+
430
+ .pr__intro-title {
431
+ font-size: 1.5rem;
432
+ }
433
+
434
+ .pr__hud {
435
+ flex-wrap: wrap;
436
+ padding: 0;
437
+ justify-content: center;
438
+ gap: 0.5rem;
439
+ }
440
+
441
+ .pr__progress-track {
442
+ order: -1;
443
+ width: 100%;
444
+ height: 8px;
445
+ }
446
+
447
+ .pr__stat-box {
448
+ flex: 1 1 auto;
449
+ padding: 0.5rem 1rem;
450
+ border-radius: 12px;
451
+ }
452
+
453
+ .pr__stat-number {
454
+ font-size: 1.1rem;
455
+ }
456
+
457
+ .pr__stat-label {
458
+ font-size: 0.6rem;
459
+ }
460
+
461
+ .pr__screen {
462
+ height: 180px;
463
+ border-radius: 1rem;
464
+ }
465
+
466
+ .pr__optotype {
467
+ font-size: clamp(3rem, 15vw, 6rem);
468
+ }
469
+
470
+ .pr__input-group {
471
+ max-width: 100%;
472
+ width: 100%;
473
+ }
474
+
475
+ .pr__letter-input {
476
+ padding: 0.75rem;
477
+ font-size: 1.5rem;
478
+ border-radius: 1rem;
479
+ border-width: 2px;
480
+ }
481
+
482
+ .pr__letter-input::placeholder {
483
+ font-size: 0.85rem;
484
+ }
485
+
486
+ .pr__btn {
487
+ width: 100%;
488
+ padding: 0.75rem;
489
+ font-size: 0.95rem;
490
+ border-radius: 1rem;
491
+ }
492
+
493
+ .pr__btn--wide {
494
+ max-width: 100%;
495
+ }
496
+
497
+ .pr__result-title {
498
+ font-size: 1.75rem;
499
+ }
500
+
501
+ .pr__result-text {
502
+ font-size: 0.95rem;
503
+ }
504
+ }
505
+ </style>
506
+
507
+ <script>
508
+ const LETTERS = 'CDHKNORSVZ';
509
+ const CS_STEPS = [0.00, 0.15, 0.30, 0.45, 0.60, 0.75, 0.90, 1.05, 1.20, 1.35, 1.50, 1.65, 1.80, 1.95, 2.10, 2.25];
510
+
511
+ interface PelliUI {
512
+ introSensLabel?: string;
513
+ introTestLabel?: string;
514
+ introTitle?: string;
515
+ introDescription?: string;
516
+ introBtnStart?: string;
517
+ hudLevelLabel?: string;
518
+ hudScoreLabel?: string;
519
+ inputPlaceholder?: string;
520
+ btnCantSee?: string;
521
+ resultScoreLabel?: string;
522
+ result4Title?: string;
523
+ result4Text?: string;
524
+ result3Title?: string;
525
+ result3Text?: string;
526
+ result2Title?: string;
527
+ result2Text?: string;
528
+ result1Title?: string;
529
+ result1Text?: string;
530
+ btnRestart?: string;
531
+ }
532
+
533
+ function applyUI(container: Element, ui: PelliUI): void {
534
+ container.querySelectorAll<HTMLElement>('[data-key]').forEach((el) => {
535
+ const key = el.dataset.key as keyof PelliUI;
536
+ const val = ui[key];
537
+ if (!val) return;
538
+ if (el instanceof HTMLInputElement) {
539
+ el.placeholder = val;
540
+ } else {
541
+ el.textContent = val;
542
+ }
543
+ });
544
+ }
545
+
546
+ // eslint-disable-next-line complexity
547
+ function getResultTier(score: number, ui: PelliUI): { title: string; text: string } {
548
+ const tiers: Array<[number, { title: string; text: string }]> = [
549
+ [2.0, { title: ui.result4Title ?? '', text: ui.result4Text ?? '' }],
550
+ [1.65, { title: ui.result3Title ?? '', text: ui.result3Text ?? '' }],
551
+ [1.20, { title: ui.result2Title ?? '', text: ui.result2Text ?? '' }],
552
+ ];
553
+ const tier = tiers.find(([threshold]) => score >= threshold);
554
+ return tier?.[1] ?? { title: ui.result1Title ?? '', text: ui.result1Text ?? '' };
555
+ }
556
+
557
+ // eslint-disable-next-line max-lines-per-function
558
+ function initTest(container: Element): void {
559
+ const ui: PelliUI = JSON.parse((container as HTMLElement).dataset.ui ?? '{}');
560
+ applyUI(container, ui);
561
+
562
+ const introView = container.querySelector<HTMLElement>('#pr-intro');
563
+ const testView = container.querySelector<HTMLElement>('#pr-test');
564
+ const resultView = container.querySelector<HTMLElement>('#pr-result');
565
+ const optotype = container.querySelector<HTMLElement>('#pr-optotype');
566
+ const liveScore = container.querySelector<HTMLElement>('#pr-live-score');
567
+ const levelEl = container.querySelector<HTMLElement>('#pr-level');
568
+ const progressFill = container.querySelector<HTMLElement>('#pr-progress');
569
+ const letterInput = container.querySelector<HTMLInputElement>('#pr-input');
570
+ const finalScore = container.querySelector<HTMLElement>('#pr-final-score');
571
+ const resultTitle = container.querySelector<HTMLElement>('#pr-result-title');
572
+ const resultText = container.querySelector<HTMLElement>('#pr-result-text');
573
+ const startBtn = container.querySelector<HTMLElement>('#pr-start');
574
+ const cantSeeBtn = container.querySelector<HTMLElement>('#pr-cant-see');
575
+ const restartBtn = container.querySelector<HTMLElement>('#pr-restart');
576
+
577
+ let currentStepIndex = 0;
578
+ let score = 0;
579
+ let currentTargetLetter = '';
580
+
581
+ function updateOptotype(): void {
582
+ if (!optotype || !liveScore || !levelEl || !progressFill || !letterInput) return;
583
+ const logCS = CS_STEPS[currentStepIndex] ?? 0;
584
+ const opacity = Math.pow(10, -logCS);
585
+ currentTargetLetter = LETTERS[Math.floor(Math.random() * LETTERS.length)] ?? 'C';
586
+
587
+ optotype.style.opacity = '0';
588
+ optotype.style.transform = 'scale(0.9)';
589
+
590
+ setTimeout(() => {
591
+ optotype.textContent = currentTargetLetter;
592
+ optotype.style.opacity = opacity.toString();
593
+ optotype.style.transform = 'scale(1)';
594
+ liveScore.textContent = logCS.toFixed(2);
595
+ levelEl.textContent = `${currentStepIndex + 1} / ${CS_STEPS.length}`;
596
+ progressFill.style.width = `${((currentStepIndex + 1) / CS_STEPS.length) * 100}%`;
597
+ letterInput.value = '';
598
+ letterInput.className = 'pr__letter-input';
599
+ }, 150);
600
+ }
601
+
602
+ function startTest(): void {
603
+ currentStepIndex = 0;
604
+ score = 0;
605
+ introView?.classList.add('pr__view--hidden');
606
+ resultView?.classList.add('pr__view--hidden');
607
+ testView?.classList.remove('pr__view--hidden');
608
+ setTimeout(() => letterInput?.focus(), 100);
609
+ updateOptotype();
610
+ }
611
+
612
+ function showResults(): void {
613
+ testView?.classList.add('pr__view--hidden');
614
+ resultView?.classList.remove('pr__view--hidden');
615
+ if (finalScore) finalScore.textContent = score.toFixed(2);
616
+ if (resultTitle && resultText) {
617
+ const tier = getResultTier(score, ui);
618
+ resultTitle.textContent = tier.title;
619
+ resultText.textContent = tier.text;
620
+ }
621
+ }
622
+
623
+ function validateInput(): void {
624
+ if (!letterInput) return;
625
+ const inputVal = letterInput.value.trim().toUpperCase();
626
+ if (inputVal === '') return;
627
+
628
+ if (inputVal === currentTargetLetter) {
629
+ score = CS_STEPS[currentStepIndex] ?? 0;
630
+ currentStepIndex++;
631
+ letterInput.classList.add('pr__letter-input--correct');
632
+ if (currentStepIndex >= CS_STEPS.length) {
633
+ setTimeout(showResults, 400);
634
+ } else {
635
+ setTimeout(updateOptotype, 400);
636
+ }
637
+ } else {
638
+ letterInput.classList.add('pr__letter-input--error');
639
+ setTimeout(showResults, 600);
640
+ }
641
+ }
642
+
643
+ letterInput?.addEventListener('input', validateInput);
644
+ startBtn?.addEventListener('click', startTest);
645
+ restartBtn?.addEventListener('click', startTest);
646
+ cantSeeBtn?.addEventListener('click', showResults);
647
+ container.addEventListener('click', () => {
648
+ if (!testView?.classList.contains('pr__view--hidden')) letterInput?.focus();
649
+ });
650
+ }
651
+
652
+ document.querySelectorAll<HTMLElement>('.pr').forEach(initTest);
653
+ </script>