@astronox/core 0.5.10 → 0.5.12

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 (32) 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 +7 -6
  4. package/src/components/actions/ThemeToggle/themeToggleController.ts +17 -98
  5. package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.astro +60 -0
  6. package/src/components/feedback/SeasonalDecorations/SeasonalDecorations.css +134 -0
  7. package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +129 -13
  8. package/src/components/icons/DayIcon.astro +9 -5
  9. package/src/components/icons/NightIcon.astro +5 -3
  10. package/src/components/icons/PumpkinIcon.astro +37 -0
  11. package/src/components/index.ts +1 -0
  12. package/src/components/navigation/Footer/Footer.astro +3 -0
  13. package/src/components/navigation/Footer/Footer.css +12 -1
  14. package/src/components/navigation/Header.css +30 -1
  15. package/src/components/navigation/MobileNavigation.css +12 -5
  16. package/src/components/navigation/NavigationBrand.astro +18 -0
  17. package/src/components/navigation/NavigationBrand.css +31 -0
  18. package/src/components/navigation/NavigationControl.css +1 -1
  19. package/src/components/navigation/SubNavigation/SubNavigation.css +1 -1
  20. package/src/components/seo/SeoHead.astro +18 -8
  21. package/src/config/siteConfig.ts +8 -2
  22. package/src/layouts/BaseLayout.astro +6 -1
  23. package/src/patterns/Dropdown/Dropdown.css +11 -2
  24. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +2 -2
  25. package/src/patterns/StageFlow/StageFlowStage.css +11 -5
  26. package/src/styles/base.css +13 -0
  27. package/src/styles/interaction.css +4 -1
  28. package/src/styles/style.css +3 -0
  29. package/src/styles/tokens.css +4 -0
  30. package/src/theme/contracts.ts +10 -0
  31. package/src/theme/halloween.css +20 -0
  32. package/src/theme/runtime.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -2,11 +2,13 @@
2
2
  import type { HTMLAttributes } from 'astro/types';
3
3
  import DayIcon from '../../icons/DayIcon.astro';
4
4
  import NightIcon from '../../icons/NightIcon.astro';
5
+ import PumpkinIcon from '../../icons/PumpkinIcon.astro';
5
6
 
6
7
  type Props = HTMLAttributes<'button'> & {
7
8
  appearance?: 'circle' | 'utility';
8
9
  darkLabel: string;
9
10
  lightLabel: string;
11
+ halloweenLabel?: string;
10
12
  };
11
13
 
12
14
  const {
@@ -14,6 +16,7 @@ const {
14
16
  class: className,
15
17
  darkLabel,
16
18
  lightLabel,
19
+ halloweenLabel,
17
20
  type = 'button',
18
21
  ...attrs
19
22
  }: Props = Astro.props;
@@ -29,6 +32,7 @@ const {
29
32
  data-theme-toggle-appearance={appearance}
30
33
  data-theme-toggle-dark-label={darkLabel}
31
34
  data-theme-toggle-light-label={lightLabel}
35
+ data-theme-toggle-halloween-label={halloweenLabel}
32
36
  data-navigation-control={appearance === 'circle' ? '' : undefined}
33
37
  data-navigation-control-appearance={appearance === 'circle'
34
38
  ? 'bar'
@@ -48,6 +52,13 @@ const {
48
52
  >
49
53
  <DayIcon />
50
54
  </span>
55
+ <span
56
+ class="theme-toggle__icon"
57
+ data-theme-toggle-target="halloween"
58
+ aria-hidden="true"
59
+ >
60
+ <PumpkinIcon />
61
+ </span>
51
62
  </button>
52
63
 
53
64
  <script>
@@ -49,15 +49,16 @@
49
49
  block-size: 100%;
50
50
  }
51
51
 
52
- .theme-toggle [data-theme-toggle-target='light'] {
52
+ .theme-toggle [data-theme-toggle-target] {
53
53
  display: none;
54
54
  }
55
55
 
56
- :root[data-theme='dark'] .theme-toggle [data-theme-toggle-target='dark'] {
57
- display: none;
58
- }
59
-
60
- :root[data-theme='dark'] .theme-toggle [data-theme-toggle-target='light'] {
56
+ :root:not([data-next-theme]) .theme-toggle [data-theme-toggle-target='dark'],
57
+ :root[data-next-theme='dark'] .theme-toggle [data-theme-toggle-target='dark'],
58
+ :root[data-next-theme='light'] .theme-toggle [data-theme-toggle-target='light'],
59
+ :root[data-next-theme='halloween']
60
+ .theme-toggle
61
+ [data-theme-toggle-target='halloween'] {
61
62
  display: grid;
62
63
  }
63
64
 
@@ -1,114 +1,33 @@
1
- import type { ColorTheme } from '../../../theme/contracts';
2
- import { defaultThemeStorageKey } from '../../../theme/storage';
1
+ import type { ThemeRuntime } from '../../../theme/runtime';
3
2
 
4
3
  const initializedToggles = new WeakSet<HTMLElement>();
5
- const systemThemeQuery = '(prefers-color-scheme: dark)';
6
- let globalListenersInitialized = false;
7
-
8
- function isColorTheme(value: string | null): value is ColorTheme {
9
- return value === 'light' || value === 'dark';
10
- }
11
-
12
- function getStorageKey(): string {
13
- return (
14
- document.documentElement.dataset.themeStorageKey ?? defaultThemeStorageKey
15
- );
16
- }
17
-
18
- function readStoredTheme(): ColorTheme | null {
19
- try {
20
- const value = window.localStorage.getItem(getStorageKey());
21
- return isColorTheme(value) ? value : null;
22
- } catch {
23
- return null;
24
- }
25
- }
26
-
27
- function getSystemTheme(): ColorTheme {
28
- return window.matchMedia(systemThemeQuery).matches ? 'dark' : 'light';
29
- }
30
-
31
- function getCurrentTheme(): ColorTheme {
32
- const value = document.documentElement.dataset.theme ?? null;
33
- return isColorTheme(value) ? value : (readStoredTheme() ?? getSystemTheme());
34
- }
35
-
36
- function updateToggle(toggle: HTMLElement, theme: ColorTheme): void {
37
- const label =
38
- theme === 'light'
39
- ? toggle.dataset.themeToggleDarkLabel
40
- : toggle.dataset.themeToggleLightLabel;
41
-
42
- if (label) {
43
- toggle.setAttribute('aria-label', label);
44
- toggle.setAttribute('title', label);
45
- }
46
- }
47
-
48
- function updateToggles(theme: ColorTheme): void {
49
- for (const toggle of document.querySelectorAll<HTMLElement>(
50
- '[data-theme-toggle]',
51
- )) {
52
- updateToggle(toggle, theme);
53
- }
54
- }
55
-
56
- function applyTheme(theme: ColorTheme, persist: boolean): void {
57
- const root = document.documentElement;
58
- root.dataset.themeSwitching = 'true';
59
- root.dataset.theme = theme;
60
- void root.offsetWidth;
61
- delete root.dataset.themeSwitching;
62
-
63
- if (persist) {
64
- try {
65
- window.localStorage.setItem(getStorageKey(), theme);
66
- } catch {
67
- // The selected theme remains active for the current document.
68
- }
69
- }
70
-
71
- updateToggles(theme);
72
- document.dispatchEvent(
73
- new CustomEvent('astronox:theme-change', { detail: { theme } }),
74
- );
75
- }
76
-
77
- function initializeGlobalListeners(): void {
78
- if (globalListenersInitialized) return;
79
- globalListenersInitialized = true;
80
-
81
- const media = window.matchMedia(systemThemeQuery);
82
- media.addEventListener('change', (event) => {
83
- if (readStoredTheme() === null) {
84
- applyTheme(event.matches ? 'dark' : 'light', false);
85
- }
86
- });
87
-
88
- window.addEventListener('storage', (event) => {
89
- if (event.key !== getStorageKey()) return;
90
-
91
- const theme = isColorTheme(event.newValue)
92
- ? event.newValue
93
- : getSystemTheme();
94
- applyTheme(theme, false);
95
- });
96
- }
97
4
 
98
5
  export function initThemeToggle(toggle: HTMLElement): () => void {
99
6
  if (initializedToggles.has(toggle)) return () => {};
7
+ const runtime: ThemeRuntime | undefined = window.__astronoxTheme;
8
+ if (!runtime) return () => {};
100
9
  initializedToggles.add(toggle);
101
- initializeGlobalListeners();
102
- updateToggle(toggle, getCurrentTheme());
103
10
 
104
- const handleClick = (): void => {
105
- applyTheme(getCurrentTheme() === 'light' ? 'dark' : 'light', true);
11
+ const update = (): void => {
12
+ const labels = {
13
+ light: toggle.dataset.themeToggleLightLabel,
14
+ dark: toggle.dataset.themeToggleDarkLabel,
15
+ halloween: toggle.dataset.themeToggleHalloweenLabel,
16
+ };
17
+ const label = labels[runtime.next()];
18
+ if (!label) return;
19
+ toggle.setAttribute('aria-label', label);
20
+ toggle.setAttribute('title', label);
106
21
  };
22
+ const handleClick = (): void => runtime.cycle();
107
23
 
24
+ update();
108
25
  toggle.addEventListener('click', handleClick);
26
+ document.addEventListener('astronox:theme-change', update);
109
27
 
110
28
  return () => {
111
29
  toggle.removeEventListener('click', handleClick);
30
+ document.removeEventListener('astronox:theme-change', update);
112
31
  initializedToggles.delete(toggle);
113
32
  };
114
33
  }
@@ -0,0 +1,60 @@
1
+ ---
2
+ const drips = [
3
+ {
4
+ x: 'calc(var(--nox-page-gutter) / 2)',
5
+ length: 34,
6
+ delay: 0,
7
+ edge: true,
8
+ },
9
+ { x: '14%', length: 22, delay: 2000, edge: false },
10
+ { x: '29%', length: 39, delay: 6000, edge: false },
11
+ { x: '47%', length: 26, delay: 10000, edge: false },
12
+ { x: '66%', length: 32, delay: 4000, edge: false },
13
+ { x: '83%', length: 20, delay: 8000, edge: false },
14
+ {
15
+ x: 'calc(100% - var(--nox-page-gutter) / 2)',
16
+ length: 42,
17
+ delay: 4000,
18
+ edge: true,
19
+ },
20
+ ];
21
+ ---
22
+
23
+ <div class="seasonal-decorations" aria-hidden="true" inert>
24
+ {
25
+ drips.map(({ x, length, delay, edge }) => (
26
+ <div
27
+ class="seasonal-decorations__drip"
28
+ data-drip-edge={edge ? '' : undefined}
29
+ style={`--drip-x: ${x}; --drip-length: ${length}px; --drip-delay: ${delay}ms;`}
30
+ >
31
+ <svg
32
+ viewBox="0 0 24 48"
33
+ preserveAspectRatio="none"
34
+ fill="currentColor"
35
+ focusable="false"
36
+ >
37
+ <path d="M0 0H24C18 2 17 5 17 13V33C17 43 15 48 12 48S7 43 7 33V13C7 5 6 2 0 0Z" />
38
+ </svg>
39
+ <span class="seasonal-decorations__drop" />
40
+ </div>
41
+ ))
42
+ }
43
+ {
44
+ ['start', 'end'].map((side) => (
45
+ <svg
46
+ class="seasonal-decorations__web"
47
+ data-web-side={side}
48
+ viewBox="0 0 120 120"
49
+ fill="none"
50
+ focusable="false"
51
+ >
52
+ <path
53
+ d="M0 0V116M0 0L43 110M0 0L85 85M0 0L110 43M0 0H116M0 24Q7 21 9 23Q15 16 17 17Q21 8 23 9Q22 4 24 0M0 48Q13 43 18 46Q28 33 34 34Q41 20 46 18Q42 7 48 0M0 77Q22 68 29 72Q47 51 54 54Q65 35 72 29Q68 14 77 0M0 108Q30 93 40 101Q64 71 76 76Q90 49 101 40Q92 20 108 0"
54
+ stroke="currentColor"
55
+ stroke-width="1"
56
+ />
57
+ </svg>
58
+ ))
59
+ }
60
+ </div>
@@ -0,0 +1,134 @@
1
+ .seasonal-decorations {
2
+ --seasonal-decoration-top: calc(
3
+ var(--nox-site-header-compact-height) + var(--nox-seasonal-border-size)
4
+ );
5
+
6
+ position: fixed;
7
+ z-index: -1;
8
+ inset: var(--seasonal-decoration-top) 0 0;
9
+ display: none;
10
+ overflow: hidden;
11
+ scrollbar-gutter: inherit;
12
+ pointer-events: none;
13
+ user-select: none;
14
+ color: var(--nox-seasonal-blood);
15
+ }
16
+
17
+ :root[data-appearance='halloween']:is(
18
+ [data-theme-decorations='static'],
19
+ [data-theme-decorations='animated']
20
+ )
21
+ .seasonal-decorations {
22
+ display: block;
23
+ }
24
+
25
+ .seasonal-decorations__drip {
26
+ --drip-period: 12s;
27
+
28
+ position: absolute;
29
+ inset-block-start: 0;
30
+ inset-inline-start: var(--drip-x);
31
+ inline-size: 18px;
32
+ block-size: var(--drip-length);
33
+ transform: translateX(-50%);
34
+ }
35
+
36
+ .seasonal-decorations__drip[data-drip-edge] {
37
+ --drip-period: 8s;
38
+ }
39
+
40
+ .seasonal-decorations__drip > svg {
41
+ display: block;
42
+ inline-size: 100%;
43
+ block-size: 100%;
44
+ }
45
+
46
+ .seasonal-decorations__drop {
47
+ position: absolute;
48
+ inset-block-start: calc(100% - 8px);
49
+ inset-inline-start: 5px;
50
+ inline-size: 8px;
51
+ block-size: 13px;
52
+ border-radius: 45% 45% 55% 55%;
53
+ background: var(--nox-seasonal-blood);
54
+ opacity: 0;
55
+ transform-origin: top;
56
+ }
57
+
58
+ .seasonal-decorations__web {
59
+ position: absolute;
60
+ inset-block-start: 0;
61
+ inline-size: clamp(3rem, 7vw, 7rem);
62
+ block-size: auto;
63
+ color: var(--nox-seasonal-web);
64
+ opacity: 0.35;
65
+ }
66
+
67
+ .seasonal-decorations__web[data-web-side='start'] {
68
+ inset-inline-start: 0;
69
+ }
70
+
71
+ .seasonal-decorations__web[data-web-side='end'] {
72
+ inset-inline-end: 0;
73
+ transform: scaleX(-1);
74
+ }
75
+
76
+ @media (width >= 72rem) {
77
+ .seasonal-decorations {
78
+ --seasonal-decoration-top: calc(
79
+ var(--nox-site-header-height) - var(--nox-border-width) +
80
+ var(--nox-seasonal-border-size)
81
+ );
82
+ }
83
+ }
84
+
85
+ @media (width < 48rem) {
86
+ .seasonal-decorations__drip {
87
+ block-size: calc(var(--drip-length) * 0.65);
88
+ inline-size: 14px;
89
+ }
90
+
91
+ .seasonal-decorations__drip:not([data-drip-edge]) {
92
+ display: none;
93
+ }
94
+
95
+ .seasonal-decorations__drop {
96
+ inset-inline-start: 3px;
97
+ }
98
+ }
99
+
100
+ @media (prefers-reduced-motion: no-preference) {
101
+ :root[data-appearance='halloween'][data-theme-decorations='animated']
102
+ .seasonal-decorations__drop {
103
+ animation: seasonal-blood-fall var(--drip-period) linear var(--drip-delay)
104
+ infinite both;
105
+ animation-play-state: var(--seasonal-animation-state, running);
106
+ }
107
+ }
108
+
109
+ :root[data-theme-animation-paused] .seasonal-decorations {
110
+ --seasonal-animation-state: paused;
111
+ }
112
+
113
+ @keyframes seasonal-blood-fall {
114
+ 0% {
115
+ opacity: 0;
116
+ transform: translateY(0) scale(0.5, 0.2);
117
+ }
118
+
119
+ 4% {
120
+ opacity: 1;
121
+ transform: translateY(0) scale(1, 1.15);
122
+ }
123
+
124
+ 8% {
125
+ opacity: 1;
126
+ transform: translateY(20px) scale(0.85, 1.3);
127
+ }
128
+
129
+ 25%,
130
+ 100% {
131
+ opacity: 0;
132
+ transform: translateY(min(65vh, 30rem)) scale(0.75, 1.4);
133
+ }
134
+ }
@@ -1,31 +1,147 @@
1
1
  ---
2
2
  import { defaultThemeStorageKey } from '../../../theme/storage';
3
+ import type { SeasonalThemeConfig } from '../../../theme/contracts';
3
4
 
4
5
  type Props = {
5
6
  storageKey?: string;
7
+ seasonalTheme?: SeasonalThemeConfig;
6
8
  };
7
9
 
8
- const { storageKey = defaultThemeStorageKey }: Props = Astro.props;
10
+ const { storageKey = defaultThemeStorageKey, seasonalTheme }: Props =
11
+ Astro.props;
9
12
  ---
10
13
 
11
- <script is:inline define:vars={{ storageKey }}>
14
+ <script
15
+ is:inline
16
+ define:vars={{ storageKey, seasonalTheme: seasonalTheme ?? null }}
17
+ >
12
18
  (() => {
19
+ window.__astronoxTheme?.destroy();
13
20
  const root = document.documentElement;
14
- let storedTheme = null;
21
+ const seasonalKey = `${storageKey}:seasonal`;
22
+ const media = window.matchMedia?.('(prefers-color-scheme: dark)');
23
+ const choices = seasonalTheme
24
+ ? ['light', 'dark', seasonalTheme.appearance]
25
+ : ['light', 'dark'];
26
+ const read = (key) => {
27
+ try {
28
+ return window.localStorage.getItem(key);
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+ const write = (key, value) => {
34
+ try {
35
+ window.localStorage.setItem(key, value);
36
+ } catch {
37
+ // Keep the document's choice when browser storage is unavailable.
38
+ }
39
+ };
40
+ const readClassic = () => {
41
+ const value = read(storageKey);
42
+ return value === 'light' || value === 'dark' ? value : null;
43
+ };
44
+ const readSeasonal = () => {
45
+ if (!seasonalTheme) return null;
46
+ try {
47
+ const value = JSON.parse(read(seasonalKey) ?? 'null');
48
+ if (
49
+ value?.id === seasonalTheme.id &&
50
+ (value.choice === 'seasonal' || value.choice === 'classic')
51
+ ) {
52
+ return value.choice;
53
+ }
54
+ } catch {
55
+ // An invalid or older event preference does not disable a new event.
56
+ }
57
+ return null;
58
+ };
59
+ const saveSeasonal = (choice) => {
60
+ if (!seasonalTheme) return;
61
+ write(seasonalKey, JSON.stringify({ id: seasonalTheme.id, choice }));
62
+ };
15
63
 
16
- try {
17
- const value = window.localStorage.getItem(storageKey);
18
- if (value === 'light' || value === 'dark') storedTheme = value;
19
- } catch {
20
- // The system preference remains available without local storage.
64
+ let classic = readClassic();
65
+ let seasonal = readSeasonal();
66
+ let current;
67
+ if (seasonalTheme && seasonal === null) {
68
+ seasonal = 'seasonal';
69
+ saveSeasonal(seasonal);
21
70
  }
22
71
 
23
- const systemTheme = window.matchMedia?.('(prefers-color-scheme: dark)')
24
- .matches
25
- ? 'dark'
26
- : 'light';
72
+ const handleVisibility = () => {
73
+ root.toggleAttribute('data-theme-animation-paused', document.hidden);
74
+ };
75
+ const next = () => choices[(choices.indexOf(current) + 1) % choices.length];
76
+ const resolve = () =>
77
+ seasonalTheme && seasonal === 'seasonal'
78
+ ? seasonalTheme.appearance
79
+ : (classic ?? (media?.matches ? 'dark' : 'light'));
80
+ const apply = () => {
81
+ const appearance = resolve();
82
+ if (current === appearance) return;
83
+ const theme = appearance === 'light' ? 'light' : 'dark';
84
+ root.dataset.themeSwitching = 'true';
85
+ root.dataset.theme = theme;
86
+ root.dataset.appearance = appearance;
87
+ 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
+ }),
95
+ );
96
+ };
97
+ const cycle = () => {
98
+ const appearance = next();
99
+ if (appearance === 'light' || appearance === 'dark') {
100
+ classic = appearance;
101
+ write(storageKey, classic);
102
+ seasonal = 'classic';
103
+ } else {
104
+ seasonal = 'seasonal';
105
+ }
106
+ saveSeasonal(seasonal);
107
+ apply();
108
+ };
109
+ const handleSystem = () => apply();
110
+ const handleStorage = (event) => {
111
+ if (
112
+ event.key !== null &&
113
+ event.key !== storageKey &&
114
+ event.key !== seasonalKey
115
+ ) {
116
+ return;
117
+ }
118
+ classic = readClassic();
119
+ seasonal = readSeasonal();
120
+ if (seasonalTheme && seasonal === null) {
121
+ seasonal = 'seasonal';
122
+ saveSeasonal(seasonal);
123
+ }
124
+ apply();
125
+ };
27
126
 
28
- root.dataset.theme = storedTheme ?? systemTheme;
29
127
  root.dataset.themeStorageKey = storageKey;
128
+ root.dataset.themeDecorations = seasonalTheme?.decorations ?? 'none';
129
+ window.__astronoxTheme = {
130
+ next,
131
+ cycle,
132
+ destroy: () => {
133
+ media?.removeEventListener('change', handleSystem);
134
+ document.removeEventListener('visibilitychange', handleVisibility);
135
+ delete root.dataset.themeAnimationPaused;
136
+ window.removeEventListener('storage', handleStorage);
137
+ },
138
+ };
139
+ media?.addEventListener('change', handleSystem);
140
+ if (seasonalTheme?.decorations === 'animated') {
141
+ document.addEventListener('visibilitychange', handleVisibility);
142
+ handleVisibility();
143
+ }
144
+ window.addEventListener('storage', handleStorage);
145
+ apply();
30
146
  })();
31
147
  </script>
@@ -23,11 +23,15 @@ const {
23
23
  aria-hidden={ariaHidden}
24
24
  focusable={focusable}
25
25
  >
26
- <circle cx="16" cy="16" r="4" stroke="currentColor" stroke-width="1.5"
27
- ></circle>
28
- <path
29
- d="M16 8v2M16 22v2M8 16h2M22 16h2M10.35 10.35l1.4 1.4M20.25 20.25l1.4 1.4M21.65 10.35l-1.4 1.4M11.75 20.25l-1.4 1.4"
26
+ <g
30
27
  stroke="currentColor"
31
28
  stroke-width="1.5"
32
- stroke-linecap="round"></path>
29
+ stroke-linecap="round"
30
+ stroke-linejoin="round"
31
+ >
32
+ <circle cx="16" cy="16" r="4.5"></circle>
33
+ <path
34
+ d="M16 7v2M16 23v2M7 16h2M23 16h2M9.64 9.64l1.41 1.41M20.95 20.95l1.41 1.41M22.36 9.64l-1.41 1.41M11.05 20.95l-1.41 1.41"
35
+ ></path>
36
+ </g>
33
37
  </svg>
@@ -23,10 +23,12 @@ const {
23
23
  aria-hidden={ariaHidden}
24
24
  focusable={focusable}
25
25
  >
26
- <path
27
- d="M22.5 19.25A7.5 7.5 0 0 1 12.75 9.5a7.5 7.5 0 1 0 9.75 9.75Z"
26
+ <g
28
27
  stroke="currentColor"
29
28
  stroke-width="1.5"
30
29
  stroke-linecap="round"
31
- stroke-linejoin="round"></path>
30
+ stroke-linejoin="round"
31
+ >
32
+ <path d="M25 16.8A9 9 0 1 1 15.2 7A7 7 0 0 0 25 16.8Z"></path>
33
+ </g>
32
34
  </svg>
@@ -0,0 +1,37 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+
4
+ type Props = HTMLAttributes<'svg'>;
5
+
6
+ const {
7
+ width = 32,
8
+ height = 32,
9
+ 'aria-hidden': ariaHidden = 'true',
10
+ focusable = 'false',
11
+ ...attrs
12
+ }: Props = Astro.props;
13
+ ---
14
+
15
+ <svg
16
+ {...attrs}
17
+ width={width}
18
+ height={height}
19
+ viewBox="0 0 32 32"
20
+ fill="none"
21
+ aria-hidden={ariaHidden}
22
+ focusable={focusable}
23
+ >
24
+ <g
25
+ stroke="currentColor"
26
+ stroke-width="1.5"
27
+ stroke-linecap="round"
28
+ stroke-linejoin="round"
29
+ >
30
+ <path d="M16 11Q15 8 18 7"></path>
31
+ <path
32
+ d="M16 11C13 8.5 7 9.5 7 16C7 21 9.8 24.5 13 23.5Q16 25 19 23.5C22.2 24.5 25 21 25 16C25 9.5 19 8.5 16 11Z"
33
+ ></path>
34
+ <path d="M11 16.5l1.5-2 1.5 2ZM18 16.5l1.5-2 1.5 2Z"></path>
35
+ <path d="M12 19.5l2 1.5 2-1 2 1 2-1.5"></path>
36
+ </g>
37
+ </svg>
@@ -94,6 +94,7 @@ export { default as InfoIcon } from './icons/InfoIcon.astro';
94
94
  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
+ export { default as PumpkinIcon } from './icons/PumpkinIcon.astro';
97
98
  export { default as PlayIcon } from './icons/PlayIcon.astro';
98
99
  export { default as PlusIcon } from './icons/PlusIcon.astro';
99
100
  export { default as PoisonIcon } from './icons/PoisonIcon.astro';
@@ -7,6 +7,7 @@ import ArrowRightIcon from '../../icons/ArrowRightIcon.astro';
7
7
  type Props = HTMLAttributes<'footer'> & {
8
8
  columnSeparator?: 'divider' | 'none';
9
9
  columns?: 1 | 2;
10
+ endSpacing?: 'default' | 'extended';
10
11
  groups: readonly FooterGroup[];
11
12
  label: string;
12
13
  tone?: Tone;
@@ -17,6 +18,7 @@ const {
17
18
  columnSeparator = 'none',
18
19
  columns = 1,
19
20
  'data-tone': dataTone,
21
+ endSpacing = 'default',
20
22
  groups,
21
23
  label,
22
24
  tone,
@@ -31,6 +33,7 @@ const resolveRel = (target: FooterItem['target'], rel?: FooterItem['rel']) =>
31
33
  <footer
32
34
  {...attrs}
33
35
  class:list={['site-footer', className]}
36
+ data-footer-end-spacing={endSpacing}
34
37
  data-tone={resolvedTone}
35
38
  data-widget="site-footer"
36
39
  >
@@ -2,6 +2,16 @@
2
2
  color: var(--nox-text-muted);
3
3
  }
4
4
 
5
+ .site-footer[data-footer-end-spacing='extended'] {
6
+ --site-footer-end-reserve: calc(
7
+ clamp(10rem, 20vh, 16rem) + env(safe-area-inset-bottom, 0px)
8
+ );
9
+ }
10
+
11
+ :root[data-appearance='halloween'] .site-footer {
12
+ background: var(--nox-page-background);
13
+ }
14
+
5
15
  .site-footer__content {
6
16
  --site-footer-item-gap: var(--nox-space-2);
7
17
  --site-footer-marker-gap: 0.0625rem;
@@ -12,7 +22,8 @@
12
22
  justify-items: center;
13
23
  gap: var(--nox-space-1);
14
24
  padding-block-end: calc(
15
- var(--site-content-gap) + var(--site-floating-control-reserve, 0px)
25
+ var(--site-content-gap) + var(--site-floating-control-reserve, 0px) +
26
+ var(--site-footer-end-reserve, 0px)
16
27
  );
17
28
  font-size: var(--nox-font-size-sm);
18
29
  line-height: 1.25;
@@ -4,10 +4,14 @@
4
4
  var(--nox-site-header-height) - var(--site-header-border-width)
5
5
  );
6
6
 
7
+ position: relative;
7
8
  z-index: 50;
8
9
  top: 0;
9
10
  border-bottom: var(--site-header-border-width) solid var(--nox-border);
10
- background: color-mix(in srgb, var(--nox-surface) 94%, transparent);
11
+ background: var(
12
+ --nox-seasonal-header-background,
13
+ color-mix(in srgb, var(--nox-surface) 94%, transparent)
14
+ );
11
15
  backdrop-filter: blur(14px);
12
16
  }
13
17
 
@@ -15,6 +19,31 @@
15
19
  position: sticky;
16
20
  }
17
21
 
22
+ :root[data-appearance='halloween']:is(
23
+ [data-theme-decorations='static'],
24
+ [data-theme-decorations='animated']
25
+ )
26
+ .site-header {
27
+ --site-header-menu-block-offset: var(--nox-seasonal-border-size);
28
+ --site-header-menu-border-width: 0px;
29
+ --dropdown-attached-block-offset: var(--site-header-menu-block-offset);
30
+ --dropdown-attached-border-top-width: var(--site-header-menu-border-width);
31
+ }
32
+
33
+ :root[data-appearance='halloween']:is(
34
+ [data-theme-decorations='static'],
35
+ [data-theme-decorations='animated']
36
+ )
37
+ .site-header::after {
38
+ position: absolute;
39
+ inset-inline: 0;
40
+ inset-block-start: 100%;
41
+ block-size: var(--nox-seasonal-border-size);
42
+ background: var(--nox-seasonal-blood);
43
+ content: '';
44
+ pointer-events: none;
45
+ }
46
+
18
47
  .site-nav {
19
48
  position: relative;
20
49
  }
@@ -111,15 +111,22 @@
111
111
  z-index: 2;
112
112
  display: grid;
113
113
  inset-inline: 0;
114
- top: 100%;
114
+ top: calc(100% + var(--site-header-menu-block-offset, 0px));
115
115
  width: 100%;
116
- height: calc(100vh - var(--nox-site-header-compact-height));
117
- height: calc(100dvh - var(--nox-site-header-compact-height));
116
+ height: calc(
117
+ 100vh - var(--nox-site-header-compact-height) -
118
+ var(--site-header-menu-block-offset, 0px)
119
+ );
120
+ height: calc(
121
+ 100dvh - var(--nox-site-header-compact-height) -
122
+ var(--site-header-menu-block-offset, 0px)
123
+ );
118
124
  grid-template-rows: minmax(0, 1fr) auto;
119
125
  overflow-x: hidden;
120
126
  overflow-y: hidden;
121
127
  padding: var(--nox-space-3);
122
- border-top: 3px solid var(--nox-link-active);
128
+ border-top: var(--site-header-menu-border-width, 3px) solid
129
+ var(--nox-theme-accent-color, var(--nox-link-active));
123
130
  border-bottom: var(--nox-border-width) solid var(--nox-border);
124
131
  border-end-start-radius: var(--nox-radius-md);
125
132
  border-end-end-radius: var(--nox-radius-md);
@@ -160,7 +167,7 @@
160
167
  left: 0;
161
168
  height: 2px;
162
169
  border-radius: var(--nox-radius-pill);
163
- background: var(--nox-source-tone-success);
170
+ background: var(--nox-theme-accent-color, var(--nox-source-tone-success));
164
171
  content: '';
165
172
  }
166
173
 
@@ -36,9 +36,11 @@ const logoAspectRatio = logo ? `${logo.width} / ${logo.height}` : undefined;
36
36
  <div
37
37
  class="site-nav__brand-logo-frame"
38
38
  style={`--site-nav-brand-logo-aspect-ratio: ${logoAspectRatio};`}
39
+ data-brand-variants={Object.keys(logo.variants ?? {}).join(' ')}
39
40
  >
40
41
  <Media
41
42
  class="site-nav__brand-logo"
43
+ data-brand-appearance="default"
42
44
  media={{
43
45
  src: logo.src,
44
46
  alt: logo.alt,
@@ -50,6 +52,22 @@ const logoAspectRatio = logo ? `${logo.width} / ${logo.height}` : undefined;
50
52
  }}
51
53
  fit="contain"
52
54
  />
55
+ {Object.entries(logo.variants ?? {}).map(([appearance, src]) => (
56
+ <Media
57
+ class="site-nav__brand-logo"
58
+ data-brand-appearance={appearance}
59
+ media={{
60
+ src,
61
+ alt: logo.alt,
62
+ width: logo.width,
63
+ height: logo.height,
64
+ loading: 'eager',
65
+ decoding: 'sync',
66
+ fetchPriority: 'high',
67
+ }}
68
+ fit="contain"
69
+ />
70
+ ))}
53
71
  </div>
54
72
  ) : (
55
73
  <>
@@ -21,6 +21,37 @@
21
21
  aspect-ratio: var(--site-nav-brand-logo-aspect-ratio);
22
22
  }
23
23
 
24
+ .site-nav__brand-logo[data-brand-appearance] {
25
+ grid-area: 1 / 1;
26
+ }
27
+
28
+ .site-nav__brand-logo[data-brand-appearance]:not(
29
+ [data-brand-appearance='default']
30
+ ) {
31
+ display: none;
32
+ }
33
+
34
+ :root[data-appearance='light']
35
+ .site-nav__brand-logo[data-brand-appearance='light'],
36
+ :root[data-appearance='dark']
37
+ .site-nav__brand-logo[data-brand-appearance='dark'],
38
+ :root[data-appearance='halloween']
39
+ .site-nav__brand-logo[data-brand-appearance='halloween'] {
40
+ display: grid;
41
+ }
42
+
43
+ :root[data-appearance='light']
44
+ .site-nav__brand-logo-frame[data-brand-variants~='light']
45
+ [data-brand-appearance='default'],
46
+ :root[data-appearance='dark']
47
+ .site-nav__brand-logo-frame[data-brand-variants~='dark']
48
+ [data-brand-appearance='default'],
49
+ :root[data-appearance='halloween']
50
+ .site-nav__brand-logo-frame[data-brand-variants~='halloween']
51
+ [data-brand-appearance='default'] {
52
+ display: none;
53
+ }
54
+
24
55
  .site-nav__brand-logo-frame [data-widget='media'] {
25
56
  inline-size: 100%;
26
57
  block-size: 100%;
@@ -110,7 +110,7 @@
110
110
  left: var(--nox-space-4);
111
111
  height: 3px;
112
112
  border-radius: var(--nox-radius-pill);
113
- background: var(--nox-source-tone-success);
113
+ background: var(--nox-theme-accent-color, var(--nox-source-tone-success));
114
114
  content: '';
115
115
  opacity: 0;
116
116
  transform: translateY(var(--nox-space-1));
@@ -77,7 +77,7 @@
77
77
  inset-block-end: 0;
78
78
  height: 3px;
79
79
  border-radius: 0;
80
- background: var(--nox-tone-text);
80
+ background: var(--nox-theme-accent-color, var(--nox-tone-text));
81
81
  content: '';
82
82
  opacity: 0;
83
83
  pointer-events: none;
@@ -101,20 +101,30 @@ const jsonLdNodes = [
101
101
  }
102
102
  <script is:inline>
103
103
  (() => {
104
- const applyThemeColor = (theme) => {
105
- if (theme !== 'light' && theme !== 'dark') return;
104
+ const applyThemeColor = (theme, appearance) => {
105
+ const candidates = [
106
+ ...document.querySelectorAll(
107
+ 'meta[name="theme-color"][data-theme-color]',
108
+ ),
109
+ ];
110
+ const target = candidates.some(
111
+ (meta) => meta.dataset.themeColor === appearance,
112
+ )
113
+ ? appearance
114
+ : theme;
106
115
 
107
- for (const meta of document.querySelectorAll(
108
- 'meta[name="theme-color"][data-theme-color]',
109
- )) {
116
+ for (const meta of candidates) {
110
117
  if (!(meta instanceof HTMLMetaElement)) continue;
111
- meta.media = meta.dataset.themeColor === theme ? 'all' : 'not all';
118
+ meta.media = meta.dataset.themeColor === target ? 'all' : 'not all';
112
119
  }
113
120
  };
114
121
 
115
- applyThemeColor(document.documentElement.dataset.theme);
122
+ applyThemeColor(
123
+ document.documentElement.dataset.theme,
124
+ document.documentElement.dataset.appearance,
125
+ );
116
126
  document.addEventListener('astronox:theme-change', (event) => {
117
- applyThemeColor(event.detail?.theme);
127
+ applyThemeColor(event.detail?.theme, event.detail?.appearance);
118
128
  });
119
129
  })();
120
130
  </script>
@@ -1,5 +1,5 @@
1
1
  import type { PageMeta } from './pageMeta';
2
- import type { ColorTheme } from '../theme/contracts';
2
+ import type { SeasonalThemeConfig, ThemeAppearance } from '../theme/contracts';
3
3
 
4
4
  export type SiteTextDirection = 'ltr' | 'rtl';
5
5
 
@@ -22,6 +22,7 @@ export type SiteIdentity = {
22
22
  alt: string;
23
23
  width: number;
24
24
  height: number;
25
+ variants?: Partial<Record<ThemeAppearance, string>>;
25
26
  };
26
27
  };
27
28
 
@@ -36,7 +37,7 @@ export type SiteIconConfig = {
36
37
  export type SiteThemeColor = {
37
38
  color: string;
38
39
  media?: string;
39
- theme?: ColorTheme;
40
+ theme?: ThemeAppearance;
40
41
  };
41
42
 
42
43
  export type SiteSeoConfig = {
@@ -56,6 +57,7 @@ export type SiteSeoConfig = {
56
57
 
57
58
  export type SiteConfig = {
58
59
  identity: SiteIdentity;
60
+ seasonalTheme?: SeasonalThemeConfig;
59
61
  defaultLocale: string;
60
62
  locales: readonly [SiteLocaleConfig, ...SiteLocaleConfig[]];
61
63
  routing: {
@@ -75,6 +77,10 @@ export function defineSiteConfig<const TConfig extends SiteConfig>(
75
77
  throw new Error(`Invalid site identity: ${config.identity.id}`);
76
78
  }
77
79
 
80
+ if (config.seasonalTheme && !config.seasonalTheme.id.trim()) {
81
+ throw new Error('A seasonal theme requires a non-empty event id.');
82
+ }
83
+
78
84
  const localeIds = new Set<string>();
79
85
  const urlCodes = new Set<string>();
80
86
 
@@ -11,6 +11,7 @@ import PageSurface from './PageSurface.astro';
11
11
  import Breadcrumbs from '../components/navigation/Breadcrumbs/Breadcrumbs.astro';
12
12
  import ScrollToTop from '../components/actions/ScrollToTop/ScrollToTop.astro';
13
13
  import ThemeBootstrap from '../components/feedback/ThemeBootstrap/ThemeBootstrap.astro';
14
+ import SeasonalDecorations from '../components/feedback/SeasonalDecorations/SeasonalDecorations.astro';
14
15
  import { defaultThemeStorageKey } from '../theme/storage';
15
16
 
16
17
  type Props = HTMLAttributes<'html'> & {
@@ -51,7 +52,10 @@ const resolvedTone = tone ?? dataTone;
51
52
  <head>
52
53
  <meta charset="utf-8" />
53
54
  <meta name="viewport" content="width=device-width, initial-scale=1" />
54
- <ThemeBootstrap storageKey={themeStorageKey} />
55
+ <ThemeBootstrap
56
+ storageKey={themeStorageKey}
57
+ seasonalTheme={site.seasonalTheme}
58
+ />
55
59
  <SeoHead
56
60
  breadcrumbs={breadcrumbs}
57
61
  meta={meta}
@@ -65,6 +69,7 @@ const resolvedTone = tone ?? dataTone;
65
69
  <div class="site-shell" data-site-scroll-root>
66
70
  <slot name="skip-link" />
67
71
  <slot name="header" />
72
+ {site.seasonalTheme?.decorations ? <SeasonalDecorations /> : null}
68
73
  <div class="site-viewport" data-site-viewport>
69
74
  <main id="content" class="site-main" tabindex="-1">
70
75
  <div class="site-main__content">
@@ -57,7 +57,7 @@
57
57
  padding: var(--nox-space-2) 0;
58
58
  gap: 0;
59
59
  border: var(--nox-border-width) solid var(--nox-border);
60
- border-top: 3px solid var(--nox-link-active);
60
+ border-top: 3px solid var(--nox-theme-accent-color, var(--nox-link-active));
61
61
  border-radius: var(--nox-radius-md);
62
62
  background: var(--nox-surface);
63
63
  box-shadow: var(--nox-shadow-surface);
@@ -76,12 +76,21 @@
76
76
  .dropdown[data-dropdown-attached][data-dropdown-placement='bottom']
77
77
  .dropdown__content {
78
78
  top: calc(100% + var(--dropdown-attached-block-offset, 0px));
79
- border-top: 3px solid var(--nox-link-active);
79
+ border-top-width: var(--dropdown-attached-border-top-width, 3px);
80
80
  border-start-start-radius: 0;
81
81
  border-start-end-radius: 0;
82
82
  box-shadow: none;
83
83
  }
84
84
 
85
+ .dropdown[data-dropdown-attached][data-dropdown-placement='bottom'][data-dropdown-open]::before {
86
+ position: absolute;
87
+ z-index: 90;
88
+ inset-inline: 0;
89
+ inset-block-start: 100%;
90
+ block-size: var(--dropdown-attached-block-offset, 0px);
91
+ content: '';
92
+ }
93
+
85
94
  .dropdown[data-dropdown-placement='top'] .dropdown__content {
86
95
  bottom: calc(100% + var(--nox-space-2));
87
96
  }
@@ -106,11 +106,11 @@
106
106
  }
107
107
 
108
108
  .magnetic-field-puzzle__identity[data-polarity='north'] {
109
- color: var(--nox-source-tone-info);
109
+ color: var(--nox-source-magnetic-north);
110
110
  }
111
111
 
112
112
  .magnetic-field-puzzle__identity[data-polarity='south'] {
113
- color: var(--nox-source-tone-danger);
113
+ color: var(--nox-source-magnetic-south);
114
114
  }
115
115
 
116
116
  .magnetic-field-puzzle__polarity-icon {
@@ -4,6 +4,11 @@
4
4
  }
5
5
 
6
6
  .stage-flow__stage {
7
+ --stage-flow-accent-color: var(
8
+ --nox-theme-accent-color,
9
+ var(--nox-tone-color)
10
+ );
11
+
7
12
  display: grid;
8
13
  inline-size: max-content;
9
14
  min-inline-size: var(--stage-flow-stage-min-inline-size);
@@ -33,7 +38,7 @@
33
38
 
34
39
  .stage-flow__stage[aria-current='page'] [data-widget='media-frame'] {
35
40
  border-width: 4px;
36
- border-color: var(--nox-tone-color);
41
+ border-color: var(--stage-flow-accent-color);
37
42
  background:
38
43
  radial-gradient(
39
44
  circle at 50% 42%,
@@ -42,7 +47,7 @@
42
47
  ),
43
48
  var(--nox-tone-background);
44
49
  box-shadow:
45
- 0 0 0 1px var(--nox-tone-color),
50
+ 0 0 0 1px var(--stage-flow-accent-color),
46
51
  0 0.35rem 0.75rem
47
52
  color-mix(in srgb, var(--nox-tone-shadow) 16%, transparent);
48
53
  }
@@ -56,9 +61,9 @@
56
61
 
57
62
  .stage-flow__stage[aria-current='page']:focus-visible
58
63
  [data-widget='media-frame'] {
59
- border-color: var(--nox-tone-color);
64
+ border-color: var(--stage-flow-accent-color);
60
65
  box-shadow:
61
- 0 0 0 1px var(--nox-tone-color),
66
+ 0 0 0 1px var(--stage-flow-accent-color),
62
67
  0 0 0 6px color-mix(in srgb, var(--nox-tone-focus-ring) 18%, transparent),
63
68
  0 0.5rem 1rem color-mix(in srgb, var(--nox-tone-shadow) 18%, transparent);
64
69
  }
@@ -83,7 +88,8 @@
83
88
  [data-widget='media-frame'] {
84
89
  box-shadow:
85
90
  0 0 0 1px var(--nox-border),
86
- 0 0 0 5px color-mix(in srgb, var(--nox-tone-color) 16%, transparent),
91
+ 0 0 0 5px
92
+ color-mix(in srgb, var(--stage-flow-accent-color) 16%, transparent),
87
93
  0 0.5rem 1rem color-mix(in srgb, var(--nox-tone-shadow) 18%, transparent);
88
94
  }
89
95
  }
@@ -167,6 +167,19 @@ p {
167
167
  gap: 0;
168
168
  }
169
169
 
170
+ :root[data-appearance='halloween'] .site-shell {
171
+ isolation: isolate;
172
+ }
173
+
174
+ :root[data-appearance='halloween'] .site-viewport {
175
+ background: transparent;
176
+ }
177
+
178
+ :root[data-appearance='halloween'] .site-main__content {
179
+ background: var(--nox-page-background);
180
+ border-radius: var(--nox-radius-lg);
181
+ }
182
+
170
183
  @media (width < 48rem) {
171
184
  .site-viewport {
172
185
  --site-content-gap: var(--nox-space-4);
@@ -16,7 +16,10 @@
16
16
  var(--nox-link-active) 8%,
17
17
  var(--nox-surface)
18
18
  );
19
- --nox-interaction-accent-color: var(--nox-link-active);
19
+ --nox-interaction-accent-color: var(
20
+ --nox-theme-accent-color,
21
+ var(--nox-link-active)
22
+ );
20
23
 
21
24
  position: relative;
22
25
  }
@@ -1,6 +1,7 @@
1
1
  @layer settings, base, composition, primitives, components, patterns, surfaces, states, responsive;
2
2
 
3
3
  @import './tokens.css' layer(settings);
4
+ @import '../theme/halloween.css' layer(settings);
4
5
  @import './base.css' layer(base);
5
6
  @import './interaction.css' layer(states);
6
7
 
@@ -46,6 +47,8 @@
46
47
  @import '../components/forms/Textarea/Textarea.css' layer(components);
47
48
  @import '../components/feedback/Tooltip/Tooltip.css' layer(components);
48
49
  @import '../components/feedback/Spinner/Spinner.css' layer(components);
50
+ @import '../components/feedback/SeasonalDecorations/SeasonalDecorations.css'
51
+ layer(components);
49
52
  @import '../components/icons/CurrencyIcon.css' layer(components);
50
53
  @import '../components/icons/CircleSymbolIcon.css' layer(components);
51
54
  @import '../components/media/CanvasFrame/CanvasFrame.css' layer(components);
@@ -23,6 +23,10 @@
23
23
  --nox-source-map-water: #0ea5e9;
24
24
  --nox-source-map-range: #0000ff;
25
25
 
26
+ /* Magnetic identities stay stable across UI color themes. */
27
+ --nox-source-magnetic-north: #2f6bff;
28
+ --nox-source-magnetic-south: #dc2626;
29
+
26
30
  /* Internal recipe anchors. */
27
31
  --nox-mix-light: #ffffff;
28
32
  --nox-mix-dark: #020617;
@@ -2,3 +2,13 @@ export type Tone =
2
2
  'neutral' | 'primary' | 'info' | 'success' | 'warning' | 'danger';
3
3
 
4
4
  export type ColorTheme = 'light' | 'dark';
5
+
6
+ export type SeasonalAppearance = 'halloween';
7
+
8
+ export type ThemeAppearance = ColorTheme | SeasonalAppearance;
9
+
10
+ export type SeasonalThemeConfig = {
11
+ id: string;
12
+ appearance: SeasonalAppearance;
13
+ decorations?: 'static' | 'animated';
14
+ };
@@ -0,0 +1,20 @@
1
+ :root[data-appearance='halloween'] {
2
+ color-scheme: dark;
3
+
4
+ --nox-source-tone-neutral: #b6aaa0;
5
+ --nox-source-tone-primary: #ff9a3c;
6
+ --nox-content-link-color: #ffb66c;
7
+ --nox-theme-page-base: #12100f;
8
+ --nox-theme-surface-base: #201b18;
9
+ --nox-theme-text-base: #f3eee8;
10
+ --nox-theme-text-disabled-base: #776b63;
11
+ --nox-theme-border-base: #342820;
12
+ --nox-theme-inverse-base: #12100f;
13
+ --nox-theme-shadow-base: #090706;
14
+
15
+ --nox-seasonal-header-background: #211512;
16
+ --nox-seasonal-blood: #a52330;
17
+ --nox-seasonal-border-size: 3px;
18
+ --nox-seasonal-web: #d1bfa9;
19
+ --nox-theme-accent-color: var(--nox-seasonal-blood);
20
+ }
@@ -0,0 +1,13 @@
1
+ import type { ThemeAppearance } from './contracts';
2
+
3
+ export type ThemeRuntime = {
4
+ next: () => ThemeAppearance;
5
+ cycle: () => void;
6
+ destroy: () => void;
7
+ };
8
+
9
+ declare global {
10
+ interface Window {
11
+ __astronoxTheme?: ThemeRuntime;
12
+ }
13
+ }