@astronox/core 0.2.3 → 0.2.4

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.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -150,7 +150,7 @@
150
150
  background: color-mix(
151
151
  in srgb,
152
152
  var(--nox-button-hover-tone-seed) 12%,
153
- var(--nox-mix-light)
153
+ var(--nox-theme-surface-base)
154
154
  );
155
155
  color: var(--nox-button-hover-tone-seed);
156
156
  }
@@ -130,7 +130,7 @@
130
130
  background: color-mix(
131
131
  in srgb,
132
132
  var(--card-action-hover-seed) 12%,
133
- var(--nox-mix-light)
133
+ var(--nox-theme-surface-base)
134
134
  );
135
135
  }
136
136
 
@@ -145,7 +145,7 @@
145
145
  background: color-mix(
146
146
  in srgb,
147
147
  var(--card-action-hover-seed) 12%,
148
- var(--nox-mix-light)
148
+ var(--nox-theme-surface-base)
149
149
  );
150
150
  }
151
151
  }
@@ -154,7 +154,7 @@
154
154
  background: color-mix(
155
155
  in srgb,
156
156
  var(--card-action-hover-seed) 18%,
157
- var(--nox-mix-light)
157
+ var(--nox-theme-surface-base)
158
158
  );
159
159
  }
160
160
 
@@ -0,0 +1,59 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import DayIcon from '../../icons/DayIcon.astro';
4
+ import NightIcon from '../../icons/NightIcon.astro';
5
+
6
+ type Props = HTMLAttributes<'button'> & {
7
+ appearance?: 'circle' | 'utility';
8
+ darkLabel: string;
9
+ lightLabel: string;
10
+ };
11
+
12
+ const {
13
+ appearance = 'circle',
14
+ class: className,
15
+ darkLabel,
16
+ lightLabel,
17
+ type = 'button',
18
+ ...attrs
19
+ }: Props = Astro.props;
20
+ ---
21
+
22
+ <button
23
+ {...attrs}
24
+ class:list={['theme-toggle', className]}
25
+ type={type}
26
+ aria-label={darkLabel}
27
+ title={darkLabel}
28
+ data-theme-toggle
29
+ data-theme-toggle-appearance={appearance}
30
+ data-theme-toggle-dark-label={darkLabel}
31
+ data-theme-toggle-light-label={lightLabel}
32
+ data-navigation-control={appearance === 'circle' ? '' : undefined}
33
+ data-navigation-control-appearance={
34
+ appearance === 'circle' ? 'bar' : undefined
35
+ }
36
+ >
37
+ <span
38
+ class="theme-toggle__icon"
39
+ data-theme-toggle-target="dark"
40
+ aria-hidden="true"
41
+ >
42
+ <NightIcon />
43
+ </span>
44
+ <span
45
+ class="theme-toggle__icon"
46
+ data-theme-toggle-target="light"
47
+ aria-hidden="true"
48
+ >
49
+ <DayIcon />
50
+ </span>
51
+ </button>
52
+
53
+ <script>
54
+ import { initThemeToggle } from './themeToggleController';
55
+
56
+ for (const toggle of document.querySelectorAll('[data-theme-toggle]')) {
57
+ if (toggle instanceof HTMLElement) initThemeToggle(toggle);
58
+ }
59
+ </script>
@@ -0,0 +1,83 @@
1
+ .theme-toggle {
2
+ display: grid;
3
+ flex: 0 0 auto;
4
+ place-items: center;
5
+ padding: 0;
6
+ border: var(--nox-border-width) solid var(--nox-border);
7
+ background: var(--nox-surface);
8
+ color: var(--nox-text-muted);
9
+ cursor: pointer;
10
+ }
11
+
12
+ .theme-toggle[data-theme-toggle-appearance='circle'] {
13
+ min-inline-size: 4.75rem;
14
+ block-size: 100%;
15
+ border: 0;
16
+ border-radius: 0;
17
+ background: transparent;
18
+ }
19
+
20
+ .theme-toggle[data-theme-toggle-appearance='utility'] {
21
+ inline-size: 100%;
22
+ min-block-size: var(--nox-navigation-block-size);
23
+ border-radius: var(--nox-radius-md);
24
+ background: var(--nox-surface-muted);
25
+ }
26
+
27
+ .theme-toggle__icon {
28
+ display: grid;
29
+ inline-size: 2rem;
30
+ block-size: 2rem;
31
+ grid-area: 1 / 1;
32
+ place-items: center;
33
+ }
34
+
35
+ .theme-toggle[data-theme-toggle-appearance='utility'] .theme-toggle__icon {
36
+ inline-size: 2.125rem;
37
+ block-size: 2.125rem;
38
+ }
39
+
40
+ .theme-toggle[data-theme-toggle-appearance='circle'] .theme-toggle__icon {
41
+ border: var(--nox-border-width) solid var(--nox-border);
42
+ border-radius: 50%;
43
+ background: var(--nox-surface);
44
+ }
45
+
46
+ .theme-toggle__icon > svg {
47
+ display: block;
48
+ inline-size: 100%;
49
+ block-size: 100%;
50
+ }
51
+
52
+ .theme-toggle [data-theme-toggle-target='light'] {
53
+ display: none;
54
+ }
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'] {
61
+ display: grid;
62
+ }
63
+
64
+ .theme-toggle:focus-visible {
65
+ outline: var(--nox-focus-ring-width) solid var(--nox-focus-ring);
66
+ outline-offset: var(--nox-focus-ring-offset);
67
+ }
68
+
69
+ @media (hover: hover) {
70
+ .theme-toggle:hover {
71
+ background: var(--nox-surface-strong);
72
+ color: var(--nox-link-hover);
73
+ }
74
+ }
75
+
76
+ @media (prefers-reduced-motion: no-preference) {
77
+ .theme-toggle {
78
+ transition:
79
+ background-color var(--nox-motion-duration-sm)
80
+ var(--nox-motion-easing-standard),
81
+ color var(--nox-motion-duration-sm) var(--nox-motion-easing-standard);
82
+ }
83
+ }
@@ -0,0 +1,111 @@
1
+ import type { ColorTheme } from '../../../theme/contracts';
2
+ import { defaultThemeStorageKey } from '../../../theme/storage';
3
+
4
+ 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 ??
15
+ defaultThemeStorageKey
16
+ );
17
+ }
18
+
19
+ function readStoredTheme(): ColorTheme | null {
20
+ try {
21
+ const value = window.localStorage.getItem(getStorageKey());
22
+ return isColorTheme(value) ? value : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function getSystemTheme(): ColorTheme {
29
+ return window.matchMedia(systemThemeQuery).matches ? 'dark' : 'light';
30
+ }
31
+
32
+ function getCurrentTheme(): ColorTheme {
33
+ const value = document.documentElement.dataset.theme ?? null;
34
+ return isColorTheme(value) ? value : readStoredTheme() ?? getSystemTheme();
35
+ }
36
+
37
+ function updateToggle(toggle: HTMLElement, theme: ColorTheme): void {
38
+ const label =
39
+ theme === 'light'
40
+ ? toggle.dataset.themeToggleDarkLabel
41
+ : toggle.dataset.themeToggleLightLabel;
42
+
43
+ if (label) {
44
+ toggle.setAttribute('aria-label', label);
45
+ toggle.setAttribute('title', label);
46
+ }
47
+ }
48
+
49
+ function updateToggles(theme: ColorTheme): void {
50
+ for (const toggle of document.querySelectorAll<HTMLElement>(
51
+ '[data-theme-toggle]',
52
+ )) {
53
+ updateToggle(toggle, theme);
54
+ }
55
+ }
56
+
57
+ function applyTheme(theme: ColorTheme, persist: boolean): void {
58
+ document.documentElement.dataset.theme = theme;
59
+
60
+ if (persist) {
61
+ try {
62
+ window.localStorage.setItem(getStorageKey(), theme);
63
+ } catch {
64
+ // The selected theme remains active for the current document.
65
+ }
66
+ }
67
+
68
+ updateToggles(theme);
69
+ document.dispatchEvent(
70
+ new CustomEvent('astronox:theme-change', { detail: { theme } }),
71
+ );
72
+ }
73
+
74
+ function initializeGlobalListeners(): void {
75
+ if (globalListenersInitialized) return;
76
+ globalListenersInitialized = true;
77
+
78
+ const media = window.matchMedia(systemThemeQuery);
79
+ media.addEventListener('change', (event) => {
80
+ if (readStoredTheme() === null) {
81
+ applyTheme(event.matches ? 'dark' : 'light', false);
82
+ }
83
+ });
84
+
85
+ window.addEventListener('storage', (event) => {
86
+ if (event.key !== getStorageKey()) return;
87
+
88
+ const theme = isColorTheme(event.newValue)
89
+ ? event.newValue
90
+ : getSystemTheme();
91
+ applyTheme(theme, false);
92
+ });
93
+ }
94
+
95
+ export function initThemeToggle(toggle: HTMLElement): () => void {
96
+ if (initializedToggles.has(toggle)) return () => {};
97
+ initializedToggles.add(toggle);
98
+ initializeGlobalListeners();
99
+ updateToggle(toggle, getCurrentTheme());
100
+
101
+ const handleClick = (): void => {
102
+ applyTheme(getCurrentTheme() === 'light' ? 'dark' : 'light', true);
103
+ };
104
+
105
+ toggle.addEventListener('click', handleClick);
106
+
107
+ return () => {
108
+ toggle.removeEventListener('click', handleClick);
109
+ initializedToggles.delete(toggle);
110
+ };
111
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ import { defaultThemeStorageKey } from '../../../theme/storage';
3
+
4
+ type Props = {
5
+ storageKey?: string;
6
+ };
7
+
8
+ const { storageKey = defaultThemeStorageKey }: Props = Astro.props;
9
+ ---
10
+
11
+ <script is:inline define:vars={{ storageKey }}>
12
+ (() => {
13
+ const root = document.documentElement;
14
+ let storedTheme = null;
15
+
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.
21
+ }
22
+
23
+ const systemTheme = window.matchMedia?.('(prefers-color-scheme: dark)')
24
+ .matches
25
+ ? 'dark'
26
+ : 'light';
27
+
28
+ root.dataset.theme = storedTheme ?? systemTheme;
29
+ root.dataset.themeStorageKey = storageKey;
30
+ })();
31
+ </script>
@@ -81,7 +81,7 @@
81
81
  background: color-mix(
82
82
  in srgb,
83
83
  var(--nox-tone-seed) 12%,
84
- var(--nox-mix-light)
84
+ var(--nox-theme-surface-base)
85
85
  );
86
86
  color: var(--nox-tone-text);
87
87
  }
@@ -3,17 +3,17 @@
3
3
  --circle-symbol-icon-surface: color-mix(
4
4
  in srgb,
5
5
  var(--circle-symbol-icon-seed) 32%,
6
- var(--nox-mix-light)
6
+ var(--nox-theme-surface-base)
7
7
  );
8
8
  --circle-symbol-icon-border: color-mix(
9
9
  in srgb,
10
10
  var(--circle-symbol-icon-seed) 74%,
11
- var(--nox-mix-dark)
11
+ var(--nox-theme-text-base)
12
12
  );
13
13
  --circle-symbol-icon-mark: color-mix(
14
14
  in srgb,
15
15
  var(--circle-symbol-icon-seed) 82%,
16
- var(--nox-mix-dark)
16
+ var(--nox-theme-text-base)
17
17
  );
18
18
 
19
19
  color: var(--circle-symbol-icon-mark);
@@ -1,5 +1,6 @@
1
1
  export { default as ActionListItem } from './actions/ActionListItem/ActionListItem.astro';
2
2
  export { default as Button } from './actions/Button/Button.astro';
3
+ export { default as ThemeToggle } from './actions/ThemeToggle/ThemeToggle.astro';
3
4
  export { default as CardAction } from './actions/CardAction/CardAction.astro';
4
5
  export { default as IdentityProviderAction } from './actions/IdentityProviderAction/IdentityProviderAction.astro';
5
6
  export { default as MediaTrigger } from './actions/MediaTrigger/MediaTrigger.astro';
@@ -33,6 +34,7 @@ export { default as TextInput } from './forms/TextInput/TextInput.astro';
33
34
  export { default as Textarea } from './forms/Textarea/Textarea.astro';
34
35
  export { default as Spinner } from './feedback/Spinner/Spinner.astro';
35
36
  export { default as PreferenceBootstrap } from './feedback/PreferenceBootstrap/PreferenceBootstrap.astro';
37
+ export { default as ThemeBootstrap } from './feedback/ThemeBootstrap/ThemeBootstrap.astro';
36
38
  export { default as Tooltip } from './feedback/Tooltip/Tooltip.astro';
37
39
  export { default as CanvasFrame } from './media/CanvasFrame/CanvasFrame.astro';
38
40
  export { default as EmbedFrame } from './media/EmbedFrame/EmbedFrame.astro';
@@ -81,7 +81,10 @@
81
81
 
82
82
  .site-nav__action > * {
83
83
  min-width: 5.75rem;
84
- border-radius: var(--nox-radius-md);
84
+ }
85
+
86
+ .site-nav__action > [data-theme-toggle-appearance='circle'] {
87
+ min-width: 4.75rem;
85
88
  }
86
89
 
87
90
  .site-nav__action
@@ -1,8 +1,14 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types';
3
3
  import type { SiteIdentity } from '../../config/siteConfig';
4
- import type { MobileNavigationLabels } from './navigationTypes';
5
- import type { NavigationItem } from '../../display/contracts';
4
+ import type {
5
+ MobileNavigationControlItem,
6
+ MobileNavigationLabels,
7
+ } from './navigationTypes';
8
+ import type {
9
+ NavigationItem,
10
+ NavigationLinkItem,
11
+ } from '../../display/contracts';
6
12
  import type { Tone } from '../../theme/contracts';
7
13
  import DesktopNavigation from './DesktopNavigation.astro';
8
14
  import MobileNavigation from './MobileNavigation.astro';
@@ -15,6 +21,9 @@ type Props = HTMLAttributes<'header'> & {
15
21
  identity: SiteIdentity;
16
22
  leftItems?: readonly NavigationItem[];
17
23
  menu: MobileNavigationLabels;
24
+ mobileFooterItems?: readonly MobileNavigationControlItem[];
25
+ mobileItems?: readonly NavigationLinkItem[];
26
+ mobileUtilityItems?: readonly MobileNavigationControlItem[];
18
27
  rightItems?: readonly NavigationItem[];
19
28
  sticky?: boolean;
20
29
  tone?: Tone;
@@ -30,12 +39,16 @@ const {
30
39
  identity,
31
40
  leftItems = [],
32
41
  menu,
42
+ mobileFooterItems = [],
43
+ mobileItems,
44
+ mobileUtilityItems = [],
33
45
  rightItems = [],
34
46
  sticky = false,
35
47
  tone,
36
48
  ...attrs
37
49
  } = Astro.props;
38
50
  const resolvedTone = tone ?? dataTone;
51
+ const hasMobileUtilityActions = Astro.slots.has('mobile-utility-actions');
39
52
  ---
40
53
 
41
54
  <header
@@ -52,10 +65,15 @@ const resolvedTone = tone ?? dataTone;
52
65
  homeLabel={homeLabel}
53
66
  leftItems={leftItems}
54
67
  menu={menu}
68
+ mobileFooterItems={mobileFooterItems}
69
+ mobileItems={mobileItems}
70
+ mobileUtilityItems={mobileUtilityItems}
71
+ hasUtilityActions={hasMobileUtilityActions}
55
72
  rightItems={rightItems}
56
73
  currentPath={currentPath}
57
74
  >
58
75
  <slot name="mobile-actions" slot="actions" />
76
+ <slot name="mobile-utility-actions" slot="utility-actions" />
59
77
  </MobileNavigation>
60
78
 
61
79
  <DesktopNavigation
@@ -1,52 +1,56 @@
1
1
  ---
2
2
  import type { SiteIdentity } from '../../config/siteConfig';
3
- import type { NavigationItem } from '../../display/contracts';
4
- import type { MobileNavigationLabels } from './navigationTypes';
3
+ import type {
4
+ NavigationItem,
5
+ NavigationLinkItem,
6
+ } from '../../display/contracts';
7
+ import type {
8
+ MobileNavigationControlItem,
9
+ MobileNavigationLabels,
10
+ } from './navigationTypes';
11
+ import CardLink from '../content/CardLink/CardLink.astro';
5
12
  import NavigationBrand from './NavigationBrand.astro';
6
13
  import NavigationLink from './NavigationLink.astro';
7
- import { isNavigationGroupItem } from './navigationState';
14
+ import {
15
+ isNavigationGroupItem,
16
+ isNavigationItemActive,
17
+ } from './navigationState';
8
18
 
9
19
  type Props = {
10
20
  currentPath: string;
21
+ hasUtilityActions?: boolean;
11
22
  homeHref: string;
12
23
  homeLabel: string;
13
24
  identity: SiteIdentity;
14
25
  leftItems: readonly NavigationItem[];
15
26
  menu: MobileNavigationLabels;
27
+ mobileFooterItems: readonly MobileNavigationControlItem[];
28
+ mobileItems?: readonly NavigationLinkItem[];
29
+ mobileUtilityItems: readonly MobileNavigationControlItem[];
16
30
  rightItems: readonly NavigationItem[];
17
31
  };
18
32
 
19
- type MobileNavigationGroup = {
20
- items: readonly NavigationItem[];
21
- label?: string;
22
- };
23
-
24
33
  const {
25
34
  currentPath,
35
+ hasUtilityActions,
26
36
  homeHref,
27
37
  homeLabel,
28
38
  identity,
29
39
  leftItems,
30
40
  menu,
41
+ mobileFooterItems,
42
+ mobileItems,
43
+ mobileUtilityItems,
31
44
  rightItems,
32
45
  } = Astro.props;
33
46
  const panelId = 'site-nav-mobile-panel';
34
47
  const closeLabel = menu.closeLabel ?? menu.openLabel;
35
- const mobileGroups: MobileNavigationGroup[] = [
36
- {
37
- items: leftItems,
38
- },
39
- ...rightItems.map((item) =>
40
- isNavigationGroupItem(item)
41
- ? {
42
- label: item.label,
43
- items: item.items,
44
- }
45
- : {
46
- items: [item],
47
- },
48
- ),
49
- ];
48
+ const fallbackItems = [...leftItems, ...rightItems].flatMap((item) =>
49
+ isNavigationGroupItem(item) ? [...item.items] : [item],
50
+ );
51
+ const resolvedMobileItems = mobileItems ?? fallbackItems;
52
+ const resolvedHasUtilityActions =
53
+ hasUtilityActions ?? Astro.slots.has('utility-actions');
50
54
  ---
51
55
 
52
56
  <div class="site-nav__mobile" data-mobile-navigation>
@@ -88,28 +92,88 @@ const mobileGroups: MobileNavigationGroup[] = [
88
92
  >
89
93
  <ul class="site-nav__mobile-list">
90
94
  {
91
- mobileGroups.map((group) => (
92
- <>
93
- {group.label ? (
94
- <li class="site-nav__mobile-group-label">
95
- <span>{group.label}</span>
96
- </li>
97
- ) : null}
98
- {group.items.map((item) => (
99
- <li>
100
- <NavigationLink
101
- item={item}
102
- currentPath={currentPath}
103
- accent="start"
104
- appearance="menu"
105
- class="site-nav__mobile-link"
106
- />
107
- </li>
108
- ))}
109
- </>
110
- ))
95
+ resolvedMobileItems.map((item) => {
96
+ const active = isNavigationItemActive(item, currentPath);
97
+ const rel =
98
+ item.rel ??
99
+ (item.target === '_blank' ? 'noopener noreferrer' : undefined);
100
+
101
+ return (
102
+ <li>
103
+ <CardLink
104
+ class="site-nav__mobile-link"
105
+ disabled={item.disabled}
106
+ href={item.href}
107
+ media={item.media}
108
+ mediaSize="compact"
109
+ rel={rel}
110
+ target={item.target}
111
+ textTreatment="heading"
112
+ title={item.label}
113
+ aria-current={active ? 'page' : undefined}
114
+ />
115
+ </li>
116
+ );
117
+ })
111
118
  }
112
119
  </ul>
120
+
121
+ {
122
+ mobileUtilityItems.length > 0 ||
123
+ resolvedHasUtilityActions ||
124
+ mobileFooterItems.length > 0 ? (
125
+ <div class="site-nav__mobile-controls">
126
+ {mobileUtilityItems.length > 0 || resolvedHasUtilityActions ? (
127
+ <ul class="site-nav__mobile-utility">
128
+ {mobileUtilityItems.map((item) => (
129
+ <li>
130
+ <NavigationLink
131
+ item={item}
132
+ currentPath={currentPath}
133
+ appearance="menu"
134
+ class="site-nav__mobile-utility-link"
135
+ data-mobile-navigation-label-visibility={
136
+ item.labelVisibility
137
+ }
138
+ />
139
+ </li>
140
+ ))}
141
+ {resolvedHasUtilityActions ? (
142
+ <li>
143
+ <slot name="utility-actions" />
144
+ </li>
145
+ ) : null}
146
+ </ul>
147
+ ) : null}
148
+
149
+ {mobileFooterItems.length > 0 ? (
150
+ <ul class="site-nav__mobile-footer">
151
+ {mobileFooterItems.map((item) => (
152
+ <li>
153
+ <NavigationLink
154
+ item={item}
155
+ currentPath={currentPath}
156
+ appearance="menu"
157
+ class="site-nav__mobile-footer-link"
158
+ data-mobile-navigation-accent={
159
+ item.accentColor ? true : undefined
160
+ }
161
+ data-mobile-navigation-label-visibility={
162
+ item.labelVisibility
163
+ }
164
+ style={
165
+ item.accentColor
166
+ ? `--mobile-navigation-accent: ${item.accentColor};`
167
+ : undefined
168
+ }
169
+ />
170
+ </li>
171
+ ))}
172
+ </ul>
173
+ ) : null}
174
+ </div>
175
+ ) : null
176
+ }
113
177
  </div>
114
178
  </div>
115
179
 
@@ -4,14 +4,18 @@
4
4
  }
5
5
 
6
6
  .site-nav__mobile-bar {
7
+ --site-nav-mobile-side-size: 4.75rem;
8
+
7
9
  position: relative;
8
10
  display: grid;
9
- grid-template-columns: auto minmax(0, 1fr) auto;
11
+ grid-template-columns:
12
+ var(--site-nav-mobile-side-size)
13
+ minmax(0, 1fr)
14
+ var(--site-nav-mobile-side-size);
10
15
  align-items: center;
11
- width: var(--nox-page-width);
16
+ width: 100%;
12
17
  min-height: var(--nox-site-header-compact-height);
13
- margin: 0 auto;
14
- gap: clamp(var(--nox-space-1), 2vw, var(--nox-space-3));
18
+ gap: 0;
15
19
  }
16
20
 
17
21
  .site-nav__menu-button {
@@ -29,6 +33,7 @@
29
33
  background: var(--nox-surface);
30
34
  color: var(--nox-text);
31
35
  cursor: pointer;
36
+ justify-self: center;
32
37
  }
33
38
 
34
39
  .site-nav__menu-icon {
@@ -72,12 +77,16 @@
72
77
 
73
78
  .site-nav__brand--mobile {
74
79
  z-index: 1;
75
- justify-self: stretch;
80
+ padding-inline: var(--nox-page-gutter);
81
+ justify-self: center;
76
82
  }
77
83
 
78
84
  .site-nav__mobile-action {
79
85
  z-index: 2;
80
- justify-self: end;
86
+ display: flex;
87
+ width: 100%;
88
+ justify-content: center;
89
+ justify-self: center;
81
90
  }
82
91
 
83
92
  .site-nav__mobile-action > * {
@@ -101,13 +110,15 @@
101
110
  .site-nav__mobile-panel {
102
111
  position: absolute;
103
112
  z-index: 2;
113
+ display: grid;
104
114
  inset-inline: 0;
105
115
  top: 100%;
106
116
  width: 100%;
107
- max-height: calc(100vh - var(--nox-site-header-compact-height));
108
- max-height: calc(100dvh - var(--nox-site-header-compact-height));
117
+ height: calc(100vh - var(--nox-site-header-compact-height));
118
+ height: calc(100dvh - var(--nox-site-header-compact-height));
119
+ grid-template-rows: minmax(0, 1fr) auto;
109
120
  overflow-x: hidden;
110
- overflow-y: auto;
121
+ overflow-y: hidden;
111
122
  padding: var(--nox-space-3);
112
123
  border-top: 3px solid var(--nox-link-active);
113
124
  border-bottom: var(--nox-border-width) solid var(--nox-border);
@@ -125,30 +136,90 @@
125
136
  .site-nav__mobile-list {
126
137
  display: grid;
127
138
  width: 100%;
139
+ min-height: 0;
128
140
  margin: 0;
129
141
  padding: 0;
130
- gap: var(--nox-space-1);
142
+ align-content: start;
143
+ gap: var(--nox-space-3);
131
144
  list-style: none;
145
+ overflow-x: hidden;
146
+ overflow-y: auto;
132
147
  }
133
148
 
134
149
  .site-nav__mobile-link {
135
- min-height: 3.125rem;
136
- padding-block: 0;
137
- border-radius: var(--nox-radius-sm);
150
+ --surface-radius: var(--nox-radius-md);
138
151
  }
139
152
 
140
- .site-nav__mobile-group-label {
141
- display: flex;
142
- min-height: 1.5rem;
143
- align-items: flex-end;
153
+ .site-nav__mobile-link[aria-current='page'] .text-pair__primary {
154
+ position: relative;
155
+ }
156
+
157
+ .site-nav__mobile-link[aria-current='page'] .text-pair__primary::after {
158
+ position: absolute;
159
+ right: 0;
160
+ bottom: calc(0px - var(--nox-space-2));
161
+ left: 0;
162
+ height: 2px;
163
+ border-radius: var(--nox-radius-pill);
164
+ background: var(--nox-source-tone-success);
165
+ content: '';
166
+ }
167
+
168
+ .site-nav__mobile-controls {
169
+ display: grid;
144
170
  margin-top: var(--nox-space-2);
145
- padding: var(--nox-space-2) var(--nox-space-4) 0;
146
- color: var(--nox-text-subtle);
147
- font-size: var(--nox-font-size-xs);
148
- font-weight: var(--nox-font-weight-strong);
149
- letter-spacing: var(--nox-letter-spacing-wider);
150
- line-height: 1;
151
- text-transform: uppercase;
171
+ padding: var(--nox-space-2) 0 0;
172
+ gap: var(--nox-space-3);
173
+ }
174
+
175
+ .site-nav__mobile-utility,
176
+ .site-nav__mobile-footer {
177
+ display: grid;
178
+ margin: 0;
179
+ padding: 0;
180
+ list-style: none;
181
+ }
182
+
183
+ .site-nav__mobile-utility {
184
+ grid-template-columns: repeat(2, minmax(0, 1fr));
185
+ gap: var(--nox-space-3);
186
+ }
187
+
188
+ .site-nav__mobile-footer {
189
+ grid-template-columns: repeat(3, minmax(0, 1fr));
190
+ gap: var(--nox-space-3);
191
+ }
192
+
193
+ .site-nav__mobile-utility-link,
194
+ .site-nav__mobile-footer-link {
195
+ min-height: var(--nox-navigation-block-size);
196
+ justify-content: center;
197
+ padding: 0;
198
+ border: var(--nox-border-width) solid var(--nox-border);
199
+ border-radius: var(--nox-radius-md);
200
+ background: var(--nox-surface-muted);
201
+ }
202
+
203
+ .site-nav__mobile-footer-link[data-mobile-navigation-accent] {
204
+ border-color: var(--mobile-navigation-accent);
205
+ background: var(--mobile-navigation-accent);
206
+ color: #fff;
207
+ }
208
+
209
+ [data-mobile-navigation-label-visibility='accessible']
210
+ [data-navigation-control-label] {
211
+ position: absolute;
212
+ overflow: hidden;
213
+ width: 1px;
214
+ height: 1px;
215
+ clip: rect(0 0 0 0);
216
+ white-space: nowrap;
217
+ }
218
+
219
+ .site-nav__mobile-footer-link [data-navigation-control-media] {
220
+ inline-size: 1.5rem;
221
+ block-size: 1.5rem;
222
+ border-radius: 0;
152
223
  }
153
224
 
154
225
  html[data-mobile-navigation-open],
@@ -166,6 +237,20 @@ html[data-mobile-navigation-open] body {
166
237
  .site-nav__menu-button:hover {
167
238
  background: var(--nox-surface-muted);
168
239
  }
240
+
241
+ .site-nav__mobile-footer-link[data-mobile-navigation-accent]:hover {
242
+ border-color: color-mix(
243
+ in srgb,
244
+ var(--mobile-navigation-accent) 88%,
245
+ #000
246
+ );
247
+ background: color-mix(
248
+ in srgb,
249
+ var(--mobile-navigation-accent) 88%,
250
+ #000
251
+ );
252
+ color: #fff;
253
+ }
169
254
  }
170
255
 
171
256
  @media (prefers-reduced-motion: no-preference) {
@@ -33,7 +33,7 @@
33
33
  .site-nav__brand--mobile {
34
34
  --site-nav-brand-block: var(--nox-site-header-compact-height);
35
35
  --site-nav-brand-inline: 100%;
36
- --site-nav-brand-image-scale: 56%;
36
+ --site-nav-brand-image-scale: 64%;
37
37
  }
38
38
 
39
39
  .site-nav__brand-name {
@@ -39,6 +39,20 @@
39
39
  block-size: 100%;
40
40
  }
41
41
 
42
+ [data-navigation-control-icon] {
43
+ display: grid;
44
+ inline-size: 1.5rem;
45
+ block-size: 1.5rem;
46
+ flex: 0 0 auto;
47
+ place-items: center;
48
+ }
49
+
50
+ [data-navigation-control-icon] > svg {
51
+ display: block;
52
+ inline-size: 100%;
53
+ block-size: 100%;
54
+ }
55
+
42
56
  [data-navigation-control-media-frame='bordered'] {
43
57
  border: var(--nox-border-width) solid var(--nox-border);
44
58
  }
@@ -92,11 +106,11 @@
92
106
  [data-navigation-control][data-navigation-control-appearance='bar']::after {
93
107
  position: absolute;
94
108
  right: var(--nox-space-4);
95
- bottom: var(--nox-space-2);
109
+ bottom: var(--nox-space-4);
96
110
  left: var(--nox-space-4);
97
111
  height: 3px;
98
112
  border-radius: var(--nox-radius-pill);
99
- background: var(--nox-link-active);
113
+ background: var(--nox-source-tone-success);
100
114
  content: '';
101
115
  opacity: 0;
102
116
  transform: translateY(var(--nox-space-1));
@@ -7,10 +7,15 @@ type Props = {
7
7
  };
8
8
 
9
9
  const { item } = Astro.props;
10
+ const Icon = item.icon;
10
11
  ---
11
12
 
12
13
  {
13
- item.media ? (
14
+ Icon ? (
15
+ <span data-navigation-control-icon aria-hidden="true">
16
+ <Icon />
17
+ </span>
18
+ ) : item.media ? (
14
19
  <span
15
20
  data-navigation-control-media
16
21
  data-navigation-control-media-frame={item.mediaFrame}
@@ -82,20 +82,6 @@ export function initMobileNavigation(root: HTMLElement): () => void {
82
82
  }
83
83
  };
84
84
 
85
- const handlePanelClick = (event: MouseEvent): void => {
86
- const target = event.target;
87
-
88
- if (!(target instanceof Element)) {
89
- return;
90
- }
91
-
92
- const link = target.closest('a[href]');
93
-
94
- if (link && panel.contains(link)) {
95
- close(true);
96
- }
97
- };
98
-
99
85
  const handleDesktopMediaChange = (): void => {
100
86
  if (desktopMedia.matches) {
101
87
  close(false);
@@ -105,7 +91,6 @@ export function initMobileNavigation(root: HTMLElement): () => void {
105
91
  setOpen(isOpen);
106
92
 
107
93
  trigger.addEventListener('click', handleTriggerClick);
108
- panel.addEventListener('click', handlePanelClick);
109
94
  document.addEventListener('click', handleDocumentClick);
110
95
  document.addEventListener('keydown', handleDocumentKeydown);
111
96
  desktopMedia.addEventListener('change', handleDesktopMediaChange);
@@ -113,7 +98,6 @@ export function initMobileNavigation(root: HTMLElement): () => void {
113
98
  return () => {
114
99
  setOpen(false);
115
100
  trigger.removeEventListener('click', handleTriggerClick);
116
- panel.removeEventListener('click', handlePanelClick);
117
101
  document.removeEventListener('click', handleDocumentClick);
118
102
  document.removeEventListener('keydown', handleDocumentKeydown);
119
103
  desktopMedia.removeEventListener('change', handleDesktopMediaChange);
@@ -1,11 +1,19 @@
1
1
  import type { AstroComponent } from '../widgetTypes';
2
- import type { DisplayMedia } from '../../display/contracts';
2
+ import type {
3
+ DisplayMedia,
4
+ NavigationLinkItem,
5
+ } from '../../display/contracts';
3
6
 
4
7
  export type MobileNavigationLabels = {
5
8
  closeLabel?: string;
6
9
  openLabel: string;
7
10
  };
8
11
 
12
+ export type MobileNavigationControlItem = NavigationLinkItem & {
13
+ accentColor?: string;
14
+ labelVisibility?: 'accessible' | 'visible';
15
+ };
16
+
9
17
  type SubNavigationVisual =
10
18
  | { icon?: AstroComponent; media?: never }
11
19
  | { icon?: never; media: DisplayMedia };
@@ -311,6 +311,7 @@ type NavigationItemBase = {
311
311
  rel?: string;
312
312
  target?: '_blank' | '_self';
313
313
  hreflang?: string;
314
+ icon?: AstroComponent;
314
315
  media?: DisplayMedia;
315
316
  mediaFrame?: 'bordered';
316
317
  mediaShape?: 'circle' | 'rectangle';
@@ -9,6 +9,8 @@ import type { Tone } from '../theme/contracts';
9
9
  import PageSurface from './PageSurface.astro';
10
10
  import Breadcrumbs from '../components/navigation/Breadcrumbs/Breadcrumbs.astro';
11
11
  import ScrollToTop from '../components/actions/ScrollToTop/ScrollToTop.astro';
12
+ import ThemeBootstrap from '../components/feedback/ThemeBootstrap/ThemeBootstrap.astro';
13
+ import { defaultThemeStorageKey } from '../theme/storage';
12
14
 
13
15
  type Props = HTMLAttributes<'html'> & {
14
16
  meta: PageMeta;
@@ -20,6 +22,7 @@ type Props = HTMLAttributes<'html'> & {
20
22
  scrollToTopLabel?: string;
21
23
  structuredData?: readonly JsonLdNode[];
22
24
  tone?: Tone;
25
+ themeStorageKey?: string;
23
26
  };
24
27
 
25
28
  const {
@@ -33,6 +36,7 @@ const {
33
36
  site,
34
37
  structuredData,
35
38
  tone,
39
+ themeStorageKey = defaultThemeStorageKey,
36
40
  ...htmlAttrs
37
41
  } = Astro.props;
38
42
  const lang = meta.lang ?? site.defaultMeta.lang;
@@ -44,6 +48,7 @@ const resolvedTone = tone ?? dataTone;
44
48
  <head>
45
49
  <meta charset="utf-8" />
46
50
  <meta name="viewport" content="width=device-width, initial-scale=1" />
51
+ <ThemeBootstrap storageKey={themeStorageKey} />
47
52
  <SeoHead
48
53
  breadcrumbs={breadcrumbs}
49
54
  meta={meta}
@@ -155,12 +155,12 @@
155
155
  --map-viewport-cell-surface: color-mix(
156
156
  in srgb,
157
157
  var(--map-viewport-cell-seed) 38%,
158
- var(--nox-mix-light)
158
+ var(--nox-theme-surface-base)
159
159
  );
160
160
  --map-viewport-cell-surface-hover: color-mix(
161
161
  in srgb,
162
162
  var(--map-viewport-cell-seed) 52%,
163
- var(--nox-mix-light)
163
+ var(--nox-theme-surface-base)
164
164
  );
165
165
  --map-viewport-cell-background: color-mix(
166
166
  in srgb,
@@ -175,7 +175,7 @@
175
175
  --map-viewport-cell-border: color-mix(
176
176
  in srgb,
177
177
  var(--map-viewport-cell-seed) 74%,
178
- var(--nox-mix-dark)
178
+ var(--nox-theme-text-base)
179
179
  );
180
180
 
181
181
  fill: var(--map-viewport-cell-background);
@@ -17,6 +17,7 @@
17
17
  @import '../components/actions/ActionListItem/ActionListItem.css'
18
18
  layer(components);
19
19
  @import '../components/actions/Button/Button.css' layer(components);
20
+ @import '../components/actions/ThemeToggle/ThemeToggle.css' layer(components);
20
21
  @import '../components/actions/CardAction/CardAction.css'
21
22
  layer(components);
22
23
  @import '../components/actions/ScrollToTop/ScrollToTop.css'
@@ -27,6 +27,42 @@
27
27
  --nox-mix-light: #ffffff;
28
28
  --nox-mix-dark: #020617;
29
29
  --nox-tone-seed: var(--nox-source-tone-neutral);
30
+
31
+ /* Theme recipe anchors. Light mode preserves the original palette. */
32
+ --nox-theme-page-base: var(--nox-mix-light);
33
+ --nox-theme-surface-base: var(--nox-mix-light);
34
+ --nox-theme-text-base: var(--nox-mix-dark);
35
+ --nox-theme-text-disabled-base: var(--nox-mix-light);
36
+ --nox-theme-border-base: var(--nox-mix-light);
37
+ --nox-theme-inverse-base: var(--nox-mix-light);
38
+ --nox-theme-shadow-base: var(--nox-mix-dark);
39
+ }
40
+
41
+ :root[data-theme='dark'] {
42
+ color-scheme: dark;
43
+
44
+ --nox-source-tone-neutral: #8f9bad;
45
+ --nox-source-tone-primary: #a78bfa;
46
+ --nox-source-tone-info: #73a4ff;
47
+ --nox-source-tone-success: #42d392;
48
+ --nox-source-tone-warning: #f0ad55;
49
+ --nox-source-tone-danger: #f47777;
50
+
51
+ --nox-source-map-available: #42d392;
52
+ --nox-source-map-unavailable: #fb7185;
53
+ --nox-source-map-field: #fbbf24;
54
+ --nox-source-map-grass: #84cc16;
55
+ --nox-source-map-mountain: #94a3b8;
56
+ --nox-source-map-water: #38bdf8;
57
+ --nox-source-map-range: #60a5fa;
58
+
59
+ --nox-theme-page-base: #0f1218;
60
+ --nox-theme-surface-base: #171b23;
61
+ --nox-theme-text-base: #f8fafc;
62
+ --nox-theme-text-disabled-base: #657184;
63
+ --nox-theme-border-base: #252b35;
64
+ --nox-theme-inverse-base: #0f1218;
65
+ --nox-theme-shadow-base: #000000;
30
66
  }
31
67
 
32
68
  :root,
@@ -35,51 +71,55 @@
35
71
  --nox-page-background: color-mix(
36
72
  in srgb,
37
73
  var(--nox-tone-seed) 11%,
38
- var(--nox-mix-light)
74
+ var(--nox-theme-page-base)
39
75
  );
40
76
  --nox-surface: color-mix(
41
77
  in srgb,
42
78
  var(--nox-tone-seed) 2%,
43
- var(--nox-mix-light)
79
+ var(--nox-theme-surface-base)
44
80
  );
45
81
  --nox-surface-muted: color-mix(
46
82
  in srgb,
47
83
  var(--nox-tone-seed) 8%,
48
- var(--nox-mix-light)
84
+ var(--nox-theme-surface-base)
49
85
  );
50
86
  --nox-surface-strong: color-mix(
51
87
  in srgb,
52
88
  var(--nox-tone-seed) 14%,
53
- var(--nox-mix-light)
89
+ var(--nox-theme-surface-base)
54
90
  );
55
91
 
56
- --nox-text: color-mix(in srgb, var(--nox-tone-seed) 18%, var(--nox-mix-dark));
92
+ --nox-text: color-mix(
93
+ in srgb,
94
+ var(--nox-tone-seed) 18%,
95
+ var(--nox-theme-text-base)
96
+ );
57
97
  --nox-text-muted: color-mix(
58
98
  in srgb,
59
99
  var(--nox-tone-seed) 52%,
60
- var(--nox-mix-dark)
100
+ var(--nox-theme-text-base)
61
101
  );
62
102
  --nox-text-subtle: color-mix(
63
103
  in srgb,
64
104
  var(--nox-tone-seed) 68%,
65
- var(--nox-mix-dark)
105
+ var(--nox-theme-text-base)
66
106
  );
67
107
  --nox-text-disabled: color-mix(
68
108
  in srgb,
69
109
  var(--nox-tone-seed) 36%,
70
- var(--nox-mix-light)
110
+ var(--nox-theme-text-disabled-base)
71
111
  );
72
- --nox-text-inverse: var(--nox-mix-light);
112
+ --nox-text-inverse: var(--nox-theme-inverse-base);
73
113
 
74
114
  --nox-border: color-mix(
75
115
  in srgb,
76
116
  var(--nox-tone-seed) 28%,
77
- var(--nox-mix-light)
117
+ var(--nox-theme-border-base)
78
118
  );
79
119
  --nox-border-muted: color-mix(
80
120
  in srgb,
81
121
  var(--nox-tone-seed) 18%,
82
- var(--nox-mix-light)
122
+ var(--nox-theme-border-base)
83
123
  );
84
124
 
85
125
  /* Tone interaction slots. */
@@ -89,12 +129,12 @@
89
129
  --nox-tone-hover-background: color-mix(
90
130
  in srgb,
91
131
  var(--nox-tone-seed) 12%,
92
- var(--nox-mix-light)
132
+ var(--nox-theme-surface-base)
93
133
  );
94
134
  --nox-tone-active-background: color-mix(
95
135
  in srgb,
96
136
  var(--nox-tone-seed) 20%,
97
- var(--nox-mix-light)
137
+ var(--nox-theme-surface-base)
98
138
  );
99
139
  --nox-tone-border: color-mix(
100
140
  in srgb,
@@ -104,13 +144,13 @@
104
144
  --nox-tone-text: color-mix(
105
145
  in srgb,
106
146
  var(--nox-tone-seed) 72%,
107
- var(--nox-mix-dark)
147
+ var(--nox-theme-text-base)
108
148
  );
109
149
  --nox-tone-focus-ring: var(--nox-tone-seed);
110
150
  --nox-tone-shadow: color-mix(
111
151
  in srgb,
112
152
  var(--nox-tone-seed) 24%,
113
- var(--nox-mix-dark)
153
+ var(--nox-theme-shadow-base)
114
154
  );
115
155
 
116
156
  /* Shared framed-data surface slots. */
@@ -5,3 +5,5 @@ export type Tone =
5
5
  | 'success'
6
6
  | 'warning'
7
7
  | 'danger';
8
+
9
+ export type ColorTheme = 'light' | 'dark';
@@ -1 +1,2 @@
1
1
  export * from './contracts';
2
+ export * from './storage';
@@ -0,0 +1 @@
1
+ export const defaultThemeStorageKey = 'astronox:theme';