@at-flux/astroflare 0.0.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/dist/index.cjs ADDED
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ composeEmailAddress: () => composeEmailAddress,
24
+ forms: () => forms_exports,
25
+ generateFormResultHtml: () => generateFormResultHtml,
26
+ generateFormSectionHtml: () => generateFormSectionHtml,
27
+ renderEmailTemplate: () => renderEmailTemplate,
28
+ sendEmail: () => sendEmail
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+
32
+ // src/forms/index.ts
33
+ var forms_exports = {};
34
+ __export(forms_exports, {
35
+ composeEmailAddress: () => composeEmailAddress,
36
+ generateFormResultHtml: () => generateFormResultHtml,
37
+ generateFormSectionHtml: () => generateFormSectionHtml,
38
+ renderEmailTemplate: () => renderEmailTemplate,
39
+ sendEmail: () => sendEmail
40
+ });
41
+ var import_resend = require("resend");
42
+ async function sendEmail(config, payload) {
43
+ if (config.mock) {
44
+ console.info("[astroflare:forms] Mock email payload", payload);
45
+ return { id: "mock-email-id" };
46
+ }
47
+ const resend = new import_resend.Resend(config.apiKey);
48
+ const { data, error } = await resend.emails.send({
49
+ from: payload.from,
50
+ to: payload.to,
51
+ subject: payload.subject,
52
+ html: payload.html
53
+ });
54
+ if (error) {
55
+ console.error("[astroflare:forms] Resend API error", error);
56
+ throw new Error(`Failed to send email: ${error.message ?? "Unknown error"}`);
57
+ }
58
+ return data;
59
+ }
60
+ function composeEmailAddress(local, domain, tag) {
61
+ const taggedLocal = tag ? `${local}+${tag}` : local;
62
+ return `${taggedLocal}@${domain}`;
63
+ }
64
+ function generateFormSectionHtml(section) {
65
+ const itemsHtml = section.items.map((item) => {
66
+ const value = item.value ?? "";
67
+ const isHtml = /<[^>]+>/.test(value);
68
+ return `
69
+ <div style="margin-bottom:16px;">
70
+ <p style="margin:0 0 4px;font-weight:600;color:#111827;">${item.key}</p>
71
+ ${isHtml ? `<div style="margin:0;color:#111827;line-height:1.6;">${value || "Not specified"}</div>` : `<p style="margin:0;color:#111827;line-height:1.6;white-space:pre-wrap;">${value || "Not specified"}</p>`}
72
+ </div>
73
+ `;
74
+ }).join("");
75
+ return `
76
+ <section style="background:#f3f4f6;padding:16px;border-radius:8px;margin:16px 0;border:1px solid #e5e7eb;">
77
+ <h3 style="margin:0 0 12px;color:#111827;font-size:16px;font-weight:600;">${section.title}</h3>
78
+ <div style="color:#111827;">
79
+ ${itemsHtml}
80
+ </div>
81
+ </section>
82
+ `;
83
+ }
84
+ function generateFormResultHtml(sections) {
85
+ return sections.map(generateFormSectionHtml).join("\n");
86
+ }
87
+ async function renderEmailTemplate(args) {
88
+ const { title, contentHtml, brandName, footerText } = args;
89
+ const footer = footerText ?? `This message was generated by the ${brandName ?? "site"} contact form on ${(/* @__PURE__ */ new Date()).toLocaleString(
90
+ "en-GB"
91
+ )}.`;
92
+ return `<!DOCTYPE html>
93
+ <html lang="en">
94
+ <head>
95
+ <meta charset="utf-8">
96
+ <meta name="viewport" content="width=device-width, initial-scale=1">
97
+ <title>${title}</title>
98
+ <meta http-equiv="x-ua-compatible" content="ie=edge">
99
+ </head>
100
+ <body style="margin:0;padding:0;background:#f3f4f6;color:#111827;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';">
101
+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f3f4f6;">
102
+ <tr>
103
+ <td align="center" style="padding:24px;">
104
+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:720px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
105
+ <tr>
106
+ <td style="padding:24px 24px 16px 24px;border-bottom:1px solid #e5e7eb;">
107
+ <h1 style="margin:0 0 8px 0;font-size:20px;line-height:28px;color:#111827;letter-spacing:0.3px;">
108
+ ${title}
109
+ </h1>
110
+ <p style="margin:0;font-size:13px;color:#6b7280;line-height:20px;">
111
+ Contact form submission
112
+ </p>
113
+ </td>
114
+ </tr>
115
+ <tr>
116
+ <td style="padding:24px;">
117
+ ${contentHtml}
118
+ </td>
119
+ </tr>
120
+ <tr>
121
+ <td style="padding:16px 24px;background:#f9fafb;border-top:1px solid #e5e7eb;">
122
+ <p style="margin:0;color:#6b7280;font-size:12px;line-height:18px;">
123
+ ${footer}
124
+ </p>
125
+ </td>
126
+ </tr>
127
+ </table>
128
+ </td>
129
+ </tr>
130
+ </table>
131
+ </body>
132
+ </html>`;
133
+ }
134
+ // Annotate the CommonJS export names for ESM import in node:
135
+ 0 && (module.exports = {
136
+ composeEmailAddress,
137
+ forms,
138
+ generateFormResultHtml,
139
+ generateFormSectionHtml,
140
+ renderEmailTemplate,
141
+ sendEmail
142
+ });
@@ -0,0 +1,2 @@
1
+ export { EmailPayload, FormSection, ResendConfig, composeEmailAddress, i as forms, generateFormResultHtml, generateFormSectionHtml, renderEmailTemplate, sendEmail } from './forms/index.cjs';
2
+ import 'resend';
@@ -0,0 +1,2 @@
1
+ export { EmailPayload, FormSection, ResendConfig, composeEmailAddress, i as forms, generateFormResultHtml, generateFormSectionHtml, renderEmailTemplate, sendEmail } from './forms/index.js';
2
+ import 'resend';
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import "./chunk-ALMPH3A2.js";
2
+ import {
3
+ composeEmailAddress,
4
+ forms_exports,
5
+ generateFormResultHtml,
6
+ generateFormSectionHtml,
7
+ renderEmailTemplate,
8
+ sendEmail
9
+ } from "./chunk-DRYHJSYC.js";
10
+ export {
11
+ composeEmailAddress,
12
+ forms_exports as forms,
13
+ generateFormResultHtml,
14
+ generateFormSectionHtml,
15
+ renderEmailTemplate,
16
+ sendEmail
17
+ };
package/package.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "name": "@at-flux/astroflare",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "description": "Reusable headless components, styles, and utilities for Astro + Tailwind v4 + Cloudflare projects",
6
+ "author": "Taylor Siviter <taylor@siviter.xyz>",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "astro",
10
+ "tailwind",
11
+ "cloudflare",
12
+ "components",
13
+ "web-components"
14
+ ],
15
+ "main": "./dist/index.cjs",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "require": {
25
+ "types": "./dist/index.d.cts",
26
+ "default": "./dist/index.cjs"
27
+ }
28
+ },
29
+ "./core": {
30
+ "import": {
31
+ "types": "./dist/core.d.ts",
32
+ "default": "./dist/core.js"
33
+ },
34
+ "require": {
35
+ "types": "./dist/core.d.cts",
36
+ "default": "./dist/core.cjs"
37
+ }
38
+ },
39
+ "./forms": {
40
+ "import": {
41
+ "types": "./dist/forms/index.d.ts",
42
+ "default": "./dist/forms/index.js"
43
+ },
44
+ "require": {
45
+ "types": "./dist/forms/index.d.cts",
46
+ "default": "./dist/forms/index.cjs"
47
+ }
48
+ },
49
+ "./components/*": "./src/components/*",
50
+ "./styles/*": "./src/styles/*"
51
+ },
52
+ "files": [
53
+ "dist",
54
+ "src/components",
55
+ "src/styles",
56
+ "README.md"
57
+ ],
58
+ "devDependencies": {
59
+ "jsdom": "^25.0.1",
60
+ "tsup": "^8.0.0",
61
+ "typescript": "^5.0.0",
62
+ "vitest": "^3.2.4"
63
+ },
64
+ "dependencies": {
65
+ "resend": "^4.0.0"
66
+ },
67
+ "peerDependencies": {
68
+ "astro": ">=5.0.0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "astro": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "engines": {
76
+ "node": ">=18"
77
+ },
78
+ "repository": {
79
+ "type": "git",
80
+ "url": "git+https://github.com/at-flux/astroflare.git"
81
+ },
82
+ "bugs": {
83
+ "url": "https://github.com/at-flux/astroflare/issues"
84
+ },
85
+ "homepage": "https://github.com/at-flux/astroflare#readme",
86
+ "publishConfig": {
87
+ "access": "public"
88
+ },
89
+ "scripts": {
90
+ "build": "tsup",
91
+ "dev": "tsup --watch",
92
+ "test": "vitest run",
93
+ "test:watch": "vitest",
94
+ "typecheck": "tsc --noEmit",
95
+ "lint": "echo 'no lint configured yet'"
96
+ }
97
+ }
@@ -0,0 +1,76 @@
1
+ ---
2
+ /**
3
+ * Loading spinner for Astro view transitions.
4
+ * Uses native <dialog> shown during page transitions.
5
+ * Override styles via CSS custom properties or by targeting #loading.
6
+ */
7
+ ---
8
+
9
+ <dialog
10
+ id='loading'
11
+ class='backdrop:bg-black/60 bg-transparent p-0 w-full h-full max-w-none max-h-none'
12
+ >
13
+ <div class='flex items-center justify-center min-h-full p-0'>
14
+ <div class='loading-spinner'>
15
+ <slot>
16
+ <div class='loading-ring' />
17
+ </slot>
18
+ </div>
19
+ </div>
20
+ </dialog>
21
+
22
+ <style>
23
+ .loading-spinner {
24
+ opacity: 0;
25
+ transform: scale(0.8);
26
+ transition: opacity 0.3s ease-out, transform 0.3s ease-out;
27
+ }
28
+
29
+ #loading[open] .loading-spinner {
30
+ opacity: 1;
31
+ transform: scale(1);
32
+ }
33
+
34
+ #loading.fade-out .loading-spinner {
35
+ opacity: 0;
36
+ transform: scale(0.8);
37
+ transition: opacity 0.15s ease-out, transform 0.15s ease-out;
38
+ }
39
+
40
+ .loading-ring {
41
+ width: 3rem;
42
+ height: 3rem;
43
+ border: 3px solid transparent;
44
+ border-top-color: var(--color-brand, #7c5cff);
45
+ border-right-color: color-mix(in oklab, var(--color-brand, #7c5cff) 50%, transparent);
46
+ border-radius: 50%;
47
+ animation: spin 1s linear infinite;
48
+ }
49
+
50
+ @keyframes spin {
51
+ to { transform: rotate(360deg); }
52
+ }
53
+
54
+ @media (prefers-reduced-motion: reduce) {
55
+ .loading-spinner { transition: none; }
56
+ .loading-ring { animation: none; border-top-color: var(--color-brand, #7c5cff); }
57
+ }
58
+ </style>
59
+
60
+ <script is:inline>
61
+ document.addEventListener('astro:before-preparation', () => {
62
+ const d = document.querySelector('#loading');
63
+ if (d) d.showModal();
64
+ });
65
+
66
+ document.addEventListener('astro:page-load', () => {
67
+ const d = document.querySelector('#loading');
68
+ if (d) {
69
+ d.classList.add('fade-out');
70
+ setTimeout(() => {
71
+ d.close();
72
+ d.classList.remove('fade-out');
73
+ }, 150);
74
+ }
75
+ });
76
+ </script>
@@ -0,0 +1,80 @@
1
+ ---
2
+ /**
3
+ * Accessible icon button component.
4
+ * Renders as <button> by default, or <a> when href is provided.
5
+ * Consumers must supply their own icon via slot.
6
+ */
7
+ interface Props {
8
+ label: string;
9
+ href?: string;
10
+ class?: string;
11
+ id?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ const { label, href, class: extraClass, id, ...rest } = Astro.props as Props;
16
+ ---
17
+
18
+ {
19
+ href ? (
20
+ <a
21
+ href={href}
22
+ id={id}
23
+ aria-label={label}
24
+ class:list={['icon-btn', extraClass]}
25
+ {...rest}
26
+ >
27
+ <slot />
28
+ </a>
29
+ ) : (
30
+ <button
31
+ type='button'
32
+ id={id}
33
+ aria-label={label}
34
+ class:list={['icon-btn', extraClass]}
35
+ {...rest}
36
+ >
37
+ <slot />
38
+ </button>
39
+ )
40
+ }
41
+
42
+ <style>
43
+ .icon-btn {
44
+ display: inline-flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ cursor: pointer;
48
+ user-select: none;
49
+ font-size: 1rem;
50
+ width: 2rem;
51
+ height: 2rem;
52
+ border-radius: 0.5rem;
53
+ border: 1px solid var(--color-light-border, #e8e4f0);
54
+ color: var(--color-light-text-body, #2f2a44);
55
+ text-decoration: none;
56
+ background: transparent;
57
+ transition:
58
+ color 0.3s ease,
59
+ border-color 0.3s ease,
60
+ border-radius 0.4s ease;
61
+ position: relative;
62
+ overflow: hidden;
63
+ }
64
+
65
+ :global([data-theme='dark']) .icon-btn {
66
+ border-color: var(--color-dark-border, #2a2545);
67
+ color: var(--color-dark-text-body, #e0dfe6);
68
+ }
69
+
70
+ .icon-btn:hover {
71
+ border-radius: 0.75rem;
72
+ border-color: var(--color-brand, #8b5cf6);
73
+ color: var(--color-brand, #8b5cf6);
74
+ }
75
+
76
+ :global([data-theme='dark']) .icon-btn:hover {
77
+ border-color: var(--color-brand, #8b5cf6);
78
+ color: var(--color-brand, #8b5cf6);
79
+ }
80
+ </style>
@@ -0,0 +1,63 @@
1
+ ---
2
+ /**
3
+ * Headless modal component using native <dialog>.
4
+ * Provides no visual styling beyond structure -- consumers apply their own theme.
5
+ */
6
+ interface Props {
7
+ id: string;
8
+ class?: string;
9
+ backdropClass?: string;
10
+ panelClass?: string;
11
+ }
12
+
13
+ const {
14
+ id,
15
+ class: className = '',
16
+ backdropClass = 'backdrop:bg-black/80',
17
+ panelClass = '',
18
+ } = Astro.props;
19
+ ---
20
+
21
+ <app-modal>
22
+ <dialog
23
+ id={id}
24
+ class:list={['bg-transparent p-0 w-full h-full max-w-none max-h-none', backdropClass, className]}
25
+ >
26
+ <div class='flex items-center justify-center min-h-full p-0 md:p-4'>
27
+ <div
28
+ class:list={['relative overflow-hidden outline-none w-[100dvw] max-w-4xl max-h-[100dvh]', panelClass]}
29
+ role='dialog'
30
+ >
31
+ <button
32
+ id={`${id}-close`}
33
+ class='absolute top-3 right-6 w-10 h-10 rounded-full z-50 flex items-center justify-center'
34
+ aria-label='Close modal'
35
+ >
36
+ <svg width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>
37
+ <path d='M18 6L6 18' /><path d='M6 6L18 18' />
38
+ </svg>
39
+ </button>
40
+
41
+ <div class='relative overflow-y-auto max-h-[calc(100dvh-2rem)]'>
42
+ <slot />
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </dialog>
47
+ </app-modal>
48
+
49
+ <script>
50
+ class AppModal extends HTMLElement {
51
+ connectedCallback() {
52
+ const dialog = this.querySelector<HTMLDialogElement>('dialog');
53
+ if (!dialog) return;
54
+
55
+ const closeBtn = this.querySelector<HTMLButtonElement>(`#${dialog.id}-close`);
56
+ closeBtn?.addEventListener('click', () => dialog.close());
57
+ }
58
+ }
59
+
60
+ if (!customElements.get('app-modal')) {
61
+ customElements.define('app-modal', AppModal);
62
+ }
63
+ </script>
@@ -0,0 +1,40 @@
1
+ ---
2
+ /**
3
+ * Headless modal trigger. Clicking opens a <dialog> by ID.
4
+ */
5
+ interface Props {
6
+ modalId: string;
7
+ class?: string;
8
+ }
9
+
10
+ const { modalId, class: className = '' } = Astro.props;
11
+ ---
12
+
13
+ <modal-trigger data-modal-id={modalId} class={className}>
14
+ <slot />
15
+ </modal-trigger>
16
+
17
+ <script>
18
+ class ModalTrigger extends HTMLElement {
19
+ connectedCallback() {
20
+ this.addEventListener('click', this.onClick);
21
+ }
22
+
23
+ disconnectedCallback() {
24
+ this.removeEventListener('click', this.onClick);
25
+ }
26
+
27
+ onClick = () => {
28
+ const id = this.getAttribute('data-modal-id');
29
+ if (!id) return;
30
+ const dialog = document.getElementById(id);
31
+ if (dialog instanceof HTMLDialogElement) {
32
+ dialog.showModal();
33
+ }
34
+ };
35
+ }
36
+
37
+ if (!customElements.get('modal-trigger')) {
38
+ customElements.define('modal-trigger', ModalTrigger);
39
+ }
40
+ </script>
@@ -0,0 +1,127 @@
1
+ ---
2
+ /**
3
+ * Animated theme toggle web component.
4
+ * Uses data-theme="dark"|"light" on <html>.
5
+ * Persists to localStorage. Respects prefers-color-scheme on first visit.
6
+ * Sun/moon icons animate in/out with opacity and rotation transitions.
7
+ */
8
+ interface Props {
9
+ class?: string;
10
+ }
11
+
12
+ const { class: className = '' } = Astro.props;
13
+ ---
14
+
15
+ <theme-toggle class={className}>
16
+ <button class='theme-toggle-btn icon-btn' aria-label='Toggle light/dark mode' type='button'>
17
+ <!-- Sun (shown in dark mode to switch to light) -->
18
+ <span class='tt-icon tt-sun' aria-hidden='true'>
19
+ <svg width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>
20
+ <circle cx='12' cy='12' r='5' />
21
+ <line x1='12' y1='1' x2='12' y2='3' />
22
+ <line x1='12' y1='21' x2='12' y2='23' />
23
+ <line x1='4.22' y1='4.22' x2='5.64' y2='5.64' />
24
+ <line x1='18.36' y1='18.36' x2='19.78' y2='19.78' />
25
+ <line x1='1' y1='12' x2='3' y2='12' />
26
+ <line x1='21' y1='12' x2='23' y2='12' />
27
+ <line x1='4.22' y1='19.78' x2='5.64' y2='18.36' />
28
+ <line x1='18.36' y1='5.64' x2='19.78' y2='4.22' />
29
+ </svg>
30
+ </span>
31
+ <!-- Moon (shown in light mode to switch to dark) -->
32
+ <span class='tt-icon tt-moon' aria-hidden='true'>
33
+ <svg width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>
34
+ <path d='M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' />
35
+ </svg>
36
+ </span>
37
+ </button>
38
+ </theme-toggle>
39
+
40
+ <style>
41
+ .icon-btn {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ cursor: pointer;
46
+ width: 2rem;
47
+ height: 2rem;
48
+ border-radius: 0.5rem;
49
+ border: 1px solid var(--color-light-border, #e8e4f0);
50
+ color: var(--color-light-text-body, #2f2a44);
51
+ background: transparent;
52
+ transition:
53
+ color 0.3s ease,
54
+ border-color 0.3s ease,
55
+ border-radius 0.4s ease;
56
+ position: relative;
57
+ overflow: hidden;
58
+ }
59
+
60
+ :global([data-theme='dark']) .icon-btn {
61
+ border-color: var(--color-dark-border, #2a2545);
62
+ color: var(--color-dark-text-body, #e0dfe6);
63
+ }
64
+
65
+ .icon-btn:hover {
66
+ border-radius: 0.75rem;
67
+ border-color: var(--color-brand, #8b5cf6);
68
+ color: var(--color-brand, #8b5cf6);
69
+ }
70
+
71
+ /* Icon animation */
72
+ .tt-icon {
73
+ position: absolute;
74
+ inset: 0;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ pointer-events: none;
79
+ transition:
80
+ opacity 240ms ease,
81
+ transform 240ms ease;
82
+ }
83
+
84
+ /* Light mode: show moon, hide sun */
85
+ .tt-sun {
86
+ opacity: 0;
87
+ transform: rotate(90deg) scale(0.9);
88
+ }
89
+ .tt-moon {
90
+ opacity: 1;
91
+ transform: rotate(0deg) scale(1);
92
+ }
93
+
94
+ /* Dark mode: show sun, hide moon */
95
+ :global([data-theme='dark']) .tt-sun {
96
+ opacity: 1;
97
+ transform: rotate(0deg) scale(1);
98
+ }
99
+ :global([data-theme='dark']) .tt-moon {
100
+ opacity: 0;
101
+ transform: rotate(-90deg) scale(0.9);
102
+ }
103
+ </style>
104
+
105
+ <script>
106
+ class ThemeToggle extends HTMLElement {
107
+ private btn: HTMLButtonElement | null = null;
108
+
109
+ connectedCallback() {
110
+ this.btn = this.querySelector<HTMLButtonElement>('.theme-toggle-btn');
111
+ this.btn?.addEventListener('click', () => this.toggle());
112
+ }
113
+
114
+ toggle() {
115
+ const current = document.documentElement.getAttribute('data-theme');
116
+ const next = current === 'dark' ? 'light' : 'dark';
117
+ document.documentElement.setAttribute('data-theme', next);
118
+ try {
119
+ localStorage.setItem('theme', next);
120
+ } catch {}
121
+ }
122
+ }
123
+
124
+ if (!customElements.get('theme-toggle')) {
125
+ customElements.define('theme-toggle', ThemeToggle);
126
+ }
127
+ </script>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Accessibility utilities.
3
+ * Focus styles, reduced motion, and selection styling.
4
+ * Uses --color-brand for theming.
5
+ */
6
+
7
+ *:focus-visible {
8
+ outline: 2px solid var(--color-brand, #7c5cff);
9
+ outline-offset: 2px;
10
+ }
11
+
12
+ ::selection {
13
+ background: color-mix(in oklab, var(--color-brand, #7c5cff) 25%, transparent);
14
+ }
15
+
16
+ :where([data-theme='dark']) ::selection {
17
+ background: color-mix(in oklab, var(--color-brand, #7c5cff) 35%, transparent);
18
+ }
19
+
20
+ @media (prefers-reduced-motion: reduce) {
21
+ *,
22
+ *::before,
23
+ *::after {
24
+ animation-duration: 0.01ms !important;
25
+ animation-iteration-count: 1 !important;
26
+ transition-duration: 0.01ms !important;
27
+ scroll-behavior: auto !important;
28
+ }
29
+ }