@parche/core 0.3.0-alpha.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arthelokyo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @parche/core
2
+
3
+ The Parche host: the engine that turns data-driven content into Astro pages.
4
+
5
+ Part of [Parche](https://github.com/withparche/parche). See the repository for docs and examples.
6
+
7
+ > Pre-1.0 — the public API is not yet stable.
8
+
9
+ ## License
10
+
11
+ MIT © Arthelokyo
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@parche/core",
3
+ "version": "0.3.0-alpha.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/integration/index.ts",
7
+ "./types": "./src/integration/types.ts",
8
+ "./config": "./src/types/config.ts",
9
+ "./fonts": "./src/config/fonts.ts",
10
+ "./styles": "./src/styles/base.css",
11
+ "./content": "./src/content/index.ts",
12
+ "./schemas": "./src/content/schemas.ts"
13
+ },
14
+ "dependencies": {
15
+ "tailwind-merge": "^3.6.0",
16
+ "tailwindcss": "^4.3.3",
17
+ "zod": "^4.4.3"
18
+ },
19
+ "peerDependencies": {
20
+ "astro": "^7.2.4"
21
+ },
22
+ "devDependencies": {
23
+ "astro": "^7.2.4",
24
+ "vite": "^8.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "description": "The Parche host: the engine that turns data-driven content into Astro pages.",
30
+ "license": "MIT",
31
+ "author": "Arthelokyo",
32
+ "homepage": "https://github.com/withparche/parche#readme",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/withparche/parche.git",
36
+ "directory": "packages/core"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/withparche/parche/issues"
40
+ },
41
+ "keywords": [
42
+ "astro",
43
+ "parche",
44
+ "framework",
45
+ "design-system"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "files": [
51
+ "src",
52
+ "README.md",
53
+ "LICENSE"
54
+ ]
55
+ }
@@ -0,0 +1,96 @@
1
+ ---
2
+ import { widgetMap } from 'parche:registry/widgets';
3
+ import SectionWrapper from './SectionWrapper.astro';
4
+
5
+ interface WrapperConfig {
6
+ id?: string;
7
+ isDark?: boolean;
8
+ bg?: string;
9
+ classes?: Record<string, unknown>;
10
+ as?: string;
11
+ }
12
+
13
+ interface Section {
14
+ widget: string;
15
+ props?: Record<string, any>;
16
+ wrapper?: false | WrapperConfig;
17
+ }
18
+
19
+ interface Props {
20
+ sections: Section[];
21
+ /** Optional Astro component that wraps each widget.
22
+ * Receives `section` and `index` as props, renders the widget via <slot />. */
23
+ Wrapper?: any;
24
+ }
25
+
26
+ const { sections, Wrapper } = Astro.props;
27
+
28
+ // ---- Image resolution: ~/assets/images/foo.png → optimized import ----
29
+ const imageModules = import.meta.glob<{ default: ImageMetadata }>(
30
+ '/src/assets/images/**/*.{png,jpg,jpeg,gif,svg,webp,avif}',
31
+ { eager: true }
32
+ );
33
+
34
+ function resolveImage(src: string): string {
35
+ if (!src || !src.startsWith('@/assets/')) return src;
36
+ const fsPath = src.replace('@/', '/src/');
37
+ const meta = imageModules[fsPath]?.default;
38
+ return meta?.src ?? src;
39
+ }
40
+
41
+ /** Walk props and resolve any ~/assets/ image paths */
42
+ function resolveProps(props: Record<string, any>): Record<string, any> {
43
+ const resolved = { ...props };
44
+ for (const [key, value] of Object.entries(resolved)) {
45
+ if (typeof value === 'string' && value.startsWith('@/assets/')) {
46
+ resolved[key] = resolveImage(value);
47
+ } else if (value && typeof value === 'object' && !Array.isArray(value) && 'src' in value && typeof value.src === 'string' && value.src.startsWith('@/assets/')) {
48
+ resolved[key] = { ...value, src: resolveImage(value.src) };
49
+ }
50
+ }
51
+ return resolved;
52
+ }
53
+
54
+ // Widgets that render full-bleed and manage their own padding — no wrapper by default
55
+ const noWrapperWidgets = new Set(['Hero', 'Hero2', 'HeroText', 'Announcement', 'Note']);
56
+ function widgetSkipsWrapper(name: string): boolean {
57
+ const key = name.includes('/') ? name.split('/').pop()! : name;
58
+ return noWrapperWidgets.has(key);
59
+ }
60
+ ---
61
+
62
+ {sections.map((section, index) => {
63
+ const Widget = widgetMap[section.widget];
64
+ if (!Widget) return null;
65
+
66
+ const props = resolveProps(section.props ?? {});
67
+
68
+ // Explicit wrapper config always wins. When omitted: Hero/Announcement skip, others get default wrapper.
69
+ const hasExplicitWrapper = section.wrapper !== undefined;
70
+ const useWrapper = hasExplicitWrapper
71
+ ? typeof section.wrapper === 'object' && section.wrapper !== null
72
+ : !widgetSkipsWrapper(section.widget);
73
+ const wrapperProps = typeof section.wrapper === 'object' && section.wrapper !== null ? section.wrapper : {};
74
+
75
+ if (Wrapper) {
76
+ return (
77
+ <Wrapper section={section} index={index}>
78
+ {useWrapper ? (
79
+ <SectionWrapper {...wrapperProps}>
80
+ <Widget {...props} />
81
+ </SectionWrapper>
82
+ ) : (
83
+ <Widget {...props} />
84
+ )}
85
+ </Wrapper>
86
+ );
87
+ }
88
+
89
+ return useWrapper ? (
90
+ <SectionWrapper {...wrapperProps}>
91
+ <Widget {...props} />
92
+ </SectionWrapper>
93
+ ) : (
94
+ <Widget {...props} />
95
+ );
96
+ })}
@@ -0,0 +1,65 @@
1
+ ---
2
+ import { widgetMap } from 'parche:registry/widgets';
3
+ import DynamicRenderer from 'parche:DynamicRenderer';
4
+
5
+ interface WrapperConfig {
6
+ id?: string;
7
+ isDark?: boolean;
8
+ bg?: string;
9
+ classes?: Record<string, unknown>;
10
+ as?: string;
11
+ }
12
+
13
+ interface Section {
14
+ widget: string;
15
+ props?: Record<string, any>;
16
+ wrapper?: false | WrapperConfig;
17
+ }
18
+
19
+ interface Props {
20
+ layoutSections: Section[];
21
+ pageSections?: Section[];
22
+ PageTemplateComponent?: any;
23
+ pageData?: any;
24
+ PageMarkdownContent?: any;
25
+ pageTemplate?: string;
26
+ SectionWrapper?: any;
27
+ mainId?: string;
28
+ }
29
+
30
+ const {
31
+ layoutSections,
32
+ pageSections,
33
+ PageTemplateComponent,
34
+ pageData,
35
+ PageMarkdownContent,
36
+ pageTemplate = 'dynamic',
37
+ SectionWrapper: PreviewWrapper,
38
+ mainId,
39
+ } = Astro.props;
40
+ ---
41
+
42
+ {layoutSections.map((section) => {
43
+ if (section.widget === 'layout/Main') {
44
+ return (
45
+ <main id="main-content" class="flex-1">
46
+ <div id={mainId}>
47
+ {pageTemplate === 'dynamic' && pageSections && (
48
+ <DynamicRenderer sections={pageSections} Wrapper={PreviewWrapper} />
49
+ )}
50
+ {PageTemplateComponent && (
51
+ <PageTemplateComponent data={pageData}>
52
+ {PageMarkdownContent && <PageMarkdownContent />}
53
+ </PageTemplateComponent>
54
+ )}
55
+ <slot />
56
+ </div>
57
+ </main>
58
+ );
59
+ }
60
+
61
+ const Widget = widgetMap[section.widget];
62
+ if (!Widget) return null;
63
+
64
+ return <Widget {...(section.props ?? {})} />;
65
+ })}
@@ -0,0 +1,46 @@
1
+ ---
2
+ /**
3
+ * Default section wrapper used by DynamicRenderer.
4
+ * Provides: configurable HTML tag, background layer, isDark per-widget,
5
+ * responsive container with padding, and class overrides via twMerge.
6
+ */
7
+ import type { HTMLTag } from 'astro/types';
8
+ import { twMerge } from 'tailwind-merge';
9
+
10
+ interface Props {
11
+ id?: string;
12
+ isDark?: boolean;
13
+ bg?: string;
14
+ classes?: Record<string, unknown>;
15
+ as?: string;
16
+ }
17
+
18
+ const { id, isDark = false, bg, classes = {}, as = 'section' } = Astro.props;
19
+
20
+ const Tag = as as HTMLTag;
21
+ const containerClass = (classes.container as string) ?? '';
22
+ ---
23
+
24
+ <Tag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}>
25
+ {bg && (
26
+ <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
27
+ <Fragment set:html={bg} />
28
+ </div>
29
+ )}
30
+ {isDark && !bg && (
31
+ <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true">
32
+ <div class="absolute inset-0 bg-neutral-900 dark:bg-transparent" />
33
+ </div>
34
+ )}
35
+ <div
36
+ class:list={[
37
+ twMerge(
38
+ 'relative mx-auto max-w-7xl px-4 md:px-6 py-12 md:py-16 lg:py-20',
39
+ containerClass,
40
+ ),
41
+ { dark: isDark },
42
+ ]}
43
+ >
44
+ <slot />
45
+ </div>
46
+ </Tag>
@@ -0,0 +1,168 @@
1
+ ---
2
+ import { locales, defaultLocale } from 'parche:config/i18n';
3
+ import { buildSlugMap, getAlternateUrls, resolvePageFromSlug } from 'parche:utils/i18n';
4
+
5
+ // Only render when there are multiple locales configured
6
+ const hasMultipleLocales = Array.isArray(locales) && locales.length > 1;
7
+
8
+ let alternates: Array<{ locale: string; href: string; path: string }> = [];
9
+ let hasAlternates = false;
10
+
11
+ if (hasMultipleLocales) {
12
+ // Reverse-lookup the current page from the URL to find pageKey
13
+ const pathname = Astro.url.pathname.replace(/\/$/, '') || '';
14
+ const urlSlug = pathname.startsWith('/') ? pathname.slice(1) : pathname;
15
+
16
+ const resolved = await resolvePageFromSlug(urlSlug || undefined, defaultLocale);
17
+
18
+ if (resolved) {
19
+ const slugMap = await buildSlugMap();
20
+ alternates = getAlternateUrls(resolved.pageKey, slugMap, defaultLocale, Astro.site);
21
+ hasAlternates = alternates.length > 1;
22
+ }
23
+ }
24
+
25
+ const currentLocale = Astro.currentLocale || defaultLocale;
26
+
27
+ /**
28
+ * Get the native display name for a locale using Intl.DisplayNames.
29
+ * e.g. 'es' → 'español', 'en' → 'English'
30
+ */
31
+ function getLocaleName(locale: string): string {
32
+ try {
33
+ const display = new Intl.DisplayNames([locale], { type: 'language' });
34
+ const name = display.of(locale) || locale;
35
+ return name.charAt(0).toUpperCase() + name.slice(1);
36
+ } catch {
37
+ return locale.toUpperCase();
38
+ }
39
+ }
40
+
41
+ const currentName = getLocaleName(currentLocale);
42
+
43
+ // Build full list of other locales, marking which ones have alternates
44
+ const allOtherLocales = hasMultipleLocales
45
+ ? (locales as Array<string | { path: string; codes: string[] }>)
46
+ .map((l) => typeof l === 'string' ? l : l.codes[0])
47
+ .filter((l) => l !== currentLocale)
48
+ .map((locale) => {
49
+ const alt = alternates.find((a) => a.locale === locale);
50
+ return { locale, href: alt?.path, disabled: !alt };
51
+ })
52
+ : [];
53
+ ---
54
+
55
+ {hasMultipleLocales && (
56
+ <div class="relative" data-locale-switcher>
57
+ <button
58
+ type="button"
59
+ class="inline-flex items-center gap-1.5 text-sm font-medium text-muted hover:text-heading transition-colors px-2 h-8 rounded-md hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer"
60
+ aria-expanded="false"
61
+ aria-haspopup="true"
62
+ aria-label={`Language: ${currentName}`}
63
+ data-locale-trigger
64
+ >
65
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
66
+ <circle cx="12" cy="12" r="10"></circle>
67
+ <path d="M2 12h20"></path>
68
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
69
+ </svg>
70
+ {/* <span class="uppercase text-xs font-semibold">{currentLocale}</span> */}
71
+ </button>
72
+
73
+ <div
74
+ class="absolute right-0 top-full pt-1 opacity-0 invisible translate-y-1 transition-all duration-200 z-50"
75
+ role="menu"
76
+ data-locale-panel
77
+ >
78
+ <div class="bg-surface border border-border rounded-lg shadow-lg py-1 min-w-[150px]">
79
+ {/* Current locale (shown as active) */}
80
+ <div class="flex items-center justify-between px-3 py-2 text-sm font-medium text-heading bg-black/5 dark:bg-white/5">
81
+ <span>{currentName}</span>
82
+ <svg class="w-3.5 h-3.5 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
83
+ <polyline points="20 6 9 17 4 12"></polyline>
84
+ </svg>
85
+ </div>
86
+
87
+ {/* Other locales */}
88
+ {allOtherLocales.map((item) =>
89
+ item.disabled ? (
90
+ <span
91
+ class="flex items-center px-3 py-2 text-sm text-muted/40 cursor-default"
92
+ role="menuitem"
93
+ aria-disabled="true"
94
+ >
95
+ {getLocaleName(item.locale)}
96
+ </span>
97
+ ) : (
98
+ <a
99
+ href={item.href}
100
+ class="flex items-center px-3 py-2 text-sm text-muted hover:text-heading hover:bg-black/5 dark:hover:bg-white/5 transition-colors no-underline"
101
+ role="menuitem"
102
+ hreflang={item.locale}
103
+ >
104
+ {getLocaleName(item.locale)}
105
+ </a>
106
+ )
107
+ )}
108
+ </div>
109
+ </div>
110
+ </div>
111
+ )}
112
+
113
+ <script>
114
+ function initLocaleSwitchers() {
115
+ document.querySelectorAll('[data-locale-switcher]').forEach((switcher) => {
116
+ if ((switcher as any)._localeSwitcherInit) return;
117
+ (switcher as any)._localeSwitcherInit = true;
118
+
119
+ const trigger = switcher.querySelector('[data-locale-trigger]') as HTMLButtonElement;
120
+ const panel = switcher.querySelector('[data-locale-panel]') as HTMLElement;
121
+ if (!trigger || !panel) return;
122
+
123
+ let isOpen = false;
124
+
125
+ const open = () => {
126
+ isOpen = true;
127
+ trigger.setAttribute('aria-expanded', 'true');
128
+ panel.classList.remove('opacity-0', 'invisible', 'translate-y-1');
129
+ panel.classList.add('opacity-100', 'visible', 'translate-y-0');
130
+ };
131
+
132
+ const close = () => {
133
+ isOpen = false;
134
+ trigger.setAttribute('aria-expanded', 'false');
135
+ panel.classList.add('opacity-0', 'invisible', 'translate-y-1');
136
+ panel.classList.remove('opacity-100', 'visible', 'translate-y-0');
137
+ };
138
+
139
+ // Toggle on click
140
+ trigger.addEventListener('click', (e) => {
141
+ e.preventDefault();
142
+ e.stopPropagation();
143
+ isOpen ? close() : open();
144
+ });
145
+
146
+ // Prevent clicks inside the panel from closing it
147
+ panel.addEventListener('click', (e) => e.stopPropagation());
148
+
149
+ // Close on Escape
150
+ switcher.addEventListener('keydown', (e: Event) => {
151
+ const ke = e as KeyboardEvent;
152
+ if (ke.key === 'Escape' && isOpen) {
153
+ close();
154
+ trigger.focus();
155
+ }
156
+ });
157
+
158
+ // Close when clicking outside
159
+ document.addEventListener('click', (e) => {
160
+ if (isOpen && !switcher.contains(e.target as Node)) close();
161
+ });
162
+ });
163
+ }
164
+
165
+ // Run on initial load and after each client-side navigation
166
+ initLocaleSwitchers();
167
+ document.addEventListener('astro:page-load', initLocaleSwitchers);
168
+ </script>
@@ -0,0 +1,41 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+
4
+ interface Props {
5
+ src: string | ImageMetadata;
6
+ alt: string;
7
+ width?: number;
8
+ height?: number;
9
+ loading?: 'eager' | 'lazy';
10
+ fetchpriority?: 'high' | 'low' | 'auto';
11
+ class?: string;
12
+ widths?: number[];
13
+ sizes?: string;
14
+ }
15
+
16
+ const {
17
+ src,
18
+ alt,
19
+ width = 600,
20
+ height = 400,
21
+ loading = 'lazy',
22
+ fetchpriority,
23
+ class: className = '',
24
+ widths = [400, 600, 800],
25
+ sizes = '(max-width: 768px) 100vw, 50vw',
26
+ } = Astro.props;
27
+ ---
28
+
29
+ <Image
30
+ src={src}
31
+ alt={alt}
32
+ width={width}
33
+ height={height}
34
+ loading={loading}
35
+ fetchpriority={fetchpriority}
36
+ class={className}
37
+ widths={widths}
38
+ sizes={sizes}
39
+ format="webp"
40
+ quality={80}
41
+ />
@@ -0,0 +1,152 @@
1
+ ---
2
+ import { themes as configuredThemes } from 'parche:config/themes';
3
+
4
+ interface ThemeOption {
5
+ label: string;
6
+ value: string;
7
+ }
8
+
9
+ interface Props {
10
+ themes?: ThemeOption[];
11
+ }
12
+
13
+ const { themes = configuredThemes } = Astro.props;
14
+ ---
15
+
16
+ {themes.length > 1 && (
17
+ <theme-panel>
18
+ {/* Floating trigger button */}
19
+ <button
20
+ type="button"
21
+ data-panel-trigger
22
+ class="fixed bottom-5 right-5 z-50 w-11 h-11 rounded-full bg-surface border border-border shadow-lg flex items-center justify-center cursor-pointer hover:bg-primary hover:text-white hover:border-primary transition-all duration-200"
23
+ aria-label="Theme settings"
24
+ >
25
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
26
+ <circle cx="13.5" cy="6.5" r="0.5" fill="currentColor"></circle>
27
+ <circle cx="17.5" cy="10.5" r="0.5" fill="currentColor"></circle>
28
+ <circle cx="8.5" cy="7.5" r="0.5" fill="currentColor"></circle>
29
+ <circle cx="6.5" cy="12.5" r="0.5" fill="currentColor"></circle>
30
+ <path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.563-2.512 5.563-5.563C22 6.5 17.5 2 12 2Z"></path>
31
+ </svg>
32
+ </button>
33
+
34
+ {/* Panel overlay */}
35
+ <div
36
+ data-panel-overlay
37
+ class="fixed inset-0 z-50 bg-black/10 hidden transition-opacity"
38
+ ></div>
39
+
40
+ {/* Panel content */}
41
+ <div
42
+ data-panel-content
43
+ class="fixed bottom-20 right-5 z-50 w-64 bg-surface border border-border rounded-xl shadow-2xl p-4 hidden transition-all duration-200 opacity-0 translate-y-2"
44
+ >
45
+ {/* Header */}
46
+ <div class="flex items-center justify-between mb-4">
47
+ <span class="type-caption font-semibold text-heading">Style</span>
48
+ <button
49
+ type="button"
50
+ data-panel-close
51
+ class="w-6 h-6 flex items-center justify-center rounded-md text-muted hover:text-heading hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
52
+ aria-label="Close"
53
+ >
54
+ <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
55
+ <line x1="18" y1="6" x2="6" y2="18"></line>
56
+ <line x1="6" y1="6" x2="18" y2="18"></line>
57
+ </svg>
58
+ </button>
59
+ </div>
60
+
61
+ {/* Theme grid */}
62
+ <div class="space-y-1">
63
+ {themes.map((theme) => (
64
+ <button
65
+ type="button"
66
+ data-theme-value={theme.value}
67
+ class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left cursor-pointer transition-colors text-muted hover:bg-neutral-100 dark:hover:bg-neutral-800"
68
+ >
69
+ <span
70
+ class="w-4 h-4 rounded-full border-2 border-border flex items-center justify-center shrink-0"
71
+ data-theme-radio
72
+ >
73
+ <span class="w-2 h-2 rounded-full bg-primary hidden" data-theme-dot></span>
74
+ </span>
75
+ <span class="type-caption">{theme.label}</span>
76
+ </button>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ </theme-panel>
81
+ )}
82
+
83
+ <script>
84
+ class ThemePanelElement extends HTMLElement {
85
+ private isOpen = false;
86
+
87
+ connectedCallback() {
88
+ const trigger = this.querySelector('[data-panel-trigger]') as HTMLElement;
89
+ const overlay = this.querySelector('[data-panel-overlay]') as HTMLElement;
90
+ const content = this.querySelector('[data-panel-content]') as HTMLElement;
91
+ const closeBtn = this.querySelector('[data-panel-close]') as HTMLElement;
92
+ const themeButtons = this.querySelectorAll<HTMLElement>('[data-theme-value]');
93
+
94
+ if (!trigger || !content) return;
95
+
96
+ // Toggle panel
97
+ const toggle = (open?: boolean) => {
98
+ this.isOpen = open ?? !this.isOpen;
99
+ if (this.isOpen) {
100
+ overlay?.classList.remove('hidden');
101
+ content.classList.remove('hidden');
102
+ requestAnimationFrame(() => {
103
+ content.classList.remove('opacity-0', 'translate-y-2');
104
+ });
105
+ } else {
106
+ content.classList.add('opacity-0', 'translate-y-2');
107
+ overlay?.classList.add('hidden');
108
+ setTimeout(() => content.classList.add('hidden'), 200);
109
+ }
110
+ };
111
+
112
+ trigger.addEventListener('click', () => toggle());
113
+ overlay?.addEventListener('click', () => toggle(false));
114
+ closeBtn?.addEventListener('click', () => toggle(false));
115
+
116
+ // Theme selection
117
+ const currentTheme = document.documentElement.getAttribute('data-theme') || '';
118
+ const updateTheme = (active: string) => {
119
+ themeButtons.forEach((btn) => {
120
+ const isActive = btn.dataset.themeValue === active;
121
+ const dot = btn.querySelector('[data-theme-dot]') as HTMLElement;
122
+ if (dot) dot.classList.toggle('hidden', !isActive);
123
+ btn.classList.toggle('text-heading', isActive);
124
+ btn.classList.toggle('bg-neutral-100', isActive);
125
+ btn.classList.toggle('text-muted', !isActive);
126
+ });
127
+ };
128
+ updateTheme(currentTheme);
129
+
130
+ themeButtons.forEach((btn) => {
131
+ btn.addEventListener('click', () => {
132
+ const value = btn.dataset.themeValue || '';
133
+ if (value) {
134
+ document.documentElement.setAttribute('data-theme', value);
135
+ localStorage.setItem('site-theme', value);
136
+ } else {
137
+ document.documentElement.removeAttribute('data-theme');
138
+ localStorage.removeItem('site-theme');
139
+ }
140
+ updateTheme(value);
141
+ });
142
+ });
143
+
144
+ // Close on Escape
145
+ document.addEventListener('keydown', (e) => {
146
+ if (e.key === 'Escape' && this.isOpen) toggle(false);
147
+ });
148
+ }
149
+ }
150
+
151
+ customElements.define('theme-panel', ThemePanelElement);
152
+ </script>