@astronox/core 0.5.12 → 0.5.13

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -20,9 +20,41 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
20
20
  const root = document.documentElement;
21
21
  const seasonalKey = `${storageKey}:seasonal`;
22
22
  const media = window.matchMedia?.('(prefers-color-scheme: dark)');
23
- const choices = seasonalTheme
24
- ? ['light', 'dark', seasonalTheme.appearance]
25
- : ['light', 'dark'];
23
+ let choices = ['light', 'dark'];
24
+ let activeEventId = null;
25
+ let boundaryTimeout;
26
+
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 };
31
+
32
+ const spansNewYear =
33
+ period.start.month * 100 + period.start.day >
34
+ period.end.month * 100 + period.end.day;
35
+ const year = now.getFullYear();
36
+ for (const startYear of [year - 1, year, year + 1]) {
37
+ const start = new Date(
38
+ startYear,
39
+ period.start.month - 1,
40
+ period.start.day,
41
+ );
42
+ const end = new Date(
43
+ startYear + Number(spansNewYear),
44
+ period.end.month - 1,
45
+ period.end.day,
46
+ );
47
+ if (now < start) return { id: null, boundary: start.getTime() };
48
+ if (now < end) {
49
+ return {
50
+ id: `${seasonalTheme.id}-${startYear}`,
51
+ boundary: end.getTime(),
52
+ };
53
+ }
54
+ }
55
+ return { id: null, boundary: null };
56
+ };
57
+
26
58
  const read = (key) => {
27
59
  try {
28
60
  return window.localStorage.getItem(key);
@@ -42,11 +74,11 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
42
74
  return value === 'light' || value === 'dark' ? value : null;
43
75
  };
44
76
  const readSeasonal = () => {
45
- if (!seasonalTheme) return null;
77
+ if (!activeEventId) return null;
46
78
  try {
47
79
  const value = JSON.parse(read(seasonalKey) ?? 'null');
48
80
  if (
49
- value?.id === seasonalTheme.id &&
81
+ value?.id === activeEventId &&
50
82
  (value.choice === 'seasonal' || value.choice === 'classic')
51
83
  ) {
52
84
  return value.choice;
@@ -57,44 +89,81 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
57
89
  return null;
58
90
  };
59
91
  const saveSeasonal = (choice) => {
60
- if (!seasonalTheme) return;
61
- write(seasonalKey, JSON.stringify({ id: seasonalTheme.id, choice }));
92
+ if (!activeEventId) return;
93
+ write(seasonalKey, JSON.stringify({ id: activeEventId, choice }));
62
94
  };
63
95
 
64
96
  let classic = readClassic();
65
- let seasonal = readSeasonal();
97
+ let seasonal = null;
66
98
  let current;
67
- if (seasonalTheme && seasonal === null) {
68
- seasonal = 'seasonal';
69
- saveSeasonal(seasonal);
70
- }
71
99
 
72
100
  const handleVisibility = () => {
73
101
  root.toggleAttribute('data-theme-animation-paused', document.hidden);
102
+ if (!document.hidden) reconcile();
74
103
  };
75
104
  const next = () => choices[(choices.indexOf(current) + 1) % choices.length];
76
105
  const resolve = () =>
77
- seasonalTheme && seasonal === 'seasonal'
106
+ activeEventId && seasonal === 'seasonal'
78
107
  ? seasonalTheme.appearance
79
108
  : (classic ?? (media?.matches ? 'dark' : 'light'));
80
109
  const apply = () => {
81
110
  const appearance = resolve();
82
- if (current === appearance) return;
111
+ const appearanceChanged = current !== appearance;
83
112
  const theme = appearance === 'light' ? 'light' : 'dark';
84
- root.dataset.themeSwitching = 'true';
113
+ const decorations = activeEventId
114
+ ? (seasonalTheme.decorations ?? 'none')
115
+ : 'none';
116
+ if (appearanceChanged) root.dataset.themeSwitching = 'true';
85
117
  root.dataset.theme = theme;
86
118
  root.dataset.appearance = appearance;
87
119
  current = appearance;
88
- root.dataset.nextTheme = next();
89
- void root.offsetWidth;
90
- delete root.dataset.themeSwitching;
91
- document.dispatchEvent(
92
- new CustomEvent('astronox:theme-change', {
93
- detail: { theme, appearance },
94
- }),
120
+ const nextTheme = next();
121
+ const controlsChanged =
122
+ root.dataset.nextTheme !== nextTheme ||
123
+ root.dataset.themeDecorations !== decorations;
124
+ root.dataset.nextTheme = nextTheme;
125
+ root.dataset.themeDecorations = decorations;
126
+ root.toggleAttribute(
127
+ 'data-theme-animation-paused',
128
+ document.hidden && decorations === 'animated',
95
129
  );
130
+ if (appearanceChanged) {
131
+ void root.offsetWidth;
132
+ delete root.dataset.themeSwitching;
133
+ }
134
+ if (appearanceChanged || controlsChanged) {
135
+ document.dispatchEvent(
136
+ new CustomEvent('astronox:theme-change', {
137
+ detail: { theme, appearance },
138
+ }),
139
+ );
140
+ }
141
+ };
142
+ const reconcile = () => {
143
+ const now = new Date();
144
+ const season = resolveSeason(now);
145
+ if (activeEventId !== season.id) {
146
+ activeEventId = season.id;
147
+ seasonal = readSeasonal();
148
+ }
149
+ if (activeEventId && seasonal === null) {
150
+ seasonal = 'seasonal';
151
+ saveSeasonal(seasonal);
152
+ }
153
+ choices = activeEventId
154
+ ? ['light', 'dark', seasonalTheme.appearance]
155
+ : ['light', 'dark'];
156
+ 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
+ }
163
+ apply();
96
164
  };
97
165
  const cycle = () => {
166
+ reconcile();
98
167
  const appearance = next();
99
168
  if (appearance === 'light' || appearance === 'dark') {
100
169
  classic = appearance;
@@ -106,7 +175,6 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
106
175
  saveSeasonal(seasonal);
107
176
  apply();
108
177
  };
109
- const handleSystem = () => apply();
110
178
  const handleStorage = (event) => {
111
179
  if (
112
180
  event.key !== null &&
@@ -117,31 +185,28 @@ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
117
185
  }
118
186
  classic = readClassic();
119
187
  seasonal = readSeasonal();
120
- if (seasonalTheme && seasonal === null) {
121
- seasonal = 'seasonal';
122
- saveSeasonal(seasonal);
123
- }
124
- apply();
188
+ reconcile();
125
189
  };
126
190
 
127
191
  root.dataset.themeStorageKey = storageKey;
128
- root.dataset.themeDecorations = seasonalTheme?.decorations ?? 'none';
129
192
  window.__astronoxTheme = {
130
193
  next,
131
194
  cycle,
132
195
  destroy: () => {
133
- media?.removeEventListener('change', handleSystem);
196
+ clearTimeout(boundaryTimeout);
197
+ media?.removeEventListener('change', reconcile);
134
198
  document.removeEventListener('visibilitychange', handleVisibility);
135
199
  delete root.dataset.themeAnimationPaused;
136
200
  window.removeEventListener('storage', handleStorage);
201
+ window.removeEventListener('focus', reconcile);
202
+ window.removeEventListener('pageshow', reconcile);
137
203
  },
138
204
  };
139
- media?.addEventListener('change', handleSystem);
140
- if (seasonalTheme?.decorations === 'animated') {
141
- document.addEventListener('visibilitychange', handleVisibility);
142
- handleVisibility();
143
- }
205
+ media?.addEventListener('change', reconcile);
206
+ document.addEventListener('visibilitychange', handleVisibility);
144
207
  window.addEventListener('storage', handleStorage);
145
- apply();
208
+ window.addEventListener('focus', reconcile);
209
+ window.addEventListener('pageshow', reconcile);
210
+ reconcile();
146
211
  })();
147
212
  </script>
@@ -81,6 +81,30 @@ export function defineSiteConfig<const TConfig extends SiteConfig>(
81
81
  throw new Error('A seasonal theme requires a non-empty event id.');
82
82
  }
83
83
 
84
+ const period = config.seasonalTheme?.annualPeriod;
85
+ if (period) {
86
+ for (const { month, day } of [period.start, period.end]) {
87
+ const date = new Date(Date.UTC(2001, month - 1, day));
88
+ if (
89
+ !Number.isInteger(month) ||
90
+ !Number.isInteger(day) ||
91
+ date.getUTCFullYear() !== 2001 ||
92
+ date.getUTCMonth() !== month - 1 ||
93
+ date.getUTCDate() !== day
94
+ ) {
95
+ throw new Error(
96
+ 'Annual theme periods require month/day dates valid every year.',
97
+ );
98
+ }
99
+ }
100
+ if (
101
+ period.start.month === period.end.month &&
102
+ period.start.day === period.end.day
103
+ ) {
104
+ throw new Error('An annual theme period requires distinct boundaries.');
105
+ }
106
+ }
107
+
84
108
  const localeIds = new Set<string>();
85
109
  const urlCodes = new Set<string>();
86
110
 
@@ -7,8 +7,16 @@ export type SeasonalAppearance = 'halloween';
7
7
 
8
8
  export type ThemeAppearance = ColorTheme | SeasonalAppearance;
9
9
 
10
+ export type AnnualThemePeriod = {
11
+ /** Inclusive local date, with months numbered from 1 to 12. */
12
+ start: { month: number; day: number };
13
+ /** Exclusive local date, possibly in the following year. */
14
+ end: { month: number; day: number };
15
+ };
16
+
10
17
  export type SeasonalThemeConfig = {
11
18
  id: string;
12
19
  appearance: SeasonalAppearance;
13
20
  decorations?: 'static' | 'animated';
21
+ annualPeriod?: AnnualThemePeriod;
14
22
  };