@levino/shipyard-base 0.5.11 → 0.6.0

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.
@@ -0,0 +1,44 @@
1
+ ---
2
+ /**
3
+ * Admonition component for displaying callouts/alerts.
4
+ * Supports: note, tip, info, warning, danger
5
+ *
6
+ * @example
7
+ * ```astro
8
+ * <Admonition type="note">
9
+ * This is a note
10
+ * </Admonition>
11
+ *
12
+ * <Admonition type="warning" title="Be Careful">
13
+ * This is a warning with custom title
14
+ * </Admonition>
15
+ * ```
16
+ */
17
+ export type AdmonitionType = 'note' | 'tip' | 'info' | 'warning' | 'danger'
18
+
19
+ interface Props {
20
+ type: AdmonitionType
21
+ title?: string
22
+ }
23
+
24
+ const { type, title } = Astro.props
25
+
26
+ const defaultTitles: Record<AdmonitionType, string> = {
27
+ note: 'Note',
28
+ tip: 'Tip',
29
+ info: 'Info',
30
+ warning: 'Warning',
31
+ danger: 'Danger',
32
+ }
33
+
34
+ const displayTitle = title ?? defaultTitles[type]
35
+ ---
36
+
37
+ <div class={`admonition admonition-${type}`} data-admonition-type={type}>
38
+ <div class="admonition-heading">
39
+ {displayTitle}
40
+ </div>
41
+ <div class="admonition-content">
42
+ <slot />
43
+ </div>
44
+ </div>
@@ -0,0 +1,131 @@
1
+ ---
2
+ import type { AnnouncementBar } from '../../src/schemas/config'
3
+
4
+ interface Props {
5
+ config: AnnouncementBar
6
+ }
7
+
8
+ const { config } = Astro.props
9
+ const {
10
+ id = 'announcement-bar',
11
+ content,
12
+ backgroundColor = 'primary',
13
+ textColor = 'primary-content',
14
+ isCloseable = true,
15
+ } = config
16
+
17
+ // Determine CSS classes for colors
18
+ const getDaisyClass = (
19
+ color: string,
20
+ type: 'bg' | 'text',
21
+ ): string | undefined => {
22
+ const daisyColors = [
23
+ 'primary',
24
+ 'secondary',
25
+ 'accent',
26
+ 'info',
27
+ 'success',
28
+ 'warning',
29
+ 'error',
30
+ 'primary-content',
31
+ 'secondary-content',
32
+ 'accent-content',
33
+ 'base-content',
34
+ ]
35
+ if (daisyColors.includes(color)) {
36
+ return `${type}-${color}`
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ const bgClass = getDaisyClass(backgroundColor, 'bg')
42
+ const textClass = getDaisyClass(textColor, 'text')
43
+
44
+ // Use custom style if not a DaisyUI color
45
+ const customStyle = [
46
+ bgClass ? undefined : `background-color: ${backgroundColor}`,
47
+ textClass ? undefined : `color: ${textColor}`,
48
+ ]
49
+ .filter(Boolean)
50
+ .join('; ')
51
+ ---
52
+
53
+ <div
54
+ id={id}
55
+ class:list={[
56
+ 'announcement-bar',
57
+ 'py-2 px-4 text-center text-sm font-medium relative',
58
+ bgClass,
59
+ textClass,
60
+ ]}
61
+ style={customStyle || undefined}
62
+ role="banner"
63
+ >
64
+ <div class="announcement-content" set:html={content} />
65
+ {
66
+ isCloseable && (
67
+ <button
68
+ type="button"
69
+ class="announcement-close absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:opacity-70 transition-opacity"
70
+ aria-label="Close announcement"
71
+ data-announcement-id={id}
72
+ >
73
+ <svg
74
+ xmlns="http://www.w3.org/2000/svg"
75
+ class="h-4 w-4"
76
+ fill="none"
77
+ viewBox="0 0 24 24"
78
+ stroke="currentColor"
79
+ stroke-width="2"
80
+ >
81
+ <path
82
+ stroke-linecap="round"
83
+ stroke-linejoin="round"
84
+ d="M6 18L18 6M6 6l12 12"
85
+ />
86
+ </svg>
87
+ </button>
88
+ )
89
+ }
90
+ </div>
91
+
92
+ <script>
93
+ // Handle announcement bar dismissal with localStorage persistence
94
+ document.addEventListener('DOMContentLoaded', () => {
95
+ const closeButtons = document.querySelectorAll('.announcement-close')
96
+
97
+ closeButtons.forEach((button) => {
98
+ const announcementId = button.getAttribute('data-announcement-id')
99
+ const storageKey = `shipyard-announcement-dismissed-${announcementId}`
100
+
101
+ // Check if already dismissed
102
+ if (localStorage.getItem(storageKey) === 'true') {
103
+ const bar = button.closest('.announcement-bar')
104
+ if (bar) {
105
+ bar.remove()
106
+ }
107
+ return
108
+ }
109
+
110
+ // Handle close click
111
+ button.addEventListener('click', () => {
112
+ const bar = button.closest('.announcement-bar')
113
+ if (bar) {
114
+ bar.remove()
115
+ localStorage.setItem(storageKey, 'true')
116
+ }
117
+ })
118
+ })
119
+ })
120
+ </script>
121
+
122
+ <style>
123
+ .announcement-content :global(a) {
124
+ text-decoration: underline;
125
+ font-weight: 600;
126
+ }
127
+
128
+ .announcement-content :global(a:hover) {
129
+ opacity: 0.8;
130
+ }
131
+ </style>
@@ -2,6 +2,7 @@
2
2
  import type { Config } from '../../src/schemas/config'
3
3
  import { cn } from '../../src/tools/cn'
4
4
  import LanguageSwitcher from './LanguageSwitcher.astro'
5
+ import ThemeToggle from './ThemeToggle.astro'
5
6
 
6
7
  type Props = Pick<Config, 'brand' | 'navigation'> & { showBrand: boolean }
7
8
 
@@ -69,6 +70,7 @@ const { brand, navigation, showBrand = false } = Astro.props as Props
69
70
  )
70
71
  }
71
72
  </ul>
73
+ <ThemeToggle class="btn btn-ghost btn-circle" />
72
74
  <LanguageSwitcher variant="dropdown" />
73
75
  </div>
74
76
  </div>
@@ -0,0 +1,54 @@
1
+ ---
2
+ /**
3
+ * Client-side script for npm2yarn tabs functionality.
4
+ * Include this component once in your layout to enable tab switching.
5
+ */
6
+ ---
7
+
8
+ <script>
9
+ // Initialize npm2yarn tabs
10
+ document.addEventListener('DOMContentLoaded', () => {
11
+ const tabContainers = document.querySelectorAll('.npm2yarn-tabs')
12
+
13
+ tabContainers.forEach((container) => {
14
+ const buttons = container.querySelectorAll('.tab-button')
15
+ const contents = container.querySelectorAll('.tab-content')
16
+
17
+ buttons.forEach((button) => {
18
+ button.addEventListener('click', () => {
19
+ const tabId = button.getAttribute('data-tab')
20
+
21
+ // Update buttons
22
+ buttons.forEach((btn) => btn.classList.remove('active'))
23
+ button.classList.add('active')
24
+
25
+ // Update content
26
+ contents.forEach((content) => {
27
+ const contentId = content.getAttribute('data-tab-content')
28
+ if (contentId === tabId) {
29
+ content.classList.add('active')
30
+ } else {
31
+ content.classList.remove('active')
32
+ }
33
+ })
34
+
35
+ // Store preference in localStorage
36
+ if (tabId) {
37
+ localStorage.setItem('shipyard-package-manager', tabId)
38
+ }
39
+ })
40
+ })
41
+
42
+ // Restore preference from localStorage
43
+ const savedPm = localStorage.getItem('shipyard-package-manager')
44
+ if (savedPm) {
45
+ const savedButton = container.querySelector(
46
+ `.tab-button[data-tab="${savedPm}"]`,
47
+ )
48
+ if (savedButton) {
49
+ ;(savedButton as HTMLButtonElement).click()
50
+ }
51
+ }
52
+ })
53
+ })
54
+ </script>
@@ -30,20 +30,72 @@ const isActiveOrHasActiveChild = (entryValue: Entry[string]): boolean => {
30
30
  const label = entryValue.label ?? key;
31
31
  const isActive = entryValue.href && normalizePath(entryValue.href) === normalizePath(currentPath);
32
32
  const hasActiveChild = entryValue.subEntry && Object.values(entryValue.subEntry).some(isActiveOrHasActiveChild);
33
+ const hasChildren = entryValue.subEntry && Object.keys(entryValue.subEntry).length > 0;
34
+ const isCollapsible = hasChildren && (entryValue.collapsible !== false);
35
+ // Auto-expand if has active child, otherwise use collapsed setting (default true)
36
+ const shouldBeOpen = hasActiveChild || !(entryValue.collapsed ?? true);
37
+
38
+ // Extract badge info from customProps
39
+ const badge = entryValue.customProps?.badge as string | undefined;
40
+ const badgeType = (entryValue.customProps?.badgeType as string) ?? 'info';
41
+ const badgeClass = badge ? `badge badge-${badgeType} badge-sm` : undefined;
42
+
43
+ // Helper to render label with optional badge
44
+ const renderLabelWithBadge = (labelContent: any, extraClass?: string) => (
45
+ <span class={cn('flex items-center gap-2', extraClass)}>
46
+ {labelContent}
47
+ {badge && <span class={badgeClass}>{badge}</span>}
48
+ </span>
49
+ );
50
+
33
51
  return (
34
52
  <li class={entryValue.className}>
35
- {entryValue.href
36
- ? entryValue.labelHtml
37
- ? <Fragment set:html={entryValue.labelHtml} />
38
- : <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{label}</a>
39
- : entryValue.labelHtml
40
- ? <Fragment set:html={entryValue.labelHtml} />
41
- : <span class='menu-title'>{label}</span>}
42
- {entryValue.subEntry ? (
43
- <ul>
44
- <Astro.self entry={entryValue.subEntry} currentPath={currentPath} />
45
- </ul>
46
- ) : null}
53
+ {hasChildren && isCollapsible ? (
54
+ // Collapsible category with children
55
+ <details open={shouldBeOpen}>
56
+ <summary>
57
+ {entryValue.href ? (
58
+ entryValue.labelHtml
59
+ ? <Fragment set:html={entryValue.labelHtml} />
60
+ : <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{renderLabelWithBadge(label)}</a>
61
+ ) : (
62
+ entryValue.labelHtml
63
+ ? <Fragment set:html={entryValue.labelHtml} />
64
+ : <span class='menu-title'>{renderLabelWithBadge(label)}</span>
65
+ )}
66
+ </summary>
67
+ <ul>
68
+ <Astro.self entry={entryValue.subEntry} currentPath={currentPath} />
69
+ </ul>
70
+ </details>
71
+ ) : hasChildren ? (
72
+ // Non-collapsible category with children (always expanded)
73
+ <>
74
+ {entryValue.href ? (
75
+ entryValue.labelHtml
76
+ ? <Fragment set:html={entryValue.labelHtml} />
77
+ : <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{renderLabelWithBadge(label)}</a>
78
+ ) : (
79
+ entryValue.labelHtml
80
+ ? <Fragment set:html={entryValue.labelHtml} />
81
+ : <span class='menu-title'>{renderLabelWithBadge(label)}</span>
82
+ )}
83
+ <ul>
84
+ <Astro.self entry={entryValue.subEntry} currentPath={currentPath} />
85
+ </ul>
86
+ </>
87
+ ) : (
88
+ // Leaf node (no children)
89
+ entryValue.href ? (
90
+ entryValue.labelHtml
91
+ ? <Fragment set:html={entryValue.labelHtml} />
92
+ : <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{renderLabelWithBadge(label)}</a>
93
+ ) : (
94
+ entryValue.labelHtml
95
+ ? <Fragment set:html={entryValue.labelHtml} />
96
+ : <span class='menu-title'>{renderLabelWithBadge(label)}</span>
97
+ )
98
+ )}
47
99
  </li>
48
100
  )
49
101
  })}
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../../src/tools/cn'
3
3
  import LanguageSwitcher from './LanguageSwitcher.astro'
4
4
  import SidebarElement from './SidebarElement.astro'
5
+ import ThemeToggle from './ThemeToggle.astro'
5
6
  import type { Entry } from './types'
6
7
 
7
8
  interface Props {
@@ -36,6 +37,10 @@ const withLocale = (path: string) =>
36
37
  }
37
38
  </li>
38
39
  <LanguageSwitcher variant="list" />
40
+ <li class="flex flex-row items-center gap-2 px-4 py-2">
41
+ <span class="text-sm opacity-70">Theme</span>
42
+ <ThemeToggle />
43
+ </li>
39
44
  <div class={cn("divider my-1 block md:hidden", { hidden: !local })}></div>
40
45
  </div>
41
46
  {local && <div data-testid="sidebar-local-nav"><SidebarElement entry={local} /></div>}
@@ -0,0 +1,39 @@
1
+ ---
2
+ /**
3
+ * TabItem component for use inside Tabs.
4
+ *
5
+ * @example
6
+ * ```astro
7
+ * <Tabs items={['Tab 1', 'Tab 2']}>
8
+ * <TabItem value="Tab 1">
9
+ * Content for tab 1
10
+ * </TabItem>
11
+ * <TabItem value="Tab 2">
12
+ * Content for tab 2
13
+ * </TabItem>
14
+ * </Tabs>
15
+ * ```
16
+ */
17
+
18
+ interface Props {
19
+ /**
20
+ * The value that matches the tab button label
21
+ */
22
+ value: string
23
+ /**
24
+ * Whether this tab is shown by default
25
+ */
26
+ default?: boolean
27
+ }
28
+
29
+ const { value, default: isDefault } = Astro.props
30
+ ---
31
+
32
+ <div
33
+ class="shipyard-tab-panel"
34
+ role="tabpanel"
35
+ data-tab-value={value}
36
+ hidden={!isDefault}
37
+ >
38
+ <slot />
39
+ </div>
@@ -0,0 +1,200 @@
1
+ ---
2
+ /**
3
+ * Tabs component for organizing content into tabbed panels.
4
+ * Supports groupId for syncing tabs across the page.
5
+ *
6
+ * @example
7
+ * ```astro
8
+ * <Tabs items={['JavaScript', 'TypeScript', 'Python']}>
9
+ * <TabItem value="JavaScript">
10
+ * JS content
11
+ * </TabItem>
12
+ * <TabItem value="TypeScript">
13
+ * TS content
14
+ * </TabItem>
15
+ * <TabItem value="Python">
16
+ * Python content
17
+ * </TabItem>
18
+ * </Tabs>
19
+ * ```
20
+ */
21
+
22
+ interface Props {
23
+ /**
24
+ * Array of tab labels
25
+ */
26
+ items: string[]
27
+ /**
28
+ * Group ID for syncing tabs across the page
29
+ */
30
+ groupId?: string
31
+ /**
32
+ * Whether to persist selection in URL query string
33
+ */
34
+ queryString?: boolean | string
35
+ /**
36
+ * Default selected tab (by value/label)
37
+ */
38
+ defaultValue?: string
39
+ }
40
+
41
+ const { items, groupId, queryString, defaultValue } = Astro.props
42
+
43
+ const tabId = `tabs-${Math.random().toString(36).slice(2, 9)}`
44
+ const defaultTab = defaultValue ?? items[0]
45
+ ---
46
+
47
+ <div
48
+ class="shipyard-tabs"
49
+ data-tabs-id={tabId}
50
+ data-group-id={groupId}
51
+ data-query-string={queryString === true ? groupId : (queryString || undefined)}
52
+ >
53
+ <div class="shipyard-tabs-list" role="tablist" aria-label="Content tabs">
54
+ {items.map((item, index) => (
55
+ <button
56
+ type="button"
57
+ role="tab"
58
+ class="shipyard-tab-button"
59
+ id={`${tabId}-tab-${index}`}
60
+ aria-controls={`${tabId}-panel-${index}`}
61
+ aria-selected={item === defaultTab ? 'true' : 'false'}
62
+ tabindex={item === defaultTab ? 0 : -1}
63
+ data-tab-value={item}
64
+ >
65
+ {item}
66
+ </button>
67
+ ))}
68
+ </div>
69
+ <slot />
70
+ </div>
71
+
72
+ <script>
73
+ // Tab synchronization and interaction logic
74
+ function initTabs() {
75
+ const tabContainers = document.querySelectorAll('.shipyard-tabs')
76
+
77
+ tabContainers.forEach((container) => {
78
+ const buttons = container.querySelectorAll<HTMLButtonElement>('.shipyard-tab-button')
79
+ const panels = container.querySelectorAll<HTMLDivElement>('.shipyard-tab-panel')
80
+ const groupId = container.getAttribute('data-group-id')
81
+ const queryStringParam = container.getAttribute('data-query-string')
82
+
83
+ // Check URL for initial selection
84
+ if (queryStringParam) {
85
+ const urlParams = new URLSearchParams(window.location.search)
86
+ const urlValue = urlParams.get(queryStringParam)
87
+ if (urlValue) {
88
+ const matchingButton = Array.from(buttons).find(
89
+ (button) => button.getAttribute('data-tab-value') === urlValue
90
+ )
91
+ if (matchingButton) {
92
+ selectTab(matchingButton, buttons, panels, false)
93
+ }
94
+ }
95
+ }
96
+
97
+ // Check localStorage for group selection
98
+ if (groupId) {
99
+ const storedValue = localStorage.getItem(`shipyard-tabs-${groupId}`)
100
+ if (storedValue) {
101
+ const matchingButton = Array.from(buttons).find(
102
+ (button) => button.getAttribute('data-tab-value') === storedValue
103
+ )
104
+ if (matchingButton) {
105
+ selectTab(matchingButton, buttons, panels, false)
106
+ }
107
+ }
108
+ }
109
+
110
+ buttons.forEach((button) => {
111
+ button.addEventListener('click', () => {
112
+ selectTab(button, buttons, panels, true)
113
+
114
+ const value = button.getAttribute('data-tab-value')
115
+
116
+ // Sync with other tabs in the same group
117
+ if (groupId && value) {
118
+ localStorage.setItem(`shipyard-tabs-${groupId}`, value)
119
+ syncTabsInGroup(groupId, value)
120
+ }
121
+
122
+ // Update URL query string
123
+ if (queryStringParam && value) {
124
+ const url = new URL(window.location.href)
125
+ url.searchParams.set(queryStringParam, value)
126
+ window.history.replaceState({}, '', url.toString())
127
+ }
128
+ })
129
+
130
+ // Keyboard navigation
131
+ button.addEventListener('keydown', (event: KeyboardEvent) => {
132
+ const currentIndex = Array.from(buttons).indexOf(button)
133
+ let newIndex = currentIndex
134
+
135
+ if (event.key === 'ArrowRight') {
136
+ newIndex = (currentIndex + 1) % buttons.length
137
+ } else if (event.key === 'ArrowLeft') {
138
+ newIndex = (currentIndex - 1 + buttons.length) % buttons.length
139
+ } else if (event.key === 'Home') {
140
+ newIndex = 0
141
+ } else if (event.key === 'End') {
142
+ newIndex = buttons.length - 1
143
+ } else {
144
+ return
145
+ }
146
+
147
+ event.preventDefault()
148
+ buttons[newIndex].focus()
149
+ buttons[newIndex].click()
150
+ })
151
+ })
152
+ })
153
+ }
154
+
155
+ function selectTab(
156
+ selectedButton: HTMLButtonElement,
157
+ allButtons: NodeListOf<HTMLButtonElement>,
158
+ panels: NodeListOf<HTMLDivElement>,
159
+ focus: boolean
160
+ ) {
161
+ const selectedValue = selectedButton.getAttribute('data-tab-value')
162
+
163
+ allButtons.forEach((button, index) => {
164
+ const isSelected = button === selectedButton
165
+ button.setAttribute('aria-selected', isSelected ? 'true' : 'false')
166
+ button.tabIndex = isSelected ? 0 : -1
167
+
168
+ if (panels[index]) {
169
+ panels[index].hidden = !isSelected
170
+ }
171
+ })
172
+
173
+ if (focus) {
174
+ selectedButton.focus()
175
+ }
176
+ }
177
+
178
+ function syncTabsInGroup(groupId: string, value: string) {
179
+ const tabContainers = document.querySelectorAll(
180
+ `.shipyard-tabs[data-group-id="${groupId}"]`
181
+ )
182
+
183
+ tabContainers.forEach((container) => {
184
+ const buttons = container.querySelectorAll<HTMLButtonElement>('.shipyard-tab-button')
185
+ const panels = container.querySelectorAll<HTMLDivElement>('.shipyard-tab-panel')
186
+
187
+ const matchingButton = Array.from(buttons).find(
188
+ (button) => button.getAttribute('data-tab-value') === value
189
+ )
190
+
191
+ if (matchingButton) {
192
+ selectTab(matchingButton, buttons, panels, false)
193
+ }
194
+ })
195
+ }
196
+
197
+ // Initialize on DOMContentLoaded and after Astro page transitions
198
+ document.addEventListener('DOMContentLoaded', initTabs)
199
+ document.addEventListener('astro:page-load', initTabs)
200
+ </script>
@@ -0,0 +1,98 @@
1
+ ---
2
+ /**
3
+ * Theme toggle component for switching between light and dark modes.
4
+ * Uses DaisyUI themes and persists preference in localStorage.
5
+ */
6
+ interface Props {
7
+ /**
8
+ * The light theme name (DaisyUI theme).
9
+ * @default 'light'
10
+ */
11
+ lightTheme?: string
12
+ /**
13
+ * The dark theme name (DaisyUI theme).
14
+ * @default 'dark'
15
+ */
16
+ darkTheme?: string
17
+ /**
18
+ * Additional CSS classes for the toggle button.
19
+ */
20
+ class?: string
21
+ }
22
+
23
+ const {
24
+ lightTheme = 'light',
25
+ darkTheme = 'dark',
26
+ class: className,
27
+ } = Astro.props
28
+ ---
29
+
30
+ <label class:list={['swap swap-rotate', className]}>
31
+ <input
32
+ type="checkbox"
33
+ class="theme-controller"
34
+ data-light-theme={lightTheme}
35
+ data-dark-theme={darkTheme}
36
+ />
37
+ <!-- Sun icon (light mode) -->
38
+ <svg
39
+ class="swap-off h-6 w-6 fill-current"
40
+ xmlns="http://www.w3.org/2000/svg"
41
+ viewBox="0 0 24 24"
42
+ >
43
+ <path
44
+ d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"
45
+ ></path>
46
+ </svg>
47
+ <!-- Moon icon (dark mode) -->
48
+ <svg
49
+ class="swap-on h-6 w-6 fill-current"
50
+ xmlns="http://www.w3.org/2000/svg"
51
+ viewBox="0 0 24 24"
52
+ >
53
+ <path
54
+ d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"
55
+ ></path>
56
+ </svg>
57
+ </label>
58
+
59
+ <script>
60
+ // Initialize theme on page load
61
+ function initTheme() {
62
+ const toggles = document.querySelectorAll<HTMLInputElement>('.theme-controller')
63
+
64
+ toggles.forEach((toggle) => {
65
+ const lightTheme = toggle.dataset.lightTheme || 'light'
66
+ const darkTheme = toggle.dataset.darkTheme || 'dark'
67
+
68
+ // Get saved theme or detect system preference
69
+ const savedTheme = localStorage.getItem('theme')
70
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
71
+ const currentTheme = savedTheme || (prefersDark ? darkTheme : lightTheme)
72
+
73
+ // Set initial state
74
+ document.documentElement.setAttribute('data-theme', currentTheme)
75
+ toggle.checked = currentTheme === darkTheme
76
+
77
+ // Handle toggle changes
78
+ toggle.addEventListener('change', () => {
79
+ const newTheme = toggle.checked ? darkTheme : lightTheme
80
+ document.documentElement.setAttribute('data-theme', newTheme)
81
+ localStorage.setItem('theme', newTheme)
82
+
83
+ // Sync other toggles on the page
84
+ toggles.forEach((otherToggle) => {
85
+ if (otherToggle !== toggle) {
86
+ otherToggle.checked = toggle.checked
87
+ }
88
+ })
89
+ })
90
+ })
91
+ }
92
+
93
+ // Run on initial load
94
+ initTheme()
95
+
96
+ // Re-run on Astro navigation (View Transitions)
97
+ document.addEventListener('astro:after-swap', initTheme)
98
+ </script>