@astronox/core 0.5.13 → 0.5.14

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/actions/ThemeToggle/ThemeToggle.astro +11 -0
  3. package/src/components/actions/ThemeToggle/ThemeToggle.css +4 -1
  4. package/src/components/actions/ThemeToggle/themeToggleController.ts +1 -0
  5. package/src/components/content/CardLink/CardLink.astro +2 -0
  6. package/src/components/content/CardLink/CardLink.css +50 -5
  7. package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.astro +46 -1
  8. package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.css +51 -1
  9. package/src/components/feedback/SeasonalDecorations/snowfall.ts +38 -0
  10. package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +51 -26
  11. package/src/components/icons/CurrencyIcon.css +2 -2
  12. package/src/components/icons/FirTreeIcon.astro +32 -0
  13. package/src/components/icons/GiftIcon.astro +34 -0
  14. package/src/components/index.ts +2 -0
  15. package/src/components/navigation/Header.css +22 -3
  16. package/src/components/navigation/NavigationBrand.astro +38 -19
  17. package/src/components/navigation/NavigationBrand.css +12 -2
  18. package/src/config/siteConfig.ts +24 -8
  19. package/src/display/contracts.ts +2 -1
  20. package/src/layouts/BaseLayout.astro +6 -2
  21. package/src/patterns/Modal/modalController.ts +16 -0
  22. package/src/patterns/RewardCalendar/RewardCalendar.astro +174 -0
  23. package/src/patterns/RewardCalendar/RewardCalendar.css +406 -0
  24. package/src/patterns/RewardCalendar/RewardPreview.astro +18 -0
  25. package/src/patterns/RewardCalendar/contracts.ts +55 -0
  26. package/src/patterns/RewardCalendar/rewardCalendarController.ts +268 -0
  27. package/src/patterns/index.ts +3 -0
  28. package/src/styles/base.css +6 -3
  29. package/src/styles/style.css +2 -0
  30. package/src/styles/tokens.css +6 -0
  31. package/src/theme/christmas-garland.svg +28 -0
  32. package/src/theme/christmas.css +25 -0
  33. package/src/theme/contracts.ts +7 -3
  34. package/src/theme/halloween.css +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.5.13",
3
+ "version": "0.5.14",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -3,12 +3,14 @@ import type { HTMLAttributes } from 'astro/types';
3
3
  import DayIcon from '../../icons/DayIcon.astro';
4
4
  import NightIcon from '../../icons/NightIcon.astro';
5
5
  import PumpkinIcon from '../../icons/PumpkinIcon.astro';
6
+ import FirTreeIcon from '../../icons/FirTreeIcon.astro';
6
7
 
7
8
  type Props = HTMLAttributes<'button'> & {
8
9
  appearance?: 'circle' | 'utility';
9
10
  darkLabel: string;
10
11
  lightLabel: string;
11
12
  halloweenLabel?: string;
13
+ christmasLabel?: string;
12
14
  };
13
15
 
14
16
  const {
@@ -17,6 +19,7 @@ const {
17
19
  darkLabel,
18
20
  lightLabel,
19
21
  halloweenLabel,
22
+ christmasLabel,
20
23
  type = 'button',
21
24
  ...attrs
22
25
  }: Props = Astro.props;
@@ -33,6 +36,7 @@ const {
33
36
  data-theme-toggle-dark-label={darkLabel}
34
37
  data-theme-toggle-light-label={lightLabel}
35
38
  data-theme-toggle-halloween-label={halloweenLabel}
39
+ data-theme-toggle-christmas-label={christmasLabel}
36
40
  data-navigation-control={appearance === 'circle' ? '' : undefined}
37
41
  data-navigation-control-appearance={appearance === 'circle'
38
42
  ? 'bar'
@@ -59,6 +63,13 @@ const {
59
63
  >
60
64
  <PumpkinIcon />
61
65
  </span>
66
+ <span
67
+ class="theme-toggle__icon"
68
+ data-theme-toggle-target="christmas"
69
+ aria-hidden="true"
70
+ >
71
+ <FirTreeIcon />
72
+ </span>
62
73
  </button>
63
74
 
64
75
  <script>
@@ -58,7 +58,10 @@
58
58
  :root[data-next-theme='light'] .theme-toggle [data-theme-toggle-target='light'],
59
59
  :root[data-next-theme='halloween']
60
60
  .theme-toggle
61
- [data-theme-toggle-target='halloween'] {
61
+ [data-theme-toggle-target='halloween'],
62
+ :root[data-next-theme='christmas']
63
+ .theme-toggle
64
+ [data-theme-toggle-target='christmas'] {
62
65
  display: grid;
63
66
  }
64
67
 
@@ -13,6 +13,7 @@ export function initThemeToggle(toggle: HTMLElement): () => void {
13
13
  light: toggle.dataset.themeToggleLightLabel,
14
14
  dark: toggle.dataset.themeToggleDarkLabel,
15
15
  halloween: toggle.dataset.themeToggleHalloweenLabel,
16
+ christmas: toggle.dataset.themeToggleChristmasLabel,
16
17
  };
17
18
  const label = labels[runtime.next()];
18
19
  if (!label) return;
@@ -31,6 +31,7 @@ const {
31
31
  media,
32
32
  mediaPosition = 'start',
33
33
  mediaSize = 'default',
34
+ mediaTreatment = 'panel',
34
35
  meta,
35
36
  mode = 'link',
36
37
  textLayout = 'inline',
@@ -79,6 +80,7 @@ const resolvedTone = tone ?? dataTone;
79
80
  data-has-visual={hasVisual ? 'true' : 'false'}
80
81
  data-media-position={hasVisual ? mediaPosition : undefined}
81
82
  data-media-size={hasVisual ? mediaSize : undefined}
83
+ data-media-treatment={hasVisual ? mediaTreatment : undefined}
82
84
  data-mode={mode}
83
85
  data-text-treatment={textTreatment}
84
86
  data-tone={resolvedTone}
@@ -10,6 +10,8 @@
10
10
  --card-link-border-color: var(--surface-border-color);
11
11
  --card-link-hover-background: var(--nox-surface-strong);
12
12
  --card-link-active-background: var(--nox-border-muted);
13
+ --card-link-padding-block: var(--nox-space-2);
14
+ --card-link-padding-inline: var(--nox-space-4);
13
15
 
14
16
  container-type: inline-size;
15
17
  display: block;
@@ -94,7 +96,7 @@
94
96
  grid-template-rows: minmax(0, 1fr);
95
97
  align-items: center;
96
98
  box-sizing: border-box;
97
- padding: var(--nox-space-2) var(--nox-space-4);
99
+ padding: var(--card-link-padding-block) var(--card-link-padding-inline);
98
100
  overflow-wrap: anywhere;
99
101
  }
100
102
 
@@ -154,6 +156,11 @@
154
156
  );
155
157
  }
156
158
 
159
+ .card-link[data-media-size='large'] .card-link__layout {
160
+ --card-link-size: clamp(5rem, 8vw, 6rem);
161
+ --card-link-media-size: clamp(3rem, 5vw, 4rem);
162
+ }
163
+
157
164
  .card-link:is([data-media-size='compact'], [data-media-size='small'])
158
165
  .card-link__layout {
159
166
  --card-link-artwork-max-size: var(--card-link-resolved-media-size);
@@ -197,14 +204,51 @@
197
204
  --card-link-size: calc(var(--nox-navigation-block-size) + var(--nox-space-5));
198
205
  }
199
206
 
200
- .card-link[data-density='relaxed'] .card-link__content {
201
- padding: var(--nox-space-5) var(--nox-space-6);
207
+ .card-link[data-density='relaxed'] {
208
+ --card-link-padding-block: var(--nox-space-5);
209
+ --card-link-padding-inline: var(--nox-space-6);
202
210
  }
203
211
 
204
212
  .card-link[data-density='relaxed'] .card-link__text {
205
213
  gap: var(--nox-space-3);
206
214
  }
207
215
 
216
+ .card-link[data-media-treatment='inline'] .card-link__layout {
217
+ align-items: center;
218
+ column-gap: var(--nox-space-4);
219
+ padding: var(--card-link-padding-block) var(--card-link-padding-inline);
220
+ }
221
+
222
+ .card-link[data-media-treatment='inline'][data-media-position='start']
223
+ .card-link__layout {
224
+ grid-template-columns: var(--card-link-media-size) minmax(0, 1fr);
225
+ }
226
+
227
+ .card-link[data-media-treatment='inline'][data-media-position='end']
228
+ .card-link__layout {
229
+ grid-template-columns: minmax(0, 1fr) var(--card-link-media-size);
230
+ }
231
+
232
+ .card-link[data-media-treatment='inline'] .card-link__media {
233
+ min-height: 0;
234
+ border-inline: 0;
235
+ background: transparent;
236
+ }
237
+
238
+ .card-link[data-media-treatment='inline']
239
+ .card-link__media
240
+ [data-widget='media'] {
241
+ inline-size: var(--card-link-media-size);
242
+ block-size: var(--card-link-media-size);
243
+ --media-object-max-inline-size: 100%;
244
+ --media-object-max-block-size: 100%;
245
+ }
246
+
247
+ .card-link[data-media-treatment='inline'] .card-link__content {
248
+ min-block-size: 0;
249
+ padding: 0;
250
+ }
251
+
208
252
  .card-link__compact-text {
209
253
  display: none;
210
254
  min-width: 0;
@@ -304,8 +348,9 @@
304
348
  );
305
349
  }
306
350
 
307
- .card-link[data-density='relaxed'] .card-link__content {
308
- padding: var(--nox-space-4) var(--nox-space-5);
351
+ .card-link[data-density='relaxed'] {
352
+ --card-link-padding-block: var(--nox-space-4);
353
+ --card-link-padding-inline: var(--nox-space-5);
309
354
  }
310
355
 
311
356
  .card-link[data-compact-content] .card-link__layout {
@@ -1,4 +1,7 @@
1
1
  ---
2
+ import { createSnowfallStyles } from './snowfall';
3
+
4
+ const snowflakes = createSnowfallStyles(() => 0.5);
2
5
  const drips = [
3
6
  {
4
7
  x: 'calc(var(--nox-page-gutter) / 2)',
@@ -20,7 +23,12 @@ const drips = [
20
23
  ];
21
24
  ---
22
25
 
23
- <div class="seasonal-decorations" aria-hidden="true" inert>
26
+ <div
27
+ class="seasonal-decorations"
28
+ data-seasonal-decoration="halloween"
29
+ aria-hidden="true"
30
+ inert
31
+ >
24
32
  {
25
33
  drips.map(({ x, length, delay, edge }) => (
26
34
  <div
@@ -58,3 +66,40 @@ const drips = [
58
66
  ))
59
67
  }
60
68
  </div>
69
+
70
+ <div
71
+ class="seasonal-decorations"
72
+ data-seasonal-decoration="christmas"
73
+ aria-hidden="true"
74
+ inert
75
+ >
76
+ {
77
+ snowflakes.map((style) => (
78
+ <span class="seasonal-decorations__snow" style={style} />
79
+ ))
80
+ }
81
+ </div>
82
+
83
+ <script>
84
+ import { createSnowfallStyles } from './snowfall';
85
+
86
+ const initialized = new WeakSet<HTMLElement>();
87
+
88
+ const initializeSnowfall = (): void => {
89
+ for (const decoration of document.querySelectorAll<HTMLElement>(
90
+ '[data-seasonal-decoration="christmas"]',
91
+ )) {
92
+ if (initialized.has(decoration)) continue;
93
+ initialized.add(decoration);
94
+ const styles = createSnowfallStyles();
95
+ decoration
96
+ .querySelectorAll<HTMLElement>('.seasonal-decorations__snow')
97
+ .forEach((flake, index) => {
98
+ flake.style.cssText = styles[index];
99
+ });
100
+ }
101
+ };
102
+
103
+ initializeSnowfall();
104
+ document.addEventListener('astro:page-load', initializeSnowfall);
105
+ </script>
@@ -18,7 +18,12 @@
18
18
  [data-theme-decorations='static'],
19
19
  [data-theme-decorations='animated']
20
20
  )
21
- .seasonal-decorations {
21
+ .seasonal-decorations[data-seasonal-decoration='halloween'],
22
+ :root[data-appearance='christmas']:is(
23
+ [data-theme-decorations='static'],
24
+ [data-theme-decorations='animated']
25
+ )
26
+ .seasonal-decorations[data-seasonal-decoration='christmas'] {
22
27
  display: block;
23
28
  }
24
29
 
@@ -132,3 +137,48 @@
132
137
  transform: translateY(min(65vh, 30rem)) scale(0.75, 1.4);
133
138
  }
134
139
  }
140
+
141
+ .seasonal-decorations__snow {
142
+ position: absolute;
143
+ inset-inline-start: var(--snow-x);
144
+ inset-block-start: var(--snow-y);
145
+ inline-size: var(--snow-size);
146
+ block-size: var(--snow-size);
147
+ border-radius: 50%;
148
+ background: var(--nox-seasonal-snow);
149
+ opacity: 0.65;
150
+ }
151
+
152
+ @media (width < 48rem) {
153
+ .seasonal-decorations__snow:nth-child(even) {
154
+ display: none;
155
+ }
156
+ }
157
+
158
+ @media (prefers-reduced-motion: no-preference) {
159
+ :root[data-appearance='christmas'][data-theme-decorations='animated']
160
+ .seasonal-decorations__snow {
161
+ inset-block-start: -1rem;
162
+ animation: seasonal-snow-fall var(--snow-duration) linear var(--snow-delay)
163
+ infinite;
164
+ animation-play-state: var(--seasonal-animation-state, running);
165
+ }
166
+ }
167
+
168
+ @keyframes seasonal-snow-fall {
169
+ 0% {
170
+ opacity: 0;
171
+ transform: translate(0, 0);
172
+ }
173
+ 3%,
174
+ 97% {
175
+ opacity: 0.8;
176
+ }
177
+ 100% {
178
+ opacity: 0;
179
+ transform: translate(
180
+ var(--snow-drift),
181
+ calc(100vh - var(--seasonal-decoration-top) + 2rem)
182
+ );
183
+ }
184
+ }
@@ -0,0 +1,38 @@
1
+ const columns = 8;
2
+ const flakesPerColumn = 8;
3
+
4
+ export function createSnowfallStyles(
5
+ random: () => number = Math.random,
6
+ ): string[] {
7
+ const lanes = Array.from({ length: columns }, () => ({
8
+ duration: 8 + random() * 6,
9
+ offset: random(),
10
+ horizontalOffset: Math.floor(random() * (flakesPerColumn / 2)) * 2,
11
+ }));
12
+
13
+ return Array.from({ length: columns * flakesPerColumn }, (_, index) => {
14
+ const pair = Math.floor(index / 2);
15
+ const column = pair % columns;
16
+ // Interleaving two layers keeps all columns covered when mobile hides one.
17
+ const row = Math.floor(pair / columns) * 2 + (index % 2);
18
+ const lane = lanes[column];
19
+ const phase =
20
+ (lane.offset + (row + 0.2 + random() * 0.6) / flakesPerColumn) % 1;
21
+ const slot = (row * 3 + lane.horizontalOffset) % flakesPerColumn;
22
+ const localX = (slot + 0.2 + random() * 0.6) / flakesPerColumn;
23
+ // Mirror alternating lanes so mobile also keeps flakes near both edges.
24
+ const x = ((column + (column % 2 ? 1 - localX : localX)) / columns) * 100;
25
+ const driftLimit = Math.min(10 / columns, x / 2, (100 - x) / 2);
26
+ const drift = (random() * 2 - 1) * driftLimit;
27
+
28
+ // Shared speed within each lane preserves the spacing across every loop.
29
+ return [
30
+ `--snow-x: ${x.toFixed(3)}%`,
31
+ `--snow-y: ${(phase * 100).toFixed(3)}%`,
32
+ `--snow-size: ${2 + Math.floor(random() * 4)}px`,
33
+ `--snow-duration: ${lane.duration.toFixed(3)}s`,
34
+ `--snow-delay: ${(-phase * lane.duration).toFixed(3)}s`,
35
+ `--snow-drift: ${drift.toFixed(3)}vw`,
36
+ ].join('; ');
37
+ });
38
+ }
@@ -4,17 +4,14 @@ import type { SeasonalThemeConfig } from '../../../theme/contracts';
4
4
 
5
5
  type Props = {
6
6
  storageKey?: string;
7
- seasonalTheme?: SeasonalThemeConfig;
7
+ seasonalThemes?: readonly SeasonalThemeConfig[];
8
8
  };
9
9
 
10
- const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
10
+ const { storageKey = defaultThemeStorageKey, seasonalThemes = [] }: Props =
11
11
  Astro.props;
12
12
  ---
13
13
 
14
- <script
15
- is:inline
16
- define:vars={{ storageKey, seasonalTheme: seasonalTheme ?? null }}
17
- >
14
+ <script is:inline define:vars={{ storageKey, seasonalThemes }}>
18
15
  (() => {
19
16
  window.__astronoxTheme?.destroy();
20
17
  const root = document.documentElement;
@@ -22,12 +19,13 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
22
19
  const media = window.matchMedia?.('(prefers-color-scheme: dark)');
23
20
  let choices = ['light', 'dark'];
24
21
  let activeEventId = null;
22
+ let activeTheme = null;
25
23
  let boundaryTimeout;
26
24
 
27
- const resolveSeason = (now) => {
28
- if (!seasonalTheme) return { id: null, boundary: null };
29
- const period = seasonalTheme.annualPeriod;
30
- if (!period) return { id: seasonalTheme.id, boundary: null };
25
+ const resolveSeason = (season, now) => {
26
+ if (season.mode === 'disabled') return { id: null, boundary: null };
27
+ if (season.mode === 'always') return { id: season.id, boundary: null };
28
+ const { period } = season;
31
29
 
32
30
  const spansNewYear =
33
31
  period.start.month * 100 + period.start.day >
@@ -47,7 +45,7 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
47
45
  if (now < start) return { id: null, boundary: start.getTime() };
48
46
  if (now < end) {
49
47
  return {
50
- id: `${seasonalTheme.id}-${startYear}`,
48
+ id: `${season.id}-${startYear}`,
51
49
  boundary: end.getTime(),
52
50
  };
53
51
  }
@@ -76,7 +74,12 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
76
74
  const readSeasonal = () => {
77
75
  if (!activeEventId) return null;
78
76
  try {
79
- const value = JSON.parse(read(seasonalKey) ?? 'null');
77
+ // Migrate the original single-season preference without resetting Halloween.
78
+ const value = JSON.parse(
79
+ read(`${seasonalKey}:${activeEventId}`) ??
80
+ read(seasonalKey) ??
81
+ 'null',
82
+ );
80
83
  if (
81
84
  value?.id === activeEventId &&
82
85
  (value.choice === 'seasonal' || value.choice === 'classic')
@@ -90,7 +93,10 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
90
93
  };
91
94
  const saveSeasonal = (choice) => {
92
95
  if (!activeEventId) return;
93
- write(seasonalKey, JSON.stringify({ id: activeEventId, choice }));
96
+ write(
97
+ `${seasonalKey}:${activeEventId}`,
98
+ JSON.stringify({ id: activeEventId, choice }),
99
+ );
94
100
  };
95
101
 
96
102
  let classic = readClassic();
@@ -104,14 +110,14 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
104
110
  const next = () => choices[(choices.indexOf(current) + 1) % choices.length];
105
111
  const resolve = () =>
106
112
  activeEventId && seasonal === 'seasonal'
107
- ? seasonalTheme.appearance
113
+ ? activeTheme.appearance
108
114
  : (classic ?? (media?.matches ? 'dark' : 'light'));
109
115
  const apply = () => {
110
116
  const appearance = resolve();
111
117
  const appearanceChanged = current !== appearance;
112
118
  const theme = appearance === 'light' ? 'light' : 'dark';
113
119
  const decorations = activeEventId
114
- ? (seasonalTheme.decorations ?? 'none')
120
+ ? (activeTheme.decorations ?? 'none')
115
121
  : 'none';
116
122
  if (appearanceChanged) root.dataset.themeSwitching = 'true';
117
123
  root.dataset.theme = theme;
@@ -141,9 +147,17 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
141
147
  };
142
148
  const reconcile = () => {
143
149
  const now = new Date();
144
- const season = resolveSeason(now);
145
- if (activeEventId !== season.id) {
146
- activeEventId = season.id;
150
+ const seasons = seasonalThemes.map((theme) => ({
151
+ ...resolveSeason(theme, now),
152
+ theme,
153
+ }));
154
+ const season = seasons
155
+ .filter((candidate) => candidate.id !== null)
156
+ .sort((left, right) => right.theme.priority - left.theme.priority)[0];
157
+ const eventId = season?.id ?? null;
158
+ activeTheme = season?.theme ?? null;
159
+ if (activeEventId !== eventId) {
160
+ activeEventId = eventId;
147
161
  seasonal = readSeasonal();
148
162
  }
149
163
  if (activeEventId && seasonal === null) {
@@ -151,15 +165,25 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
151
165
  saveSeasonal(seasonal);
152
166
  }
153
167
  choices = activeEventId
154
- ? ['light', 'dark', seasonalTheme.appearance]
168
+ ? ['light', 'dark', activeTheme.appearance]
155
169
  : ['light', 'dark'];
156
170
  clearTimeout(boundaryTimeout);
157
- if (season.boundary !== null) {
158
- boundaryTimeout = setTimeout(
159
- reconcile,
160
- Math.min(season.boundary - now.getTime(), 2_147_483_647),
161
- );
162
- }
171
+ // A daily reconciliation also catches a timezone change on an open page.
172
+ const midnight = new Date(
173
+ now.getFullYear(),
174
+ now.getMonth(),
175
+ now.getDate() + 1,
176
+ );
177
+ const boundary = Math.min(
178
+ midnight.getTime(),
179
+ ...seasons.flatMap((candidate) =>
180
+ candidate.boundary === null ? [] : [candidate.boundary],
181
+ ),
182
+ );
183
+ boundaryTimeout = setTimeout(
184
+ reconcile,
185
+ Math.max(1, boundary - now.getTime()),
186
+ );
163
187
  apply();
164
188
  };
165
189
  const cycle = () => {
@@ -179,7 +203,8 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
179
203
  if (
180
204
  event.key !== null &&
181
205
  event.key !== storageKey &&
182
- event.key !== seasonalKey
206
+ event.key !== seasonalKey &&
207
+ !event.key.startsWith(`${seasonalKey}:`)
183
208
  ) {
184
209
  return;
185
210
  }
@@ -1,9 +1,9 @@
1
1
  .dollar-icon {
2
2
  display: block;
3
- color: var(--nox-source-tone-success);
3
+ color: var(--nox-currency-icon-color, var(--nox-source-tone-success));
4
4
  }
5
5
 
6
6
  .ribbon-icon {
7
7
  display: block;
8
- color: var(--nox-source-tone-warning);
8
+ color: var(--nox-currency-icon-color, var(--nox-source-tone-warning));
9
9
  }
@@ -0,0 +1,32 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+
4
+ type Props = HTMLAttributes<'svg'>;
5
+ const {
6
+ width = 32,
7
+ height = 32,
8
+ 'aria-hidden': ariaHidden = 'true',
9
+ focusable = 'false',
10
+ ...attrs
11
+ }: Props = Astro.props;
12
+ ---
13
+
14
+ <svg
15
+ {...attrs}
16
+ width={width}
17
+ height={height}
18
+ viewBox="0 0 32 32"
19
+ fill="none"
20
+ aria-hidden={ariaHidden}
21
+ focusable={focusable}
22
+ >
23
+ <g
24
+ stroke="currentColor"
25
+ stroke-width="1.5"
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ >
29
+ <path d="M16 6L10 13H13L8 19H12L6.5 24H25.5L20 19H24L19 13H22Z"></path>
30
+ <path d="M16 24V27"></path>
31
+ </g>
32
+ </svg>
@@ -0,0 +1,34 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+
4
+ type Props = HTMLAttributes<'svg'>;
5
+ const {
6
+ width = 32,
7
+ height = 32,
8
+ 'aria-hidden': ariaHidden = 'true',
9
+ focusable = 'false',
10
+ ...attrs
11
+ }: Props = Astro.props;
12
+ ---
13
+
14
+ <svg
15
+ {...attrs}
16
+ width={width}
17
+ height={height}
18
+ viewBox="0 0 32 32"
19
+ fill="none"
20
+ aria-hidden={ariaHidden}
21
+ focusable={focusable}
22
+ >
23
+ <g
24
+ stroke="currentColor"
25
+ stroke-width="1.5"
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ >
29
+ <path d="M5 12H27V17H5ZM7 17V27H25V17M16 12V27"></path>
30
+ <path
31
+ d="M16 12C10 12 7 10 9 7C11 4 16 8 16 12ZM16 12C22 12 25 10 23 7C21 4 16 8 16 12Z"
32
+ ></path>
33
+ </g>
34
+ </svg>
@@ -95,6 +95,8 @@ export { default as MinusIcon } from './icons/MinusIcon.astro';
95
95
  export { default as MountainIcon } from './icons/MountainIcon.astro';
96
96
  export { default as NightIcon } from './icons/NightIcon.astro';
97
97
  export { default as PumpkinIcon } from './icons/PumpkinIcon.astro';
98
+ export { default as FirTreeIcon } from './icons/FirTreeIcon.astro';
99
+ export { default as GiftIcon } from './icons/GiftIcon.astro';
98
100
  export { default as PlayIcon } from './icons/PlayIcon.astro';
99
101
  export { default as PlusIcon } from './icons/PlusIcon.astro';
100
102
  export { default as PoisonIcon } from './icons/PoisonIcon.astro';
@@ -19,7 +19,7 @@
19
19
  position: sticky;
20
20
  }
21
21
 
22
- :root[data-appearance='halloween']:is(
22
+ :root:is([data-appearance='halloween'], [data-appearance='christmas']):is(
23
23
  [data-theme-decorations='static'],
24
24
  [data-theme-decorations='animated']
25
25
  )
@@ -30,7 +30,7 @@
30
30
  --dropdown-attached-border-top-width: var(--site-header-menu-border-width);
31
31
  }
32
32
 
33
- :root[data-appearance='halloween']:is(
33
+ :root:is([data-appearance='halloween'], [data-appearance='christmas']):is(
34
34
  [data-theme-decorations='static'],
35
35
  [data-theme-decorations='animated']
36
36
  )
@@ -39,7 +39,26 @@
39
39
  inset-inline: 0;
40
40
  inset-block-start: 100%;
41
41
  block-size: var(--nox-seasonal-border-size);
42
- background: var(--nox-seasonal-blood);
42
+ background: var(--nox-seasonal-header-border);
43
+ content: '';
44
+ pointer-events: none;
45
+ }
46
+
47
+ :root[data-appearance='christmas']:is(
48
+ [data-theme-decorations='static'],
49
+ [data-theme-decorations='animated']
50
+ )
51
+ .site-header::before {
52
+ position: absolute;
53
+ /* Decorations stay behind every navigation menu in the header context. */
54
+ z-index: -1;
55
+ inset-inline: 0;
56
+ inset-block-start: calc(100% + 1px);
57
+ block-size: var(--nox-seasonal-header-ornament-size);
58
+ background-image: var(--nox-seasonal-header-ornament);
59
+ /* Repeat whole motifs so neither edge cuts through a hanging bulb. */
60
+ background-repeat: round no-repeat;
61
+ background-size: 144px 100%;
43
62
  content: '';
44
63
  pointer-events: none;
45
64
  }