@roxyapi/ui 0.0.1 → 0.1.1

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 (166) hide show
  1. package/AGENTS.md +169 -0
  2. package/THEMING.md +129 -0
  3. package/dist/cdn/components/biorhythm-chart.js +261 -0
  4. package/dist/cdn/components/biorhythm-chart.js.map +7 -0
  5. package/dist/cdn/components/compatibility-card.js +257 -0
  6. package/dist/cdn/components/compatibility-card.js.map +7 -0
  7. package/dist/cdn/components/dasha-timeline.js +244 -0
  8. package/dist/cdn/components/dasha-timeline.js.map +7 -0
  9. package/dist/cdn/components/data.js +258 -0
  10. package/dist/cdn/components/data.js.map +7 -0
  11. package/dist/cdn/components/dosha-card.js +254 -0
  12. package/dist/cdn/components/dosha-card.js.map +7 -0
  13. package/dist/cdn/components/endpoint-form.js +253 -0
  14. package/dist/cdn/components/endpoint-form.js.map +7 -0
  15. package/dist/cdn/components/guna-milan.js +256 -0
  16. package/dist/cdn/components/guna-milan.js.map +7 -0
  17. package/dist/cdn/components/hexagram.js +275 -0
  18. package/dist/cdn/components/hexagram.js.map +7 -0
  19. package/dist/cdn/components/horoscope-card.js +302 -0
  20. package/dist/cdn/components/horoscope-card.js.map +7 -0
  21. package/dist/cdn/components/kp-planets-table.js +224 -0
  22. package/dist/cdn/components/kp-planets-table.js.map +7 -0
  23. package/dist/cdn/components/location-search.js +267 -0
  24. package/dist/cdn/components/location-search.js.map +7 -0
  25. package/dist/cdn/components/moon-phase.js +251 -0
  26. package/dist/cdn/components/moon-phase.js.map +7 -0
  27. package/dist/cdn/components/natal-chart.js +237 -0
  28. package/dist/cdn/components/natal-chart.js.map +7 -0
  29. package/dist/cdn/components/numerology-card.js +252 -0
  30. package/dist/cdn/components/numerology-card.js.map +7 -0
  31. package/dist/cdn/components/panchang-table.js +234 -0
  32. package/dist/cdn/components/panchang-table.js.map +7 -0
  33. package/dist/cdn/components/synastry-chart.js +303 -0
  34. package/dist/cdn/components/synastry-chart.js.map +7 -0
  35. package/dist/cdn/components/tarot-card.js +260 -0
  36. package/dist/cdn/components/tarot-card.js.map +7 -0
  37. package/dist/cdn/components/tarot-spread.js +261 -0
  38. package/dist/cdn/components/tarot-spread.js.map +7 -0
  39. package/dist/cdn/components/vedic-kundli.js +189 -0
  40. package/dist/cdn/components/vedic-kundli.js.map +7 -0
  41. package/dist/cdn/roxy-ui.js +2552 -0
  42. package/dist/cdn/roxy-ui.js.map +7 -0
  43. package/dist/cdn/widgets.js +114 -0
  44. package/dist/components/biorhythm-chart.d.ts +66 -0
  45. package/dist/components/biorhythm-chart.d.ts.map +1 -0
  46. package/dist/components/biorhythm-chart.js +318 -0
  47. package/dist/components/biorhythm-chart.js.map +7 -0
  48. package/dist/components/compatibility-card.d.ts +46 -0
  49. package/dist/components/compatibility-card.d.ts.map +1 -0
  50. package/dist/components/compatibility-card.js +279 -0
  51. package/dist/components/compatibility-card.js.map +7 -0
  52. package/dist/components/dasha-timeline.d.ts +53 -0
  53. package/dist/components/dasha-timeline.d.ts.map +1 -0
  54. package/dist/components/dasha-timeline.js +269 -0
  55. package/dist/components/dasha-timeline.js.map +7 -0
  56. package/dist/components/data.d.ts +40 -0
  57. package/dist/components/data.d.ts.map +1 -0
  58. package/dist/components/data.js +339 -0
  59. package/dist/components/data.js.map +7 -0
  60. package/dist/components/dosha-card.d.ts +35 -0
  61. package/dist/components/dosha-card.d.ts.map +1 -0
  62. package/dist/components/dosha-card.js +278 -0
  63. package/dist/components/dosha-card.js.map +7 -0
  64. package/dist/components/endpoint-form.d.ts +39 -0
  65. package/dist/components/endpoint-form.d.ts.map +1 -0
  66. package/dist/components/endpoint-form.js +432 -0
  67. package/dist/components/endpoint-form.js.map +7 -0
  68. package/dist/components/guna-milan.d.ts +35 -0
  69. package/dist/components/guna-milan.d.ts.map +1 -0
  70. package/dist/components/guna-milan.js +302 -0
  71. package/dist/components/guna-milan.js.map +7 -0
  72. package/dist/components/hexagram.d.ts +47 -0
  73. package/dist/components/hexagram.d.ts.map +1 -0
  74. package/dist/components/hexagram.js +334 -0
  75. package/dist/components/hexagram.js.map +7 -0
  76. package/dist/components/horoscope-card.d.ts +38 -0
  77. package/dist/components/horoscope-card.d.ts.map +1 -0
  78. package/dist/components/horoscope-card.js +332 -0
  79. package/dist/components/horoscope-card.js.map +7 -0
  80. package/dist/components/kp-planets-table.d.ts +36 -0
  81. package/dist/components/kp-planets-table.d.ts.map +1 -0
  82. package/dist/components/kp-planets-table.js +227 -0
  83. package/dist/components/kp-planets-table.js.map +7 -0
  84. package/dist/components/location-search.d.ts +56 -0
  85. package/dist/components/location-search.d.ts.map +1 -0
  86. package/dist/components/location-search.js +401 -0
  87. package/dist/components/location-search.js.map +7 -0
  88. package/dist/components/moon-phase.d.ts +38 -0
  89. package/dist/components/moon-phase.d.ts.map +1 -0
  90. package/dist/components/moon-phase.js +284 -0
  91. package/dist/components/moon-phase.js.map +7 -0
  92. package/dist/components/natal-chart.d.ts +65 -0
  93. package/dist/components/natal-chart.d.ts.map +1 -0
  94. package/dist/components/natal-chart.js +407 -0
  95. package/dist/components/natal-chart.js.map +7 -0
  96. package/dist/components/numerology-card.d.ts +55 -0
  97. package/dist/components/numerology-card.d.ts.map +1 -0
  98. package/dist/components/numerology-card.js +274 -0
  99. package/dist/components/numerology-card.js.map +7 -0
  100. package/dist/components/panchang-table.d.ts +77 -0
  101. package/dist/components/panchang-table.d.ts.map +1 -0
  102. package/dist/components/panchang-table.js +285 -0
  103. package/dist/components/panchang-table.js.map +7 -0
  104. package/dist/components/synastry-chart.d.ts +52 -0
  105. package/dist/components/synastry-chart.d.ts.map +1 -0
  106. package/dist/components/synastry-chart.js +415 -0
  107. package/dist/components/synastry-chart.js.map +7 -0
  108. package/dist/components/tarot-card.d.ts +47 -0
  109. package/dist/components/tarot-card.d.ts.map +1 -0
  110. package/dist/components/tarot-card.js +281 -0
  111. package/dist/components/tarot-card.js.map +7 -0
  112. package/dist/components/tarot-spread.d.ts +42 -0
  113. package/dist/components/tarot-spread.d.ts.map +1 -0
  114. package/dist/components/tarot-spread.js +271 -0
  115. package/dist/components/tarot-spread.js.map +7 -0
  116. package/dist/components/vedic-kundli.d.ts +45 -0
  117. package/dist/components/vedic-kundli.d.ts.map +1 -0
  118. package/dist/components/vedic-kundli.js +325 -0
  119. package/dist/components/vedic-kundli.js.map +7 -0
  120. package/dist/index.cjs +4174 -0
  121. package/dist/index.cjs.map +7 -0
  122. package/dist/index.d.ts +30 -0
  123. package/dist/index.d.ts.map +1 -0
  124. package/dist/index.js +4154 -0
  125. package/dist/index.js.map +7 -0
  126. package/dist/manifest.json +24 -0
  127. package/dist/styles/tokens.css +147 -0
  128. package/dist/tokens/index.d.ts +17 -0
  129. package/dist/tokens/index.d.ts.map +1 -0
  130. package/dist/utils/base-styles.d.ts +6 -0
  131. package/dist/utils/base-styles.d.ts.map +1 -0
  132. package/dist/utils/debounce.d.ts +5 -0
  133. package/dist/utils/debounce.d.ts.map +1 -0
  134. package/dist/utils/degree.d.ts +29 -0
  135. package/dist/utils/degree.d.ts.map +1 -0
  136. package/dist/utils/motion.d.ts +13 -0
  137. package/dist/utils/motion.d.ts.map +1 -0
  138. package/package.json +69 -3
  139. package/src/components/biorhythm-chart.ts +290 -0
  140. package/src/components/compatibility-card.ts +231 -0
  141. package/src/components/dasha-timeline.ts +251 -0
  142. package/src/components/data.ts +287 -0
  143. package/src/components/dosha-card.ts +215 -0
  144. package/src/components/endpoint-form.ts +433 -0
  145. package/src/components/guna-milan.ts +245 -0
  146. package/src/components/hexagram.ts +279 -0
  147. package/src/components/horoscope-card.ts +291 -0
  148. package/src/components/kp-planets-table.ts +156 -0
  149. package/src/components/location-search.ts +335 -0
  150. package/src/components/moon-phase.ts +221 -0
  151. package/src/components/natal-chart.ts +298 -0
  152. package/src/components/numerology-card.ts +243 -0
  153. package/src/components/panchang-table.ts +265 -0
  154. package/src/components/synastry-chart.ts +341 -0
  155. package/src/components/tarot-card.ts +235 -0
  156. package/src/components/tarot-spread.ts +224 -0
  157. package/src/components/vedic-kundli.ts +257 -0
  158. package/src/index.ts +61 -0
  159. package/src/styles/tokens.css +147 -0
  160. package/src/tokens/index.ts +130 -0
  161. package/src/types/index.ts +3 -0
  162. package/src/types/types.gen.ts +28526 -0
  163. package/src/utils/base-styles.ts +89 -0
  164. package/src/utils/debounce.ts +13 -0
  165. package/src/utils/degree.ts +64 -0
  166. package/src/utils/motion.ts +18 -0
@@ -0,0 +1,335 @@
1
+ import { css, html, LitElement, nothing } from 'lit';
2
+ import { customElement, property, state } from 'lit/decorators.js';
3
+ import { baseStyles } from '../utils/base-styles.js';
4
+ import { debounce } from '../utils/debounce.js';
5
+
6
+ export interface CityResult {
7
+ city: string;
8
+ province?: string;
9
+ country: string;
10
+ iso2?: string;
11
+ latitude: number;
12
+ longitude: number;
13
+ timezone: string;
14
+ utcOffset: number;
15
+ population?: number;
16
+ }
17
+
18
+ interface CitySearchResponse {
19
+ total?: number;
20
+ cities?: CityResult[];
21
+ }
22
+
23
+ /**
24
+ * Stateful location search input. Calls /location/search and emits
25
+ * `roxy-location-select` CustomEvent with the chosen city. Required for any
26
+ * chart endpoint.
27
+ *
28
+ * Lifted from jyotish-vedic-astrology-app/src/components/city-search.tsx,
29
+ * keeping the 300ms debounce and click-outside behavior, replacing React
30
+ * state with Lit reactive properties and using direct fetch to RoxyAPI.
31
+ *
32
+ * Attributes:
33
+ * api-key optional. Direct call to roxyapi.com when set.
34
+ * publishable-key optional. Browser-safe pk_* key with allowed_origins.
35
+ * endpoint optional. Override URL (default https://roxyapi.com/api/v2/location/search).
36
+ * placeholder optional. Input placeholder.
37
+ * default-value optional. Pre-filled query.
38
+ */
39
+ @customElement('roxy-location-search')
40
+ export class RoxyLocationSearch extends LitElement {
41
+ static styles = [
42
+ baseStyles,
43
+ css`
44
+ :host {
45
+ display: block;
46
+ position: relative;
47
+ }
48
+ .field {
49
+ position: relative;
50
+ }
51
+ input {
52
+ width: 100%;
53
+ padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem);
54
+ font-size: var(--roxy-text-base, 1rem);
55
+ font-family: inherit;
56
+ color: var(--roxy-fg, #0a0a0a);
57
+ background: var(--roxy-bg, #fff);
58
+ border: 1px solid var(--roxy-border, #e4e4e7);
59
+ border-radius: var(--roxy-radius-md, 8px);
60
+ transition:
61
+ border-color var(--roxy-motion-duration, 200ms)
62
+ var(--roxy-motion-easing, cubic-bezier(0.4, 0, 0.2, 1));
63
+ box-sizing: border-box;
64
+ }
65
+ input:focus {
66
+ outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4));
67
+ outline-offset: 2px;
68
+ border-color: var(--roxy-accent-fg, #b45309);
69
+ }
70
+ .spinner {
71
+ position: absolute;
72
+ right: 12px;
73
+ top: 50%;
74
+ transform: translateY(-50%);
75
+ width: 14px;
76
+ height: 14px;
77
+ border: 2px solid var(--roxy-muted, #71717a);
78
+ border-top-color: transparent;
79
+ border-radius: 50%;
80
+ animation: roxy-spin 700ms linear infinite;
81
+ }
82
+ @keyframes roxy-spin {
83
+ to {
84
+ transform: translateY(-50%) rotate(360deg);
85
+ }
86
+ }
87
+ @media (prefers-reduced-motion: reduce) {
88
+ .spinner {
89
+ animation: none;
90
+ }
91
+ }
92
+
93
+ .results {
94
+ position: absolute;
95
+ z-index: 50;
96
+ top: calc(100% + 4px);
97
+ left: 0;
98
+ right: 0;
99
+ background: var(--roxy-bg, #fff);
100
+ border: 1px solid var(--roxy-border, #e4e4e7);
101
+ border-radius: var(--roxy-radius-md, 8px);
102
+ box-shadow: var(--roxy-shadow-md);
103
+ max-height: 22rem;
104
+ overflow-y: auto;
105
+ animation: roxy-fade-in var(--roxy-motion-duration, 200ms)
106
+ var(--roxy-motion-easing, cubic-bezier(0.4, 0, 0.2, 1));
107
+ }
108
+ .option {
109
+ display: flex;
110
+ align-items: baseline;
111
+ gap: var(--roxy-space-sm, 0.5rem);
112
+ width: 100%;
113
+ padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem);
114
+ background: transparent;
115
+ border: 0;
116
+ text-align: left;
117
+ font-family: inherit;
118
+ font-size: var(--roxy-text-sm, 0.875rem);
119
+ color: var(--roxy-fg, #0a0a0a);
120
+ cursor: pointer;
121
+ transition: background-color var(--roxy-motion-duration, 200ms);
122
+ }
123
+ .option:hover,
124
+ .option[aria-selected='true'] {
125
+ background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 10%, transparent);
126
+ }
127
+ .option .city {
128
+ font-weight: var(--roxy-weight-bold, 600);
129
+ }
130
+ .option .where {
131
+ color: var(--roxy-muted, #71717a);
132
+ flex-grow: 1;
133
+ }
134
+ .option .tz {
135
+ color: var(--roxy-muted, #71717a);
136
+ font-size: var(--roxy-text-xs, 0.75rem);
137
+ font-variant-numeric: tabular-nums;
138
+ }
139
+ .empty {
140
+ padding: var(--roxy-space-md, 1rem);
141
+ color: var(--roxy-muted, #71717a);
142
+ font-size: var(--roxy-text-sm, 0.875rem);
143
+ }
144
+ `,
145
+ ];
146
+
147
+ @property({ type: String, attribute: 'api-key' })
148
+ apiKey?: string;
149
+
150
+ @property({ type: String, attribute: 'publishable-key' })
151
+ publishableKey?: string;
152
+
153
+ @property({ type: String })
154
+ endpoint = 'https://roxyapi.com/api/v2/location/search';
155
+
156
+ @property({ type: String })
157
+ placeholder = 'Search city';
158
+
159
+ @property({ type: String, attribute: 'default-value' })
160
+ defaultValue = '';
161
+
162
+ @state()
163
+ private query = '';
164
+
165
+ @state()
166
+ private results: CityResult[] = [];
167
+
168
+ @state()
169
+ private isOpen = false;
170
+
171
+ @state()
172
+ private isLoading = false;
173
+
174
+ @state()
175
+ private highlight = -1;
176
+
177
+ private clickOutsideHandler?: (e: MouseEvent) => void;
178
+ private debouncedFetch = debounce((q: string) => {
179
+ void this.fetchResults(q);
180
+ }, 300);
181
+
182
+ connectedCallback(): void {
183
+ super.connectedCallback();
184
+ this.query = this.defaultValue;
185
+ this.clickOutsideHandler = (e: MouseEvent) => {
186
+ const path = e.composedPath();
187
+ if (!path.includes(this)) this.isOpen = false;
188
+ };
189
+ document.addEventListener('mousedown', this.clickOutsideHandler);
190
+ }
191
+
192
+ disconnectedCallback(): void {
193
+ super.disconnectedCallback();
194
+ if (this.clickOutsideHandler) {
195
+ document.removeEventListener('mousedown', this.clickOutsideHandler);
196
+ }
197
+ }
198
+
199
+ private async fetchResults(q: string) {
200
+ this.isLoading = true;
201
+ try {
202
+ const url = new URL(this.endpoint);
203
+ url.searchParams.set('q', q);
204
+ url.searchParams.set('limit', '8');
205
+ const headers: Record<string, string> = {
206
+ Accept: 'application/json',
207
+ };
208
+ if (this.apiKey) headers['X-API-Key'] = this.apiKey;
209
+ if (this.publishableKey) headers['X-API-Key'] = this.publishableKey;
210
+ const res = await fetch(url, { headers });
211
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
212
+ const json = (await res.json()) as CitySearchResponse;
213
+ this.results = json.cities ?? [];
214
+ this.isOpen = this.results.length > 0;
215
+ this.highlight = this.results.length > 0 ? 0 : -1;
216
+ } catch (_err) {
217
+ this.results = [];
218
+ this.isOpen = false;
219
+ } finally {
220
+ this.isLoading = false;
221
+ }
222
+ }
223
+
224
+ private onInput = (e: Event) => {
225
+ const value = (e.target as HTMLInputElement).value;
226
+ this.query = value;
227
+ if (value.length < 2) {
228
+ this.results = [];
229
+ this.isOpen = false;
230
+ this.highlight = -1;
231
+ return;
232
+ }
233
+ this.debouncedFetch(value);
234
+ };
235
+
236
+ private select(city: CityResult) {
237
+ this.query = `${city.city}${city.province ? `, ${city.province}` : ''}, ${city.country}`;
238
+ this.isOpen = false;
239
+ this.results = [];
240
+ this.dispatchEvent(
241
+ new CustomEvent('roxy-location-select', {
242
+ detail: city,
243
+ bubbles: true,
244
+ composed: true,
245
+ }),
246
+ );
247
+ }
248
+
249
+ private onKeyDown = (e: KeyboardEvent) => {
250
+ if (!this.isOpen || this.results.length === 0) {
251
+ if (e.key === 'ArrowDown' && this.query.length >= 2) {
252
+ void this.fetchResults(this.query);
253
+ e.preventDefault();
254
+ }
255
+ return;
256
+ }
257
+ if (e.key === 'ArrowDown') {
258
+ e.preventDefault();
259
+ this.highlight = (this.highlight + 1) % this.results.length;
260
+ } else if (e.key === 'ArrowUp') {
261
+ e.preventDefault();
262
+ this.highlight =
263
+ (this.highlight - 1 + this.results.length) % this.results.length;
264
+ } else if (e.key === 'Enter') {
265
+ e.preventDefault();
266
+ const target = this.results[this.highlight] ?? this.results[0];
267
+ if (target) this.select(target);
268
+ } else if (e.key === 'Escape') {
269
+ this.isOpen = false;
270
+ }
271
+ };
272
+
273
+ render() {
274
+ return html`<div class="field">
275
+ <input
276
+ type="text"
277
+ role="combobox"
278
+ aria-expanded=${this.isOpen ? 'true' : 'false'}
279
+ aria-controls="roxy-location-listbox"
280
+ aria-autocomplete="list"
281
+ autocomplete="off"
282
+ placeholder=${this.placeholder}
283
+ .value=${this.query}
284
+ @input=${this.onInput}
285
+ @keydown=${this.onKeyDown}
286
+ @focus=${() => {
287
+ if (this.results.length > 0) this.isOpen = true;
288
+ }}
289
+ />
290
+ ${this.isLoading ? html`<span class="spinner" role="status" aria-label="Loading"></span>` : nothing}
291
+ ${
292
+ this.isOpen
293
+ ? html`<ul
294
+ id="roxy-location-listbox"
295
+ class="results"
296
+ role="listbox"
297
+ >
298
+ ${
299
+ this.results.length === 0
300
+ ? html`<li class="empty" role="status">No cities found</li>`
301
+ : this.results.map(
302
+ (city, idx) => html`<li role="presentation">
303
+ <button
304
+ type="button"
305
+ class="option"
306
+ role="option"
307
+ aria-selected=${this.highlight === idx ? 'true' : 'false'}
308
+ @click=${() => this.select(city)}
309
+ @mouseenter=${() => {
310
+ this.highlight = idx;
311
+ }}
312
+ >
313
+ <span class="city">${city.city}</span>
314
+ <span class="where"
315
+ >${city.province ? html`${city.province}, ` : ''}${city.country}</span
316
+ >
317
+ <span class="tz"
318
+ >UTC${city.utcOffset >= 0 ? '+' : ''}${city.utcOffset}</span
319
+ >
320
+ </button>
321
+ </li>`,
322
+ )
323
+ }
324
+ </ul>`
325
+ : nothing
326
+ }
327
+ </div>`;
328
+ }
329
+ }
330
+
331
+ declare global {
332
+ interface HTMLElementTagNameMap {
333
+ 'roxy-location-search': RoxyLocationSearch;
334
+ }
335
+ }
@@ -0,0 +1,221 @@
1
+ import { css, html, LitElement, nothing } from 'lit';
2
+ import { customElement, property } from 'lit/decorators.js';
3
+ import { MOON_PHASE_EMOJI } from '../tokens/index.js';
4
+ import { baseStyles } from '../utils/base-styles.js';
5
+
6
+ interface MoonPhaseData {
7
+ date?: string;
8
+ phase?: string;
9
+ illumination?: number;
10
+ age?: number;
11
+ sign?: string;
12
+ degree?: number;
13
+ distance?: number;
14
+ meaning?: {
15
+ name?: string;
16
+ symbol?: string;
17
+ description?: string;
18
+ keywords?: string[];
19
+ };
20
+ month?: string;
21
+ year?: number;
22
+ phases?: Array<MoonPhaseData>;
23
+ upcoming?: Array<MoonPhaseData>;
24
+ }
25
+
26
+ /**
27
+ * Moon phase card. Renders /astrology/moon-phase/{current,upcoming,calendar/...}.
28
+ */
29
+ @customElement('roxy-moon-phase')
30
+ export class RoxyMoonPhase extends LitElement {
31
+ static styles = [
32
+ baseStyles,
33
+ css`
34
+ .card {
35
+ background: var(--roxy-bg, #fff);
36
+ border: 1px solid var(--roxy-border, #e4e4e7);
37
+ border-radius: var(--roxy-radius-md, 8px);
38
+ padding: var(--roxy-space-lg, 1.5rem);
39
+ box-shadow: var(--roxy-shadow-sm);
40
+ display: grid;
41
+ gap: var(--roxy-space-md, 1rem);
42
+ }
43
+
44
+ .hero {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: var(--roxy-space-md, 1rem);
48
+ }
49
+ .emoji {
50
+ font-size: 3rem;
51
+ line-height: 1;
52
+ }
53
+ .label {
54
+ margin: 0;
55
+ font-size: var(--roxy-text-lg, 1.125rem);
56
+ font-weight: var(--roxy-weight-bold, 600);
57
+ text-transform: capitalize;
58
+ }
59
+ .date {
60
+ color: var(--roxy-muted, #71717a);
61
+ font-size: var(--roxy-text-sm, 0.875rem);
62
+ }
63
+
64
+ .stats {
65
+ display: grid;
66
+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
67
+ gap: var(--roxy-space-md, 1rem);
68
+ font-size: var(--roxy-text-sm, 0.875rem);
69
+ color: var(--roxy-secondary, #475569);
70
+ }
71
+ .stats div span:first-child {
72
+ display: block;
73
+ color: var(--roxy-muted, #71717a);
74
+ font-size: var(--roxy-text-xs, 0.75rem);
75
+ text-transform: uppercase;
76
+ letter-spacing: 0.06em;
77
+ }
78
+ .stats strong {
79
+ color: var(--roxy-fg, #0a0a0a);
80
+ font-variant-numeric: tabular-nums;
81
+ }
82
+
83
+ .meaning {
84
+ color: var(--roxy-fg, #0a0a0a);
85
+ }
86
+ .keywords {
87
+ display: flex;
88
+ flex-wrap: wrap;
89
+ gap: var(--roxy-space-xs, 0.25rem);
90
+ margin-top: var(--roxy-space-sm, 0.5rem);
91
+ }
92
+ .keywords span {
93
+ background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 14%, transparent);
94
+ padding: 2px 8px;
95
+ border-radius: var(--roxy-radius-full, 9999px);
96
+ font-size: var(--roxy-text-xs, 0.75rem);
97
+ }
98
+
99
+ .list {
100
+ display: grid;
101
+ gap: var(--roxy-space-sm, 0.5rem);
102
+ }
103
+ .list-item {
104
+ display: grid;
105
+ grid-template-columns: 2.5rem 1fr auto;
106
+ gap: var(--roxy-space-sm, 0.5rem);
107
+ align-items: center;
108
+ border-bottom: 1px solid var(--roxy-border, #e4e4e7);
109
+ padding: var(--roxy-space-sm, 0.5rem) 0;
110
+ font-size: var(--roxy-text-sm, 0.875rem);
111
+ }
112
+ .list-item:last-child {
113
+ border-bottom: none;
114
+ }
115
+ `,
116
+ ];
117
+
118
+ @property({ attribute: false })
119
+ data: MoonPhaseData | null = null;
120
+
121
+ @property({ type: String, reflect: true })
122
+ mode: 'current' | 'upcoming' | 'calendar' = 'current';
123
+
124
+ render() {
125
+ const d = this.data;
126
+ if (!d)
127
+ return html`<div class="roxy-empty" role="status">No moon phase data</div>`;
128
+ const list = d.phases ?? d.upcoming ?? [];
129
+ if (this.mode !== 'current' && list.length > 0) {
130
+ return html`<article
131
+ class="card"
132
+ aria-label="Moon phase calendar"
133
+ >
134
+ <h2 class="label">${d.month ?? 'Moon phases'} ${d.year ?? ''}</h2>
135
+ <div class="list" role="list">
136
+ ${list.map((phase) => this.renderListItem(phase))}
137
+ </div>
138
+ </article>`;
139
+ }
140
+ return this.renderSingle(d);
141
+ }
142
+
143
+ private renderSingle(d: MoonPhaseData) {
144
+ const emoji = phaseEmoji(d.phase);
145
+ return html`<article class="card" aria-label="Current moon phase">
146
+ <div class="hero">
147
+ <span class="emoji" aria-hidden="true">${emoji}</span>
148
+ <div>
149
+ <h2 class="label">${d.phase ?? 'Moon'}</h2>
150
+ ${d.date ? html`<div class="date">${d.date}</div>` : nothing}
151
+ </div>
152
+ </div>
153
+ <div class="stats">
154
+ ${
155
+ typeof d.illumination === 'number'
156
+ ? html`<div>
157
+ <span>Illumination</span>
158
+ <strong>${(d.illumination * 100).toFixed(0)}%</strong>
159
+ </div>`
160
+ : nothing
161
+ }
162
+ ${
163
+ typeof d.age === 'number'
164
+ ? html`<div>
165
+ <span>Age</span>
166
+ <strong>${d.age.toFixed(1)} days</strong>
167
+ </div>`
168
+ : nothing
169
+ }
170
+ ${
171
+ d.sign
172
+ ? html`<div>
173
+ <span>Sign</span>
174
+ <strong>${d.sign}</strong>
175
+ </div>`
176
+ : nothing
177
+ }
178
+ ${
179
+ typeof d.distance === 'number'
180
+ ? html`<div>
181
+ <span>Distance</span>
182
+ <strong>${(d.distance / 1000).toFixed(0)}k km</strong>
183
+ </div>`
184
+ : nothing
185
+ }
186
+ </div>
187
+ ${
188
+ d.meaning?.description
189
+ ? html`<p class="meaning">${d.meaning.description}</p>`
190
+ : nothing
191
+ }
192
+ ${
193
+ d.meaning?.keywords?.length
194
+ ? html`<div class="keywords">
195
+ ${d.meaning.keywords.map((k) => html`<span>${k}</span>`)}
196
+ </div>`
197
+ : nothing
198
+ }
199
+ </article>`;
200
+ }
201
+
202
+ private renderListItem(p: MoonPhaseData) {
203
+ const emoji = phaseEmoji(p.phase);
204
+ return html`<div class="list-item" role="listitem">
205
+ <span aria-hidden="true">${emoji}</span>
206
+ <span>${p.phase}</span>
207
+ <span>${p.date ?? ''}</span>
208
+ </div>`;
209
+ }
210
+ }
211
+
212
+ function phaseEmoji(phase: string | undefined): string {
213
+ if (!phase) return '🌙';
214
+ return MOON_PHASE_EMOJI[phase.toLowerCase()] ?? '🌙';
215
+ }
216
+
217
+ declare global {
218
+ interface HTMLElementTagNameMap {
219
+ 'roxy-moon-phase': RoxyMoonPhase;
220
+ }
221
+ }