@astronox/core 0.2.2 → 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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/src/admin/cache.ts +217 -0
  3. package/src/admin/index.ts +1 -0
  4. package/src/auth/firebase/client.ts +37 -7
  5. package/src/client/formSubmission.ts +177 -0
  6. package/src/client/index.ts +1 -0
  7. package/src/components/actions/Button/Button.css +1 -1
  8. package/src/components/actions/CardAction/CardAction.css +3 -3
  9. package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
  10. package/src/components/actions/ThemeToggle/ThemeToggle.astro +59 -0
  11. package/src/components/actions/ThemeToggle/ThemeToggle.css +83 -0
  12. package/src/components/actions/ThemeToggle/themeToggleController.ts +111 -0
  13. package/src/components/content/CardBody/CardBody.css +6 -2
  14. package/src/components/content/CardFooter/CardFooter.astro +9 -0
  15. package/src/components/content/CardFooter/CardFooter.css +45 -3
  16. package/src/components/content/CardLink/CardLink.astro +12 -3
  17. package/src/components/content/CardLink/CardLink.css +32 -0
  18. package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
  19. package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
  20. package/src/components/content/MarkdownContent/contracts.ts +1 -1
  21. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +7 -4
  22. package/src/components/data-display/TextPair/TextPair.css +8 -2
  23. package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +31 -0
  24. package/src/components/forms/ChoiceGroup/ChoiceGroup.css +1 -1
  25. package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
  26. package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
  27. package/src/components/forms/Textarea/Textarea.astro +3 -0
  28. package/src/components/forms/Textarea/Textarea.css +8 -0
  29. package/src/components/icons/CircleSymbolIcon.css +3 -3
  30. package/src/components/index.ts +10 -0
  31. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
  32. package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
  33. package/src/components/navigation/DesktopNavigation.css +4 -1
  34. package/src/components/navigation/Footer/Footer.astro +102 -0
  35. package/src/components/navigation/Footer/Footer.css +155 -0
  36. package/src/components/navigation/Footer/contracts.ts +17 -0
  37. package/src/components/navigation/Header.astro +20 -2
  38. package/src/components/navigation/MobileNavigation.astro +107 -43
  39. package/src/components/navigation/MobileNavigation.css +109 -24
  40. package/src/components/navigation/NavigationBrand.css +1 -1
  41. package/src/components/navigation/NavigationControl.css +16 -2
  42. package/src/components/navigation/NavigationItemContent.astro +6 -1
  43. package/src/components/navigation/mobileNavigationController.ts +0 -16
  44. package/src/components/navigation/navigationTypes.ts +9 -1
  45. package/src/composition/Grid/Grid.css +16 -0
  46. package/src/data/firestore/client/contracts.ts +23 -0
  47. package/src/data/firestore/client/firebase.ts +45 -1
  48. package/src/display/contracts.ts +5 -1
  49. package/src/layouts/BaseLayout.astro +13 -1
  50. package/src/patterns/Combobox/comboboxController.ts +69 -0
  51. package/src/patterns/MapViewport/MapViewport.css +3 -3
  52. package/src/patterns/Table/TableBody.css +3 -3
  53. package/src/patterns/Table/TableCell.css +5 -1
  54. package/src/styles/base.css +24 -7
  55. package/src/styles/style.css +2 -0
  56. package/src/styles/tokens.css +60 -15
  57. package/src/theme/contracts.ts +2 -0
  58. package/src/theme/index.ts +1 -0
  59. package/src/theme/storage.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.2.2",
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": [
@@ -0,0 +1,217 @@
1
+ import type { AdminPage } from './contracts';
2
+
3
+ export type AdminResourceCacheSerializer<TItem, TStored> = {
4
+ parse(value: unknown): TItem | null;
5
+ serialize(item: TItem): TStored;
6
+ };
7
+
8
+ export type AdminResourceCacheConfig<TItem, TStored> = {
9
+ collectionKey: string;
10
+ editorKey: string;
11
+ getKey(item: TItem): string;
12
+ serializer: AdminResourceCacheSerializer<TItem, TStored>;
13
+ sort(items: readonly TItem[]): TItem[];
14
+ ttlMs?: number;
15
+ };
16
+
17
+ type StoredPage<TStored> = {
18
+ expiresAt: number;
19
+ items: TStored[];
20
+ nextCursor?: string;
21
+ };
22
+
23
+ type StoredEditor<TStored> = {
24
+ actorId: string;
25
+ expiresAt: number;
26
+ item: TStored;
27
+ };
28
+
29
+ const localCursorPrefix = 'cache:';
30
+
31
+ export function createAdminResourceCache<TItem, TStored>({
32
+ collectionKey,
33
+ editorKey,
34
+ getKey,
35
+ serializer,
36
+ sort,
37
+ ttlMs = 10 * 60 * 1000,
38
+ }: AdminResourceCacheConfig<TItem, TStored>) {
39
+ const collectionCacheKey = (actorId: string) =>
40
+ `${collectionKey}:${encodeURIComponent(actorId)}`;
41
+ const editorCacheKey = (id: string) =>
42
+ `${editorKey}:${encodeURIComponent(id)}`;
43
+
44
+ const readSnapshot = (actorId: string): AdminPage<TItem> | null => {
45
+ try {
46
+ const key = collectionCacheKey(actorId);
47
+ const raw = sessionStorage.getItem(key);
48
+ if (!raw) return null;
49
+ const stored = JSON.parse(raw) as Partial<StoredPage<TStored>>;
50
+ if (
51
+ typeof stored.expiresAt !== 'number' ||
52
+ stored.expiresAt <= Date.now() ||
53
+ !Array.isArray(stored.items)
54
+ ) {
55
+ sessionStorage.removeItem(key);
56
+ return null;
57
+ }
58
+
59
+ const items: TItem[] = [];
60
+ for (const value of stored.items) {
61
+ const item = serializer.parse(value);
62
+ if (!item) {
63
+ sessionStorage.removeItem(key);
64
+ return null;
65
+ }
66
+ items.push(item);
67
+ }
68
+
69
+ return {
70
+ items: sort(items),
71
+ nextCursor:
72
+ typeof stored.nextCursor === 'string' ? stored.nextCursor : undefined,
73
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+
79
+ const write = (actorId: string, page: AdminPage<TItem>): void => {
80
+ try {
81
+ const payload: StoredPage<TStored> = {
82
+ expiresAt: Date.now() + ttlMs,
83
+ items: sort(page.items).map(serializer.serialize),
84
+ nextCursor: page.nextCursor,
85
+ };
86
+ sessionStorage.setItem(
87
+ collectionCacheKey(actorId),
88
+ JSON.stringify(payload),
89
+ );
90
+ } catch {
91
+ // Storage is an optimization and must never block administration.
92
+ }
93
+ };
94
+
95
+ return {
96
+ editorKey,
97
+ isLocalCursor(cursor?: string): boolean {
98
+ return cursor?.startsWith(localCursorPrefix) === true;
99
+ },
100
+ read(
101
+ actorId: string,
102
+ input?: { cursor?: string; limit?: number },
103
+ ): AdminPage<TItem> | null {
104
+ const cached = readSnapshot(actorId);
105
+ if (!cached) return null;
106
+ if (input?.cursor && !input.cursor.startsWith(localCursorPrefix)) {
107
+ return null;
108
+ }
109
+ const offset = input?.cursor
110
+ ? Number.parseInt(input.cursor.slice(localCursorPrefix.length), 10)
111
+ : 0;
112
+ if (!Number.isSafeInteger(offset) || offset < 0) return null;
113
+ const limit = Math.max(1, Math.floor(input?.limit ?? 25));
114
+ const end = Math.min(cached.items.length, offset + limit);
115
+ return {
116
+ items: cached.items.slice(offset, end),
117
+ nextCursor:
118
+ end < cached.items.length
119
+ ? `${localCursorPrefix}${end}`
120
+ : cached.nextCursor,
121
+ };
122
+ },
123
+ find(actorId: string, id: string): TItem | null {
124
+ return (
125
+ readSnapshot(actorId)?.items.find((item) => getKey(item) === id) ??
126
+ null
127
+ );
128
+ },
129
+ readEditor(actorId: string, id: string): TItem | null {
130
+ try {
131
+ const key = editorCacheKey(id);
132
+ const raw = sessionStorage.getItem(key);
133
+ if (!raw) return null;
134
+ const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
135
+ if (
136
+ stored.actorId !== actorId ||
137
+ typeof stored.expiresAt !== 'number' ||
138
+ stored.expiresAt <= Date.now()
139
+ ) {
140
+ sessionStorage.removeItem(key);
141
+ return null;
142
+ }
143
+ return serializer.parse(stored.item);
144
+ } catch {
145
+ return null;
146
+ }
147
+ },
148
+ write,
149
+ append(actorId: string, page: AdminPage<TItem>): void {
150
+ const cached = readSnapshot(actorId);
151
+ if (!cached) return;
152
+ const items = new Map(cached.items.map((item) => [getKey(item), item]));
153
+ page.items.forEach((item) => items.set(getKey(item), item));
154
+ write(actorId, {
155
+ items: [...items.values()],
156
+ nextCursor: page.nextCursor,
157
+ });
158
+ },
159
+ upsert(actorId: string, item: TItem): void {
160
+ const cached = readSnapshot(actorId);
161
+ if (!cached) return;
162
+ const items = new Map(
163
+ cached.items.map((candidate) => [getKey(candidate), candidate]),
164
+ );
165
+ items.set(getKey(item), item);
166
+ write(actorId, {
167
+ items: [...items.values()],
168
+ nextCursor: cached.nextCursor,
169
+ });
170
+ },
171
+ remove(actorId: string, id: string): void {
172
+ const cached = readSnapshot(actorId);
173
+ if (cached) {
174
+ write(actorId, {
175
+ items: cached.items.filter((item) => getKey(item) !== id),
176
+ nextCursor: cached.nextCursor,
177
+ });
178
+ }
179
+ try {
180
+ sessionStorage.removeItem(editorCacheKey(id));
181
+ } catch {
182
+ // Cleanup is best effort.
183
+ }
184
+ },
185
+ writeEditor(actorId: string, item: TItem): boolean {
186
+ try {
187
+ const payload: StoredEditor<TStored> = {
188
+ actorId,
189
+ expiresAt: Date.now() + ttlMs,
190
+ item: serializer.serialize(item),
191
+ };
192
+ sessionStorage.setItem(
193
+ editorCacheKey(getKey(item)),
194
+ JSON.stringify(payload),
195
+ );
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ },
201
+ clear(actorId: string): void {
202
+ try {
203
+ sessionStorage.removeItem(collectionCacheKey(actorId));
204
+ for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
205
+ const key = sessionStorage.key(index);
206
+ if (!key?.startsWith(`${editorKey}:`)) continue;
207
+ const raw = sessionStorage.getItem(key);
208
+ if (!raw) continue;
209
+ const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
210
+ if (stored.actorId === actorId) sessionStorage.removeItem(key);
211
+ }
212
+ } catch {
213
+ // Cleanup is best effort.
214
+ }
215
+ },
216
+ } as const;
217
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './contracts';
2
+ export * from './cache';
2
3
  export * from './module';
@@ -1,21 +1,47 @@
1
- import { getApps, initializeApp } from 'firebase/app';
1
+ import { getApps, initializeApp, type FirebaseApp } from 'firebase/app';
2
2
  import {
3
+ browserLocalPersistence,
4
+ browserPopupRedirectResolver,
3
5
  GoogleAuthProvider,
4
6
  getAuth,
7
+ initializeAuth,
5
8
  onAuthStateChanged,
6
9
  signInWithPopup,
7
10
  signOut,
11
+ type Auth,
8
12
  type User,
9
13
  } from 'firebase/auth';
10
14
  import type { AuthActor } from '../contracts';
11
15
  import type { FirebaseClientConfig } from './contracts';
12
16
 
13
17
  export type FirebaseGoogleAuthClient = {
14
- observe(listener: (actor: AuthActor | null) => void): () => void;
18
+ observe(
19
+ listener: (actor: AuthActor | null) => void,
20
+ onError?: (reason: unknown) => void,
21
+ ): () => void;
15
22
  signIn(): Promise<AuthActor>;
16
23
  signOut(): Promise<void>;
17
24
  };
18
25
 
26
+ function initializeBrowserAuth(app: FirebaseApp): Auth {
27
+ try {
28
+ return initializeAuth(app, {
29
+ persistence: browserLocalPersistence,
30
+ popupRedirectResolver: browserPopupRedirectResolver,
31
+ });
32
+ } catch (reason) {
33
+ if (
34
+ typeof reason === 'object' &&
35
+ reason !== null &&
36
+ 'code' in reason &&
37
+ reason.code === 'auth/already-initialized'
38
+ ) {
39
+ return getAuth(app);
40
+ }
41
+ throw reason;
42
+ }
43
+ }
44
+
19
45
  function toAuthActor(user: User): AuthActor {
20
46
  return {
21
47
  id: user.uid,
@@ -32,15 +58,19 @@ export function createFirebaseGoogleAuthClient(
32
58
  const app =
33
59
  getApps().find((candidate) => candidate.name === config.projectId) ??
34
60
  initializeApp(config, config.projectId);
35
- const auth = getAuth(app);
61
+ const auth = initializeBrowserAuth(app);
36
62
  const provider = new GoogleAuthProvider();
37
63
  provider.setCustomParameters({ prompt: 'select_account' });
38
64
 
39
65
  return {
40
- observe(listener) {
41
- return onAuthStateChanged(auth, (user) => {
42
- listener(user ? toAuthActor(user) : null);
43
- });
66
+ observe(listener, onError) {
67
+ return onAuthStateChanged(
68
+ auth,
69
+ (user) => {
70
+ listener(user ? toAuthActor(user) : null);
71
+ },
72
+ onError,
73
+ );
44
74
  },
45
75
  async signIn() {
46
76
  const result = await signInWithPopup(auth, provider);
@@ -0,0 +1,177 @@
1
+ export type FormSubmissionMode = 'create' | 'edit';
2
+
3
+ export type FormSubmissionState = {
4
+ dirty: boolean;
5
+ pending: boolean;
6
+ };
7
+
8
+ export type FormSubmissionResult<TValue> = {
9
+ status: 'saved' | 'unchanged';
10
+ value: TValue;
11
+ };
12
+
13
+ export type FormSubmissionControllerConfig<TValue> = {
14
+ form: HTMLFormElement;
15
+ mode: FormSubmissionMode;
16
+ readValue(): TValue;
17
+ save(value: TValue): Promise<void>;
18
+ onSuccess(result: FormSubmissionResult<TValue>): void | Promise<void>;
19
+ onError?(reason: unknown): void;
20
+ onStateChange?(state: FormSubmissionState): void;
21
+ changeEvents?: readonly string[];
22
+ equals?(left: TValue, right: TValue): boolean;
23
+ };
24
+
25
+ export type FormSubmissionController = {
26
+ destroy(): void;
27
+ refresh(): void;
28
+ };
29
+
30
+ const defaultChangeEvents = ['input', 'change'] as const;
31
+
32
+ export function createFormSubmissionController<TValue>({
33
+ form,
34
+ mode,
35
+ readValue,
36
+ save,
37
+ onSuccess,
38
+ onError,
39
+ onStateChange,
40
+ changeEvents = defaultChangeEvents,
41
+ equals = areStructurallyEqual,
42
+ }: FormSubmissionControllerConfig<TValue>): FormSubmissionController {
43
+ let baseline!: TValue;
44
+ if (mode === 'edit') baseline = readValue();
45
+ let dirty = mode === 'create';
46
+ let pending = false;
47
+ let destroyed = false;
48
+
49
+ const publishState = (): void => {
50
+ onStateChange?.({ dirty, pending });
51
+ };
52
+
53
+ const refresh = (): void => {
54
+ if (destroyed || pending) return;
55
+
56
+ let nextDirty = true;
57
+ if (mode === 'edit') {
58
+ try {
59
+ nextDirty = !equals(baseline, readValue());
60
+ } catch {
61
+ // An incomplete value is still a change; native validation owns errors.
62
+ }
63
+ }
64
+
65
+ if (nextDirty === dirty) return;
66
+ dirty = nextDirty;
67
+ publishState();
68
+ };
69
+
70
+ const handleChange = (): void => refresh();
71
+
72
+ const handleSubmit = async (event: SubmitEvent): Promise<void> => {
73
+ event.preventDefault();
74
+ if (pending || !form.reportValidity()) return;
75
+
76
+ let value: TValue;
77
+ try {
78
+ value = readValue();
79
+ } catch (reason) {
80
+ onError?.(reason);
81
+ return;
82
+ }
83
+
84
+ if (mode === 'edit' && equals(baseline, value)) {
85
+ await onSuccess({ status: 'unchanged', value });
86
+ return;
87
+ }
88
+
89
+ pending = true;
90
+ publishState();
91
+
92
+ try {
93
+ await save(value);
94
+ baseline = value;
95
+ dirty = false;
96
+ pending = false;
97
+ publishState();
98
+ await onSuccess({ status: 'saved', value });
99
+ } catch (reason) {
100
+ pending = false;
101
+ refresh();
102
+ publishState();
103
+ onError?.(reason);
104
+ }
105
+ };
106
+
107
+ for (const eventName of new Set(changeEvents)) {
108
+ form.addEventListener(eventName, handleChange);
109
+ }
110
+ form.addEventListener('submit', handleSubmit);
111
+ publishState();
112
+
113
+ return {
114
+ destroy(): void {
115
+ if (destroyed) return;
116
+ destroyed = true;
117
+ for (const eventName of new Set(changeEvents)) {
118
+ form.removeEventListener(eventName, handleChange);
119
+ }
120
+ form.removeEventListener('submit', handleSubmit);
121
+ },
122
+ refresh,
123
+ };
124
+ }
125
+
126
+ function areStructurallyEqual<TValue>(left: TValue, right: TValue): boolean {
127
+ return compareValues(left, right, new WeakMap<object, object>());
128
+ }
129
+
130
+ function compareValues(
131
+ left: unknown,
132
+ right: unknown,
133
+ visited: WeakMap<object, object>,
134
+ ): boolean {
135
+ if (Object.is(left, right)) return true;
136
+ if (left instanceof Date || right instanceof Date) {
137
+ return (
138
+ left instanceof Date &&
139
+ right instanceof Date &&
140
+ left.getTime() === right.getTime()
141
+ );
142
+ }
143
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') {
144
+ return false;
145
+ }
146
+
147
+ const knownRight = visited.get(left);
148
+ if (knownRight) return knownRight === right;
149
+ visited.set(left, right);
150
+
151
+ if (Array.isArray(left) || Array.isArray(right)) {
152
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
153
+ return (
154
+ left.length === right.length &&
155
+ left.every((value, index) => compareValues(value, right[index], visited))
156
+ );
157
+ }
158
+
159
+ const leftPrototype = Object.getPrototypeOf(left);
160
+ const rightPrototype = Object.getPrototypeOf(right);
161
+ if (leftPrototype !== rightPrototype) return false;
162
+
163
+ const leftKeys = Object.keys(left).sort();
164
+ const rightKeys = Object.keys(right).sort();
165
+ if (
166
+ leftKeys.length !== rightKeys.length ||
167
+ leftKeys.some((key, index) => key !== rightKeys[index])
168
+ ) {
169
+ return false;
170
+ }
171
+
172
+ const leftRecord = left as Record<string, unknown>;
173
+ const rightRecord = right as Record<string, unknown>;
174
+ return leftKeys.every((key) =>
175
+ compareValues(leftRecord[key], rightRecord[key], visited),
176
+ );
177
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './clipboard';
2
2
  export * from './focus';
3
+ export * from './formSubmission';
3
4
  export * from './preferences';
4
5
  export * from './scroll';
5
6
  export * from '../components/content/MarkdownContent/markdownContentRenderer';
@@ -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
 
@@ -11,9 +11,20 @@
11
11
  block-size: var(--nox-section-compact-block-size);
12
12
  min-block-size: 0;
13
13
  box-shadow: var(--nox-shadow-surface);
14
+ opacity: 0.8;
15
+ }
16
+
17
+ .scroll-to-top.nox-button:is(:focus-visible, :active) {
18
+ opacity: 1;
14
19
  }
15
20
 
16
21
  html:has(dialog[open]) .scroll-to-top,
17
22
  html:has(:fullscreen) .scroll-to-top {
18
23
  display: none;
19
24
  }
25
+
26
+ @media (hover: hover) and (pointer: fine) {
27
+ .scroll-to-top.nox-button:hover {
28
+ opacity: 1;
29
+ }
30
+ }
@@ -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
+ }