@jjlmoya/utils-astronomy 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 (57) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +57 -0
  3. package/src/category/i18n/es.ts +57 -0
  4. package/src/category/i18n/fr.ts +58 -0
  5. package/src/category/index.ts +16 -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 +19 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +22 -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 +24 -0
  17. package/src/tests/mocks/astro_mock.js +2 -0
  18. package/src/tests/seo_length.test.ts +57 -0
  19. package/src/tests/tool_validation.test.ts +145 -0
  20. package/src/tool/bortleVisualizer/bibliography.astro +14 -0
  21. package/src/tool/bortleVisualizer/component.astro +491 -0
  22. package/src/tool/bortleVisualizer/i18n/en.ts +153 -0
  23. package/src/tool/bortleVisualizer/i18n/es.ts +161 -0
  24. package/src/tool/bortleVisualizer/i18n/fr.ts +153 -0
  25. package/src/tool/bortleVisualizer/index.ts +41 -0
  26. package/src/tool/bortleVisualizer/logic.ts +118 -0
  27. package/src/tool/bortleVisualizer/seo.astro +61 -0
  28. package/src/tool/bortleVisualizer/style.css +5 -0
  29. package/src/tool/deepSpaceScope/bibliography.astro +14 -0
  30. package/src/tool/deepSpaceScope/component.astro +849 -0
  31. package/src/tool/deepSpaceScope/i18n/en.ts +157 -0
  32. package/src/tool/deepSpaceScope/i18n/es.ts +157 -0
  33. package/src/tool/deepSpaceScope/i18n/fr.ts +157 -0
  34. package/src/tool/deepSpaceScope/index.ts +48 -0
  35. package/src/tool/deepSpaceScope/logic.ts +41 -0
  36. package/src/tool/deepSpaceScope/seo.astro +61 -0
  37. package/src/tool/deepSpaceScope/style.css +5 -0
  38. package/src/tool/starExposureCalculator/bibliography.astro +14 -0
  39. package/src/tool/starExposureCalculator/component.astro +562 -0
  40. package/src/tool/starExposureCalculator/i18n/en.ts +163 -0
  41. package/src/tool/starExposureCalculator/i18n/es.ts +163 -0
  42. package/src/tool/starExposureCalculator/i18n/fr.ts +158 -0
  43. package/src/tool/starExposureCalculator/index.ts +53 -0
  44. package/src/tool/starExposureCalculator/logic.ts +49 -0
  45. package/src/tool/starExposureCalculator/seo.astro +61 -0
  46. package/src/tool/starExposureCalculator/style.css +5 -0
  47. package/src/tool/telescopeResolution/bibliography.astro +14 -0
  48. package/src/tool/telescopeResolution/component.astro +556 -0
  49. package/src/tool/telescopeResolution/i18n/en.ts +168 -0
  50. package/src/tool/telescopeResolution/i18n/es.ts +163 -0
  51. package/src/tool/telescopeResolution/i18n/fr.ts +168 -0
  52. package/src/tool/telescopeResolution/index.ts +52 -0
  53. package/src/tool/telescopeResolution/logic.ts +39 -0
  54. package/src/tool/telescopeResolution/seo.astro +61 -0
  55. package/src/tool/telescopeResolution/style.css +5 -0
  56. package/src/tools.ts +19 -0
  57. package/src/types.ts +71 -0
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { astronomyCategory } from '../data';
6
+ import type { ToolDefinition } from '../types';
7
+
8
+ function extractSeoText(sections: any[]): string {
9
+ return sections
10
+ .map((section) => extractSectionText(section))
11
+ .join(' ');
12
+ }
13
+
14
+ function extractHtmlOrText(section: any): string {
15
+ const hasContent = 'html' in section || 'text' in section;
16
+ const isNotTable = section.type !== 'table';
17
+ return hasContent && isNotTable ? (section.html || section.text || '') : '';
18
+ }
19
+
20
+ function extractListItems(section: any): string {
21
+ return 'items' in section && Array.isArray(section.items) ? section.items.join(' ') : '';
22
+ }
23
+
24
+ function extractTableRows(section: any): string {
25
+ return 'rows' in section && Array.isArray(section.rows) ? section.rows.flat().join(' ') : '';
26
+ }
27
+
28
+ function extractSectionText(section: any): string {
29
+ let text = '';
30
+ text += extractHtmlOrText(section);
31
+ text += extractListItems(section);
32
+ text += extractTableRows(section);
33
+ return text;
34
+ }
35
+
36
+ function countWords(text: string): number {
37
+ return text
38
+ .replace(/<[^>]*>/g, '')
39
+ .trim()
40
+ .split(/\s+/)
41
+ .filter((w) => w.length > 0).length;
42
+ }
43
+
44
+ describe('Tool Validation Suite', () => {
45
+ describe('Strict Tool Definition Validation', () => {
46
+ ALL_TOOLS.forEach((tool: ToolDefinition) => {
47
+ const { entry } = tool;
48
+
49
+ describe(`${entry.id} structure`, () => {
50
+ it('should fulfill ToolDefinition interface', () => {
51
+ expect(tool.entry).toBeDefined();
52
+ expect(tool.Component).toBeDefined();
53
+ expect(tool.SEOComponent).toBeDefined();
54
+ expect(tool.BibliographyComponent).toBeDefined();
55
+ });
56
+
57
+ it('should have valid ID in kebab-case', () => {
58
+ expect(entry.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
59
+ });
60
+
61
+ it('should have valid icons with bg and fg', () => {
62
+ expect(entry.icons.bg).toMatch(/^mdi:/);
63
+ expect(entry.icons.fg).toMatch(/^mdi:/);
64
+ });
65
+
66
+ describe('i18n Content Validation', () => {
67
+ Object.entries(entry.i18n).forEach(([locale, loader]) => {
68
+ it(`should load ${locale} content and follow slug rules`, async () => {
69
+ const content = await loader();
70
+ expect(content.slug).toBeDefined();
71
+ expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
72
+
73
+ if (locale === 'es') {
74
+ const validSlugs = [
75
+ 'simulador-cielo-oscuro',
76
+ 'alcance-telescopio',
77
+ 'calculadora-regla-500',
78
+ 'calculadora-resolucion-telescopio',
79
+ ];
80
+ expect(validSlugs).toContain(content.slug);
81
+ }
82
+ });
83
+
84
+ it(`should have valid basic content in ${locale}`, async () => {
85
+ const content = await loader();
86
+ expect(content.title.length).toBeGreaterThan(0);
87
+ expect(content.description.length).toBeGreaterThan(0);
88
+ expect(Object.keys(content.ui).length).toBeGreaterThan(0);
89
+ });
90
+
91
+ it(`should have SEO content with > 300 words in ${locale}`, async () => {
92
+ const content = await loader();
93
+ const seoText = extractSeoText(content.seo);
94
+ const wordCount = countWords(seoText);
95
+ expect(wordCount).toBeGreaterThanOrEqual(300);
96
+ });
97
+ });
98
+ });
99
+ });
100
+ });
101
+ });
102
+
103
+ describe('Library Registration', () => {
104
+ it('should have 4 tools in ALL_TOOLS', () => {
105
+ expect(ALL_TOOLS.length).toBe(4);
106
+ });
107
+
108
+ it('should have all tools in astronomyCategory', () => {
109
+ expect(astronomyCategory.tools.length).toBe(4);
110
+ ALL_TOOLS.forEach(({ entry }) => {
111
+ const exists = astronomyCategory.tools.some((t: any) => t.id === entry.id);
112
+ expect(exists).toBe(true);
113
+ });
114
+ });
115
+ });
116
+
117
+ describe('Binary Consistency', () => {
118
+ ALL_TOOLS.forEach(({ entry }) => {
119
+ it(`${entry.id} files should exist in the tool folder`, () => {
120
+ const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
121
+ const toolDir = path.join('src', 'tool', toolVarName);
122
+
123
+ expect(fs.existsSync(path.join(toolDir, 'component.astro'))).toBe(true);
124
+ expect(fs.existsSync(path.join(toolDir, 'seo.astro'))).toBe(true);
125
+ expect(fs.existsSync(path.join(toolDir, 'bibliography.astro'))).toBe(true);
126
+ expect(fs.existsSync(path.join(toolDir, 'index.ts'))).toBe(true);
127
+ });
128
+ });
129
+ });
130
+
131
+ describe('Bibliography Components Structure', () => {
132
+ ALL_TOOLS.forEach(({ entry }) => {
133
+ it(`${entry.id} should have a standard bibliography structure`, () => {
134
+ const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
135
+ const componentPath = path.join('src', 'tool', toolVarName, 'bibliography.astro');
136
+ const content = fs.readFileSync(componentPath, 'utf-8');
137
+
138
+ expect(content).toContain("import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared'");
139
+ expect(content).toContain(`import { ${toolVarName} } from './index'`);
140
+ expect(content).toContain('<SharedBibliography links={content.bibliography} />');
141
+ });
142
+ });
143
+ });
144
+ });
145
+
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bortleVisualizer } 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 bortleVisualizer.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,491 @@
1
+ ---
2
+ import type { BortleVisualizerUI } from './index';
3
+ import type { KnownLocale } from '../../types';
4
+
5
+ interface Props {
6
+ ui: BortleVisualizerUI;
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ ---
12
+
13
+ <div class="bortle-wrapper" id="bortle-simulator">
14
+ <div
15
+ class="bortle-scene"
16
+ role="img"
17
+ aria-label={ui.toolTitle}
18
+ >
19
+ <div class="bortle-bg-gradient"></div>
20
+
21
+ <div id="layer-stars" class="bortle-layer layer-stars">
22
+ <div class="stars-image"></div>
23
+ </div>
24
+
25
+ <div id="layer-milkyway" class="bortle-layer layer-milkyway">
26
+ <div class="milkyway-image"></div>
27
+ </div>
28
+
29
+ <div id="layer-pollution" class="bortle-layer layer-pollution">
30
+ <div class="pollution-gradient"></div>
31
+ </div>
32
+
33
+ <div id="layer-glow" class="bortle-layer layer-glow"></div>
34
+
35
+ <div class="landscape-layer">
36
+ <div class="landscape-image"></div>
37
+ <div class="landscape-fade"></div>
38
+ </div>
39
+
40
+ <div class="bortle-info-overlay">
41
+ <div class="bortle-info-content">
42
+ <h3 id="display-title" class="bortle-title">--</h3>
43
+ <div class="bortle-badges">
44
+ <span id="display-class" class="bortle-class-badge">--</span>
45
+ <span id="display-nelm" class="bortle-nelm-badge">{ui.nelmLabel} --</span>
46
+ </div>
47
+ <p id="display-description" class="bortle-description">--</p>
48
+ </div>
49
+ </div>
50
+
51
+ <div id="display-description-mobile" class="bortle-description-mobile">--</div>
52
+
53
+ <div class="bortle-controls">
54
+ <div class="bortle-slider-track">
55
+ <div class="slider-gradient-bar"></div>
56
+ <input
57
+ type="range"
58
+ id="bortle-slider"
59
+ min="1"
60
+ max="9"
61
+ value="1"
62
+ step="1"
63
+ class="bortle-slider-input"
64
+ aria-label={ui.sliderLabel}
65
+ />
66
+ <div id="custom-thumb" class="bortle-thumb" style="left: 0%">
67
+ <div class="bortle-thumb-dot"></div>
68
+ </div>
69
+ <div class="bortle-tick-marks">
70
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
71
+ <div class="bortle-tick">
72
+ <div class="tick-line" />
73
+ <span class="tick-label">{n}</span>
74
+ </div>
75
+ ))}
76
+ </div>
77
+ </div>
78
+ <div class="bortle-slider-hint">{ui.sliderLabel}</div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <script>
84
+ import { getBortleData } from './logic';
85
+
86
+ const elements = {
87
+ stars: document.getElementById('layer-stars'),
88
+ milkyway: document.getElementById('layer-milkyway'),
89
+ pollution: document.getElementById('layer-pollution'),
90
+ glow: document.getElementById('layer-glow'),
91
+ title: document.getElementById('display-title'),
92
+ classLabel: document.getElementById('display-class'),
93
+ nelm: document.getElementById('display-nelm'),
94
+ desc: document.getElementById('display-description'),
95
+ descMobile: document.getElementById('display-description-mobile'),
96
+ slider: document.getElementById('bortle-slider'),
97
+ thumb: document.getElementById('custom-thumb'),
98
+ };
99
+
100
+ if (elements.slider) {
101
+ function updateText(level: number, data: ReturnType<typeof getBortleData>) {
102
+ if (elements.title) elements.title.innerText = data.title;
103
+ if (elements.classLabel) elements.classLabel.innerText = `CLASE ${level}`;
104
+ if (elements.nelm) elements.nelm.innerText = `NELM ${data.nelm.toFixed(1)}`;
105
+ if (elements.desc) elements.desc.innerText = data.description;
106
+ if (elements.descMobile) elements.descMobile.innerText = data.description;
107
+ }
108
+
109
+ function updateThumb(val: number) {
110
+ if (!elements.thumb || !elements.slider) return;
111
+ const min = parseInt((elements.slider as HTMLInputElement).min);
112
+ const max = parseInt((elements.slider as HTMLInputElement).max);
113
+ const percent = ((val - min) / (max - min)) * 100;
114
+ elements.thumb.style.left = `calc(${percent}% - 20px)`;
115
+ }
116
+
117
+ function getMilkywayOpacity(level: number) {
118
+ if (level <= 2) return 1;
119
+ if (level <= 4) return 0.6 - (level - 2) * 0.25;
120
+ return 0;
121
+ }
122
+
123
+ function updateLayers(level: number, skyBrightness: number) {
124
+ const starOpacity = Math.max(0, 1 - skyBrightness * 1.1);
125
+ if (elements.stars) elements.stars.style.opacity = starOpacity.toString();
126
+ if (elements.milkyway) elements.milkyway.style.opacity = getMilkywayOpacity(level).toString();
127
+ if (elements.pollution) elements.pollution.style.opacity = Math.pow(skyBrightness, 1.5).toString();
128
+ if (elements.glow) elements.glow.style.opacity = (skyBrightness * 0.5).toString();
129
+ }
130
+
131
+ function updateAll(val: number) {
132
+ const level = Math.floor(val);
133
+ const data = getBortleData(level);
134
+ updateText(level, data);
135
+ updateThumb(val);
136
+ updateLayers(level, data.skyBrightness);
137
+ }
138
+
139
+ elements.slider.addEventListener('input', (e) => {
140
+ const val = parseFloat((e.target as HTMLInputElement).value);
141
+ updateAll(val);
142
+ });
143
+
144
+ updateAll(1);
145
+ }
146
+ </script>
147
+
148
+ <style>
149
+ .bortle-wrapper {
150
+ width: 100%;
151
+ max-width: 80rem;
152
+ margin: 0 auto;
153
+ }
154
+
155
+ .bortle-scene {
156
+ --on-dark: #fff;
157
+
158
+ position: relative;
159
+ width: 100%;
160
+ aspect-ratio: 9 / 16;
161
+ border-radius: 2rem;
162
+ overflow: hidden;
163
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8);
164
+ background: var(--color-bg-deep, #050b14);
165
+ border: 1px solid rgba(255, 255, 255, 0.05);
166
+ user-select: none;
167
+ }
168
+
169
+ @media (min-width: 768px) {
170
+ .bortle-scene {
171
+ aspect-ratio: 16 / 9;
172
+ }
173
+ }
174
+
175
+ .bortle-bg-gradient {
176
+ position: absolute;
177
+ inset: 0;
178
+ background: linear-gradient(to bottom, #02040a, #0b1026, #1e293b);
179
+ z-index: 0;
180
+ }
181
+
182
+ .bortle-layer {
183
+ position: absolute;
184
+ inset: 0;
185
+ transition: opacity 1s ease-in-out;
186
+ }
187
+
188
+ .layer-stars {
189
+ z-index: 10;
190
+ mix-blend-mode: plus-lighter;
191
+ opacity: 1;
192
+ }
193
+
194
+ .stars-image {
195
+ position: absolute;
196
+ inset: 0;
197
+ background-image: url('/images/utilities/dark-sky/stars-bg.webp');
198
+ background-size: cover;
199
+ background-position: center;
200
+ }
201
+
202
+ .layer-milkyway {
203
+ z-index: 20;
204
+ mix-blend-mode: screen;
205
+ opacity: 1;
206
+ }
207
+
208
+ .milkyway-image {
209
+ position: absolute;
210
+ inset: 0;
211
+ background-image: url('/images/utilities/dark-sky/milky-way.webp');
212
+ background-size: contain;
213
+ background-position: center;
214
+ background-repeat: no-repeat;
215
+ opacity: 0.8;
216
+ scale: 1.25;
217
+ mask-image: radial-gradient(closest-side, black 40%, transparent 100%);
218
+ }
219
+
220
+ .layer-pollution {
221
+ position: absolute;
222
+ inset-inline: 0;
223
+ bottom: 0;
224
+ top: 25%;
225
+ z-index: 30;
226
+ opacity: 0;
227
+ transition: opacity 1s ease-in-out;
228
+ }
229
+
230
+ .pollution-gradient {
231
+ position: absolute;
232
+ inset: 0;
233
+ background: linear-gradient(to top, var(--color-pollution-warm, #f97316), rgba(251, 191, 36, 0.4), transparent);
234
+ mix-blend-mode: hard-light;
235
+ }
236
+
237
+ .layer-glow {
238
+ z-index: 40;
239
+ background: rgba(219, 234, 254, 0.1);
240
+ opacity: 0;
241
+ pointer-events: none;
242
+ mix-blend-mode: overlay;
243
+ transition: opacity 1s ease-in-out;
244
+ }
245
+
246
+ .landscape-layer {
247
+ position: absolute;
248
+ inset-inline: 0;
249
+ bottom: 0;
250
+ height: 50%;
251
+ z-index: 50;
252
+ pointer-events: none;
253
+ }
254
+
255
+ .landscape-image {
256
+ position: absolute;
257
+ inset: 0;
258
+ background-image: url('/images/utilities/dark-sky/landscape-silhouette.webp');
259
+ background-size: cover;
260
+ background-position: bottom;
261
+ background-repeat: no-repeat;
262
+ }
263
+
264
+ .landscape-fade {
265
+ position: absolute;
266
+ inset-inline: 0;
267
+ bottom: 0;
268
+ height: 50%;
269
+ background: linear-gradient(to top, black, rgba(0, 0, 0, 0.8), transparent);
270
+ }
271
+
272
+ .bortle-info-overlay {
273
+ position: absolute;
274
+ top: 1.5rem;
275
+ inset-inline: 1rem;
276
+ z-index: 60;
277
+ display: flex;
278
+ flex-direction: column;
279
+ align-items: center;
280
+ }
281
+
282
+ @media (min-width: 768px) {
283
+ .bortle-info-overlay {
284
+ top: 2.5rem;
285
+ inset-inline: auto;
286
+ left: 2.5rem;
287
+ align-items: flex-start;
288
+ }
289
+ }
290
+
291
+ .bortle-info-content {
292
+ display: flex;
293
+ flex-direction: column;
294
+ gap: 0.5rem;
295
+ text-align: center;
296
+ }
297
+
298
+ @media (min-width: 768px) {
299
+ .bortle-info-content {
300
+ text-align: left;
301
+ }
302
+ }
303
+
304
+ .bortle-title {
305
+ font-size: clamp(1.5rem, 5vw, 3rem);
306
+ font-weight: 900;
307
+ color: var(--on-dark);
308
+ letter-spacing: -0.02em;
309
+ text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
310
+ transition: all 0.3s ease;
311
+ margin: 0;
312
+ }
313
+
314
+ .bortle-badges {
315
+ display: inline-flex;
316
+ align-items: center;
317
+ gap: 1rem;
318
+ background: rgba(0, 0, 0, 0.4);
319
+ backdrop-filter: blur(12px);
320
+ padding: 0.5rem 1rem;
321
+ border-radius: 9999px;
322
+ border: 1px solid rgba(255, 255, 255, 0.1);
323
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
324
+ }
325
+
326
+ .bortle-class-badge {
327
+ font-size: 0.7rem;
328
+ font-weight: 700;
329
+ color: var(--on-dark);
330
+ border-right: 1px solid rgba(255, 255, 255, 0.2);
331
+ padding-right: 1rem;
332
+ text-transform: uppercase;
333
+ letter-spacing: 0.1em;
334
+ }
335
+
336
+ .bortle-nelm-badge {
337
+ color: var(--color-emerald, #34d399);
338
+ font-weight: 700;
339
+ font-size: 0.875rem;
340
+ }
341
+
342
+ .bortle-description {
343
+ display: none;
344
+ max-width: 24rem;
345
+ font-size: 0.875rem;
346
+ color: rgba(255, 255, 255, 0.8);
347
+ font-weight: 500;
348
+ line-height: 1.6;
349
+ margin: 0;
350
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
351
+ }
352
+
353
+ @media (min-width: 768px) {
354
+ .bortle-description {
355
+ display: block;
356
+ }
357
+ }
358
+
359
+ .bortle-description-mobile {
360
+ position: absolute;
361
+ bottom: 7rem;
362
+ inset-inline: 1rem;
363
+ z-index: 70;
364
+ text-align: center;
365
+ font-size: 0.875rem;
366
+ color: rgba(255, 255, 255, 0.9);
367
+ font-weight: 500;
368
+ line-height: 1.6;
369
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
370
+ min-height: 3em;
371
+ }
372
+
373
+ @media (min-width: 768px) {
374
+ .bortle-description-mobile {
375
+ display: none;
376
+ }
377
+ }
378
+
379
+ .bortle-controls {
380
+ position: absolute;
381
+ bottom: 2rem;
382
+ inset-inline: 1rem;
383
+ z-index: 70;
384
+ }
385
+
386
+ @media (min-width: 768px) {
387
+ .bortle-controls {
388
+ inset-inline: 3rem;
389
+ }
390
+ }
391
+
392
+ .bortle-slider-track {
393
+ position: relative;
394
+ height: 4rem;
395
+ display: flex;
396
+ align-items: center;
397
+ background: rgba(0, 0, 0, 0.5);
398
+ backdrop-filter: blur(20px);
399
+ border-radius: 9999px;
400
+ border: 1px solid rgba(255, 255, 255, 0.1);
401
+ padding: 0 0.5rem;
402
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
403
+ }
404
+
405
+ .slider-gradient-bar {
406
+ position: absolute;
407
+ inset-inline: 1rem;
408
+ height: 0.5rem;
409
+ border-radius: 9999px;
410
+ background: linear-gradient(to right, var(--color-emerald, #34d399), var(--color-amber, #f59e0b), var(--color-red, #ef4444));
411
+ opacity: 0.8;
412
+ }
413
+
414
+ .bortle-slider-input {
415
+ position: absolute;
416
+ inset: 0;
417
+ width: 100%;
418
+ height: 100%;
419
+ opacity: 0;
420
+ cursor: pointer;
421
+ z-index: 20;
422
+ }
423
+
424
+ .bortle-thumb {
425
+ position: absolute;
426
+ height: 2.5rem;
427
+ width: 2.5rem;
428
+ background: white;
429
+ border: 4px solid var(--color-indigo, #6366f1);
430
+ border-radius: 50%;
431
+ box-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
432
+ pointer-events: none;
433
+ transition: left 0.15s ease-out;
434
+ display: flex;
435
+ align-items: center;
436
+ justify-content: center;
437
+ z-index: 10;
438
+ }
439
+
440
+ .bortle-thumb-dot {
441
+ width: 0.5rem;
442
+ height: 0.5rem;
443
+ background: var(--color-indigo, #6366f1);
444
+ border-radius: 50%;
445
+ }
446
+
447
+ .bortle-tick-marks {
448
+ position: absolute;
449
+ inset-inline: 1rem;
450
+ bottom: 0.5rem;
451
+ top: 0.5rem;
452
+ display: flex;
453
+ justify-content: space-between;
454
+ pointer-events: none;
455
+ z-index: 0;
456
+ }
457
+
458
+ .bortle-tick {
459
+ display: flex;
460
+ flex-direction: column;
461
+ align-items: center;
462
+ justify-content: center;
463
+ height: 100%;
464
+ width: 1rem;
465
+ position: relative;
466
+ }
467
+
468
+ .tick-line {
469
+ width: 1px;
470
+ height: 100%;
471
+ background: rgba(255, 255, 255, 0.1);
472
+ }
473
+
474
+ .tick-label {
475
+ position: absolute;
476
+ font-size: 0.625rem;
477
+ font-weight: 700;
478
+ color: rgba(255, 255, 255, 0.6);
479
+ top: calc(100% + 0.25rem);
480
+ }
481
+
482
+ .bortle-slider-hint {
483
+ text-align: center;
484
+ margin-top: 1.5rem;
485
+ font-size: 0.625rem;
486
+ text-transform: uppercase;
487
+ letter-spacing: 0.2em;
488
+ color: rgba(255, 255, 255, 0.4);
489
+ font-weight: 700;
490
+ }
491
+ </style>