From 6328d168ac2c7cf2e41b47e3881290fd016ecd62 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Wed, 5 Nov 2025 01:26:02 +0300
Subject: [PATCH 001/341] Refactor theme setting inline script to own file and
add unit + e2e tests in 11-regression
---
.vscode/settings.json | 1 +
THEME_SYSTEM_WIP.md | 127 -----
docs/THEME_PICKER.md | 43 --
src/components/Head/index.astro | 24 +-
.../Scripts/theme/__tests__/index.spec.ts | 223 ++++++++
src/components/Scripts/theme/index.ts | 34 ++
src/layouts/BaseLayout.astro | 7 +-
src/lib/logger/README.md | 111 ----
src/styles/themes.css | 492 ++++++------------
test/e2e/helpers/baseTest.ts | 3 +-
test/e2e/helpers/index.ts | 2 +-
.../theme-initialization.spec.ts | 296 +++++++++++
12 files changed, 733 insertions(+), 630 deletions(-)
delete mode 100644 THEME_SYSTEM_WIP.md
delete mode 100644 docs/THEME_PICKER.md
create mode 100644 src/components/Scripts/theme/__tests__/index.spec.ts
create mode 100644 src/components/Scripts/theme/index.ts
create mode 100644 test/e2e/specs/11-regression/theme-initialization.spec.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index b20da8a26..b8f04f32f 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -31,6 +31,7 @@
"Flink",
"FNAME",
"fosstodon",
+ "FOUC",
"glidejs",
"GSAP",
"hocho",
diff --git a/THEME_SYSTEM_WIP.md b/THEME_SYSTEM_WIP.md
deleted file mode 100644
index 855df2f14..000000000
--- a/THEME_SYSTEM_WIP.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# Theme System - Work in Progress
-
-## Current Status
-
-### What's Working
-- ✅ Theme picker tests: 9/10 @ready (63 tests passing across 7 browsers)
-- ✅ Manual theme selection and persistence works correctly
-- ✅ Theme switching via theme picker UI works
-- ✅ View Transitions correctly maintain theme across page navigations
-- ✅ CSS architecture refactored: `@theme inline` uses `var()` references instead of hard-coded values
-
-### What's Broken
-- ❌ **System preference (prefers-color-scheme) not respected on first visit in real browser**
- - Test passes but real usage fails
- - Opening site in new incognito window with dark mode preference shows light theme
- - Google.com correctly shows dark, but our site doesn't
-
-- ❌ **Theme picker buttons should show their own theme's colors**
- - Dark theme button should use `--dark-color-*` variables
- - Default theme button should use `--light-color-*` variables
- - Currently all buttons use light theme colors (partially fixed but needs completion)
-
-## Root Cause Analysis
-
-### System Preference Issue
-
-The problem is a **race condition** between:
-1. HEAD inline script (synchronous) - correctly sets `data-theme="dark"` based on `prefers-color-scheme`
-2. `persistentAtom` restore (asynchronous) - overwrites theme back to 'default'
-
-**Sequence of events:**
-```
-1. HEAD script runs → checks localStorage (empty) → checks prefersDark=true → sets data-theme="dark" ✓
-2. persistentAtom initializes with default value 'default'
-3. Our init code runs with setTimeout(100ms)
-4. persistentAtom's restore() completes (async) → fires .listen() → overwrites to 'default' ✗
-```
-
-**Current fix attempt:**
-- Using `isInitialized` flag to prevent `.listen()` from applying themes until init completes
-- Using `setTimeout(100ms)` to delay init until after `persistentAtom.restore()` completes
-- **Problem:** The timing is unreliable - 100ms might not be enough on slower devices
-
-**Test vs Reality:**
-- Playwright test passes because it's using `emulateMedia({ colorScheme: 'dark' })`
-- Real browser behavior is different - the race condition manifests differently
-- Test needs to be improved to catch this real-world bug
-
-## Files Modified
-
-### Theme Initialization
-- `src/components/Scripts/state/store/themes.ts` (lines 127-180)
- - Changed from `.subscribe()` to `.listen()` to avoid immediate firing
- - Added `isInitialized` flag to gate theme applications
- - Added `setTimeout(100)` to wait for `persistentAtom.restore()`
- - **HAS DEBUG LOGGING** - needs to be removed before commit
-
-### Theme Picker UI
-- `src/components/ThemePicker/Themes.astro` (lines 97-120)
- - **NOT YET FIXED** - still needs to map theme.id to color prefix
- - Should use `--${colorPrefix}-color-*` variables per button
-
-### HEAD Script
-- `src/components/Head/index.astro` (lines 56-63)
- - Correctly checks `prefers-color-scheme` and sets `data-theme`
- - Logic: stored theme (if not 'default') > system preference > 'default'
-
-### CSS Architecture
-- `src/styles/themes.css`
- - ✅ Lines 65-105: `@theme inline` refactored to use `var()` references
- - ✅ All theme-specific colors defined at `:root` level
- - Has `--light-color-*`, `--dark-color-*`, and base `--color-*` variables
-
-## Next Steps
-
-### High Priority
-1. **Fix system preference detection**
- - Option A: Find more reliable way to detect when `persistentAtom.restore()` completes
- - Option B: Use `MutationObserver` to watch for theme changes from restore
- - Option C: Initialize theme BEFORE importing `persistentAtom`
- - Option D: Use regular `atom` for store, manually sync to localStorage after restore completes
- - Option E: Don't rely on setTimeout - use `requestIdleCallback` or similar
-
-2. **Fix theme picker button colors**
- - Complete the Themes.astro fix to show each theme's own colors
- - Map `theme.id` to color variable prefix: 'default' → 'light', 'dark' → 'dark'
- - Update color swatches to use theme-specific variables
-
-3. **Remove debug logging**
- - `src/components/Scripts/state/store/themes.ts` has console.log statements
- - Clean these up before final commit
-
-### Test Improvements
-4. **Make test match real browser behavior**
- - Current test uses `emulateMedia()` which might not trigger same race condition
- - Consider testing with actual localStorage clearing and page reload
- - Add test that validates theme immediately on page load (before JS runs)
-
-## Technical Constraints
-
-- **MUST use `persistentAtom`** - required for View Transitions to maintain theme across navigations
-- **CANNOT use regular `atom`** - will lose persistence across page navigations
-- HEAD script must run synchronously to prevent FOUC (Flash of Unstyled Content)
-- Theme must be applied before page renders (critical for UX)
-
-## Code Locations
-
-- Theme store: `src/components/Scripts/state/store/themes.ts`
-- Theme picker UI: `src/components/ThemePicker/Themes.astro`
-- Theme picker element: `src/components/ThemePicker/theme-picker-element.ts`
-- HEAD script: `src/components/Head/index.astro` (lines 56-63)
-- CSS themes: `src/styles/themes.css`
-- E2E tests: `test/e2e/specs/04-components/theme-picker.spec.ts` (line 134 is failing test)
-
-## Questions to Answer
-
-1. When exactly does `persistentAtom.restore()` complete?
-2. Is there an event or promise we can wait for?
-3. Should we implement our own localStorage persistence instead of using `persistentAtom`?
-4. Can we leverage the `@media (prefers-color-scheme: dark)` CSS to avoid needing JS for system preference?
-
-## Useful Context
-
-- The `@media (prefers-color-scheme: dark)` CSS rule at lines 337-373 in themes.css correctly applies dark theme
-- This CSS works WITHOUT JavaScript
-- The issue is the JS is overriding this CSS by setting `data-theme="default"`
-- Maybe we should NOT set `data-theme` at all when using system preference?
diff --git a/docs/THEME_PICKER.md b/docs/THEME_PICKER.md
deleted file mode 100644
index 7b2a5bbc9..000000000
--- a/docs/THEME_PICKER.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Theme Picker info
-
-The theme picker works by setting CSS attribute matchers for the `data-theme` attribute to the group of sitewide CSS variables, like `--color-bg`. That way, when the data theme is changed on the root document `` element, the variable values in the CSS are changed as well. This is over-ridden locally in the theme picker modal, where the `data-theme` attribute is set on individual theme item cards so the card shows that theme's colors.
-
-## Code Pieces
-
-### Client script bundle for the modal and icon button
-
-`src/assets/modules/themePicker`.
-
-### Generate HTML for the Theme Picker Modal to choose different color themes
-
-`src/_layouts/components/themePicker/themes.njk` creates the theme picker HTML by looping through `themes`. Data is generated by `_data/themes.js`.
-
-### Generate HTML for the Theme Picker Icon to make the theme picker modal visible
-
-`src/_layouts/components/themePicker/icon.njk`.
-
-### Generate `
-{/* Client-side router for Astro pages (enables partial page reloads) */}
-{/* Must be placed at the end of the to avoid blocking page rendering */}
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/components/Scripts/theme/__tests__/index.spec.ts b/src/components/Scripts/theme/__tests__/index.spec.ts
new file mode 100644
index 000000000..e59af08d3
--- /dev/null
+++ b/src/components/Scripts/theme/__tests__/index.spec.ts
@@ -0,0 +1,223 @@
+/**
+ * Unit tests for theme initialization script
+ */
+
+// @vitest-environment happy-dom
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { setInitialTheme } from '../index'
+import * as errorHandler from '@components/Scripts/errors/handler'
+
+describe('setInitialTheme', () => {
+ beforeEach(() => {
+ // Clear localStorage before each test
+ localStorage.clear()
+
+ // Clear all document attributes
+ document.documentElement.removeAttribute('data-theme')
+ document.documentElement.removeAttribute('lang')
+
+ // Reset all mocks
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ localStorage.clear()
+ vi.restoreAllMocks()
+ })
+
+ describe('localStorage theme handling', () => {
+ it('should set theme from localStorage when available', () => {
+ localStorage.setItem('theme', 'dark')
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
+ })
+
+ it('should set theme to light when stored in localStorage', () => {
+ localStorage.setItem('theme', 'light')
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light')
+ })
+
+ it('should set custom theme from localStorage', () => {
+ localStorage.setItem('theme', 'custom-theme')
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('custom-theme')
+ })
+
+ it('should handle localStorage errors gracefully', () => {
+ const handleScriptErrorSpy = vi.spyOn(errorHandler, 'handleScriptError').mockReturnValue({} as any)
+
+ // Make localStorage.getItem throw by overriding the global
+ const originalGetItem = window.localStorage.getItem
+ Object.defineProperty(window.localStorage, 'getItem', {
+ configurable: true,
+ value: vi.fn(() => {
+ throw new Error('localStorage access denied')
+ })
+ })
+
+ setInitialTheme()
+
+ expect(handleScriptErrorSpy).toHaveBeenCalledWith(
+ expect.any(Error),
+ { scriptName: 'theme-initialization', operation: 'setInitialTheme' }
+ )
+
+ // Restore original
+ Object.defineProperty(window.localStorage, 'getItem', {
+ configurable: true,
+ value: originalGetItem
+ })
+ })
+ })
+
+ describe('system preference fallback', () => {
+ it('should set dark theme when system prefers dark and no stored theme', () => {
+ // Mock matchMedia to return dark preference
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: query === '(prefers-color-scheme: dark)',
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ })
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
+ })
+
+ it('should not override stored theme with system preference', () => {
+ localStorage.setItem('theme', 'light')
+
+ // Mock matchMedia to return dark preference
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: query === '(prefers-color-scheme: dark)',
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ })
+
+ setInitialTheme()
+
+ // Should keep the stored theme, not system preference
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light')
+ })
+
+ it('should not set theme when system prefers light and no stored theme', () => {
+ // Mock matchMedia to return light preference
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false, // doesn't match dark preference
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ })
+
+ setInitialTheme()
+
+ // Should not set data-theme (defaults to light in BaseLayout.astro)
+ expect(document.documentElement.getAttribute('data-theme')).toBeNull()
+ })
+ })
+
+ describe('lang attribute fix', () => {
+ it('should always set lang attribute to "en"', () => {
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+ })
+
+ it('should restore lang attribute even when localStorage has theme', () => {
+ localStorage.setItem('theme', 'dark')
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+ })
+
+ it('should restore lang attribute even when localStorage throws error', () => {
+ vi.spyOn(errorHandler, 'handleScriptError')
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('localStorage error')
+ })
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+ })
+ })
+
+ describe('integration scenarios', () => {
+ it('should handle complete flow: stored theme + lang attribute', () => {
+ localStorage.setItem('theme', 'dark')
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+ })
+
+ it('should handle complete flow: system preference + lang attribute', () => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: query === '(prefers-color-scheme: dark)',
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ })
+
+ setInitialTheme()
+
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+ })
+
+ it('should handle error gracefully and still set lang attribute', () => {
+ const handleScriptErrorSpy = vi.spyOn(errorHandler, 'handleScriptError').mockReturnValue({} as any)
+
+ // Make localStorage.getItem throw
+ const originalGetItem = window.localStorage.getItem
+ Object.defineProperty(window.localStorage, 'getItem', {
+ configurable: true,
+ value: vi.fn(() => {
+ throw new Error('localStorage unavailable')
+ })
+ })
+
+ setInitialTheme()
+
+ expect(handleScriptErrorSpy).toHaveBeenCalled()
+ expect(document.documentElement.getAttribute('lang')).toBe('en')
+
+ // Restore original
+ Object.defineProperty(window.localStorage, 'getItem', {
+ configurable: true,
+ value: originalGetItem
+ })
+ })
+ })
+})
diff --git a/src/components/Scripts/theme/index.ts b/src/components/Scripts/theme/index.ts
new file mode 100644
index 000000000..140a0b24b
--- /dev/null
+++ b/src/components/Scripts/theme/index.ts
@@ -0,0 +1,34 @@
+import { handleScriptError } from '../errors/handler'
+
+/**
+ * Set theme name on element from storage
+ * early to prevent flash of unstyled content.
+ */
+export const setInitialTheme = () => {
+ /** 1. If there is a stored theme in localstorage, it takes priority */
+ let themeSet = false
+ try {
+ const storedTheme = localStorage.getItem('theme')
+ if (storedTheme) {
+ document.documentElement.setAttribute('data-theme', storedTheme)
+ themeSet = true
+ }
+ } catch (error) {
+ handleScriptError(error, { scriptName: 'theme-initialization', operation: 'setInitialTheme' })
+ }
+ /** 2. If no stored theme and system preference is "dark", set to dark theme */
+ if (!themeSet && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ document.documentElement.setAttribute('data-theme', 'dark')
+ }
+ /**
+ * 3. If no stored theme and no system preference, light theme
+ * is set by default in BaseLayout.astro
+ */
+
+ /**
+ * Make sure Astro View Transitions hasn't removed lang tag from
+ * after modifying attributes with . This is
+ * a known bug with Astro View Transitions.
+ */
+ document.documentElement.setAttribute('lang', 'en')
+}
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 7e9fd1f18..f3f87aa9c 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -48,7 +48,7 @@ const {
---
-
+
- {/* Only enable View Transitions in production to avoid breaking e2e tests */}
- {import.meta.env.PROD && }
+ {/* Client-side router for Astro pages (enables partial page reloads) */}
+ {/* Must be placed at the end of the to avoid blocking page rendering */}
+
{/* Cookie Consent Overlay - Only persist in production mode */}
diff --git a/src/lib/logger/README.md b/src/lib/logger/README.md
index ba80f3059..7ad677bd2 100644
--- a/src/lib/logger/README.md
+++ b/src/lib/logger/README.md
@@ -103,114 +103,3 @@ Log success messages.
- Production error tracking
- Sentry integration
- Error recovery logic
-
-## Examples
-
-### Form Validation
-
-```typescript
-import { logger } from '@lib/logger'
-import { handleError } from '@lib/errors'
-
-function validateForm(data: FormData) {
- logger.debug('Validating form', { fields: data.keys() })
-
- try {
- // validation logic
- logger.success('Form validation passed')
- return true
- } catch (error) {
- handleError(error, {
- component: 'FormValidator',
- action: 'validate'
- })
- return false
- }
-}
-```
-
-### API Calls
-
-```typescript
-import { logger } from '@lib/logger'
-import { handleError } from '@lib/errors'
-
-async function fetchData() {
- logger.info('Fetching data from API')
-
- try {
- const response = await fetch('/api/data')
- const data = await response.json()
-
- logger.success('Data fetched', { count: data.length })
- return data
- } catch (error) {
- handleError(error, {
- component: 'DataFetcher',
- action: 'fetch'
- })
- return null
- }
-}
-```
-
-### State Changes
-
-```typescript
-import { logger } from '@lib/logger'
-
-function updateTheme(theme: string) {
- logger.debug('Theme changing', { from: currentTheme, to: theme })
-
- document.documentElement.setAttribute('data-theme', theme)
-
- logger.info('Theme updated', { theme })
-}
-```
-
-## Production Behavior
-
-In production (`import.meta.env.PROD`), all logger methods are no-ops (do nothing). This ensures:
-
-- Zero console output in production
-- No performance impact
-- Clean user console experience
-
-## Migration from Old Logger
-
-### Before
-
-```typescript
-import { log } from '@components/Scripts/logger'
-
-log(new Error('Something failed'), 'Component', 'action')
-```
-
-### After
-
-For errors, use error handler:
-
-```typescript
-import { handleError } from '@lib/errors'
-
-handleError(new Error('Something failed'), {
- component: 'Component',
- action: 'action'
-})
-```
-
-For debugging, use new logger:
-
-```typescript
-import { logger } from '@lib/logger'
-
-logger.debug('Operation completed', {
- component: 'Component',
- action: 'action'
-})
-```
-
-## See Also
-
-- [Error Handler Documentation](../errors/README.md) - For error handling
-- [ERROR_LOGGING_ANALYSIS.md](/docs/ERROR_LOGGING_ANALYSIS.md) - Original analysis
diff --git a/src/styles/themes.css b/src/styles/themes.css
index 2cbf2b8aa..dd19c3b71 100644
--- a/src/styles/themes.css
+++ b/src/styles/themes.css
@@ -1,256 +1,148 @@
-@import url("tailwindcss");
-
/**
* Theme System - Pure CSS Implementation
- *
- * This file defines all available themes using CSS custom properties.
- * Each theme is applied via data attributes on the document element.
- *
- * Theme Structure:
- * - Light theme: Default theme, applied to :root
- * - Dark theme: Applied via [data-theme="dark"] and @media(prefers-color-scheme: dark)
- * - Additional themes: Applied via [data-theme="theme-name"]
- *
- * Color Variables follow this naming convention:
- * --color-{purpose}: Main color
- * --color-{purpose}-offset: Hover/interaction variant
- * --color-{purpose}-bg: Background variant (for badges, etc.)
- */
-
- /*
-Common Tailwind Color Classes
-
-accent-{color}: Sets the accent color for interactive elements like checkboxes
-bg-{color}: Sets the background color
-border-{color}: Sets the border color
-caret-{color}: Utilities for controlling the color of the text input cursor
-decoration-{color}: Utilities for controlling the color of text decorations
-fill-{color}: Sets the fill color for SVG elements
-inset-ring-{color}: Sets the color of inset ring shadows
-inset-shadow-{color}: Sets the color of inset box shadows
-outline-{color}: Sets the outline color of an element
-ring-{color}: Sets the color of ring shadows
-shadow-{color}: Sets the color of box shadows
-stroke-{color}: Sets the stroke color for SVG elements
-text-{color}: Sets the text color
-
-link-color: pale-blue;
-banner-color: pale-green;
-banner-border: pale-dark-green;
-banner-bg: pale-neon-green;
-alert-status: pale-green;
-alert-status-border: pale-dark-green;
-button-bg: pale-neon-blue;
-button-border: pale-blue;
-footer-bg: pale-neon-blue;
-
-current: 'currentColor';
-transparent: 'transparent';
-
---color-page: #f3f4f6; // --color-bg
---color-page-offset: #e5e7eb; // --color-bg-offset
---color-foreground: #001a39; // --color-bg-inverse
---color-content: #374151; // --color-text
---color-content-offset: #9ca3af; // --color-text-offset
-
---color-base
-*/
-
-/**
- * =============================================================================
- * DEFAULT THEME (Light) - Tailwind adds @theme to both theme vars and :root
- * =============================================================================
*/
@theme inline {
- --color-bg: var(--light-color-bg);
- --color-bg-offset: var(--light-color-bg-offset);
- --color-bg-inverse: var(--light-color-bg-inverse);
- --color-text: var(--light-color-text);
- --color-text-offset: var(--light-color-text-offset);
- --color-border: var(--light-color-border);
- --color-primary: var(--light-color-primary);
- --color-primary-offset: var(--light-color-primary-offset);
- --color-primary-bg: var(--light-color-primary-bg);
- --color-primary-bg-hover: var(--light-color-primary-bg-hover);
- --color-primary-hover: var(--light-color-primary-hover);
- --color-secondary: var(--light-color-secondary);
- --color-secondary-offset: var(--light-color-secondary-offset);
- --color-secondary-bg: var(--light-color-secondary-bg);
- --color-success: var(--light-color-success);
- --color-success-offset: var(--light-color-success-offset);
- --color-success-bg: var(--light-color-success-bg);
- --color-info: var(--light-color-info);
- --color-info-bg: var(--light-color-info-bg);
- --color-warning: var(--light-color-warning);
- --color-warning-offset: var(--light-color-warning-offset);
- --color-warning-bg: var(--light-color-warning-bg);
- --color-danger: var(--light-color-danger);
- --color-danger-bg: var(--light-color-danger-bg);
- --color-twitter: var(--light-color-twitter);
- --color-accent: var(--light-color-accent);
- --color-accent-bg: var(--light-color-accent-bg);
- --color-link-shadow: var(--light-color-link-shadow);
- --color-share-highlight-text: var(--light-color-share-highlight-text);
- --color-share-highlight-bg: var(--light-color-share-highlight-bg);
- --color-share-highlight-text-active: var(--light-color-share-highlight-text-active);
- --color-share-highlight-bg-active: var(--light-color-share-highlight-bg-active);
- --color-share-highlight-tooltip-text: var(--light-color-share-highlight-tooltip-text);
- --color-share-highlight-tooltip-bg: var(--light-color-share-highlight-tooltip-bg);
- --shiki-theme: var(--light-shiki-theme);
- --shadow-sm: var(--light-shadow-sm);
- --shadow-md: var(--light-shadow-md);
- --shadow-hover: var(--light-shadow-hover);
- --shadow-active: var(--light-shadow-active);
- --shadow-text: var(--light-shadow-text);
+ --color-bg: var(--theme-color-bg);
+ --color-bg-offset: var(--theme-color-bg-offset);
+ --color-bg-inverse: var(--theme-color-bg-inverse);
+ --color-text: var(--theme-color-text);
+ --color-text-offset: var(--theme-color-text-offset);
+ --color-border: var(--theme-color-border);
+ --color-primary: var(--theme-color-primary);
+ --color-primary-offset: var(--theme-color-primary-offset);
+ --color-primary-bg: var(--theme-color-primary-bg);
+ --color-primary-bg-hover: var(--theme-color-primary-bg-hover);
+ --color-primary-hover: var(--theme-color-primary-hover);
+ --color-secondary: var(--theme-color-secondary);
+ --color-secondary-offset: var(--theme-color-secondary-offset);
+ --color-secondary-bg: var(--theme-color-secondary-bg);
+ --color-success: var(--theme-color-success);
+ --color-success-offset: var(--theme-color-success-offset);
+ --color-success-bg: var(--theme-color-success-bg);
+ --color-info: var(--theme-color-info);
+ --color-info-bg: var(--theme-color-info-bg);
+ --color-warning: var(--theme-color-warning);
+ --color-warning-offset: var(--theme-color-warning-offset);
+ --color-warning-bg: var(--theme-color-warning-bg);
+ --color-danger: var(--theme-color-danger);
+ --color-danger-bg: var(--theme-color-danger-bg);
+ --color-twitter: var(--theme-color-twitter);
+ --color-accent: var(--theme-color-accent);
+ --color-accent-bg: var(--theme-color-accent-bg);
+ --color-link-shadow: var(--theme-color-link-shadow);
+ --color-share-highlight-text: var(--theme-color-share-highlight-text);
+ --color-share-highlight-bg: var(--theme-color-share-highlight-bg);
+ --color-share-highlight-text-active: var(--theme-color-share-highlight-text-active);
+ --color-share-highlight-bg-active: var(--theme-color-share-highlight-bg-active);
+ --color-share-highlight-tooltip-text: var(--theme-color-share-highlight-tooltip-text);
+ --color-share-highlight-tooltip-bg: var(--theme-color-share-highlight-tooltip-bg);
+ --shiki-theme: var(--theme-shiki-theme);
+ --shadow-sm: var(--theme-shadow-sm);
+ --shadow-md: var(--theme-shadow-md);
+ --shadow-hover: var(--theme-shadow-hover);
+ --shadow-active: var(--theme-shadow-active);
+ --shadow-text: var(--theme-shadow-text);
}
-/** @TODO: Should it only be the light color in :root, and everything else in [data-theme]? */
-
-:root {
+[data-theme="light"] {
/**
* =============================================================================
* LIGHT THEME DEFINITIONS
- * These are defined at :root level and referenced by both [data-theme="dark"]
- * and @media (prefers-color-scheme: dark) to maintain a single source of truth
* =============================================================================
*/
- --light-color-bg: #f3f4f6;
- --light-color-bg-offset: #e5e7eb;
- --light-color-bg-inverse: #001a39;
- --light-color-text: #374151;
- --light-color-text-offset: #9ca3af;
- --light-color-border: #f0f4ff;
- --light-color-primary: #006dca;
- --light-color-primary-offset: #00386d;
- --light-color-primary-bg: #dbeafe;
- --light-color-primary-bg-hover: #bfdbfe;
- --light-color-primary-hover: #0056a3;
- --light-color-secondary: #facc15;
- --light-color-secondary-offset: #ca8a04;
- --light-color-secondary-bg: #fef3c7;
- --light-color-success: #16a34a;
- --light-color-success-offset: #22c55e;
- --light-color-success-bg: #dcfce7;
- --light-color-info: #0891b2;
- --light-color-info-bg: #cffafe;
- --light-color-warning: #a16207;
- --light-color-warning-offset: #ca8a04;
- --light-color-warning-bg: #fef3c7;
- --light-color-danger: #dc2626;
- --light-color-danger-bg: #fecaca;
- --light-color-twitter: #1da1f2;
- --light-color-accent: #7c3aed;
- --light-color-accent-bg: #ede9fe;
- --light-color-link-shadow: #2563eb; /* blue-600 for link underline shadow */
- --light-color-share-highlight-text: #111827; /* gray-900 */
- --light-color-share-highlight-bg: #e5e7eb; /* gray-200 */
- --light-color-share-highlight-text-active: #fff; /* white */
- --light-color-share-highlight-bg-active: #2563eb; /* blue-600 */
- --light-color-share-highlight-tooltip-text: #fff; /* white */
- --light-color-share-highlight-tooltip-bg: #111827; /* gray-900 */
- --light-shiki-theme: 'github-light';
- --light-shadow-sm: 0 2px 16px #000026;
- --light-shadow-md: 0 4px 6px #32325d1c, 0 1px 3px #000014;
- --light-shadow-hover: 0 7px 14px #3232321a, 0 3px 6px #000014;
- --light-shadow-active: 0 3px 12px -2px inset #32323240;
- --light-shadow-text: 0 1px 3px #000026;
-
- /**
- * =============================================================================
- * DARK THEME DEFINITIONS
- * These are defined at :root level and referenced by both [data-theme="dark"]
- * and @media (prefers-color-scheme: dark) to maintain a single source of truth
- * =============================================================================
- */
- --dark-color-bg: #001a39;
- --dark-color-bg-offset: #00386d;
- --dark-color-bg-inverse: #f3f4f6;
- --dark-color-text: #f3f4f6;
- --dark-color-text-offset: #e5e7eb;
- --dark-color-border: #222;
- --dark-color-primary: #f3f4f6;
- --dark-color-primary-offset: #e5e7eb;
- --dark-color-primary-bg: #1e293b;
- --dark-color-primary-bg-hover: #334155;
- --dark-color-primary-hover: #d1d5db;
- --dark-color-secondary: #facc15;
- --dark-color-secondary-offset: #ca8a04;
- --dark-color-secondary-bg: #451a03;
- --dark-color-success: #16a34a;
- --dark-color-success-offset: #22c55e;
- --dark-color-success-bg: #052e16;
- --dark-color-info: #0891b2;
- --dark-color-info-bg: #164e63;
- --dark-color-warning: #a16207;
- --dark-color-warning-offset: #ca8a04;
- --dark-color-warning-bg: #451a03;
- --dark-color-danger: #dc2626;
- --dark-color-danger-bg: #7f1d1d;
- --dark-color-twitter: #1da1f2;
- --dark-color-accent: #8b5cf6;
- --dark-color-accent-bg: #2e1065;
- --dark-color-link-shadow: #60a5fa;
- --dark-color-share-highlight-text: #f3f4f6;
- --dark-color-share-highlight-bg: #374151;
- --dark-color-share-highlight-text-active: #111827;
- --dark-color-share-highlight-bg-active: #60a5fa;
- --dark-color-share-highlight-tooltip-text: #111827;
- --dark-color-share-highlight-tooltip-bg: #f3f4f6;
- --dark-shiki-theme: 'github-dark';
- --dark-shadow-sm: 0 2px 16px #00004d;
- --dark-shadow-md: 0 4px 6px #00004d, 0 1px 3px #003;
- --dark-shadow-hover: 0 7px 14px #006, 0 3px 6px #003;
- --dark-shadow-active: 0 3px 12px -2px inset #000080;
- --dark-shadow-text: 0 1px 3px #00004d;
+ --theme-color-bg: #f3f4f6;
+ --theme-color-bg-offset: #e5e7eb;
+ --theme-color-bg-inverse: #001a39;
+ --theme-color-text: #374151;
+ --theme-color-text-offset: #9ca3af;
+ --theme-color-border: #f0f4ff;
+ --theme-color-primary: #006dca;
+ --theme-color-primary-offset: #00386d;
+ --theme-color-primary-bg: #dbeafe;
+ --theme-color-primary-bg-hover: #bfdbfe;
+ --theme-color-primary-hover: #0056a3;
+ --theme-color-secondary: #facc15;
+ --theme-color-secondary-offset: #ca8a04;
+ --theme-color-secondary-bg: #fef3c7;
+ --theme-color-success: #16a34a;
+ --theme-color-success-offset: #22c55e;
+ --theme-color-success-bg: #dcfce7;
+ --theme-color-info: #0891b2;
+ --theme-color-info-bg: #cffafe;
+ --theme-color-warning: #a16207;
+ --theme-color-warning-offset: #ca8a04;
+ --theme-color-warning-bg: #fef3c7;
+ --theme-color-danger: #dc2626;
+ --theme-color-danger-bg: #fecaca;
+ --theme-color-twitter: #1da1f2;
+ --theme-color-accent: #7c3aed;
+ --theme-color-accent-bg: #ede9fe;
+ --theme-color-link-shadow: #2563eb; /* blue-600 for link underline shadow */
+ --theme-color-share-highlight-text: #111827; /* gray-900 */
+ --theme-color-share-highlight-bg: #e5e7eb; /* gray-200 */
+ --theme-color-share-highlight-text-active: #fff; /* white */
+ --theme-color-share-highlight-bg-active: #2563eb; /* blue-600 */
+ --theme-color-share-highlight-tooltip-text: #fff; /* white */
+ --theme-color-share-highlight-tooltip-bg: #111827; /* gray-900 */
+ --theme-shiki-theme: 'github-light';
+ --theme-shadow-sm: 0 2px 16px #000026;
+ --theme-shadow-md: 0 4px 6px #32325d1c, 0 1px 3px #000014;
+ --theme-shadow-hover: 0 7px 14px #3232321a, 0 3px 6px #000014;
+ --theme-shadow-active: 0 3px 12px -2px inset #32323240;
+ --theme-shadow-text: 0 1px 3px #000026;
}
/**
* =============================================================================
- * DARK THEME - Explicit Selection
- * Applied when user explicitly selects dark theme
+ * DARK THEME
* =============================================================================
*/
[data-theme="dark"] {
- --color-bg: var(--dark-color-bg);
- --color-bg-offset: var(--dark-color-bg-offset);
- --color-bg-inverse: var(--dark-color-bg-inverse);
- --color-text: var(--dark-color-text);
- --color-text-offset: var(--dark-color-text-offset);
- --color-border: var(--dark-color-border);
- --color-primary: var(--dark-color-primary);
- --color-primary-offset: var(--dark-color-primary-offset);
- --color-primary-bg: var(--dark-color-primary-bg);
- --color-primary-bg-hover: var(--dark-color-primary-bg-hover);
- --color-primary-hover: var(--dark-color-primary-hover);
- --color-secondary: var(--dark-color-secondary);
- --color-secondary-offset: var(--dark-color-secondary-offset);
- --color-secondary-bg: var(--dark-color-secondary-bg);
- --color-success: var(--dark-color-success);
- --color-success-offset: var(--dark-color-success-offset);
- --color-success-bg: var(--dark-color-success-bg);
- --color-info: var(--dark-color-info);
- --color-info-bg: var(--dark-color-info-bg);
- --color-warning: var(--dark-color-warning);
- --color-warning-offset: var(--dark-color-warning-offset);
- --color-warning-bg: var(--dark-color-warning-bg);
- --color-danger: var(--dark-color-danger);
- --color-danger-bg: var(--dark-color-danger-bg);
- --color-twitter: var(--dark-color-twitter);
- --color-accent: var(--dark-color-accent);
- --color-accent-bg: var(--dark-color-accent-bg);
- --color-link-shadow: var(--dark-color-link-shadow);
- --color-share-highlight-text: var(--dark-color-share-highlight-text);
- --color-share-highlight-bg: var(--dark-color-share-highlight-bg);
- --color-share-highlight-text-active: var(--dark-color-share-highlight-text-active);
- --color-share-highlight-bg-active: var(--dark-color-share-highlight-bg-active);
- --color-share-highlight-tooltip-text: var(--dark-color-share-highlight-tooltip-text);
- --color-share-highlight-tooltip-bg: var(--dark-color-share-highlight-tooltip-bg);
- --shiki-theme: var(--dark-shiki-theme);
- --shadow-sm: var(--dark-shadow-sm);
- --shadow-md: var(--dark-shadow-md);
- --shadow-hover: var(--dark-shadow-hover);
- --shadow-active: var(--dark-shadow-active);
- --shadow-text: var(--dark-shadow-text);
+ /**
+ * =============================================================================
+ * DARK THEME
+ * =============================================================================
+ */
+ --theme-color-bg: #001a39;
+ --theme-color-bg-offset: #00386d;
+ --theme-color-bg-inverse: #f3f4f6;
+ --theme-color-text: #f3f4f6;
+ --theme-color-text-offset: #e5e7eb;
+ --theme-color-border: #222;
+ --theme-color-primary: #f3f4f6;
+ --theme-color-primary-offset: #e5e7eb;
+ --theme-color-primary-bg: #1e293b;
+ --theme-color-primary-bg-hover: #334155;
+ --theme-color-primary-hover: #d1d5db;
+ --theme-color-secondary: #facc15;
+ --theme-color-secondary-offset: #ca8a04;
+ --theme-color-secondary-bg: #451a03;
+ --theme-color-success: #16a34a;
+ --theme-color-success-offset: #22c55e;
+ --theme-color-success-bg: #052e16;
+ --theme-color-info: #0891b2;
+ --theme-color-info-bg: #164e63;
+ --theme-color-warning: #a16207;
+ --theme-color-warning-offset: #ca8a04;
+ --theme-color-warning-bg: #451a03;
+ --theme-color-danger: #dc2626;
+ --theme-color-danger-bg: #7f1d1d;
+ --theme-color-twitter: #1da1f2;
+ --theme-color-accent: #8b5cf6;
+ --theme-color-accent-bg: #2e1065;
+ --theme-color-link-shadow: #60a5fa;
+ --theme-color-share-highlight-text: #f3f4f6;
+ --theme-color-share-highlight-bg: #374151;
+ --theme-color-share-highlight-text-active: #111827;
+ --theme-color-share-highlight-bg-active: #60a5fa;
+ --theme-color-share-highlight-tooltip-text: #111827;
+ --theme-color-share-highlight-tooltip-bg: #f3f4f6;
+ --theme-shiki-theme: 'github-dark';
+ --theme-shadow-sm: 0 2px 16px #00004d;
+ --theme-shadow-md: 0 4px 6px #00004d, 0 1px 3px #003;
+ --theme-shadow-hover: 0 7px 14px #006, 0 3px 6px #003;
+ --theme-shadow-active: 0 3px 12px -2px inset #000080;
+ --theme-shadow-text: 0 1px 3px #00004d;
}
/**
@@ -259,95 +151,43 @@ transparent: 'transparent';
* =============================================================================
*/
[data-theme="holiday"] {
- --color-bg: #0f172a;
- --color-bg-offset: #1e293b;
- --color-text: #f1f5f9;
- --color-text-offset: #cbd5e1;
- --color-border: #334155;
- --color-primary: #dc2626;
- --color-primary-offset: #991b1b;
- --color-primary-bg: #7f1d1d;
- --color-primary-bg-hover: #991b1b;
- --color-primary-hover: #b91c1c;
- --color-secondary: #16a34a;
- --color-secondary-offset: #15803d;
- --color-secondary-bg: #052e16;
- --color-success: #16a34a;
- --color-success-offset: #22c55e;
- --color-success-bg: #052e16;
- --color-info: #0891b2;
- --color-info-bg: #164e63;
- --color-warning: #a16207;
- --color-warning-offset: #ca8a04;
- --color-warning-bg: #451a03;
- --color-danger: #dc2626;
- --color-danger-bg: #7f1d1d;
- --color-twitter: #1da1f2;
- --color-accent: #fbbf24;
- --color-accent-bg: #451a03;
- --color-link-shadow: #2563eb; /* blue-600 for link underline shadow */
- --color-share-highlight-text: #111827; /* gray-900 */
- --color-share-highlight-bg: #e5e7eb; /* gray-200 */
- --color-share-highlight-text-active: #fff; /* white */
- --color-share-highlight-bg-active: #2563eb; /* blue-600 */
- --color-share-highlight-tooltip-text: #fff; /* white */
- --color-share-highlight-tooltip-bg: #111827; /* gray-900 */
- --shiki-theme: 'github-light';
- --shadow-sm: 0 2px 16px #000026;
- --shadow-md: 0 4px 6px #32325d1c, 0 1px 3px #000014;
- --shadow-hover: 0 7px 14px #3232321a, 0 3px 6px #000014;
- --shadow-active: 0 3px 12px -2px inset #32323240;
- --shadow-text: 0 1px 3px #000026;
-}
-
-
-/**
- * =============================================================================
- * SYSTEM PREFERENCE SUPPORT
- * Apply dark theme when user prefers dark mode and no explicit theme is set
- * =============================================================================
- */
-@media (prefers-color-scheme: dark) {
- :root:not([data-theme]) {
- --color-bg: var(--dark-color-bg);
- --color-bg-offset: var(--dark-color-bg-offset);
- --color-bg-inverse: var(--dark-color-bg-inverse);
- --color-text: var(--dark-color-text);
- --color-text-offset: var(--dark-color-text-offset);
- --color-border: var(--dark-color-border);
- --color-primary: var(--dark-color-primary);
- --color-primary-offset: var(--dark-color-primary-offset);
- --color-primary-bg: var(--dark-color-primary-bg);
- --color-primary-bg-hover: var(--dark-color-primary-bg-hover);
- --color-primary-hover: var(--dark-color-primary-hover);
- --color-secondary: var(--dark-color-secondary);
- --color-secondary-offset: var(--dark-color-secondary-offset);
- --color-secondary-bg: var(--dark-color-secondary-bg);
- --color-success: var(--dark-color-success);
- --color-success-offset: var(--dark-color-success-offset);
- --color-success-bg: var(--dark-color-success-bg);
- --color-info: var(--dark-color-info);
- --color-info-bg: var(--dark-color-info-bg);
- --color-warning: var(--dark-color-warning);
- --color-warning-offset: var(--dark-color-warning-offset);
- --color-warning-bg: var(--dark-color-warning-bg);
- --color-danger: var(--dark-color-danger);
- --color-danger-bg: var(--dark-color-danger-bg);
- --color-twitter: var(--dark-color-twitter);
- --color-accent: var(--dark-color-accent);
- --color-accent-bg: var(--dark-color-accent-bg);
- --color-link-shadow: var(--dark-color-link-shadow);
- --color-share-highlight-text: var(--dark-color-share-highlight-text);
- --color-share-highlight-bg: var(--dark-color-share-highlight-bg);
- --color-share-highlight-text-active: var(--dark-color-share-highlight-text-active);
- --color-share-highlight-bg-active: var(--dark-color-share-highlight-bg-active);
- --color-share-highlight-tooltip-text: var(--dark-color-share-highlight-tooltip-text);
- --color-share-highlight-tooltip-bg: var(--dark-color-share-highlight-tooltip-bg);
- --shiki-theme: var(--dark-shiki-theme);
- --shadow-sm: var(--dark-shadow-sm);
- --shadow-md: var(--dark-shadow-md);
- --shadow-hover: var(--dark-shadow-hover);
- --shadow-active: var(--dark-shadow-active);
- --shadow-text: var(--dark-shadow-text);
- }
+ --theme-color-bg: #0f172a;
+ --theme-color-bg-offset: #1e293b;
+ --theme-color-text: #f1f5f9;
+ --theme-color-text-offset: #cbd5e1;
+ --theme-color-border: #334155;
+ --theme-color-primary: #dc2626;
+ --theme-color-primary-offset: #991b1b;
+ --theme-color-primary-bg: #7f1d1d;
+ --theme-color-primary-bg-hover: #991b1b;
+ --theme-color-primary-hover: #b91c1c;
+ --theme-color-secondary: #16a34a;
+ --theme-color-secondary-offset: #15803d;
+ --theme-color-secondary-bg: #052e16;
+ --theme-color-success: #16a34a;
+ --theme-color-success-offset: #22c55e;
+ --theme-color-success-bg: #052e16;
+ --theme-color-info: #0891b2;
+ --theme-color-info-bg: #164e63;
+ --theme-color-warning: #a16207;
+ --theme-color-warning-offset: #ca8a04;
+ --theme-color-warning-bg: #451a03;
+ --theme-color-danger: #dc2626;
+ --theme-color-danger-bg: #7f1d1d;
+ --theme-color-twitter: #1da1f2;
+ --theme-color-accent: #fbbf24;
+ --theme-color-accent-bg: #451a03;
+ --theme-color-link-shadow: #2563eb; /* blue-600 for link underline shadow */
+ --theme-color-share-highlight-text: #111827; /* gray-900 */
+ --theme-color-share-highlight-bg: #e5e7eb; /* gray-200 */
+ --theme-color-share-highlight-text-active: #fff; /* white */
+ --theme-color-share-highlight-bg-active: #2563eb; /* blue-600 */
+ --theme-color-share-highlight-tooltip-text: #fff; /* white */
+ --theme-color-share-highlight-tooltip-bg: #111827; /* gray-900 */
+ --theme-shiki-theme: 'github-light';
+ --theme-shadow-sm: 0 2px 16px #000026;
+ --theme-shadow-md: 0 4px 6px #32325d1c, 0 1px 3px #000014;
+ --theme-shadow-hover: 0 7px 14px #3232321a, 0 3px 6px #000014;
+ --theme-shadow-active: 0 3px 12px -2px inset #32323240;
+ --theme-shadow-text: 0 1px 3px #000026;
}
diff --git a/test/e2e/helpers/baseTest.ts b/test/e2e/helpers/baseTest.ts
index b7326609e..250a42280 100644
--- a/test/e2e/helpers/baseTest.ts
+++ b/test/e2e/helpers/baseTest.ts
@@ -26,6 +26,7 @@
/* eslint-disable no-empty-pattern */
import { test as baseTest, expect } from '@playwright/test'
import pagesData from '../../../.cache/pages.json' with { type: 'json' }
+const { describe } = baseTest
/**
* Extract and normalize paths for a specific key from pages.json
@@ -142,4 +143,4 @@ export const test = baseTest.extend<{
},
})
-export { expect }
+export { describe, expect }
diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts
index 551fe7f23..87c46f34f 100644
--- a/test/e2e/helpers/index.ts
+++ b/test/e2e/helpers/index.ts
@@ -1,4 +1,4 @@
-export { test, expect } from './baseTest'
+export { test, describe, expect } from './baseTest'
export { setupConsoleErrorChecker, logConsoleErrors } from './consoleErrors'
export { clearConsentCookies } from './browserState'
export { BasePage } from './pageObjectModels/BasePage'
diff --git a/test/e2e/specs/11-regression/theme-initialization.spec.ts b/test/e2e/specs/11-regression/theme-initialization.spec.ts
new file mode 100644
index 000000000..7006c5e03
--- /dev/null
+++ b/test/e2e/specs/11-regression/theme-initialization.spec.ts
@@ -0,0 +1,296 @@
+/**
+ * Regression Tests for Theme Initialization
+ *
+ * Tests theme persistence across page navigation and ensures the lang attribute
+ * is not removed by Astro View Transitions.
+ *
+ * Related:
+ * - src/components/Scripts/theme/index.ts (setInitialTheme)
+ * - Bug: Astro View Transitions removes lang attribute from
+ */
+
+import { BasePage, test, describe, expect } from '@test/e2e/helpers'
+
+describe('Theme Initialization - Regression Tests', () => {
+ test.beforeEach(async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ // Clear localStorage and dismiss cookie modal
+ await page.goto('/')
+ await page.evaluate(() => localStorage.clear())
+ await playwrightPage.reload()
+ })
+
+ describe('localStorage theme persistence', () => {
+ test('should persist theme across page navigation', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Set theme to dark in localStorage
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'dark')
+ })
+
+ await playwrightPage.reload()
+
+ // Verify dark theme is applied
+ let themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('dark')
+
+ // Navigate to another page (dynamically fetch first article)
+ await page.goto('/articles')
+ const firstArticleLink = playwrightPage.locator('a[href^="/articles/"]').first()
+ await firstArticleLink.click()
+ await playwrightPage.waitForLoadState('networkidle')
+
+ // Verify theme persists after navigation
+ themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('dark')
+ })
+
+ test('should persist light theme across navigation', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Set theme to light in localStorage
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'light')
+ })
+
+ await playwrightPage.reload()
+
+ // Verify light theme is applied
+ let themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('light')
+
+ // Navigate to services page
+ await page.goto('/services')
+
+ // Navigate to first service (dynamically fetch)
+ const firstServiceLink = playwrightPage.locator('a[href^="/services/"]').first()
+ await firstServiceLink.click()
+ await playwrightPage.waitForLoadState('networkidle')
+
+ // Verify theme persists
+ themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('light')
+ })
+
+ test('should maintain theme when navigating back and forth', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Set custom theme
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'dark')
+ })
+
+ await playwrightPage.reload()
+
+ // Navigate forward to case studies
+ await page.goto('/case-studies')
+
+ let themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('dark')
+
+ // Navigate back
+ await playwrightPage.goBack()
+ await playwrightPage.waitForLoadState('networkidle')
+
+ themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('dark')
+
+ // Navigate forward again
+ await playwrightPage.goForward()
+ await playwrightPage.waitForLoadState('networkidle')
+
+ themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('dark')
+ })
+ })
+
+ describe('lang attribute preservation', () => {
+ test('should preserve lang="en" attribute on initial load', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ const langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+ })
+
+ test('should restore lang attribute after View Transition navigation', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Verify lang attribute on home page
+ let langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+
+ // Navigate to another page using View Transitions
+ await page.goto('/about')
+
+ // Verify lang attribute is still present (not removed by View Transitions)
+ langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+ })
+
+ test('should preserve lang attribute across multiple navigations', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ const pages = ['/', '/articles', '/services', '/case-studies', '/contact']
+
+ for (const pagePath of pages) {
+ await page.goto(pagePath)
+
+ const langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+ }
+ })
+
+ test('should preserve lang attribute with theme changes', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Set theme
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'dark')
+ })
+
+ await playwrightPage.reload()
+
+ // Verify both attributes are present
+ const langAttr = await page.getAttribute('html', 'lang')
+ const themeAttr = await page.getAttribute('html', 'data-theme')
+
+ expect(langAttr).toBe('en')
+ expect(themeAttr).toBe('dark')
+
+ // Navigate and verify both persist
+ await page.goto('/articles')
+
+ const langAttrAfter = await page.getAttribute('html', 'lang')
+ const themeAttrAfter = await page.getAttribute('html', 'data-theme')
+
+ expect(langAttrAfter).toBe('en')
+ expect(themeAttrAfter).toBe('dark')
+ })
+ })
+
+ describe('system preference fallback', () => {
+ test('should apply dark theme when system prefers dark and no stored theme', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Emulate dark color scheme preference
+ await playwrightPage.emulateMedia({ colorScheme: 'dark' })
+
+ // Clear any stored theme
+ await page.evaluate(() => localStorage.removeItem('theme'))
+
+ await playwrightPage.reload()
+
+ const themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('dark')
+ })
+
+ test('should not override stored theme with system preference', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Set light theme in storage
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'light')
+ })
+
+ // Emulate dark system preference
+ await playwrightPage.emulateMedia({ colorScheme: 'dark' })
+
+ await playwrightPage.reload()
+
+ // Stored theme should take precedence
+ const themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('light')
+ })
+ })
+
+ describe('error handling', () => {
+ test('should still set lang attribute even when localStorage throws error', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Make localStorage throw errors
+ await playwrightPage.addInitScript(() => {
+ const originalGetItem = Storage.prototype.getItem
+ Storage.prototype.getItem = function() {
+ throw new Error('localStorage access denied')
+ }
+
+ // Restore after a short delay to allow theme script to run
+ setTimeout(() => {
+ Storage.prototype.getItem = originalGetItem
+ }, 100)
+ })
+
+ await playwrightPage.reload()
+
+ // Even with localStorage error, lang attribute should be set
+ const langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+ })
+
+ test('should gracefully handle missing localStorage', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Mock missing localStorage
+ await playwrightPage.addInitScript(() => {
+ // @ts-expect-error - deliberately breaking localStorage
+ delete window.localStorage
+ })
+
+ await playwrightPage.reload()
+
+ // Should still function and set lang attribute
+ const langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+ })
+ })
+
+ describe('FOUC prevention', () => {
+ test('should apply theme before page renders (no flash)', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ // Set dark theme
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'dark')
+ })
+
+ // Reload and immediately check theme (before full page load)
+ await page.goto('/')
+
+ // Check theme is set during DOMContentLoaded (before full render)
+ const themeAttr = await page.getAttribute('html', 'data-theme')
+ expect(themeAttr).toBe('dark')
+ })
+
+ test('should apply theme synchronously on page load', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'dark')
+ })
+
+ // Monitor when theme attribute is set
+ const themeSetTime = await page.evaluate(() => {
+ return new Promise((resolve) => {
+ const observer = new MutationObserver(() => {
+ const theme = document.documentElement.getAttribute('data-theme')
+ if (theme) {
+ resolve(performance.now().toString())
+ }
+ })
+
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-theme']
+ })
+
+ // Timeout after 1 second
+ setTimeout(() => resolve('timeout'), 1000)
+ })
+ })
+
+ expect(themeSetTime).not.toBe('timeout')
+ })
+ })
+})
From 51db85e7ebc772587012c15b1451730e035ed14e Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Wed, 5 Nov 2025 16:39:49 +0300
Subject: [PATCH 002/341] Add testing fixtures for Astro View Transitions, fix
style sheet imports
---
astro.config.ts | 3 +
package-lock.json | 125 ++++++++++++-
package.json | 1 +
.../CallToAction/Newsletter/client.ts | 2 +-
src/components/Head/index.astro | 5 +-
src/components/Scripts/theme/index.ts | 1 +
src/components/Test/ViewTransitionTest.astro | 5 +
src/layouts/BaseLayout.astro | 3 +
src/styles/index.css | 28 ++-
.../view-transitions-persist.spec.ts | 177 ++++++++++++++++++
10 files changed, 322 insertions(+), 28 deletions(-)
create mode 100644 src/components/Test/ViewTransitionTest.astro
create mode 100644 test/e2e/specs/11-regression/view-transitions-persist.spec.ts
diff --git a/astro.config.ts b/astro.config.ts
index 8b6deae6e..7ff0990de 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -5,6 +5,7 @@ import vercelStatic from '@astrojs/vercel'
import sentry from "@sentry/astro"
import tailwindcss from '@tailwindcss/vite'
import AstroPWA from '@vite-pwa/astro'
+import vtbot from 'astro-vtbot'
import icon from 'astro-icon'
import { defineConfig } from 'astro/config'
import {
@@ -57,6 +58,8 @@ export default defineConfig({
},
},
},
+ // Debugging tools for Astro View Transition API
+ vtbot(),
],
output: 'static', // Most pages are static; API routes will be marked for SSR
prefetch: true,
diff --git a/package-lock.json b/package-lock.json
index e05e6e9f7..7965d6981 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
"@vite-pwa/astro": "^1.1.1",
"astro": "5.15.3",
"astro-icon": "^1.1.5",
+ "astro-vtbot": "^2.1.9",
"dotenv": "17.2.3",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
@@ -300,7 +301,8 @@
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz",
"integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@astrojs/internal-helpers": {
"version": "0.7.4",
@@ -971,6 +973,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2661,6 +2664,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -2704,6 +2708,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -4582,6 +4587,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -4603,6 +4609,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz",
"integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
@@ -4615,6 +4622,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -4630,6 +4638,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz",
"integrity": "sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.204.0",
"import-in-the-middle": "^1.8.1",
@@ -5037,6 +5046,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -5053,6 +5063,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
@@ -5070,6 +5081,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz",
"integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=14"
}
@@ -7338,6 +7350,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -7565,6 +7578,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -8598,6 +8612,56 @@
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"license": "MIT"
},
+ "node_modules/@vtbag/cam-shaft": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@vtbag/cam-shaft/-/cam-shaft-1.0.6.tgz",
+ "integrity": "sha512-Xy1bmJJLXuCqxmY2agwPfhGNv1XZViqh54H0VGK4mouGsItFsh8Mz/wWAP6mZwAOEuu9bEOJ1mJ+oNoaczZ1zw==",
+ "license": "ISC",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/martrapp"
+ }
+ },
+ "node_modules/@vtbag/element-crossing": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@vtbag/element-crossing/-/element-crossing-1.1.0.tgz",
+ "integrity": "sha512-1YL609KPwhHUKRrVNfoogQCVJPfFrE5DubOLcCJZLHVCjWZ2ZAPcaq1wR2OP6nXD0Ok9JLX41YsEtYBYzw6CxQ==",
+ "license": "ISC",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/martrapp"
+ }
+ },
+ "node_modules/@vtbag/inspection-chamber": {
+ "version": "1.0.22",
+ "resolved": "https://registry.npmjs.org/@vtbag/inspection-chamber/-/inspection-chamber-1.0.22.tgz",
+ "integrity": "sha512-M0iVmgUEjTmjX3/LEVxpNFjqZSdKN/y6otH4aD6x8c3HhJjlD9OwStJaYHS8EoiQtvmPWIdG79iXOCesXTGfaQ==",
+ "license": "ISC",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/martrapp"
+ }
+ },
+ "node_modules/@vtbag/turn-signal": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vtbag/turn-signal/-/turn-signal-1.3.1.tgz",
+ "integrity": "sha512-6rWkG+ik3U+KQGI94yNOrOh5QedB9zmP/8H51X5WQwrJz8m2MAU5YwGRRcweO/dJ6wW/Bn7OsgC1vRURnwrvCg==",
+ "license": "ISC",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/martrapp"
+ }
+ },
+ "node_modules/@vtbag/utensil-drawer": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@vtbag/utensil-drawer/-/utensil-drawer-1.2.13.tgz",
+ "integrity": "sha512-DXTxfqTAebGyZpiZ1vDs9IvXMYx/++GnjQNJzPWfuZsvoz4N9tSbGrUCaRQ3bd9PLZ6qGF978SNQFyfFBhah/g==",
+ "license": "ISC",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/martrapp"
+ }
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
@@ -8621,6 +8685,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8672,6 +8737,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -8992,6 +9058,7 @@
"resolved": "https://registry.npmjs.org/astro/-/astro-5.15.3.tgz",
"integrity": "sha512-wUO/isJrcUoduRoKacKB9jpO6TxTlPV1zw8UqQx39jSNY7z9IxusJAiib3AiNvqK+dCWhqXx+OnExCCwELmcUw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@astrojs/compiler": "^2.12.2",
"@astrojs/internal-helpers": "0.7.4",
@@ -9123,6 +9190,23 @@
"@iconify/utils": "^2.1.30"
}
},
+ "node_modules/astro-vtbot": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/astro-vtbot/-/astro-vtbot-2.1.9.tgz",
+ "integrity": "sha512-7YfeHtUwyajC1TLxpKwETLpO7oGGXWazVTkGjRpjXCKRxQDYfExeuUGzU7GNqN/gsOqUtL5EEjojVpjPPhjBqQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@vtbag/cam-shaft": "^1.0.6",
+ "@vtbag/element-crossing": "^1.1.0",
+ "@vtbag/inspection-chamber": "^1.0.22",
+ "@vtbag/turn-signal": "^1.3.1",
+ "@vtbag/utensil-drawer": "^1.2.13"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/martrapp"
+ }
+ },
"node_modules/astro/node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
@@ -9179,6 +9263,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -9748,6 +9833,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -11086,7 +11172,8 @@
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/embla-carousel-autoplay": {
"version": "8.6.0",
@@ -11491,6 +11578,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -11716,6 +11804,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -14507,6 +14596,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -14849,6 +14939,7 @@
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"license": "MPL-2.0",
+ "peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -16654,6 +16745,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
@@ -17567,8 +17659,8 @@
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
- "dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -17613,6 +17705,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -17666,6 +17759,7 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -17735,6 +17829,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -17763,8 +17858,9 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -17779,8 +17875,9 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz",
"integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@astrojs/compiler": "^2.9.1",
"prettier": "^3.0.0",
@@ -18941,6 +19038,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"license": "MIT",
+ "peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -18978,7 +19076,7 @@
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/s.color/-/s.color-0.0.15.tgz",
"integrity": "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/safe-array-concat": {
@@ -19082,7 +19180,7 @@
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/sass-formatter/-/sass-formatter-0.7.9.tgz",
"integrity": "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"suf-log": "^2.5.3"
@@ -19925,6 +20023,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4",
@@ -20122,7 +20221,7 @@
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz",
"integrity": "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"s.color": "0.0.15"
@@ -20497,7 +20596,8 @@
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -20579,6 +20679,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"license": "BSD-2-Clause",
+ "peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -21017,8 +21118,8 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -21454,6 +21555,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -21752,6 +21854,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -21969,6 +22072,7 @@
"integrity": "sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.0.7",
"@vitest/mocker": "4.0.7",
@@ -23318,6 +23422,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 70947f975..ebd8f0ad6 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"@vite-pwa/astro": "^1.1.1",
"astro": "5.15.3",
"astro-icon": "^1.1.5",
+ "astro-vtbot": "^2.1.9",
"dotenv": "17.2.3",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
diff --git a/src/components/CallToAction/Newsletter/client.ts b/src/components/CallToAction/Newsletter/client.ts
index c9e6b1cc1..caf1e5901 100644
--- a/src/components/CallToAction/Newsletter/client.ts
+++ b/src/components/CallToAction/Newsletter/client.ts
@@ -3,7 +3,7 @@
* Manages form validation, submission, and UI state changes
*/
-import { LoadableScript, type TriggerEvent } from '../../Scripts/loader/@types/loader'
+import { LoadableScript, type TriggerEvent } from '@components/Scripts/loader/@types/loader'
import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
diff --git a/src/components/Head/index.astro b/src/components/Head/index.astro
index 884fc63e7..dc48a2156 100644
--- a/src/components/Head/index.astro
+++ b/src/components/Head/index.astro
@@ -49,8 +49,9 @@ const {
{...section && { section }}
{...tags && { tags }}
/>
-
diff --git a/src/components/Scripts/theme/index.ts b/src/components/Scripts/theme/index.ts
index 140a0b24b..906319cba 100644
--- a/src/components/Scripts/theme/index.ts
+++ b/src/components/Scripts/theme/index.ts
@@ -4,6 +4,7 @@ import { handleScriptError } from '../errors/handler'
* Set theme name on element from storage
* early to prevent flash of unstyled content.
*/
+// @TODO: This will give a flash of the incorrect theme. The theme selector set on will show before this script has a chance to run. Also, the theme is being changed to "default" somewhere which selects for nothing in the current setup.
export const setInitialTheme = () => {
/** 1. If there is a stored theme in localstorage, it takes priority */
let themeSet = false
diff --git a/src/components/Test/ViewTransitionTest.astro b/src/components/Test/ViewTransitionTest.astro
new file mode 100644
index 000000000..11ebd4dd4
--- /dev/null
+++ b/src/components/Test/ViewTransitionTest.astro
@@ -0,0 +1,5 @@
+---
+// For use in regression test on Astro Page Transitions
+---
+
+
\ No newline at end of file
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index f3f87aa9c..beb5f40d5 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -8,6 +8,7 @@ import Header from '@components/Header/index.astro'
import Content from '@components/Layout/Content/index.astro'
import PwaTitleBar from '@components/PwaTitleBar/index.astro'
import Skip from '@components/Layout/Skip/index.astro'
+import ViewTransitionTest from '@components/Test/ViewTransitionTest.astro'
import ThemePicker from '@components/ThemePicker/index.astro'
import { ClientRouter } from 'astro:transitions'
@@ -95,5 +96,7 @@ const {
{/* Site footer */}
+ {/* View Transition Test Element for E2E Regression Tests */}
+
diff --git a/src/styles/index.css b/src/styles/index.css
index 990671b46..242465d20 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -1,21 +1,19 @@
-@charset "UTF-8";
-
/** Import CSS files - order matters */
-@import url("./themes.css");
-@import url("./newthemes.css");
-@import url("./reset.css");
-@import url("./fonts.css");
-@import url("./typography.css");
-@import url("./focus.css");
-@import url("./markdown.css");
-@import url("./print.css");
-@import url("./shiki.css");
-@import url("./components/SocialEmbed.css");
-@import url("./vendor/carousel.css");
-@import url("./general.css");
+@import "./themes.css";
+@import "./newthemes.css";
+@import "./reset.css";
+@import "./fonts.css";
+@import "./typography.css";
+@import "./focus.css";
+@import "./markdown.css";
+@import "./print.css";
+@import "./shiki.css";
+@import "./components/SocialEmbed.css";
+@import "./vendor/carousel.css";
+@import "./general.css";
/** Tailwind CSS base, components, and utilities */
-@import url("tailwindcss");
+@import "tailwindcss";
/** Replaces "content" key in Tailwind config */
@source "../src/**/*.{astro,ts,tsx,md,mdx}";
diff --git a/test/e2e/specs/11-regression/view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/view-transitions-persist.spec.ts
new file mode 100644
index 000000000..26b07ae55
--- /dev/null
+++ b/test/e2e/specs/11-regression/view-transitions-persist.spec.ts
@@ -0,0 +1,177 @@
+/**
+ * Regression Tests for Astro View Transitions - transition:persist directive
+ *
+ * Verifies that elements with transition:persist maintain their DOM identity
+ * across page navigations when using Astro's View Transitions API.
+ *
+ * Related:
+ * - src/layouts/BaseLayout.astro (view-transition test element)
+ * - Astro View Transitions API documentation
+ */
+
+import { BasePage, test, describe, expect } from '@test/e2e/helpers'
+
+describe('View Transitions - transition:persist', () => {
+ test('should persist DOM element identity across navigation', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Set a unique identifier on the element and store a custom property
+ const initialData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ if (!element) throw new Error('view-transition element not found')
+
+ // Create a unique identifier
+ const uniqueId = `test-${Date.now()}-${Math.random()}`
+
+ // Set it as a data attribute
+ element.setAttribute('data-unique-id', uniqueId)
+
+ // Also set a custom property directly on the DOM element
+ // This is more definitive proof of DOM identity persistence
+ ;(element as any).__testProperty = uniqueId
+
+ return {
+ uniqueId,
+ initialTimestamp: Date.now(),
+ }
+ })
+
+ // Navigate to a different page
+ await page.goto('/about')
+
+ // Verify the element still has the same unique identifier
+ const afterNavigationData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ if (!element) throw new Error('view-transition element not found after navigation')
+
+ return {
+ dataAttribute: element.getAttribute('data-unique-id'),
+ customProperty: (element as any).__testProperty,
+ elementExists: !!element,
+ }
+ })
+
+ // Assertions proving DOM identity persistence
+ expect(afterNavigationData.elementExists).toBe(true)
+ expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
+ expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
+ })
+
+ test.skip('should maintain custom properties across multiple navigations', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Set multiple custom properties on the element
+ const testData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ if (!element) throw new Error('view-transition element not found')
+
+ const data = {
+ id: `id-${Math.random()}`,
+ counter: 42,
+ timestamp: Date.now(),
+ nested: { value: 'test' },
+ }
+
+ // Store various types of data on the element
+ ;(element as any).__testData = data
+
+ return data
+ })
+
+ // Navigate through multiple pages
+ const pages = ['/about', '/articles', '/services', '/contact']
+
+ for (const pagePath of pages) {
+ await page.goto(pagePath)
+
+ // Verify the custom property persists
+ const currentData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ return (element as any).__testData
+ })
+
+ expect(currentData).toEqual(testData)
+ }
+ })
+
+ test.skip('should preserve event listener state across navigation', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Add an event listener and track if it fires
+ await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ if (!element) throw new Error('view-transition element not found')
+
+ let clickCount = 0
+
+ const handleClick = () => {
+ clickCount++
+ ;(element as any).__clickCount = clickCount
+ }
+
+ element.addEventListener('click', handleClick)
+
+ // Store initial state
+ ;(element as any).__listenerAttached = true
+ ;(element as any).__clickCount = 0
+ })
+
+ // Navigate to another page
+ await page.goto('/about')
+
+ // Click the element and verify the listener still works
+ await playwrightPage.locator('[data-testid="view-transition"]').click()
+
+ const listenerData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ return {
+ listenerAttached: (element as any).__listenerAttached,
+ clickCount: (element as any).__clickCount,
+ }
+ })
+
+ expect(listenerData.listenerAttached).toBe(true)
+ expect(listenerData.clickCount).toBe(1) // Should have incremented from the click
+ })
+
+ test('should maintain element reference across navigation', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Store a WeakMap reference test - more advanced verification
+ const refCheck = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ if (!element) throw new Error('view-transition element not found')
+
+ // Create a unique symbol that can only exist on the same object reference
+ const uniqueSymbol = Symbol('test-identity')
+ ;(element as any)[uniqueSymbol] = 'identity-marker'
+
+ // Store the symbol key as a string for later comparison
+ ;(element as any).__symbolKey = uniqueSymbol.toString()
+ ;(element as any).__symbolValue = 'identity-marker'
+
+ return {
+ symbolKey: uniqueSymbol.toString(),
+ }
+ })
+
+ // Navigate
+ await page.goto('/about')
+
+ // Check if the symbol property still exists
+ const afterNav = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ return {
+ symbolKey: (element as any).__symbolKey,
+ symbolValue: (element as any).__symbolValue,
+ }
+ })
+
+ expect(afterNav.symbolKey).toBe(refCheck.symbolKey)
+ expect(afterNav.symbolValue).toBe('identity-marker')
+ })
+})
From 74cae1b130074b7faf97076b32086ce498924857 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Wed, 5 Nov 2025 18:01:20 +0300
Subject: [PATCH 003/341] Add ESLint rule to enforce using centralized event
listeners for user events
---
eslint.config.ts | 22 +-
package-lock.json | 37 ++-
package.json | 1 +
.../Scripts/elementListeners/index.ts | 52 ++--
.../enforce-centralized-events-rule.spec.ts | 280 ++++++++++++++++++
.../eslint/enforce-centralized-events-rule.ts | 174 +++++++++++
6 files changed, 543 insertions(+), 23 deletions(-)
create mode 100644 test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
create mode 100644 test/eslint/enforce-centralized-events-rule.ts
diff --git a/eslint.config.ts b/eslint.config.ts
index 026a296ad..ef7ea9246 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -6,6 +6,7 @@ import securityPlugin from "eslint-plugin-security"
import ymlPlugin from "eslint-plugin-yml"
import tsPlugin from "typescript-eslint"
import restrictedGlobals from "confusing-browser-globals"
+import enforceCentralizedEventsRule from "./test/eslint/enforce-centralized-events-rule.ts"
const level = "error"
@@ -19,6 +20,13 @@ export default [
securityPlugin.configs.recommended,
...ymlPlugin.configs['flat/recommended'],
{
+ plugins: {
+ 'custom-rules': {
+ rules: {
+ 'enforce-centralized-events': enforceCentralizedEventsRule,
+ },
+ },
+ },
settings: {
'import/resolver': {
typescript: {
@@ -36,6 +44,10 @@ export default [
},
},
rules: {
+ /**
+ * Custom ESLint rules
+ */
+ 'custom-rules/enforce-centralized-events': 'error',
/**
* Common rule settings for all linted files
*/
@@ -53,7 +65,7 @@ export default [
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unused-vars': [
level,
- { varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
+ { varsIgnorePattern: '^_', argsIgnorePattern: '^_|^this$' },
],
'@typescript-eslint/no-var-requires': level,
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
@@ -110,7 +122,7 @@ export default [
'no-new': level,
'no-restricted-globals': ['error'].concat(restrictedGlobals),
'no-unused-expressions': [level, { allowShortCircuit: true, allowTernary: true }],
- 'no-unused-vars': [level, { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }],
+ 'no-unused-vars': [level, { varsIgnorePattern: '^_', argsIgnorePattern: '^_|^this$' }],
'no-useless-escape': 'off',
'prefer-arrow-callback': 'off',
'prefer-object-spread': level,
@@ -128,4 +140,10 @@ export default [
'@typescript-eslint/consistent-type-assertions': 'off',
},
},
+ {
+ files: ['src/components/Scripts/elementListeners/index.ts'],
+ rules: {
+ 'custom-rules/enforce-centralized-events': 'off',
+ },
+ },
]
diff --git a/package-lock.json b/package-lock.json
index 7965d6981..d824615f0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,6 +53,7 @@
"title-case": "4.3.2",
"to-ico": "1.1.5",
"ts-node": "10.9.2",
+ "tsx": "^4.20.6",
"vite": "^7.1.12",
"workbox-build": "7.3.0",
"zod": "4.1.12"
@@ -12850,7 +12851,6 @@
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz",
"integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -18890,7 +18890,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -20991,6 +20990,40 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tsx": {
+ "version": "4.20.6",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
+ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "~0.25.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tsx/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
diff --git a/package.json b/package.json
index ebd8f0ad6..04c7c79c2 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,7 @@
"title-case": "4.3.2",
"to-ico": "1.1.5",
"ts-node": "10.9.2",
+ "tsx": "^4.20.6",
"vite": "^7.1.12",
"workbox-build": "7.3.0",
"zod": "4.1.12"
diff --git a/src/components/Scripts/elementListeners/index.ts b/src/components/Scripts/elementListeners/index.ts
index 5798db2c3..3edca2630 100644
--- a/src/components/Scripts/elementListeners/index.ts
+++ b/src/components/Scripts/elementListeners/index.ts
@@ -1,21 +1,23 @@
/**
* Element event listeners utility
* Provides standardized event listener attachment with accessibility support
+ * Supports both regular functions and web component methods with 'this' context
*/
-type eventHandler = (_event: Event) => void
-const getClickEventListener = (handler: eventHandler) => {
+type eventHandler = (this: unknown, _event: Event) => void
+
+const getClickEventListener = (handler: eventHandler, context?: unknown) => {
/**
* Click event listener wrapper
* @param event - Mouse event
*/
function clickListener(event: MouseEvent) {
- if (event.type === `click`) handler(event)
+ if (event.type === `click`) handler.call(context, event)
}
return clickListener
}
-const getEnterKeyEventListener = (handler: eventHandler) => {
+const getEnterKeyEventListener = (handler: eventHandler, context?: unknown) => {
/**
* Enter key event listener wrapper
* @param event - Keyboard event
@@ -28,23 +30,23 @@ const getEnterKeyEventListener = (handler: eventHandler) => {
* `keydown` event and a key being held down.
*/
if (event.isComposing || event.repeat) return
- if (event.key === `Enter`) handler(event)
+ if (event.key === `Enter`) handler.call(context, event)
}
return keypressListener
}
-const getTouchendEventListener = (handler: eventHandler) => {
+const getTouchendEventListener = (handler: eventHandler, context?: unknown) => {
/**
* Touch end event listener wrapper
* @param event - Touch event
*/
function touchendEventHandler(event: TouchEvent) {
- if (event.type === `touchend`) handler(event)
+ if (event.type === `touchend`) handler.call(context, event)
}
return touchendEventHandler
}
-const getEscapeKeyEventListener = (handler: eventHandler) => {
+const getEscapeKeyEventListener = (handler: eventHandler, context?: unknown) => {
/**
* Escape key event listener wrapper
* @param event - Keyboard event
@@ -57,25 +59,37 @@ const getEscapeKeyEventListener = (handler: eventHandler) => {
* `keydown` event and a key being held down.
*/
if (event.isComposing || event.repeat) return
- if (event.key === `Escape`) handler(event)
+ if (event.key === `Escape`) handler.call(context, event)
}
return keypressListener
}
-export const addButtonEventListeners = (element: HTMLButtonElement, handler: eventHandler) => {
- element.addEventListener(`click`, getClickEventListener(handler))
- element.addEventListener(`keyup`, getEnterKeyEventListener(handler))
- element.addEventListener(`touchend`, getTouchendEventListener(handler))
+export const addButtonEventListeners = (
+ element: HTMLButtonElement,
+ handler: eventHandler,
+ context?: unknown,
+) => {
+ element.addEventListener(`click`, getClickEventListener(handler, context))
+ element.addEventListener(`keyup`, getEnterKeyEventListener(handler, context))
+ element.addEventListener(`touchend`, getTouchendEventListener(handler, context))
}
-export const addLinkEventListeners = (element: HTMLAnchorElement, handler: eventHandler) => {
- element.addEventListener(`click`, getClickEventListener(handler))
- element.addEventListener(`keyup`, getEnterKeyEventListener(handler))
- element.addEventListener(`touchend`, getTouchendEventListener(handler))
+export const addLinkEventListeners = (
+ element: HTMLAnchorElement,
+ handler: eventHandler,
+ context?: unknown,
+) => {
+ element.addEventListener(`click`, getClickEventListener(handler, context))
+ element.addEventListener(`keyup`, getEnterKeyEventListener(handler, context))
+ element.addEventListener(`touchend`, getTouchendEventListener(handler, context))
}
-export const addWrapperEventListeners = (element: HTMLDivElement, handler: eventHandler) => {
- element.addEventListener(`keyup`, getEscapeKeyEventListener(handler))
+export const addWrapperEventListeners = (
+ element: HTMLDivElement,
+ handler: eventHandler,
+ context?: unknown,
+) => {
+ element.addEventListener(`keyup`, getEscapeKeyEventListener(handler, context))
}
// Export the type for external use
diff --git a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
new file mode 100644
index 000000000..863ffd669
--- /dev/null
+++ b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
@@ -0,0 +1,280 @@
+/**
+ * Unit tests for the enforce-centralized-events ESLint rule
+ */
+
+import { RuleTester } from 'eslint'
+import enforceCentralizedEventsRule from '../enforce-centralized-events-rule.js'
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+})
+
+ruleTester.run('enforce-centralized-events', enforceCentralizedEventsRule, {
+ valid: [
+ // ========================================
+ // Valid: Using centralized utilities
+ // ========================================
+ {
+ code: `
+ import { addButtonEventListeners } from './elementListeners';
+ const button = document.querySelector('button');
+ addButtonEventListeners(button, handleClick);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+ {
+ code: `
+ import { addLinkEventListeners } from './elementListeners';
+ const link = document.querySelector('a');
+ addLinkEventListeners(link, handleClick);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+ {
+ code: `
+ import { addWrapperEventListeners } from './elementListeners';
+ const wrapper = document.querySelector('div');
+ addWrapperEventListeners(wrapper, handleEscape);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+
+ // ========================================
+ // Valid: Inside the centralized handler file itself
+ // ========================================
+ {
+ code: `
+ element.addEventListener('click', clickHandler);
+ element.addEventListener('keyup', keyupHandler);
+ element.addEventListener('touchend', touchendHandler);
+ `,
+ filename: 'src/components/Scripts/elementListeners/index.ts',
+ },
+
+ // ========================================
+ // Valid: Non-restricted events
+ // ========================================
+ {
+ code: `
+ element.addEventListener('focus', handleFocus);
+ element.addEventListener('blur', handleBlur);
+ element.addEventListener('scroll', handleScroll);
+ element.addEventListener('resize', handleResize);
+ element.addEventListener('load', handleLoad);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+
+ // ========================================
+ // Valid: Variable event names (can't be checked statically)
+ // ========================================
+ {
+ code: `
+ const eventType = 'click';
+ element.addEventListener(eventType, handler);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+ ],
+
+ invalid: [
+ // ========================================
+ // Invalid: Direct click event listener
+ // ========================================
+ {
+ code: `
+ button.addEventListener('click', handleClick);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'click events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: Direct keyup event listener
+ // ========================================
+ {
+ code: `
+ button.addEventListener('keyup', handleKeyup);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility:
+ 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
+ description: 'keyup events (Enter/Escape)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: Direct touchend event listener
+ // ========================================
+ {
+ code: `
+ button.addEventListener('touchend', handleTouch);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'touchend events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: keydown instead of keyup
+ // ========================================
+ {
+ code: `
+ button.addEventListener('keydown', handleKeydown);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
+ description: 'keyboard events (use keyup instead of keydown for better accessibility)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: deprecated keypress event
+ // ========================================
+ {
+ code: `
+ button.addEventListener('keypress', handleKeypress);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
+ description: 'keyboard events (use keyup instead of deprecated keypress)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: Web component context
+ // ========================================
+ {
+ code: `
+ class MyComponent extends HTMLElement {
+ connectedCallback() {
+ this.addEventListener('click', this.handleClick);
+ }
+ }
+ `,
+ filename: 'src/components/MyComponent/index.ts',
+ errors: [
+ {
+ messageId: 'webComponentException',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'click events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: Multiple violations in one file
+ // ========================================
+ {
+ code: `
+ button.addEventListener('click', handleClick);
+ button.addEventListener('keyup', handleKeyup);
+ button.addEventListener('touchend', handleTouch);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'click events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility:
+ 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
+ description: 'keyup events (Enter/Escape)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'touchend events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: Document-level listener (still needs centralization)
+ // ========================================
+ {
+ code: `
+ document.addEventListener('click', handleDocumentClick);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ },
+ ],
+ },
+
+ // ========================================
+ // Invalid: Window-level listener (still needs centralization)
+ // ========================================
+ {
+ code: `
+ window.addEventListener('keyup', handleWindowKeyup);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ },
+ ],
+ },
+ ],
+})
+
+console.log('All tests passed!')
diff --git a/test/eslint/enforce-centralized-events-rule.ts b/test/eslint/enforce-centralized-events-rule.ts
new file mode 100644
index 000000000..669e46c94
--- /dev/null
+++ b/test/eslint/enforce-centralized-events-rule.ts
@@ -0,0 +1,174 @@
+/**
+ * ESLint rule to enforce centralized event listener patterns
+ * Prevents direct use of addEventListener for specific event types that should use
+ * the centralized utilities in src/components/Scripts/elementListeners/index.ts
+ */
+
+import type { Rule } from 'eslint'
+import type { CallExpression } from 'estree'
+
+interface EventConfig {
+ eventName: string
+ suggestedUtility: string
+ description: string
+}
+
+const enforceCentralizedEventsRule: Rule.RuleModule = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Enforce centralized event handling for click, keyup, touchend, and Escape events',
+ category: 'Best Practices',
+ recommended: true,
+ },
+ messages: {
+ useCentralizedUtility:
+ 'Use `{{ utility }}` from `elementListeners` for {{ description }} instead of direct addEventListener. This ensures consistent accessibility support ({{ features }}).',
+ webComponentException:
+ 'Web components should use `{{ utility }}` from `elementListeners` with the third `context` parameter set to `this` for proper binding.',
+ },
+ schema: [],
+ fixable: undefined,
+ },
+ create(context: Rule.RuleContext): Rule.RuleListener {
+ const centralizedHandlerFile = 'src/components/Scripts/elementListeners/index.ts'
+
+ // Map event types to their centralized utilities
+ const eventConfigs: EventConfig[] = [
+ {
+ eventName: 'click',
+ suggestedUtility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'click events',
+ },
+ {
+ eventName: 'keyup',
+ suggestedUtility:
+ 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
+ description: 'keyup events (Enter/Escape)',
+ },
+ {
+ eventName: 'touchend',
+ suggestedUtility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'touchend events',
+ },
+ {
+ eventName: 'keydown',
+ suggestedUtility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
+ description: 'keyboard events (use keyup instead of keydown for better accessibility)',
+ },
+ {
+ eventName: 'keypress',
+ suggestedUtility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
+ description: 'keyboard events (use keyup instead of deprecated keypress)',
+ },
+ ]
+
+ /**
+ * Detect if the current file is likely a web component
+ * Look for class declarations extending HTMLElement in the source code
+ */
+ function isLikelyWebComponent(_node: CallExpression): boolean {
+ // Get the source code to check for class extending HTMLElement
+ const sourceCode = context.sourceCode || context.getSourceCode()
+ const ast = sourceCode.ast
+
+ // Look for ClassDeclaration or ClassExpression that extends HTMLElement
+ let foundWebComponent = false
+
+ function checkNode(astNode: any): void {
+ if (!astNode) return
+
+ if (astNode.type === 'ClassDeclaration' || astNode.type === 'ClassExpression') {
+ if (
+ astNode.superClass &&
+ astNode.superClass.type === 'Identifier' &&
+ astNode.superClass.name === 'HTMLElement'
+ ) {
+ foundWebComponent = true
+ }
+ }
+
+ // Recursively check children
+ if (astNode.body) {
+ if (Array.isArray(astNode.body)) {
+ astNode.body.forEach(checkNode)
+ } else {
+ checkNode(astNode.body)
+ }
+ }
+ }
+
+ checkNode(ast)
+ return foundWebComponent
+ }
+
+ /**
+ * Get appropriate features list based on event type
+ */
+ function getFeatures(eventName: string): string {
+ switch (eventName) {
+ case 'keyup':
+ case 'keydown':
+ case 'keypress':
+ return 'isComposing check, repeat prevention, Enter/Escape key handling'
+ case 'click':
+ case 'touchend':
+ return 'click, keyup Enter, and touchend support'
+ default:
+ return 'consistent event handling'
+ }
+ }
+
+ return {
+ CallExpression(node: CallExpression) {
+ // Check if this is an addEventListener call
+ if (
+ node.callee.type !== 'MemberExpression' ||
+ node.callee.property.type !== 'Identifier' ||
+ node.callee.property.name !== 'addEventListener' ||
+ node.arguments.length === 0
+ ) {
+ return
+ }
+
+ const firstArg = node.arguments[0]
+
+ // Check if the first argument is a string literal with a restricted event type
+ if (!firstArg || firstArg.type !== 'Literal' || typeof firstArg.value !== 'string') {
+ return
+ }
+
+ const eventName = firstArg.value as string
+ const eventConfig = eventConfigs.find((config) => config.eventName === eventName)
+
+ if (!eventConfig) {
+ return
+ }
+
+ // Allow this in the centralized handler file itself
+ const filename = context.getFilename()
+ if (filename.includes(centralizedHandlerFile)) {
+ return
+ }
+
+ // Determine if this is in a web component
+ const isWebComponent = isLikelyWebComponent(node)
+ const messageId = isWebComponent ? 'webComponentException' : 'useCentralizedUtility'
+
+ context.report({
+ node,
+ messageId,
+ data: {
+ utility: eventConfig.suggestedUtility,
+ description: eventConfig.description,
+ features: getFeatures(eventName),
+ },
+ })
+ },
+ }
+ },
+}
+
+export default enforceCentralizedEventsRule
+
From afbe8737810711b90add6b2eaaef9a93bc288146 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Wed, 5 Nov 2025 18:06:59 +0300
Subject: [PATCH 004/341] Move ESLint listener rule test to Vitest suite
---
package-lock.json | 37 +-
package.json | 1 -
.../enforce-centralized-events-rule.spec.ts | 391 +++++++++---------
vitest.config.ts | 2 +
4 files changed, 202 insertions(+), 229 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index d824615f0..d055dbd4d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,7 +53,6 @@
"title-case": "4.3.2",
"to-ico": "1.1.5",
"ts-node": "10.9.2",
- "tsx": "^4.20.6",
"vite": "^7.1.12",
"workbox-build": "7.3.0",
"zod": "4.1.12"
@@ -12851,6 +12850,7 @@
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz",
"integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -18890,6 +18890,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "devOptional": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -20990,40 +20991,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
- "node_modules/tsx": {
- "version": "4.20.6",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
- "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "esbuild": "~0.25.0",
- "get-tsconfig": "^4.7.5"
- },
- "bin": {
- "tsx": "dist/cli.mjs"
- },
- "engines": {
- "node": ">=18.0.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- }
- },
- "node_modules/tsx/node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
diff --git a/package.json b/package.json
index 04c7c79c2..ebd8f0ad6 100644
--- a/package.json
+++ b/package.json
@@ -94,7 +94,6 @@
"title-case": "4.3.2",
"to-ico": "1.1.5",
"ts-node": "10.9.2",
- "tsx": "^4.20.6",
"vite": "^7.1.12",
"workbox-build": "7.3.0",
"zod": "4.1.12"
diff --git a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
index 863ffd669..d22a20082 100644
--- a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
+++ b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
@@ -2,6 +2,7 @@
* Unit tests for the enforce-centralized-events ESLint rule
*/
+import { describe, it } from 'vitest'
import { RuleTester } from 'eslint'
import enforceCentralizedEventsRule from '../enforce-centralized-events-rule.js'
@@ -12,269 +13,273 @@ const ruleTester = new RuleTester({
},
})
-ruleTester.run('enforce-centralized-events', enforceCentralizedEventsRule, {
- valid: [
- // ========================================
- // Valid: Using centralized utilities
- // ========================================
- {
- code: `
+describe('enforce-centralized-events', () => {
+ it('should pass all valid test cases', () => {
+ ruleTester.run('enforce-centralized-events', enforceCentralizedEventsRule, {
+ valid: [
+ // ========================================
+ // Valid: Using centralized utilities
+ // ========================================
+ {
+ code: `
import { addButtonEventListeners } from './elementListeners';
const button = document.querySelector('button');
addButtonEventListeners(button, handleClick);
`,
- filename: 'src/components/SomeComponent/index.ts',
- },
- {
- code: `
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+ {
+ code: `
import { addLinkEventListeners } from './elementListeners';
const link = document.querySelector('a');
addLinkEventListeners(link, handleClick);
`,
- filename: 'src/components/SomeComponent/index.ts',
- },
- {
- code: `
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+ {
+ code: `
import { addWrapperEventListeners } from './elementListeners';
const wrapper = document.querySelector('div');
addWrapperEventListeners(wrapper, handleEscape);
`,
- filename: 'src/components/SomeComponent/index.ts',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ },
- // ========================================
- // Valid: Inside the centralized handler file itself
- // ========================================
- {
- code: `
+ // ========================================
+ // Valid: Inside the centralized handler file itself
+ // ========================================
+ {
+ code: `
element.addEventListener('click', clickHandler);
element.addEventListener('keyup', keyupHandler);
element.addEventListener('touchend', touchendHandler);
`,
- filename: 'src/components/Scripts/elementListeners/index.ts',
- },
+ filename: 'src/components/Scripts/elementListeners/index.ts',
+ },
- // ========================================
- // Valid: Non-restricted events
- // ========================================
- {
- code: `
+ // ========================================
+ // Valid: Non-restricted events
+ // ========================================
+ {
+ code: `
element.addEventListener('focus', handleFocus);
element.addEventListener('blur', handleBlur);
element.addEventListener('scroll', handleScroll);
element.addEventListener('resize', handleResize);
element.addEventListener('load', handleLoad);
`,
- filename: 'src/components/SomeComponent/index.ts',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ },
- // ========================================
- // Valid: Variable event names (can't be checked statically)
- // ========================================
- {
- code: `
+ // ========================================
+ // Valid: Variable event names (can't be checked statically)
+ // ========================================
+ {
+ code: `
const eventType = 'click';
element.addEventListener(eventType, handler);
`,
- filename: 'src/components/SomeComponent/index.ts',
- },
- ],
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+ ],
- invalid: [
- // ========================================
- // Invalid: Direct click event listener
- // ========================================
- {
- code: `
+ invalid: [
+ // ========================================
+ // Invalid: Direct click event listener
+ // ========================================
+ {
+ code: `
button.addEventListener('click', handleClick);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility: 'addButtonEventListeners or addLinkEventListeners',
- description: 'click events',
- features: 'click, keyup Enter, and touchend support',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'click events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
},
- ],
- },
- // ========================================
- // Invalid: Direct keyup event listener
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: Direct keyup event listener
+ // ========================================
+ {
+ code: `
button.addEventListener('keyup', handleKeyup);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility:
- 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
- description: 'keyup events (Enter/Escape)',
- features: 'isComposing check, repeat prevention, Enter/Escape key handling',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility:
+ 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
+ description: 'keyup events (Enter/Escape)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ ],
},
- ],
- },
- // ========================================
- // Invalid: Direct touchend event listener
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: Direct touchend event listener
+ // ========================================
+ {
+ code: `
button.addEventListener('touchend', handleTouch);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility: 'addButtonEventListeners or addLinkEventListeners',
- description: 'touchend events',
- features: 'click, keyup Enter, and touchend support',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'touchend events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
},
- ],
- },
- // ========================================
- // Invalid: keydown instead of keyup
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: keydown instead of keyup
+ // ========================================
+ {
+ code: `
button.addEventListener('keydown', handleKeydown);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
- description: 'keyboard events (use keyup instead of keydown for better accessibility)',
- features: 'isComposing check, repeat prevention, Enter/Escape key handling',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
+ description:
+ 'keyboard events (use keyup instead of keydown for better accessibility)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ ],
},
- ],
- },
- // ========================================
- // Invalid: deprecated keypress event
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: deprecated keypress event
+ // ========================================
+ {
+ code: `
button.addEventListener('keypress', handleKeypress);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
- description: 'keyboard events (use keyup instead of deprecated keypress)',
- features: 'isComposing check, repeat prevention, Enter/Escape key handling',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'keyup with addButtonEventListeners or addWrapperEventListeners',
+ description: 'keyboard events (use keyup instead of deprecated keypress)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ ],
},
- ],
- },
- // ========================================
- // Invalid: Web component context
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: Web component context
+ // ========================================
+ {
+ code: `
class MyComponent extends HTMLElement {
connectedCallback() {
this.addEventListener('click', this.handleClick);
}
}
`,
- filename: 'src/components/MyComponent/index.ts',
- errors: [
- {
- messageId: 'webComponentException',
- data: {
- utility: 'addButtonEventListeners or addLinkEventListeners',
- description: 'click events',
- features: 'click, keyup Enter, and touchend support',
- },
+ filename: 'src/components/MyComponent/index.ts',
+ errors: [
+ {
+ messageId: 'webComponentException',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'click events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
},
- ],
- },
- // ========================================
- // Invalid: Multiple violations in one file
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: Multiple violations in one file
+ // ========================================
+ {
+ code: `
button.addEventListener('click', handleClick);
button.addEventListener('keyup', handleKeyup);
button.addEventListener('touchend', handleTouch);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility: 'addButtonEventListeners or addLinkEventListeners',
- description: 'click events',
- features: 'click, keyup Enter, and touchend support',
- },
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'click events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility:
+ 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
+ description: 'keyup events (Enter/Escape)',
+ features: 'isComposing check, repeat prevention, Enter/Escape key handling',
+ },
+ },
+ {
+ messageId: 'useCentralizedUtility',
+ data: {
+ utility: 'addButtonEventListeners or addLinkEventListeners',
+ description: 'touchend events',
+ features: 'click, keyup Enter, and touchend support',
+ },
+ },
+ ],
},
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility:
- 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
- description: 'keyup events (Enter/Escape)',
- features: 'isComposing check, repeat prevention, Enter/Escape key handling',
- },
- },
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility: 'addButtonEventListeners or addLinkEventListeners',
- description: 'touchend events',
- features: 'click, keyup Enter, and touchend support',
- },
- },
- ],
- },
- // ========================================
- // Invalid: Document-level listener (still needs centralization)
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: Document-level listener (still needs centralization)
+ // ========================================
+ {
+ code: `
document.addEventListener('click', handleDocumentClick);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ },
+ ],
},
- ],
- },
- // ========================================
- // Invalid: Window-level listener (still needs centralization)
- // ========================================
- {
- code: `
+ // ========================================
+ // Invalid: Window-level listener (still needs centralization)
+ // ========================================
+ {
+ code: `
window.addEventListener('keyup', handleWindowKeyup);
`,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
+ filename: 'src/components/SomeComponent/index.ts',
+ errors: [
+ {
+ messageId: 'useCentralizedUtility',
+ },
+ ],
},
],
- },
- ],
+ })
+ })
})
-console.log('All tests passed!')
diff --git a/vitest.config.ts b/vitest.config.ts
index fc4e04d04..2058a2b50 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -31,6 +31,7 @@ export default getViteConfig({
'scripts/**/*.spec.ts',
'api/**/*.spec.ts',
'test/unit/**/*.spec.ts',
+ 'test/eslint/__tests__/**/*.spec.ts',
'test/e2e/helpers/__tests__/**/*.spec.ts',
],
environmentMatchGlobs: [
@@ -39,6 +40,7 @@ export default getViteConfig({
['scripts/**', 'node'],
['api/**', 'node'],
['test/unit/**', 'node'],
+ ['test/eslint/__tests__/**', 'node'],
['test/e2e/helpers/__tests__/**', 'node'],
],
env: {
From bb9f55fc2c0f65e4510fcb50fef86ef4de13b39f Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Wed, 5 Nov 2025 19:01:20 +0300
Subject: [PATCH 005/341] Refactor addEventListener methods in components to
consistently use centralized methods
---
eslint.config.ts | 5 +-
src/components/Carousel/client.ts | 7 +-
src/components/Cookies/Consent/client.ts | 21 ++--
src/components/Cookies/Customize/client.ts | 4 +-
src/components/Navigation/client.ts | 7 +-
.../Highlighter/__tests__/client.spec.ts | 14 +--
src/components/Social/Highlighter/client.ts | 9 +-
.../Social/Highlighter/share-highlight.ts | 2 +-
src/components/Social/Mastodon/client.ts | 9 +-
src/components/Testimonials/client.ts | 7 +-
.../ThemePicker/theme-picker-element.ts | 29 +----
.../enforce-centralized-events-rule.spec.ts | 115 ++++++------------
.../eslint/enforce-centralized-events-rule.ts | 18 +++
13 files changed, 112 insertions(+), 135 deletions(-)
diff --git a/eslint.config.ts b/eslint.config.ts
index ef7ea9246..115d6bd9c 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -141,7 +141,10 @@ export default [
},
},
{
- files: ['src/components/Scripts/elementListeners/index.ts'],
+ files: [
+ 'src/components/Scripts/elementListeners/index.ts',
+ 'src/components/Social/Shares/client.ts', // Needs direct addEventListener for pause/resume/reset lifecycle
+ ],
rules: {
'custom-rules/enforce-centralized-events': 'off',
},
diff --git a/src/components/Carousel/client.ts b/src/components/Carousel/client.ts
index e2eeb7e79..29d0b2fb3 100644
--- a/src/components/Carousel/client.ts
+++ b/src/components/Carousel/client.ts
@@ -3,6 +3,7 @@ import EmblaCarousel, { type EmblaOptionsType, type EmblaCarouselType } from 'em
import Autoplay from 'embla-carousel-autoplay'
import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { addButtonEventListeners } from '@components/Scripts/elementListeners'
/**
* Auto-discovery carousel implementation using singleton pattern
@@ -245,7 +246,7 @@ class CarouselInstance {
try {
if (!this.emblaApi || !this.prevBtn || !this.nextBtn) return
- this.prevBtn.addEventListener('click', () => {
+ addButtonEventListeners(this.prevBtn, () => {
try {
this.emblaApi!.scrollPrev()
} catch (error) {
@@ -253,7 +254,7 @@ class CarouselInstance {
}
})
- this.nextBtn.addEventListener('click', () => {
+ addButtonEventListeners(this.nextBtn, () => {
try {
this.emblaApi!.scrollNext()
} catch (error) {
@@ -310,7 +311,7 @@ class CarouselInstance {
'embla__dot w-2 h-2 rounded-full bg-[color:var(--color-text-offset)] transition-all duration-300 hover:bg-[color:var(--color-primary)]'
dot.setAttribute('aria-label', `Go to slide ${index + 1}`)
- dot.addEventListener('click', () => {
+ addButtonEventListeners(dot, () => {
try {
this.emblaApi!.scrollTo(index)
} catch (error) {
diff --git a/src/components/Cookies/Consent/client.ts b/src/components/Cookies/Consent/client.ts
index b9037f4ee..24e3b81a9 100644
--- a/src/components/Cookies/Consent/client.ts
+++ b/src/components/Cookies/Consent/client.ts
@@ -32,7 +32,7 @@ export class CookieConsent extends LoadableScript {
/** Customize button element */
customizeBtn: HTMLButtonElement
/** Focus trap handler reference for proper cleanup */
- private trapFocusHandler: ((_event: KeyboardEvent) => void) | null = null
+ private trapFocusHandler: ((_event: Event) => void) | null = null
/** Static reference to track modal visibility across View Transitions */
private static isModalCurrentlyVisible = false
/** Flag to ensure View Transitions handlers are only set up once */
@@ -275,23 +275,26 @@ export class CookieConsent extends LoadableScript {
const lastFocusable = focusableElements[focusableElements.length - 1] as HTMLElement
// Handle Tab key navigation within modal
- this.trapFocusHandler = (e: KeyboardEvent) => {
- if (e.key === 'Tab') {
- if (e.shiftKey) { // Shift + Tab
+ this.trapFocusHandler = (e: Event) => {
+ const keyEvent = e as KeyboardEvent
+ if (keyEvent.key === 'Tab') {
+ if (keyEvent.shiftKey) {
+ // Shift + Tab
if (document.activeElement === firstFocusable) {
- e.preventDefault()
+ keyEvent.preventDefault()
lastFocusable.focus()
}
- } else { // Tab
+ } else {
+ // Tab
if (document.activeElement === lastFocusable) {
- e.preventDefault()
+ keyEvent.preventDefault()
firstFocusable.focus()
}
}
}
}
- this.wrapper.addEventListener('keydown', this.trapFocusHandler)
+ addWrapperEventListeners(this.wrapper, this.trapFocusHandler)
} catch (error) {
handleScriptError(error, context)
}
@@ -306,7 +309,7 @@ export class CookieConsent extends LoadableScript {
try {
if (this.trapFocusHandler) {
- this.wrapper.removeEventListener('keydown', this.trapFocusHandler)
+ this.wrapper.removeEventListener('keyup', this.trapFocusHandler)
this.trapFocusHandler = null
}
} catch (error) {
diff --git a/src/components/Cookies/Customize/client.ts b/src/components/Cookies/Customize/client.ts
index b97073ddc..063c12b51 100644
--- a/src/components/Cookies/Customize/client.ts
+++ b/src/components/Cookies/Customize/client.ts
@@ -76,12 +76,12 @@ export class CookieCustomize extends LoadableScript {
// Allow all button
if (this.allowAllBtn) {
- this.allowAllBtn.addEventListener('click', () => this.allowAll())
+ addButtonEventListeners(this.allowAllBtn, () => this.allowAll())
}
// Save preferences button
if (this.saveBtn) {
- this.saveBtn.addEventListener('click', () => this.savePreferences())
+ addButtonEventListeners(this.saveBtn, () => this.savePreferences())
}
}
diff --git a/src/components/Navigation/client.ts b/src/components/Navigation/client.ts
index a0cd9752e..0eb053d52 100644
--- a/src/components/Navigation/client.ts
+++ b/src/components/Navigation/client.ts
@@ -13,6 +13,7 @@ import {
import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
import { dispatchScriptEvent, ScriptEvent } from '@components/Scripts/events'
+import { addButtonEventListeners, addLinkEventListeners } from '@components/Scripts/elementListeners'
export const CLASSES = {
navOpen: 'aria-expanded-true',
@@ -99,7 +100,7 @@ export class Navigation extends LoadableScript {
}
bindEvents() {
- this.toggleBtn.addEventListener('click', () => {
+ addButtonEventListeners(this.toggleBtn, () => {
this.toggleMenu()
})
// @TODO: Why is pressing enter triggering the click event, when type="button" is set?
@@ -108,7 +109,7 @@ export class Navigation extends LoadableScript {
//})
// Handle Escape key to close menu
- document.addEventListener('keydown', (event) => {
+ document.addEventListener('keyup', (event) => {
if (event.key === 'Escape' && this.isMenuOpen) {
this.toggleMenu(false)
}
@@ -132,7 +133,7 @@ export class Navigation extends LoadableScript {
navLinks.forEach(link => {
try {
- link.addEventListener('click', event => {
+ addLinkEventListeners(link as HTMLAnchorElement, (event) => {
event.preventDefault()
const href = link.getAttribute('href')
diff --git a/src/components/Social/Highlighter/__tests__/client.spec.ts b/src/components/Social/Highlighter/__tests__/client.spec.ts
index 9286d3d68..7a3f5e69e 100644
--- a/src/components/Social/Highlighter/__tests__/client.spec.ts
+++ b/src/components/Social/Highlighter/__tests__/client.spec.ts
@@ -176,8 +176,8 @@ describe('HighlighterElement', () => {
describe('Keyboard Navigation', () => {
it('should show dialog on Enter key', async () => {
- // Trigger Enter key on the host element
- const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
+ // Trigger Enter key on the host element (using keyup for accessibility)
+ const event = new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })
highlighter.dispatchEvent(event)
const dialog = highlighter.shadowRoot?.querySelector('.share-dialog')
@@ -185,8 +185,8 @@ describe('HighlighterElement', () => {
})
it('should show dialog on Space key', async () => {
- // Trigger Space key on the host element
- const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true })
+ // Trigger Space key on the host element (using keyup for accessibility)
+ const event = new KeyboardEvent('keyup', { key: ' ', bubbles: true })
highlighter.dispatchEvent(event)
const dialog = highlighter.shadowRoot?.querySelector('.share-dialog')
@@ -200,9 +200,9 @@ describe('HighlighterElement', () => {
highlighter.shadowRoot?.querySelector('.share-dialog')?.getAttribute('aria-hidden')
).toBe('false')
- // Trigger Escape key
- const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
- highlighter.dispatchEvent(event)
+ // Trigger Escape key (using keyup for accessibility)
+ const event = new KeyboardEvent('keyup', { key: 'Escape', bubbles: true })
+ document.dispatchEvent(event)
const dialog = highlighter.shadowRoot?.querySelector('.share-dialog')
expect(dialog?.getAttribute('aria-hidden')).toBe('true')
diff --git a/src/components/Social/Highlighter/client.ts b/src/components/Social/Highlighter/client.ts
index 44d91a1ac..f803df02a 100644
--- a/src/components/Social/Highlighter/client.ts
+++ b/src/components/Social/Highlighter/client.ts
@@ -10,6 +10,7 @@ import { platforms, copyToClipboard, nativeShare } from '@components/Social/comm
import { MastodonModal } from '@components/Social/Mastodon/client'
import { getSlotElement } from './selectors'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { addButtonEventListeners } from '@components/Scripts/elementListeners'
/**
* Highlighter element that creates a shareable text highlight
@@ -222,7 +223,7 @@ class HighlighterElement extends HTMLElement {
this.addEventListener('focusout', () => this.hideDialog())
// Keyboard navigation
- this.addEventListener('keydown', e => {
+ document.addEventListener('keyup', (e) => {
try {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
@@ -232,14 +233,14 @@ class HighlighterElement extends HTMLElement {
this.blur()
}
} catch (error) {
- handleScriptError(error, { scriptName: 'Highlighter', operation: 'keydown' })
+ handleScriptError(error, { scriptName: 'Highlighter', operation: 'keyup' })
}
})
// Share button clicks
const buttons = this.shadowRoot.querySelectorAll('.share-button')
buttons.forEach(button => {
- button.addEventListener('click', e => {
+ addButtonEventListeners(button, (e) => {
try {
e.stopPropagation()
const platformId = button.dataset['platform']
@@ -249,7 +250,7 @@ class HighlighterElement extends HTMLElement {
} catch (error) {
handleScriptError(error, { scriptName: 'Highlighter', operation: 'shareClick' })
}
- })
+ }, this)
})
} catch (error) {
handleScriptError(error, context)
diff --git a/src/components/Social/Highlighter/share-highlight.ts b/src/components/Social/Highlighter/share-highlight.ts
index 0d9ce459c..319e61c7f 100644
--- a/src/components/Social/Highlighter/share-highlight.ts
+++ b/src/components/Social/Highlighter/share-highlight.ts
@@ -112,7 +112,7 @@ class ShareHighlight extends HTMLElement {
}
this.addEventListener('click', () => this.share())
- this.addEventListener('keydown', e => {
+ this.addEventListener('keyup', (e) => {
if (e.key && e.key.toLowerCase() === 'enter') {
this.share()
}
diff --git a/src/components/Social/Mastodon/client.ts b/src/components/Social/Mastodon/client.ts
index 81bf73fd6..9e95eee41 100644
--- a/src/components/Social/Mastodon/client.ts
+++ b/src/components/Social/Mastodon/client.ts
@@ -17,6 +17,7 @@ import {
$mastodonInstances,
saveMastodonInstance,
} from '@components/Scripts/state'
+import { addButtonEventListeners, addWrapperEventListeners } from '@components/Scripts/elementListeners'
import {
getModalElement,
getBackdropElement,
@@ -123,9 +124,9 @@ export class MastodonModal extends LoadableScript {
})
// Set up event listeners
- this.closeButton.addEventListener('click', () => this.closeModal())
- this.cancelButton.addEventListener('click', () => this.closeModal())
- this.backdrop.addEventListener('click', () => this.closeModal())
+ addButtonEventListeners(this.closeButton, () => this.closeModal())
+ addButtonEventListeners(this.cancelButton, () => this.closeModal())
+ addWrapperEventListeners(this.backdrop, () => this.closeModal())
this.form.addEventListener('submit', (e) => this.handleSubmit(e))
// Listen for ESC key
@@ -191,7 +192,7 @@ export class MastodonModal extends LoadableScript {
button.type = 'button'
button.className = 'saved-instance'
button.textContent = instance
- button.addEventListener('click', () => {
+ addButtonEventListeners(button, () => {
if (this.instanceInput) {
this.instanceInput.value = instance
this.instanceInput.focus()
diff --git a/src/components/Testimonials/client.ts b/src/components/Testimonials/client.ts
index f97edd5fe..6d415ab40 100644
--- a/src/components/Testimonials/client.ts
+++ b/src/components/Testimonials/client.ts
@@ -8,6 +8,7 @@ import EmblaCarousel, { type EmblaOptionsType, type EmblaCarouselType } from 'em
import Autoplay from 'embla-carousel-autoplay'
import { LoadableScript, type TriggerEvent } from '../Scripts/loader/@types/loader'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { addButtonEventListeners } from '@components/Scripts/elementListeners'
/**
* Testimonials carousel using LoadableScript pattern with instance-specific approach
@@ -96,11 +97,11 @@ export class TestimonialsCarousel extends LoadableScript {
const nextBtn = this.emblaNode.querySelector('.embla__button--next')
if (prevBtn && nextBtn) {
- prevBtn.addEventListener('click', () => {
+ addButtonEventListeners(prevBtn, () => {
this.emblaApi?.scrollPrev()
})
- nextBtn.addEventListener('click', () => {
+ addButtonEventListeners(nextBtn, () => {
this.emblaApi?.scrollNext()
})
@@ -150,7 +151,7 @@ export class TestimonialsCarousel extends LoadableScript {
'embla__dot w-3 h-3 rounded-full bg-[color:var(--color-text-offset)] transition-all duration-300 hover:bg-[color:var(--color-primary)]'
dot.setAttribute('aria-label', `Go to testimonial ${index + 1}`)
- dot.addEventListener('click', () => {
+ addButtonEventListeners(dot, () => {
this.emblaApi?.scrollTo(index)
})
diff --git a/src/components/ThemePicker/theme-picker-element.ts b/src/components/ThemePicker/theme-picker-element.ts
index 0ff3083ba..9258c739a 100644
--- a/src/components/ThemePicker/theme-picker-element.ts
+++ b/src/components/ThemePicker/theme-picker-element.ts
@@ -6,6 +6,7 @@
import { $theme, $themePickerOpen, setTheme } from '@components/Scripts/state'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { addButtonEventListeners } from '@components/Scripts/elementListeners'
export const CLASSES = {
isOpen: 'is-open',
@@ -200,20 +201,9 @@ export class ThemePickerElement extends HTMLElement {
try {
// Toggle button
- this.toggleBtn.addEventListener('click', () => this.togglePicker())
- this.toggleBtn.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- this.togglePicker()
- }
- }) // Close button
- this.closeBtn.addEventListener('click', () => this.togglePicker(false))
- this.closeBtn.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- this.togglePicker(false)
- }
- })
+ addButtonEventListeners(this.toggleBtn, () => this.togglePicker(), this)
+ // Close button
+ addButtonEventListeners(this.closeBtn, () => this.togglePicker(false), this)
// Theme selection buttons
this.themeSelectBtns.forEach((button) => {
@@ -224,18 +214,11 @@ export class ThemePickerElement extends HTMLElement {
const themeId = button.dataset['theme'] as ThemeIds
if (themeId) {
- button.addEventListener('click', (e) => {
+ addButtonEventListeners(button, (e) => {
// Stop ALL event propagation to prevent "click outside" handler
e.stopImmediatePropagation()
this.applyTheme(themeId)
- })
- button.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- e.stopImmediatePropagation()
- this.applyTheme(themeId)
- }
- })
+ }, this)
}
} catch (error) {
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'bindThemeButton' })
diff --git a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
index d22a20082..80de21104 100644
--- a/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
+++ b/test/eslint/__tests__/enforce-centralized-events-rule.spec.ts
@@ -81,6 +81,44 @@ describe('enforce-centralized-events', () => {
`,
filename: 'src/components/SomeComponent/index.ts',
},
+
+ // ========================================
+ // Valid: Document-level listeners (allowed - element utilities don't work here)
+ // ========================================
+ {
+ code: `
+ document.addEventListener('click', handleDocumentClick);
+ document.addEventListener('keydown', handleKeyDown);
+ document.addEventListener('keyup', handleKeyUp);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+
+ // ========================================
+ // Valid: Window-level listeners (allowed - element utilities don't work here)
+ // ========================================
+ {
+ code: `
+ window.addEventListener('keyup', handleWindowKeyup);
+ window.addEventListener('click', handleWindowClick);
+ `,
+ filename: 'src/components/SomeComponent/index.ts',
+ },
+
+ // ========================================
+ // Valid: this.addEventListener in web components (component is the event target)
+ // ========================================
+ {
+ code: `
+ class MyComponent extends HTMLElement {
+ connectedCallback() {
+ this.addEventListener('click', this.handleClick);
+ this.addEventListener('keyup', this.handleKeyUp);
+ }
+ }
+ `,
+ filename: 'src/components/MyComponent/index.ts',
+ },
],
invalid: [
@@ -187,37 +225,12 @@ describe('enforce-centralized-events', () => {
},
// ========================================
- // Invalid: Web component context
- // ========================================
- {
- code: `
- class MyComponent extends HTMLElement {
- connectedCallback() {
- this.addEventListener('click', this.handleClick);
- }
- }
- `,
- filename: 'src/components/MyComponent/index.ts',
- errors: [
- {
- messageId: 'webComponentException',
- data: {
- utility: 'addButtonEventListeners or addLinkEventListeners',
- description: 'click events',
- features: 'click, keyup Enter, and touchend support',
- },
- },
- ],
- },
-
- // ========================================
- // Invalid: Multiple violations in one file
+ // Invalid: Non-web-component button click on child element
// ========================================
{
code: `
+ const button = this.querySelector('button');
button.addEventListener('click', handleClick);
- button.addEventListener('keyup', handleKeyup);
- button.addEventListener('touchend', handleTouch);
`,
filename: 'src/components/SomeComponent/index.ts',
errors: [
@@ -229,57 +242,9 @@ describe('enforce-centralized-events', () => {
features: 'click, keyup Enter, and touchend support',
},
},
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility:
- 'addButtonEventListeners, addLinkEventListeners, or addWrapperEventListeners',
- description: 'keyup events (Enter/Escape)',
- features: 'isComposing check, repeat prevention, Enter/Escape key handling',
- },
- },
- {
- messageId: 'useCentralizedUtility',
- data: {
- utility: 'addButtonEventListeners or addLinkEventListeners',
- description: 'touchend events',
- features: 'click, keyup Enter, and touchend support',
- },
- },
- ],
- },
-
- // ========================================
- // Invalid: Document-level listener (still needs centralization)
- // ========================================
- {
- code: `
- document.addEventListener('click', handleDocumentClick);
- `,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- },
- ],
- },
-
- // ========================================
- // Invalid: Window-level listener (still needs centralization)
- // ========================================
- {
- code: `
- window.addEventListener('keyup', handleWindowKeyup);
- `,
- filename: 'src/components/SomeComponent/index.ts',
- errors: [
- {
- messageId: 'useCentralizedUtility',
- },
],
},
],
})
})
})
-
diff --git a/test/eslint/enforce-centralized-events-rule.ts b/test/eslint/enforce-centralized-events-rule.ts
index 669e46c94..4375143dd 100644
--- a/test/eslint/enforce-centralized-events-rule.ts
+++ b/test/eslint/enforce-centralized-events-rule.ts
@@ -152,6 +152,24 @@ const enforceCentralizedEventsRule: Rule.RuleModule = {
return
}
+ // Allow document and window level listeners (can't use element-specific utilities)
+ if (
+ node.callee.type === 'MemberExpression' &&
+ node.callee.object.type === 'Identifier' &&
+ (node.callee.object.name === 'document' || node.callee.object.name === 'window')
+ ) {
+ return
+ }
+
+ // Allow this.addEventListener in web components (the component itself is the event target)
+ if (
+ node.callee.type === 'MemberExpression' &&
+ node.callee.object.type === 'ThisExpression' &&
+ isLikelyWebComponent(node)
+ ) {
+ return
+ }
+
// Determine if this is in a web component
const isWebComponent = isLikelyWebComponent(node)
const messageId = isWebComponent ? 'webComponentException' : 'useCentralizedUtility'
From bca543ddfc2dda13b7e100a38450079d3462e7cd Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Thu, 6 Nov 2025 00:06:24 +0300
Subject: [PATCH 006/341] Reorganize folder structure in Scripts component
---
.../Cookies/Consent/__tests__/cookies.spec.ts | 2 +-
.../Cookies/Consent/__tests__/state.spec.ts | 2 +-
src/components/Cookies/Consent/cookies.ts | 4 ++--
src/components/Cookies/Consent/state.ts | 2 +-
.../Customize/__tests__/client.spec.ts | 4 ++--
src/components/Cookies/Customize/client.ts | 2 +-
src/components/Head/index.astro | 2 +-
.../__tests__/index.spec.ts} | 23 ++++++++++---------
.../bootstrap.ts => bootstrap/index.ts} | 8 +++----
src/components/Scripts/loader/index.ts | 8 +++----
.../Scripts/{state => }/store/@types/index.ts | 0
.../store/__tests__/cookieConsent.spec.ts | 2 +-
.../store/__tests__/mastodonInstances.spec.ts | 0
.../store/__tests__/socialEmbeds.spec.ts | 0
.../store/__tests__/themes.spec.ts | 0
.../{state => }/store/cookieConsent.ts | 2 +-
.../Scripts/{state => store}/index.ts | 16 +++++++++----
.../{state => }/store/mastodonInstances.ts | 0
.../Scripts/{state => }/store/socialEmbeds.ts | 0
.../Scripts/{state => }/store/themes.ts | 0
.../__tests__/cookies.spec.ts | 2 +-
.../Scripts/{state => utils}/cookies.ts | 0
.../Social/Embed/__tests__/client.spec.ts | 4 ++--
src/components/Social/Embed/client.ts | 2 +-
src/components/Social/Mastodon/client.ts | 2 +-
.../ThemePicker/theme-picker-element.ts | 2 +-
26 files changed, 48 insertions(+), 41 deletions(-)
rename src/components/Scripts/{state/__tests__/bootstrap.spec.ts => bootstrap/__tests__/index.spec.ts} (88%)
rename src/components/Scripts/{state/bootstrap.ts => bootstrap/index.ts} (89%)
rename src/components/Scripts/{state => }/store/@types/index.ts (100%)
rename src/components/Scripts/{state => }/store/__tests__/cookieConsent.spec.ts (98%)
rename src/components/Scripts/{state => }/store/__tests__/mastodonInstances.spec.ts (100%)
rename src/components/Scripts/{state => }/store/__tests__/socialEmbeds.spec.ts (100%)
rename src/components/Scripts/{state => }/store/__tests__/themes.spec.ts (100%)
rename src/components/Scripts/{state => }/store/cookieConsent.ts (98%)
rename src/components/Scripts/{state => store}/index.ts (77%)
rename src/components/Scripts/{state => }/store/mastodonInstances.ts (100%)
rename src/components/Scripts/{state => }/store/socialEmbeds.ts (100%)
rename src/components/Scripts/{state => }/store/themes.ts (100%)
rename src/components/Scripts/{state => utils}/__tests__/cookies.spec.ts (99%)
rename src/components/Scripts/{state => utils}/cookies.ts (100%)
diff --git a/src/components/Cookies/Consent/__tests__/cookies.spec.ts b/src/components/Cookies/Consent/__tests__/cookies.spec.ts
index 4f78c9690..41f79d1fe 100644
--- a/src/components/Cookies/Consent/__tests__/cookies.spec.ts
+++ b/src/components/Cookies/Consent/__tests__/cookies.spec.ts
@@ -7,7 +7,7 @@ import {
removeConsentCookies,
setConsentCookie,
} from '../cookies'
-import { getCookie } from '@components/Scripts/state/cookies'
+import { getCookie } from '@components/Scripts/utils/cookies'
import { commonSetup } from '@test/unit/helpers/reset'
describe(`Consent cookies handlers work`, () => {
diff --git a/src/components/Cookies/Consent/__tests__/state.spec.ts b/src/components/Cookies/Consent/__tests__/state.spec.ts
index 389e4c596..61e15afdf 100644
--- a/src/components/Cookies/Consent/__tests__/state.spec.ts
+++ b/src/components/Cookies/Consent/__tests__/state.spec.ts
@@ -4,7 +4,7 @@
*/
// @vitest-environment happy-dom
import { describe, expect, beforeEach, test } from 'vitest'
-import { AppBootstrap } from '@components/Scripts/state/bootstrap'
+import { AppBootstrap } from '@components/Scripts/bootstrap'
import { $cookieModalVisible } from '../state'
describe(`Cookie modal visibility using state store`, () => {
diff --git a/src/components/Cookies/Consent/cookies.ts b/src/components/Cookies/Consent/cookies.ts
index 8182b7f9c..5cfde1666 100644
--- a/src/components/Cookies/Consent/cookies.ts
+++ b/src/components/Cookies/Consent/cookies.ts
@@ -3,8 +3,8 @@
* State management for cookie consent
* Now uses centralized state store from lib/state
*/
-import { getCookie, removeCookie } from '@components/Scripts/state/cookies'
-import { updateConsent } from '@components/Scripts/state'
+import { getCookie, removeCookie } from '@components/Scripts/utils/cookies'
+import { updateConsent } from '@components/Scripts/store'
type Preference = `granted` | `refused` | `unknown`
diff --git a/src/components/Cookies/Consent/state.ts b/src/components/Cookies/Consent/state.ts
index aaaa60a3e..bedb372b9 100644
--- a/src/components/Cookies/Consent/state.ts
+++ b/src/components/Cookies/Consent/state.ts
@@ -1,6 +1,6 @@
/**
* Cookie modal visibility state
*/
-import { $cookieModalVisible } from '@components/Scripts/state'
+import { $cookieModalVisible } from '@components/Scripts/store'
export { $cookieModalVisible }
diff --git a/src/components/Cookies/Customize/__tests__/client.spec.ts b/src/components/Cookies/Customize/__tests__/client.spec.ts
index cb8561eec..5b8a4b274 100644
--- a/src/components/Cookies/Customize/__tests__/client.spec.ts
+++ b/src/components/Cookies/Customize/__tests__/client.spec.ts
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { CookieCustomize } from '../client'
-import { AppBootstrap } from '@components/Scripts/state/bootstrap'
-import { $consent } from '@components/Scripts/state'
+import { AppBootstrap } from '@components/Scripts/bootstrap'
+import { $consent } from '@components/Scripts/store'
// Mock document.cookie with proper getter/setter behavior
let cookieStorage: string[] = []
diff --git a/src/components/Cookies/Customize/client.ts b/src/components/Cookies/Customize/client.ts
index 063c12b51..5900e5987 100644
--- a/src/components/Cookies/Customize/client.ts
+++ b/src/components/Cookies/Customize/client.ts
@@ -8,7 +8,7 @@ import { LoadableScript, type TriggerEvent } from '../../Scripts/loader/@types/l
import { isDivElement, isButtonElement } from '@components/Scripts/assertions/elements'
import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
import { addButtonEventListeners } from '@components/Scripts/elementListeners'
-import { updateConsent, $consent } from '@components/Scripts/state'
+import { updateConsent, $consent } from '@components/Scripts/store'
export interface CookiePreferences {
necessary: boolean
diff --git a/src/components/Head/index.astro b/src/components/Head/index.astro
index dc48a2156..f4aa8dbf4 100644
--- a/src/components/Head/index.astro
+++ b/src/components/Head/index.astro
@@ -65,6 +65,6 @@ const {
console.info('🔧 Sentry disabled in development mode')
}
- import { AppBootstrap } from '@components/Scripts/state/bootstrap'
+ import { AppBootstrap } from '@components/Scripts/bootstrap'
AppBootstrap.init()
\ No newline at end of file
diff --git a/src/components/Scripts/state/__tests__/bootstrap.spec.ts b/src/components/Scripts/bootstrap/__tests__/index.spec.ts
similarity index 88%
rename from src/components/Scripts/state/__tests__/bootstrap.spec.ts
rename to src/components/Scripts/bootstrap/__tests__/index.spec.ts
index 91a82324a..183f85b99 100644
--- a/src/components/Scripts/state/__tests__/bootstrap.spec.ts
+++ b/src/components/Scripts/bootstrap/__tests__/index.spec.ts
@@ -4,27 +4,28 @@
* Tests initialization of state management on page load
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { AppBootstrap } from '../bootstrap'
+import { AppBootstrap } from '@components/Scripts/bootstrap'
import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
+import { addScriptBreadcrumb } from '@components/Scripts/errors'
-// Mock the state initialization functions
-vi.mock('@components/Scripts/state', () => ({
+// Mock the store initialization functions
+vi.mock('@components/Scripts/store', () => ({
initConsentFromCookies: vi.fn(),
$hasFunctionalConsent: {
subscribe: vi.fn(),
},
}))
-// Mock store modules that initStateSideEffects uses internally
-vi.mock('../store/cookieConsent', () => ({
+// Mock store modules that are imported by bootstrap
+vi.mock('@components/Scripts/store/cookieConsent', () => ({
initConsentSideEffects: vi.fn(),
}))
-vi.mock('../store/themes', () => ({
+vi.mock('@components/Scripts/store/themes', () => ({
initThemeSideEffects: vi.fn(),
}))
-vi.mock('../store/socialEmbeds', () => ({
+vi.mock('@components/Scripts/store/socialEmbeds', () => ({
clearEmbedCache: vi.fn(),
}))
@@ -34,10 +35,10 @@ vi.mock('@components/Scripts/errors', () => ({
handleScriptError: vi.fn(),
}))
-import { initConsentFromCookies } from '@components/Scripts/state'
-import { addScriptBreadcrumb } from '@components/Scripts/errors'
-import { initConsentSideEffects } from '../store/cookieConsent'
-import { initThemeSideEffects } from '../store/themes'
+// Import the mocked functions after setting up mocks
+import { initConsentFromCookies } from '@components/Scripts/store'
+import { initConsentSideEffects } from '@components/Scripts/store/cookieConsent'
+import { initThemeSideEffects } from '@components/Scripts/store/themes'
describe('AppBootstrap', () => {
beforeEach(() => {
diff --git a/src/components/Scripts/state/bootstrap.ts b/src/components/Scripts/bootstrap/index.ts
similarity index 89%
rename from src/components/Scripts/state/bootstrap.ts
rename to src/components/Scripts/bootstrap/index.ts
index 52a8ed075..1a93632de 100644
--- a/src/components/Scripts/state/bootstrap.ts
+++ b/src/components/Scripts/bootstrap/index.ts
@@ -5,10 +5,10 @@
*/
import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
import { addScriptBreadcrumb, handleScriptError } from '@components/Scripts/errors'
-import { $hasFunctionalConsent, initConsentFromCookies } from '@components/Scripts/state'
-import { initConsentSideEffects } from './store/cookieConsent'
-import { initThemeSideEffects } from './store/themes'
-import { clearEmbedCache } from './store/socialEmbeds'
+import { $hasFunctionalConsent, initConsentFromCookies } from '@components/Scripts/store'
+import { initConsentSideEffects } from '@components/Scripts/store/cookieConsent'
+import { initThemeSideEffects } from '@components/Scripts/store/themes'
+import { clearEmbedCache } from '@components/Scripts/store/socialEmbeds'
/**
* Setup side effects - call once during app initialization
diff --git a/src/components/Scripts/loader/index.ts b/src/components/Scripts/loader/index.ts
index c51128161..c751ddf0f 100644
--- a/src/components/Scripts/loader/index.ts
+++ b/src/components/Scripts/loader/index.ts
@@ -13,11 +13,11 @@
import { LoadableScript } from './@types/loader'
import type { UserInteractionEvent, TriggerEvent, ConsentMetadata } from './@types/loader'
import {
- $hasAnalyticsConsent,
- $hasAdvertisingConsent,
+ $hasAdvertisingConsent,
$hasFunctionalConsent,
-} from '@components/Scripts/state'
-import type { ConsentCategory } from '@components/Scripts/state'
+ $hasAnalyticsConsent,
+} from '@components/Scripts/store'
+import type { ConsentCategory } from '@components/Scripts/store'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
export const userInteractionEvents: UserInteractionEvent[] = [
diff --git a/src/components/Scripts/state/store/@types/index.ts b/src/components/Scripts/store/@types/index.ts
similarity index 100%
rename from src/components/Scripts/state/store/@types/index.ts
rename to src/components/Scripts/store/@types/index.ts
diff --git a/src/components/Scripts/state/store/__tests__/cookieConsent.spec.ts b/src/components/Scripts/store/__tests__/cookieConsent.spec.ts
similarity index 98%
rename from src/components/Scripts/state/store/__tests__/cookieConsent.spec.ts
rename to src/components/Scripts/store/__tests__/cookieConsent.spec.ts
index 3fd5e085c..008634c8b 100644
--- a/src/components/Scripts/state/store/__tests__/cookieConsent.spec.ts
+++ b/src/components/Scripts/store/__tests__/cookieConsent.spec.ts
@@ -12,7 +12,7 @@ import {
allowAllConsent,
revokeAllConsent,
} from '../cookieConsent'
-import * as cookieUtils from '../../cookies'
+import * as cookieUtils from '@components/Scripts/utils/cookies'
// Mock js-cookie
vi.mock('js-cookie', () => ({
diff --git a/src/components/Scripts/state/store/__tests__/mastodonInstances.spec.ts b/src/components/Scripts/store/__tests__/mastodonInstances.spec.ts
similarity index 100%
rename from src/components/Scripts/state/store/__tests__/mastodonInstances.spec.ts
rename to src/components/Scripts/store/__tests__/mastodonInstances.spec.ts
diff --git a/src/components/Scripts/state/store/__tests__/socialEmbeds.spec.ts b/src/components/Scripts/store/__tests__/socialEmbeds.spec.ts
similarity index 100%
rename from src/components/Scripts/state/store/__tests__/socialEmbeds.spec.ts
rename to src/components/Scripts/store/__tests__/socialEmbeds.spec.ts
diff --git a/src/components/Scripts/state/store/__tests__/themes.spec.ts b/src/components/Scripts/store/__tests__/themes.spec.ts
similarity index 100%
rename from src/components/Scripts/state/store/__tests__/themes.spec.ts
rename to src/components/Scripts/store/__tests__/themes.spec.ts
diff --git a/src/components/Scripts/state/store/cookieConsent.ts b/src/components/Scripts/store/cookieConsent.ts
similarity index 98%
rename from src/components/Scripts/state/store/cookieConsent.ts
rename to src/components/Scripts/store/cookieConsent.ts
index 6a0031312..3818fe3c4 100644
--- a/src/components/Scripts/state/store/cookieConsent.ts
+++ b/src/components/Scripts/store/cookieConsent.ts
@@ -4,7 +4,7 @@
import { computed, atom } from 'nanostores'
import { persistentAtom } from '@nanostores/persistent'
import type { ConsentState, ConsentCategory, ConsentValue } from './@types'
-import { getCookie, setCookie } from '../cookies'
+import { getCookie, setCookie } from '@components/Scripts/utils/cookies'
import { handleScriptError } from '@components/Scripts/errors'
// ============================================================================
diff --git a/src/components/Scripts/state/index.ts b/src/components/Scripts/store/index.ts
similarity index 77%
rename from src/components/Scripts/state/index.ts
rename to src/components/Scripts/store/index.ts
index e943b798c..f4f8ae43b 100644
--- a/src/components/Scripts/state/index.ts
+++ b/src/components/Scripts/store/index.ts
@@ -11,7 +11,7 @@ export type {
ConsentState,
EmbedCacheEntry,
EmbedCacheState,
-} from './store/@types'
+} from './@types'
// Re-export cookie consent
export {
@@ -22,13 +22,19 @@ export {
$hasAdvertisingConsent,
$hasAnyConsent,
initConsentFromCookies,
+ initConsentSideEffects,
updateConsent,
allowAllConsent,
revokeAllConsent,
-} from './store/cookieConsent'
+} from './cookieConsent'
// Re-export themes
-export { $theme, $themePickerOpen, setTheme } from './store/themes'
+export {
+ $theme,
+ $themePickerOpen,
+ initThemeSideEffects,
+ setTheme,
+} from './themes'
// Re-export Mastodon instances
export {
@@ -37,7 +43,7 @@ export {
saveMastodonInstance,
removeMastodonInstance,
clearMastodonInstances,
-} from './store/mastodonInstances'
+} from './mastodonInstances'
// Re-export social embeds
-export { $embedCache, cacheEmbed, getCachedEmbed, clearEmbedCache } from './store/socialEmbeds'
+export { $embedCache, cacheEmbed, getCachedEmbed, clearEmbedCache } from './socialEmbeds'
diff --git a/src/components/Scripts/state/store/mastodonInstances.ts b/src/components/Scripts/store/mastodonInstances.ts
similarity index 100%
rename from src/components/Scripts/state/store/mastodonInstances.ts
rename to src/components/Scripts/store/mastodonInstances.ts
diff --git a/src/components/Scripts/state/store/socialEmbeds.ts b/src/components/Scripts/store/socialEmbeds.ts
similarity index 100%
rename from src/components/Scripts/state/store/socialEmbeds.ts
rename to src/components/Scripts/store/socialEmbeds.ts
diff --git a/src/components/Scripts/state/store/themes.ts b/src/components/Scripts/store/themes.ts
similarity index 100%
rename from src/components/Scripts/state/store/themes.ts
rename to src/components/Scripts/store/themes.ts
diff --git a/src/components/Scripts/state/__tests__/cookies.spec.ts b/src/components/Scripts/utils/__tests__/cookies.spec.ts
similarity index 99%
rename from src/components/Scripts/state/__tests__/cookies.spec.ts
rename to src/components/Scripts/utils/__tests__/cookies.spec.ts
index 52069400d..74ca8ee2e 100644
--- a/src/components/Scripts/state/__tests__/cookies.spec.ts
+++ b/src/components/Scripts/utils/__tests__/cookies.spec.ts
@@ -4,8 +4,8 @@
* Tests the wrapper functions around js-cookie library
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
-import { getCookie, setCookie, removeCookie, getAllCookies, hasCookie } from '../cookies'
import Cookies from 'js-cookie'
+import { getCookie, setCookie, removeCookie, getAllCookies, hasCookie } from '@components/Scripts/utils/cookies'
describe('Cookie Utilities', () => {
beforeEach(() => {
diff --git a/src/components/Scripts/state/cookies.ts b/src/components/Scripts/utils/cookies.ts
similarity index 100%
rename from src/components/Scripts/state/cookies.ts
rename to src/components/Scripts/utils/cookies.ts
diff --git a/src/components/Social/Embed/__tests__/client.spec.ts b/src/components/Social/Embed/__tests__/client.spec.ts
index b7a769ef8..476c767ee 100644
--- a/src/components/Social/Embed/__tests__/client.spec.ts
+++ b/src/components/Social/Embed/__tests__/client.spec.ts
@@ -2,8 +2,8 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { EmbedManager } from '../client'
-import { AppBootstrap } from '@components/Scripts/state/bootstrap'
-import { $embedCache, updateConsent, clearEmbedCache } from '@components/Scripts/state'
+import { AppBootstrap } from '@components/Scripts/bootstrap'
+import { $embedCache, updateConsent, clearEmbedCache } from '@components/Scripts/store'
/**
* Unit tests for Social Embed component
diff --git a/src/components/Social/Embed/client.ts b/src/components/Social/Embed/client.ts
index 6e57abfc7..ba0eb371b 100644
--- a/src/components/Social/Embed/client.ts
+++ b/src/components/Social/Embed/client.ts
@@ -1,5 +1,5 @@
import { LoadableScript, type TriggerEvent } from '../../Scripts/loader/@types/loader'
-import { cacheEmbed, getCachedEmbed } from '@components/Scripts/state'
+import { cacheEmbed, getCachedEmbed } from '@components/Scripts/store'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
/**
diff --git a/src/components/Social/Mastodon/client.ts b/src/components/Social/Mastodon/client.ts
index 9e95eee41..b2640407a 100644
--- a/src/components/Social/Mastodon/client.ts
+++ b/src/components/Social/Mastodon/client.ts
@@ -16,7 +16,7 @@ import {
$currentMastodonInstance,
$mastodonInstances,
saveMastodonInstance,
-} from '@components/Scripts/state'
+} from '@components/Scripts/store'
import { addButtonEventListeners, addWrapperEventListeners } from '@components/Scripts/elementListeners'
import {
getModalElement,
diff --git a/src/components/ThemePicker/theme-picker-element.ts b/src/components/ThemePicker/theme-picker-element.ts
index 9258c739a..800c88888 100644
--- a/src/components/ThemePicker/theme-picker-element.ts
+++ b/src/components/ThemePicker/theme-picker-element.ts
@@ -4,7 +4,7 @@
* Works seamlessly with Astro View Transitions
*/
-import { $theme, $themePickerOpen, setTheme } from '@components/Scripts/state'
+import { $theme, $themePickerOpen, setTheme } from '@components/Scripts/store'
import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
import { addButtonEventListeners } from '@components/Scripts/elementListeners'
From 96660a0ed162dfc37a8b0fcd5d8692d18428c07a Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Thu, 6 Nov 2025 14:41:51 +0300
Subject: [PATCH 007/341] Update imports to consistently use TS path aliases
---
.vscode/settings.json | 1 +
eslint.config.ts | 2 +-
src/components/Avatar/index.astro | 2 +-
src/components/Brand/index.astro | 6 ++--
.../Breadcrumbs/__tests__/server.spec.ts | 7 ++---
src/components/Breadcrumbs/index.astro | 2 +-
src/components/Button/index.astro | 6 ++--
src/components/Button/server.ts | 4 +--
.../__fixtures__/newsletter.fixture.ts | 3 +-
.../Newsletter/__tests__/client.spec.ts | 4 +--
.../CallToAction/Newsletter/client.ts | 6 ++--
.../CallToAction/Newsletter/index.astro | 4 +--
.../Carousel/__tests__/client.spec.ts | 2 +-
src/components/Carousel/client.ts | 8 +++---
src/components/Carousel/index.astro | 4 +--
.../ContactForm/__tests__/email.spec.ts | 4 +--
.../ContactForm/__tests__/error.spec.ts | 2 +-
.../ContactForm/__tests__/message.spec.ts | 4 +--
.../ContactForm/__tests__/name.spec.ts | 4 +--
src/components/ContactForm/client.ts | 6 ++--
src/components/ContactForm/email.ts | 2 +-
src/components/ContactForm/index.astro | 4 +--
src/components/ContactForm/selectors.ts | 2 +-
.../Cookies/Consent/__tests__/cookies.spec.ts | 4 +--
.../Cookies/Consent/__tests__/state.spec.ts | 4 +--
src/components/Cookies/Consent/client.ts | 16 +++++------
src/components/Cookies/Consent/cookies.ts | 4 +--
src/components/Cookies/Consent/index.astro | 4 +--
src/components/Cookies/Consent/selectors.ts | 4 +--
src/components/Cookies/Consent/state.ts | 2 +-
.../Customize/__tests__/client.spec.ts | 6 ++--
src/components/Cookies/Customize/client.ts | 10 +++----
src/components/Cookies/Customize/index.astro | 4 +--
.../Footer/__fixtures__/selectors.ts | 2 +-
.../Footer/__tests__/client.spec.ts | 6 ++--
.../__tests__/formatPhoneNumber.spec.ts | 4 +--
src/components/Footer/client.ts | 6 ++--
src/components/Footer/index.astro | 10 +++----
src/components/Footer/selectors.ts | 2 +-
.../Forms/Download/__tests__/client.spec.ts | 4 +--
.../Download/__tests__/selectors.spec.ts | 6 ++--
src/components/Forms/Download/client.ts | 6 ++--
src/components/Forms/Download/index.astro | 4 +--
.../GDPR/Consent/__tests__/client.spec.ts | 4 +--
.../GDPR/Consent/__tests__/selectors.spec.ts | 2 +-
.../GDPR/Consent/__tests__/state.spec.ts | 2 +-
src/components/GDPR/Consent/client.ts | 4 +--
src/components/GDPR/Consent/index.astro | 4 +--
src/components/Head/Meta.astro | 6 ++--
src/components/Head/Social.astro | 2 +-
src/components/Head/client.ts | 2 +-
src/components/Head/index.astro | 6 ++--
src/components/Hero/__tests__/client.spec.ts | 6 ++--
src/components/Hero/client.ts | 6 ++--
src/components/Hero/default/content.astro | 4 +--
src/components/Hero/default/ctaLinks.astro | 2 +-
src/components/Hero/default/image.astro | 2 +-
src/components/Hero/home/content.astro | 4 +--
src/components/Hero/home/ctaLinks.astro | 2 +-
src/components/Hero/home/image.astro | 4 +--
src/components/Highlighter/selectors.ts | 2 +-
src/components/Navigation/Menu.astro | 6 ++--
.../Navigation/__tests__/TestNavigation.astro | 4 +--
.../Navigation/__tests__/navigation.spec.ts | 6 ++--
.../Navigation/__tests__/selectors.spec.ts | 6 ++--
src/components/Navigation/client.ts | 12 ++++----
src/components/Navigation/selectors.ts | 2 +-
src/components/Social/Embed/README.md | 6 ++--
.../Social/Embed/__tests__/client.spec.ts | 6 ++--
src/components/Social/Embed/client.ts | 6 ++--
src/components/Social/Embed/index.astro | 6 ++--
.../Social/Highlighter/Highlighter.astro | 4 +--
.../Highlighter/__tests__/client.spec.ts | 28 ++++++++++---------
src/components/Social/Highlighter/client.ts | 8 +++---
.../Social/Highlighter/selectors.ts | 2 +-
src/components/Social/Mastodon/Modal.astro | 4 +--
.../Social/Mastodon/__tests__/config.spec.ts | 2 +-
.../Mastodon/__tests__/detector.spec.ts | 2 +-
src/components/Social/Mastodon/client.ts | 8 +++---
src/components/Social/Mastodon/selectors.ts | 2 +-
src/components/Social/Shares/Shares.astro | 10 +++----
.../Social/Shares/__tests__/client.spec.ts | 2 +-
src/components/Social/Shares/client.ts | 4 +--
src/components/Sprite/index.astro | 2 +-
.../Test/__tests__/container.spec.ts | 2 +-
.../Testimonials/__fixtures__/domHelpers.ts | 2 +-
.../Testimonials/__tests__/client.spec.ts | 6 ++--
src/components/Testimonials/client.ts | 6 ++--
src/components/Testimonials/index.astro | 4 +--
src/components/ThemePicker/Themes.astro | 6 ++--
.../ThemePicker/__fixtures__/domHelpers.ts | 2 +-
src/components/ThemePicker/index.astro | 2 +-
.../ThemePicker/theme-picker-element.ts | 6 ++--
.../NetworkStatus/__tests__/client.spec.ts | 2 +-
src/components/Toasts/NetworkStatus/client.ts | 2 +-
.../Toasts/NetworkStatus/index.astro | 4 +--
.../WebMentions/WebMentionItem.astro | 2 +-
src/components/WebMentions/WebMentions.astro | 4 +--
.../assertions/elements.ts | 0
.../{Scripts => scripts}/assertions/events.ts | 0
.../{Scripts => scripts}/assertions/index.ts | 0
.../assertions/primitives.ts | 0
.../bootstrap/__tests__/index.spec.ts | 22 +++++++--------
.../{Scripts => scripts}/bootstrap/index.ts | 12 ++++----
.../__tests__/elementListeners.spec.ts | 2 +-
.../elementListeners/index.ts | 0
.../errors/ClientScriptError.ts | 2 +-
.../__tests__/ClientScriptError.spec.ts | 3 +-
.../errors/__tests__/assertions.spec.ts | 4 +--
.../errors/__tests__/converters.spec.ts | 6 ++--
.../{Scripts => scripts}/errors/assertions.ts | 2 +-
.../{Scripts => scripts}/errors/converters.ts | 2 +-
.../{Scripts => scripts}/errors/handler.ts | 0
.../{Scripts => scripts}/errors/index.ts | 1 +
.../{Scripts => scripts}/errors/sentry.ts | 0
.../events/__tests__/index.spec.ts | 2 +-
.../{Scripts => scripts}/events/index.ts | 0
.../loader/@types/index.ts} | 1 +
.../loader/LOAD_EVENTS.md | 0
.../loader/__tests__/loader.spec.ts | 4 +--
.../{Scripts => scripts}/loader/index.ts | 16 +++++++----
.../{Scripts => scripts}/sentry/client.ts | 0
.../store/@types/index.ts | 0
.../store/__tests__/cookieConsent.spec.ts | 4 +--
.../store/__tests__/mastodonInstances.spec.ts | 4 +--
.../store/__tests__/socialEmbeds.spec.ts | 4 +--
.../store/__tests__/themes.spec.ts | 4 +--
.../store/cookieConsent.ts | 4 +--
.../{Scripts => scripts}/store/index.ts | 0
.../store/mastodonInstances.ts | 0
.../store/socialEmbeds.ts | 0
.../{Scripts => scripts}/store/themes.ts | 4 +--
.../theme/__tests__/index.spec.ts | 4 +--
.../{Scripts => scripts}/theme/index.ts | 2 +-
.../utils/__tests__/cookies.spec.ts | 2 +-
.../{Scripts => scripts}/utils/cookies.ts | 1 -
src/content.config.ts | 2 +-
src/layouts/MarkdownLayout.astro | 2 +-
src/layouts/PageLayout.astro | 2 +-
src/lib/config/markdown.ts | 8 +++---
src/lib/helpers/__tests__/absoluteUrl.spec.ts | 4 +--
src/lib/helpers/__tests__/pageTitle.spec.ts | 2 +-
src/lib/markdown/__tests__/coverage.spec.ts | 3 +-
.../__tests__/e2e/full-pipeline.spec.tsx | 2 +-
.../rehypeAccessibleEmojis.spec.tsx | 2 +-
.../rehypeAutolinkHeadings.spec.tsx | 4 +--
.../rehypeTailwindClasses.spec.tsx | 2 +-
.../remarkAbbreviations.spec.tsx | 2 +-
.../unifiedPlugins/remarkAttributes.spec.tsx | 2 +-
.../unifiedPlugins/remarkAttribution.spec.tsx | 2 +-
.../e2e/unifiedPlugins/remarkBreaks.spec.tsx | 2 +-
.../e2e/unifiedPlugins/remarkEmoji.spec.tsx | 2 +-
.../remarkLinkifyRegex.spec.tsx | 2 +-
.../remarkReplacements.spec.tsx | 2 +-
.../e2e/unifiedPlugins/remarkToc.spec.tsx | 2 +-
.../units/rehype-autolink-headings.spec.ts | 2 +-
.../__tests__/units/remark-breaks.spec.ts | 2 +-
.../__tests__/units/remark-emoji.spec.ts | 2 +-
.../units/remark-linkify-regex.spec.ts | 2 +-
.../__tests__/units/remark-toc.spec.ts | 4 +--
.../rehype-accessible-emojis-astro.spec.ts | 2 +-
.../rehype-autolink-headings-astro.spec.ts | 2 +-
.../rehype-tailwind-astro.spec.ts | 4 +--
.../rehype-tailwind-classes-astro.spec.ts | 4 +--
...ype-tailwind-simple-elements-astro.spec.ts | 4 +--
.../remark-abbreviations-astro.spec.ts | 4 +--
.../remark-attributes-astro.spec.ts | 6 ++--
.../remark-attribution-astro.spec.ts | 4 +--
.../remark-breaks-astro.spec.ts | 2 +-
.../remark-emoji-astro.spec.ts | 2 +-
.../remark-linkify-regex-astro.spec.ts | 2 +-
.../remark-replacements-astro.spec.ts | 4 +--
.../remark-toc-astro.spec.ts | 4 +--
src/lib/markdown/helpers/index.ts | 4 +--
src/lib/markdown/helpers/test-utils.ts | 2 +-
.../rehype-tailwind/__tests__/html.spec.ts | 4 +--
.../rehype-tailwind/visitors/simple.ts | 4 +--
.../__tests__/index.spec.ts | 4 +--
.../remark-attributes/__tests__/index.spec.ts | 4 +--
.../__tests__/remark-attribution.spec.ts | 2 +-
.../plugins/remark-replacements/index.spec.ts | 2 +-
src/pages/404.astro | 2 +-
src/pages/api/contact/__tests__/index.spec.ts | 2 +-
src/pages/api/contact/index.ts | 2 +-
.../api/downloads/__tests__/submit.spec.ts | 2 +-
.../api/newsletter/__tests__/confirm.spec.ts | 2 +-
.../api/newsletter/__tests__/index.spec.ts | 2 +-
src/pages/api/newsletter/confirm.ts | 6 ++--
.../api/social-card/__tests__/index.spec.ts | 2 +-
src/pages/case-studies/index.astro | 2 +-
src/pages/contact/index.astro | 4 +--
src/pages/cookies/index.astro | 2 +-
src/pages/offline/index.astro | 4 +--
src/pages/services/index.astro | 2 +-
src/pages/tags/[tag].astro | 2 +-
src/pages/tags/index.astro | 2 +-
test/e2e/helpers/__tests__/baseTest.spec.ts | 4 +--
test/e2e/helpers/index.ts | 21 +++++++++++---
.../pageObjectModels/NewsletterPage.ts | 2 +-
.../pageObjectModels/PerformancePage.ts | 2 +-
test/e2e/helpers/pageObjectModels/PwaPage.ts | 2 +-
.../specs/04-components/breadcrumbs.spec.ts | 8 ++++--
.../04-components/navigation-desktop.spec.ts | 8 ++++--
.../04-components/store-persistence.spec.ts | 2 +-
.../theme-picker-web-component.spec.ts | 2 +-
.../specs/04-components/theme-picker.spec.ts | 11 ++++++--
.../hero-animation-mobile-menu-pause.spec.ts | 2 +-
.../theme-initialization.spec.ts | 2 +-
.../enforce-centralized-events-rule.spec.ts | 10 +++----
.../eslint/enforce-centralized-events-rule.ts | 4 +--
test/unit/helpers/__tests__/reset.spec.ts | 2 +-
211 files changed, 423 insertions(+), 389 deletions(-)
rename src/components/{Scripts => scripts}/assertions/elements.ts (100%)
rename src/components/{Scripts => scripts}/assertions/events.ts (100%)
rename src/components/{Scripts => scripts}/assertions/index.ts (100%)
rename src/components/{Scripts => scripts}/assertions/primitives.ts (100%)
rename src/components/{Scripts => scripts}/bootstrap/__tests__/index.spec.ts (88%)
rename src/components/{Scripts => scripts}/bootstrap/index.ts (87%)
rename src/components/{Scripts => scripts}/elementListeners/__tests__/elementListeners.spec.ts (99%)
rename src/components/{Scripts => scripts}/elementListeners/index.ts (100%)
rename src/components/{Scripts => scripts}/errors/ClientScriptError.ts (95%)
rename src/components/{Scripts => scripts}/errors/__tests__/ClientScriptError.spec.ts (97%)
rename src/components/{Scripts => scripts}/errors/__tests__/assertions.spec.ts (93%)
rename src/components/{Scripts => scripts}/errors/__tests__/converters.spec.ts (97%)
rename src/components/{Scripts => scripts}/errors/assertions.ts (91%)
rename src/components/{Scripts => scripts}/errors/converters.ts (98%)
rename src/components/{Scripts => scripts}/errors/handler.ts (100%)
rename src/components/{Scripts => scripts}/errors/index.ts (74%)
rename src/components/{Scripts => scripts}/errors/sentry.ts (100%)
rename src/components/{Scripts => scripts}/events/__tests__/index.spec.ts (99%)
rename src/components/{Scripts => scripts}/events/index.ts (100%)
rename src/components/{Scripts/loader/@types/loader.ts => scripts/loader/@types/index.ts} (99%)
rename src/components/{Scripts => scripts}/loader/LOAD_EVENTS.md (100%)
rename src/components/{Scripts => scripts}/loader/__tests__/loader.spec.ts (99%)
rename src/components/{Scripts => scripts}/loader/index.ts (97%)
rename src/components/{Scripts => scripts}/sentry/client.ts (100%)
rename src/components/{Scripts => scripts}/store/@types/index.ts (100%)
rename src/components/{Scripts => scripts}/store/__tests__/cookieConsent.spec.ts (97%)
rename src/components/{Scripts => scripts}/store/__tests__/mastodonInstances.spec.ts (92%)
rename src/components/{Scripts => scripts}/store/__tests__/socialEmbeds.spec.ts (92%)
rename src/components/{Scripts => scripts}/store/__tests__/themes.spec.ts (92%)
rename src/components/{Scripts => scripts}/store/cookieConsent.ts (97%)
rename src/components/{Scripts => scripts}/store/index.ts (100%)
rename src/components/{Scripts => scripts}/store/mastodonInstances.ts (100%)
rename src/components/{Scripts => scripts}/store/socialEmbeds.ts (100%)
rename src/components/{Scripts => scripts}/store/themes.ts (98%)
rename src/components/{Scripts => scripts}/theme/__tests__/index.spec.ts (98%)
rename src/components/{Scripts => scripts}/theme/index.ts (95%)
rename src/components/{Scripts => scripts}/utils/__tests__/cookies.spec.ts (99%)
rename src/components/{Scripts => scripts}/utils/cookies.ts (95%)
diff --git a/.vscode/settings.json b/.vscode/settings.json
index b8f04f32f..d641cc074 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -96,6 +96,7 @@
"vercel",
"virt",
"vmax",
+ "WHATWG",
"wscript",
"youtu",
"yoyo",
diff --git a/eslint.config.ts b/eslint.config.ts
index 115d6bd9c..a2fc3d68b 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -142,7 +142,7 @@ export default [
},
{
files: [
- 'src/components/Scripts/elementListeners/index.ts',
+ 'src/components/scripts/elementListeners/index.ts',
'src/components/Social/Shares/client.ts', // Needs direct addEventListener for pause/resume/reset lifecycle
],
rules: {
diff --git a/src/components/Avatar/index.astro b/src/components/Avatar/index.astro
index f2f3ef6c8..e4438a06d 100644
--- a/src/components/Avatar/index.astro
+++ b/src/components/Avatar/index.astro
@@ -13,7 +13,7 @@
*/
import { Image } from 'astro:assets'
-import { AvatarManager, normalizeNameToFilename } from './server'
+import { AvatarManager, normalizeNameToFilename } from '@components/Avatar/server'
export interface Props {
/** The person's name (will be normalized for filename lookup) */
diff --git a/src/components/Brand/index.astro b/src/components/Brand/index.astro
index ae7d209e6..ca4fd2c96 100644
--- a/src/components/Brand/index.astro
+++ b/src/components/Brand/index.astro
@@ -2,9 +2,9 @@
/**
* Brand identity with logo and wordmark, used in the Header component
*/
-import Logo from './Logo.astro'
-import WordmarkBuilders from './WordmarkBuilders.astro'
-import WordmarkWebstack from './WordmarkWebstack.astro'
+import Logo from '@components/Brand/Logo.astro'
+import WordmarkBuilders from '@components/Brand/WordmarkBuilders.astro'
+import WordmarkWebstack from '@components/Brand/WordmarkWebstack.astro'
---
{
describe('Page Title Override', () => {
diff --git a/src/components/Breadcrumbs/index.astro b/src/components/Breadcrumbs/index.astro
index b36a31259..0b9d07da9 100644
--- a/src/components/Breadcrumbs/index.astro
+++ b/src/components/Breadcrumbs/index.astro
@@ -1,5 +1,5 @@
---
-import { generateBreadcrumbs } from './server'
+import { generateBreadcrumbs } from '@components/Breadcrumbs/server'
export interface Props {
/** The current URL path */
diff --git a/src/components/Button/index.astro b/src/components/Button/index.astro
index 52bf35004..fabe94b83 100644
--- a/src/components/Button/index.astro
+++ b/src/components/Button/index.astro
@@ -1,7 +1,7 @@
---
-import type { ButtonProps } from './server'
-import { baseClasses, sizeClasses, variantClasses } from './server'
-import Sprite from '../Sprite/index.astro'
+import type { ButtonProps } from '@components/Button/server'
+import { baseClasses, sizeClasses, variantClasses } from '@components/Button/server'
+import Sprite from '@components/Sprite/index.astro'
type Props = ButtonProps
diff --git a/src/components/Button/server.ts b/src/components/Button/server.ts
index b9b787705..cdeb947ef 100644
--- a/src/components/Button/server.ts
+++ b/src/components/Button/server.ts
@@ -1,7 +1,7 @@
/**
- * Button component types and exports
+ * Server-side utility functions for Button component
*/
-import type { SpriteName } from '../Sprite/sprites'
+import type { SpriteName } from '@components/Sprite/sprites'
export type ButtonVariant = 'primary' | 'secondary' | 'twitter' | 'success' | 'warning' | 'icon'
export type ButtonSize = 'small' | 'medium' | 'large'
diff --git a/src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts b/src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts
index 54d352a9a..ec010b4ed 100644
--- a/src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts
+++ b/src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts
@@ -3,7 +3,8 @@
* Uses the actual Newsletter/index.astro component instead of hard-coded HTML
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
-import NewsletterComponent from '../index.astro'
+
+import NewsletterComponent from '@components/CallToAction/Newsletter/index.astro'
export interface NewsletterProps {
title?: string
diff --git a/src/components/CallToAction/Newsletter/__tests__/client.spec.ts b/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
index 3ee699ce1..62e6f4ee1 100644
--- a/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
+++ b/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
@@ -3,8 +3,8 @@
* Tests for Newsletter form component using Container API pattern with happy-dom
*/
import { beforeAll, beforeEach, describe, expect, test, vi, afterEach } from 'vitest'
-import { NewsletterForm } from '../client'
-import { setupNewsletterDOM, getFormElements } from '../__fixtures__/newsletter.fixture'
+import { NewsletterForm } from '@components/CallToAction/Newsletter/client'
+import { setupNewsletterDOM, getFormElements } from '@components/CallToAction/Newsletter/__fixtures__/newsletter.fixture'
// Mock fetch for API testing
const mockFetch = vi.fn()
diff --git a/src/components/CallToAction/Newsletter/client.ts b/src/components/CallToAction/Newsletter/client.ts
index caf1e5901..c5934b736 100644
--- a/src/components/CallToAction/Newsletter/client.ts
+++ b/src/components/CallToAction/Newsletter/client.ts
@@ -3,9 +3,9 @@
* Manages form validation, submission, and UI state changes
*/
-import { LoadableScript, type TriggerEvent } from '@components/Scripts/loader/@types/loader'
-import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
* Newsletter form component using LoadableScript pattern with instance-specific approach
diff --git a/src/components/CallToAction/Newsletter/index.astro b/src/components/CallToAction/Newsletter/index.astro
index c1728be22..6060e2ecc 100644
--- a/src/components/CallToAction/Newsletter/index.astro
+++ b/src/components/CallToAction/Newsletter/index.astro
@@ -147,8 +147,8 @@ const {
diff --git a/src/components/ContactForm/__tests__/email.spec.ts b/src/components/ContactForm/__tests__/email.spec.ts
index ea3e4bcf7..a7fe8dafd 100644
--- a/src/components/ContactForm/__tests__/email.spec.ts
+++ b/src/components/ContactForm/__tests__/email.spec.ts
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { initEmailValidationHandler, emailInputElementValidator } from '../email'
-import type { ContactFormSelectors } from '../selectors'
+import { initEmailValidationHandler, emailInputElementValidator } from '@components/ContactForm/email'
+import type { ContactFormSelectors } from '@components/ContactForm/selectors'
describe('Email Validation', () => {
let mockSelector: ContactFormSelectors
diff --git a/src/components/ContactForm/__tests__/error.spec.ts b/src/components/ContactForm/__tests__/error.spec.ts
index 85872e1b2..f02becc7c 100644
--- a/src/components/ContactForm/__tests__/error.spec.ts
+++ b/src/components/ContactForm/__tests__/error.spec.ts
@@ -14,7 +14,7 @@ import {
messageWarnLength,
messageMaxLength,
nameMaxLength,
-} from '../error'
+} from '@components/ContactForm/error'
describe('Error Messages and Validation', () => {
describe('Message Length Validation', () => {
diff --git a/src/components/ContactForm/__tests__/message.spec.ts b/src/components/ContactForm/__tests__/message.spec.ts
index 8c9f4b70c..c7a927573 100644
--- a/src/components/ContactForm/__tests__/message.spec.ts
+++ b/src/components/ContactForm/__tests__/message.spec.ts
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { initMssgLengthHandler, messageInputElementValidator } from '../message'
-import type { ContactFormSelectors } from '../selectors'
+import { initMssgLengthHandler, messageInputElementValidator } from '@components/ContactForm/message'
+import type { ContactFormSelectors } from '@components/ContactForm/selectors'
describe('Message Validation', () => {
let mockSelector: ContactFormSelectors
diff --git a/src/components/ContactForm/__tests__/name.spec.ts b/src/components/ContactForm/__tests__/name.spec.ts
index f309ff65e..f70a528e7 100644
--- a/src/components/ContactForm/__tests__/name.spec.ts
+++ b/src/components/ContactForm/__tests__/name.spec.ts
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { initNameLengthHandler, nameInputElementValidator } from '../name'
-import type { ContactFormSelectors } from '../selectors'
+import { initNameLengthHandler, nameInputElementValidator } from '@components/ContactForm/name'
+import type { ContactFormSelectors } from '@components/ContactForm/selectors'
describe('Name Validation', () => {
let mockSelector: ContactFormSelectors
diff --git a/src/components/ContactForm/client.ts b/src/components/ContactForm/client.ts
index fba1e22f4..e8999299e 100644
--- a/src/components/ContactForm/client.ts
+++ b/src/components/ContactForm/client.ts
@@ -4,9 +4,9 @@
* Uses LoadableScript pattern for proper initialization timing
*/
-import { LoadableScript, type TriggerEvent } from '../Scripts/loader/@types/loader'
-import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
export interface ContactFormElements {
form: HTMLFormElement
diff --git a/src/components/ContactForm/email.ts b/src/components/ContactForm/email.ts
index 55f90ee3e..92c789136 100644
--- a/src/components/ContactForm/email.ts
+++ b/src/components/ContactForm/email.ts
@@ -1,8 +1,8 @@
import {
missingEmailAddressText,
invalidEmailAddressText,
- maxLengthEmailAddressText,
minLengthEmailAddressText,
+ maxLengthEmailAddressText,
} from './error'
import type { ContactFormSelectors } from './selectors'
diff --git a/src/components/ContactForm/index.astro b/src/components/ContactForm/index.astro
index 778eac74d..5f9800a24 100644
--- a/src/components/ContactForm/index.astro
+++ b/src/components/ContactForm/index.astro
@@ -429,8 +429,8 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
diff --git a/src/components/Cookies/Consent/selectors.ts b/src/components/Cookies/Consent/selectors.ts
index 69478c82b..eb801ee41 100644
--- a/src/components/Cookies/Consent/selectors.ts
+++ b/src/components/Cookies/Consent/selectors.ts
@@ -4,8 +4,8 @@
import {
isButtonElement,
isDivElement,
-} from '@components/Scripts/assertions/elements'
-import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
+} from '@components/scripts/assertions/elements'
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
/** Gets the HTMLDivElement wrapping the cookie consent modal */
export const getCookieConsentWrapper = () => {
diff --git a/src/components/Cookies/Consent/state.ts b/src/components/Cookies/Consent/state.ts
index bedb372b9..4e52c28f0 100644
--- a/src/components/Cookies/Consent/state.ts
+++ b/src/components/Cookies/Consent/state.ts
@@ -1,6 +1,6 @@
/**
* Cookie modal visibility state
*/
-import { $cookieModalVisible } from '@components/Scripts/store'
+import { $cookieModalVisible } from '@components/scripts/store'
export { $cookieModalVisible }
diff --git a/src/components/Cookies/Customize/__tests__/client.spec.ts b/src/components/Cookies/Customize/__tests__/client.spec.ts
index 5b8a4b274..6f68bf521 100644
--- a/src/components/Cookies/Customize/__tests__/client.spec.ts
+++ b/src/components/Cookies/Customize/__tests__/client.spec.ts
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
-import { CookieCustomize } from '../client'
-import { AppBootstrap } from '@components/Scripts/bootstrap'
-import { $consent } from '@components/Scripts/store'
+import { CookieCustomize } from '@components/Cookies/Customize/client'
+import { AppBootstrap } from '@components/scripts/bootstrap'
+import { $consent } from '@components/scripts/store'
// Mock document.cookie with proper getter/setter behavior
let cookieStorage: string[] = []
diff --git a/src/components/Cookies/Customize/client.ts b/src/components/Cookies/Customize/client.ts
index 5900e5987..5d4d119d2 100644
--- a/src/components/Cookies/Customize/client.ts
+++ b/src/components/Cookies/Customize/client.ts
@@ -4,11 +4,11 @@
* Now uses centralized state management from lib/state
*/
-import { LoadableScript, type TriggerEvent } from '../../Scripts/loader/@types/loader'
-import { isDivElement, isButtonElement } from '@components/Scripts/assertions/elements'
-import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
-import { addButtonEventListeners } from '@components/Scripts/elementListeners'
-import { updateConsent, $consent } from '@components/Scripts/store'
+import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
+import { isDivElement, isButtonElement } from '@components/scripts/assertions/elements'
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
+import { addButtonEventListeners } from '@components/scripts/elementListeners'
+import { updateConsent, $consent } from '@components/scripts/store'
export interface CookiePreferences {
necessary: boolean
diff --git a/src/components/Cookies/Customize/index.astro b/src/components/Cookies/Customize/index.astro
index b5fa4c4a0..7ef3468ec 100644
--- a/src/components/Cookies/Customize/index.astro
+++ b/src/components/Cookies/Customize/index.astro
@@ -198,8 +198,8 @@ import logoSvg from '@assets/images/site/logo.svg'
diff --git a/src/components/Footer/selectors.ts b/src/components/Footer/selectors.ts
index fcd57b774..0ecbe6b11 100644
--- a/src/components/Footer/selectors.ts
+++ b/src/components/Footer/selectors.ts
@@ -1,7 +1,7 @@
/**
* Type-safe HTML element selectors
*/
-import { isAnchorElement } from '@components/Scripts/assertions/elements'
+import { isAnchorElement } from '@components/scripts/assertions/elements'
export const SELECTORS = {
/** "Hire Me" block with current month and year set by script */
diff --git a/src/components/Forms/Download/__tests__/client.spec.ts b/src/components/Forms/Download/__tests__/client.spec.ts
index 306e408a8..d2286947f 100644
--- a/src/components/Forms/Download/__tests__/client.spec.ts
+++ b/src/components/Forms/Download/__tests__/client.spec.ts
@@ -4,8 +4,8 @@
*/
import { describe, expect, test, beforeEach, vi } from 'vitest'
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
-import { DownloadForm } from '../client'
-import DownloadFormComponent from '../index.astro'
+import { DownloadForm } from '@components/Forms/Download/client'
+import DownloadFormComponent from '@components/Forms/Download/index.astro'
// Mock the logger to suppress error output in tests
vi.mock('@lib/logger', () => ({
diff --git a/src/components/Forms/Download/__tests__/selectors.spec.ts b/src/components/Forms/Download/__tests__/selectors.spec.ts
index a48660dc3..34b7bf112 100644
--- a/src/components/Forms/Download/__tests__/selectors.spec.ts
+++ b/src/components/Forms/Download/__tests__/selectors.spec.ts
@@ -8,14 +8,14 @@ import {
isFormElement,
isButtonElement,
isDivElement,
-} from '@components/Scripts/assertions/elements'
+} from '@components/scripts/assertions/elements'
import {
getDownloadFormElement,
getDownloadSubmitButton,
getDownloadStatusDiv,
getDownloadButtonWrapper,
-} from '../selectors'
-import DownloadFormComponent from '../index.astro'
+} from '@components/Forms/Download/selectors'
+import DownloadFormComponent from '@components/Forms/Download/index.astro'
/**
* Helper function to set up DOM from Container API
diff --git a/src/components/Forms/Download/client.ts b/src/components/Forms/Download/client.ts
index d9ef0a5f2..248e39c6d 100644
--- a/src/components/Forms/Download/client.ts
+++ b/src/components/Forms/Download/client.ts
@@ -1,4 +1,4 @@
-import { LoadableScript, type TriggerEvent } from '@components/Scripts/loader/@types/loader'
+import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { logger } from '@lib/logger'
import {
getDownloadFormElement,
@@ -6,8 +6,8 @@ import {
getDownloadStatusDiv,
getDownloadButtonWrapper,
} from './selectors'
-import { ClientScriptError } from '@components/Scripts/errors/ClientScriptError'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
* DownloadForm component using LoadableScript pattern
diff --git a/src/components/Forms/Download/index.astro b/src/components/Forms/Download/index.astro
index c3d25491b..1b8e4fe44 100644
--- a/src/components/Forms/Download/index.astro
+++ b/src/components/Forms/Download/index.astro
@@ -198,8 +198,8 @@ const downloadUrl = `/downloads/${fileName}`
\ No newline at end of file
diff --git a/src/components/Hero/__tests__/client.spec.ts b/src/components/Hero/__tests__/client.spec.ts
index a10240dcf..17cbfe409 100644
--- a/src/components/Hero/__tests__/client.spec.ts
+++ b/src/components/Hero/__tests__/client.spec.ts
@@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, vi, afterEach, type Mock } from 'vite
import { JSDOM } from 'jsdom'
import { readFileSync } from 'fs'
import { resolve } from 'path'
-import { HeroLoader } from '../client'
-import { registerScript, resetDelayedLoader } from '../../Scripts/loader'
+import { HeroLoader } from '@components/Hero/client'
+import { registerScript, resetDelayedLoader } from '@components/scripts/loader'
import { gsap } from 'gsap'
// Setup function for DOM with hero SVG
@@ -33,7 +33,7 @@ vi.mock('gsap', () => ({
},
}))
-vi.mock('../../Scripts/loader', () => {
+vi.mock('@components/scripts/loader', () => {
class LoadableScript {
static scriptName = 'MockLoadableScript'
static eventType = 'astro:page-load'
diff --git a/src/components/Hero/client.ts b/src/components/Hero/client.ts
index 005dc023e..9f72314e5 100644
--- a/src/components/Hero/client.ts
+++ b/src/components/Hero/client.ts
@@ -1,7 +1,7 @@
-import { LoadableScript, type TriggerEvent } from '../Scripts/loader'
+import { LoadableScript, type TriggerEvent } from '@components/scripts/loader'
import { gsap } from 'gsap'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
-import { onScriptEvent, ScriptEvent } from '@components/Scripts/events'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
+import { onScriptEvent, ScriptEvent } from '@components/scripts/events'
type Timeline = ReturnType
diff --git a/src/components/Hero/default/content.astro b/src/components/Hero/default/content.astro
index 02654578a..8972dd970 100644
--- a/src/components/Hero/default/content.astro
+++ b/src/components/Hero/default/content.astro
@@ -2,8 +2,8 @@
/**
* Hero Content Column - Default Variant
*/
-import Sprite from '../../Sprite/index.astro'
-import type { ContentProps as Props } from '../@types/content'
+import Sprite from '@components/Sprite/index.astro'
+import type { ContentProps as Props } from '@components/Hero/@types/content'
export type { Props }
diff --git a/src/components/Hero/default/ctaLinks.astro b/src/components/Hero/default/ctaLinks.astro
index 28bd89ea1..d028c3093 100644
--- a/src/components/Hero/default/ctaLinks.astro
+++ b/src/components/Hero/default/ctaLinks.astro
@@ -2,7 +2,7 @@
/**
* Hero CTA Links - Default Variant
*/
-import type { CtaProps as Props } from '../@types/ctaLinks'
+import type { CtaProps as Props } from '@components/Hero/@types/ctaLinks'
export type { Props }
diff --git a/src/components/Hero/default/image.astro b/src/components/Hero/default/image.astro
index 0bf733700..087f1ff56 100644
--- a/src/components/Hero/default/image.astro
+++ b/src/components/Hero/default/image.astro
@@ -3,7 +3,7 @@
* Hero Image Column - Default Variant
*/
import { Image as AstroImage } from 'astro:assets'
-import type { ImageProps as Props } from '../@types/image'
+import type { ImageProps as Props } from '@components/Hero/@types/image'
export type { Props }
diff --git a/src/components/Hero/home/content.astro b/src/components/Hero/home/content.astro
index ae1d2d3be..c20ce92c8 100644
--- a/src/components/Hero/home/content.astro
+++ b/src/components/Hero/home/content.astro
@@ -2,8 +2,8 @@
/**
* Hero Content Column - Home Variant
*/
-import Sprite from '../../Sprite/index.astro'
-import type { HomeContentProps as Props } from '../@types/content'
+import Sprite from '@components/Sprite/index.astro'
+import type { HomeContentProps as Props } from '@components/Hero/@types/content'
export type { Props }
diff --git a/src/components/Hero/home/ctaLinks.astro b/src/components/Hero/home/ctaLinks.astro
index 2912ebd36..236012171 100644
--- a/src/components/Hero/home/ctaLinks.astro
+++ b/src/components/Hero/home/ctaLinks.astro
@@ -2,7 +2,7 @@
/**
* Hero CTA Links - Home Variant
*/
-import type { CtaProps as Props } from '../@types/ctaLinks'
+import type { CtaProps as Props } from '@components/Hero/@types/ctaLinks'
export type { Props }
diff --git a/src/components/Hero/home/image.astro b/src/components/Hero/home/image.astro
index 611d7be6d..2fcd06672 100644
--- a/src/components/Hero/home/image.astro
+++ b/src/components/Hero/home/image.astro
@@ -13,8 +13,8 @@ import heroSvg from '@assets/images/site/hero.svg?raw'
diff --git a/src/components/Highlighter/selectors.ts b/src/components/Highlighter/selectors.ts
index f42db8f15..bc5a54099 100644
--- a/src/components/Highlighter/selectors.ts
+++ b/src/components/Highlighter/selectors.ts
@@ -6,7 +6,7 @@ import {
isDivElement,
isHtmlElement,
isSlotElement,
-} from '@components/Scripts/assertions/elements'
+} from '@components/scripts/assertions/elements'
export const queryDocument = (selector: string): Element => {
diff --git a/src/components/Navigation/Menu.astro b/src/components/Navigation/Menu.astro
index e39a1a4a7..bf8743ac4 100644
--- a/src/components/Navigation/Menu.astro
+++ b/src/components/Navigation/Menu.astro
@@ -1,5 +1,5 @@
---
-import { navigationItems } from './server'
+import { navigationItems } from '@components/Navigation/server'
export interface Props {
path: string
}
@@ -269,8 +269,8 @@ const activeMenuItem = (url: string) => {
\ No newline at end of file
diff --git a/src/components/Social/Highlighter/__tests__/client.spec.ts b/src/components/Social/Highlighter/__tests__/client.spec.ts
index 7a3f5e69e..08783b03e 100644
--- a/src/components/Social/Highlighter/__tests__/client.spec.ts
+++ b/src/components/Social/Highlighter/__tests__/client.spec.ts
@@ -3,22 +3,24 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
// Mock the common utilities BEFORE importing
-vi.mock('../../common/platforms', async () => {
- const actual =
- await vi.importActual('../../common/platforms')
- return {
- ...actual,
- copyToClipboard: vi.fn().mockResolvedValue(true),
- nativeShare: vi.fn().mockResolvedValue(false),
- }
-})
+vi.mock('@components/scripts/errors', () => ({
+ handleScriptError: vi.fn(),
+ addScriptBreadcrumb: vi.fn(),
+}))
+
+vi.mock('@components/Social/common/platforms', () => ({
+ copyToClipboard: vi.fn(),
+ nativeShare: vi.fn(),
+ getPlatform: vi.fn(),
+ platforms: [],
+}))
-import { Highlighter } from '../client'
-import { copyToClipboard, nativeShare } from '../../common/platforms'
+import { Highlighter } from '@components/Social/Highlighter/client'
+import { copyToClipboard, nativeShare } from '@components/Social/common/platforms'
// Cast mocked functions for assertions
-const mockCopyToClipboard = copyToClipboard as ReturnType
-const mockNativeShare = nativeShare as ReturnType
+const mockCopyToClipboard = vi.mocked(copyToClipboard)
+const mockNativeShare = vi.mocked(nativeShare)
describe('Highlighter LoadableScript', () => {
beforeEach(() => {
diff --git a/src/components/Social/Highlighter/client.ts b/src/components/Social/Highlighter/client.ts
index f803df02a..ec6b5d6c2 100644
--- a/src/components/Social/Highlighter/client.ts
+++ b/src/components/Social/Highlighter/client.ts
@@ -4,13 +4,13 @@
* Uses LoadableScript pattern for optimized loading
*/
-import { LoadableScript } from '@components/Scripts/loader'
+import { LoadableScript } from '@components/scripts/loader'
import type { ShareData } from '@components/Social/common'
import { platforms, copyToClipboard, nativeShare } from '@components/Social/common'
import { MastodonModal } from '@components/Social/Mastodon/client'
-import { getSlotElement } from './selectors'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
-import { addButtonEventListeners } from '@components/Scripts/elementListeners'
+import { getSlotElement } from '@components/Social/Highlighter/selectors'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
+import { addButtonEventListeners } from '@components/scripts/elementListeners'
/**
* Highlighter element that creates a shareable text highlight
diff --git a/src/components/Social/Highlighter/selectors.ts b/src/components/Social/Highlighter/selectors.ts
index cf630ab65..4839f3396 100644
--- a/src/components/Social/Highlighter/selectors.ts
+++ b/src/components/Social/Highlighter/selectors.ts
@@ -6,7 +6,7 @@ import {
isDivElement,
isHtmlElement,
isSlotElement,
-} from '@components/Scripts/assertions/elements'
+} from '@components/scripts/assertions/elements'
export const queryDocument = (selector: string): Element => {
const element = document.querySelector(selector)
diff --git a/src/components/Social/Mastodon/Modal.astro b/src/components/Social/Mastodon/Modal.astro
index 71af73f95..6e509d016 100644
--- a/src/components/Social/Mastodon/Modal.astro
+++ b/src/components/Social/Mastodon/Modal.astro
@@ -148,8 +148,8 @@ const { id = 'mastodon-modal' } = Astro.props
diff --git a/src/components/Social/Mastodon/__tests__/config.spec.ts b/src/components/Social/Mastodon/__tests__/config.spec.ts
index 75ca55c28..b51ef1246 100644
--- a/src/components/Social/Mastodon/__tests__/config.spec.ts
+++ b/src/components/Social/Mastodon/__tests__/config.spec.ts
@@ -8,7 +8,7 @@
*/
import { describe, it, expect } from 'vitest'
-import { mastodonConfig, buildShareUrl } from '../config'
+import { mastodonConfig, buildShareUrl } from '@components/Social/Mastodon/config'
describe('Mastodon Configuration', () => {
describe('mastodonConfig', () => {
diff --git a/src/components/Social/Mastodon/__tests__/detector.spec.ts b/src/components/Social/Mastodon/__tests__/detector.spec.ts
index c7e622bc7..64670355a 100644
--- a/src/components/Social/Mastodon/__tests__/detector.spec.ts
+++ b/src/components/Social/Mastodon/__tests__/detector.spec.ts
@@ -9,7 +9,7 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { normalizeURL, getUrlDomain, getSoftwareName, isMastodonInstance } from '../detector'
+import { normalizeURL, getUrlDomain, getSoftwareName, isMastodonInstance } from '@components/Social/Mastodon/detector'
// Type alias for mocked fetch function
type MockedFetch = ReturnType
diff --git a/src/components/Social/Mastodon/client.ts b/src/components/Social/Mastodon/client.ts
index b2640407a..09a8e6372 100644
--- a/src/components/Social/Mastodon/client.ts
+++ b/src/components/Social/Mastodon/client.ts
@@ -9,15 +9,15 @@
*/
import { createFocusTrap, type FocusTrap } from 'focus-trap'
-import { LoadableScript, type TriggerEvent } from '@components/Scripts/loader/@types/loader'
+import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { isMastodonInstance, normalizeURL, getUrlDomain } from './detector'
import { buildShareUrl } from './config'
import {
$currentMastodonInstance,
$mastodonInstances,
saveMastodonInstance,
-} from '@components/Scripts/store'
-import { addButtonEventListeners, addWrapperEventListeners } from '@components/Scripts/elementListeners'
+} from '@components/scripts/store'
+import { addButtonEventListeners, addWrapperEventListeners } from '@components/scripts/elementListeners'
import {
getModalElement,
getBackdropElement,
@@ -32,7 +32,7 @@ import {
getSavedInstancesContainer,
getSavedInstancesList,
} from './selectors'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
* Custom event detail for modal open
diff --git a/src/components/Social/Mastodon/selectors.ts b/src/components/Social/Mastodon/selectors.ts
index 9b519075c..c55734a16 100644
--- a/src/components/Social/Mastodon/selectors.ts
+++ b/src/components/Social/Mastodon/selectors.ts
@@ -10,7 +10,7 @@ import {
isDivElement,
isFormElement,
isInputElement,
-} from '@components/Scripts/assertions/elements'
+} from '@components/scripts/assertions/elements'
export const SELECTORS = {
/** Main modal container */
diff --git a/src/components/Social/Shares/Shares.astro b/src/components/Social/Shares/Shares.astro
index 064f002c7..018310522 100644
--- a/src/components/Social/Shares/Shares.astro
+++ b/src/components/Social/Shares/Shares.astro
@@ -2,9 +2,9 @@
/**
* Social media sharing component with buttons for various platforms
*/
-import Sprite from '../../Sprite/index.astro'
-import type { SpriteName } from '../../Sprite/sprites'
-import { platforms, type ShareData } from '../common'
+import Sprite from '@components/Sprite/index.astro'
+import type { SpriteName } from '@components/Sprite/sprites'
+import { platforms, type ShareData } from '@components/Social/common'
export interface Props {
/** Page URL to share */
@@ -122,8 +122,8 @@ const containerClasses = [
\ No newline at end of file
diff --git a/src/components/Social/Shares/__tests__/client.spec.ts b/src/components/Social/Shares/__tests__/client.spec.ts
index edff68e7a..57c7910b5 100644
--- a/src/components/Social/Shares/__tests__/client.spec.ts
+++ b/src/components/Social/Shares/__tests__/client.spec.ts
@@ -5,7 +5,7 @@
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
-import { setupSocialShareDOM } from './testHelper'
+import { setupSocialShareDOM } from '@components/Social/Shares/__tests__/testHelper'
// Mock gtag function
const mockGtag = vi.fn()
diff --git a/src/components/Social/Shares/client.ts b/src/components/Social/Shares/client.ts
index 906eb39a9..60dac57c9 100644
--- a/src/components/Social/Shares/client.ts
+++ b/src/components/Social/Shares/client.ts
@@ -4,8 +4,8 @@
* Uses LoadableScript pattern for optimized loading
*/
-import { LoadableScript } from '@components/Scripts/loader'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
+import { LoadableScript } from '@components/scripts/loader'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
* Handle click event for share button
diff --git a/src/components/Sprite/index.astro b/src/components/Sprite/index.astro
index 0165561e2..3ae3ef697 100644
--- a/src/components/Sprite/index.astro
+++ b/src/components/Sprite/index.astro
@@ -4,7 +4,7 @@
* Refactored to use Tailwind classes instead of CSS classes.
*/
import { Icon } from 'astro-icon/components'
-import type { SpriteName } from './sprites'
+import type { SpriteName } from '@components/Sprite/sprites'
export interface Props {
class?: string
diff --git a/src/components/Test/__tests__/container.spec.ts b/src/components/Test/__tests__/container.spec.ts
index 65c710aed..d62cea337 100644
--- a/src/components/Test/__tests__/container.spec.ts
+++ b/src/components/Test/__tests__/container.spec.ts
@@ -12,7 +12,7 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import { JSDOM } from 'jsdom'
-import ContainerComponent from '../container.astro'
+import ContainerComponent from '@components/Test/container.astro'
describe('Container API Reference Implementation', () => {
let container: AstroContainer
diff --git a/src/components/Testimonials/__fixtures__/domHelpers.ts b/src/components/Testimonials/__fixtures__/domHelpers.ts
index fc110543c..ee4548de5 100644
--- a/src/components/Testimonials/__fixtures__/domHelpers.ts
+++ b/src/components/Testimonials/__fixtures__/domHelpers.ts
@@ -3,7 +3,7 @@
* Creates minimal DOM structure needed for testing testimonials carousel JavaScript behavior
*/
-import { mockTestimonials, type TestimonialData } from './mockData'
+import { mockTestimonials, type TestimonialData } from '@components/Testimonials/__fixtures__/mockData'
/**
* Generates a single testimonial card HTML
diff --git a/src/components/Testimonials/__tests__/client.spec.ts b/src/components/Testimonials/__tests__/client.spec.ts
index f55d3889b..a3a3bf996 100644
--- a/src/components/Testimonials/__tests__/client.spec.ts
+++ b/src/components/Testimonials/__tests__/client.spec.ts
@@ -5,14 +5,14 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
-import { TestimonialsCarousel } from '../client'
+import { TestimonialsCarousel } from '@components/Testimonials/client'
import {
setupTestimonialsCarouselDOM,
cleanupTestimonialsCarouselDOM,
getDOMElements,
userInteractions,
-} from '../__fixtures__/domHelpers'
-import { mockTestimonials } from '../__fixtures__/mockData'
+} from '@components/Testimonials/__fixtures__/domHelpers'
+import { mockTestimonials } from '@components/Testimonials/__fixtures__/mockData'
// Mock embla-carousel
const mockScrollPrev = vi.fn()
diff --git a/src/components/Testimonials/client.ts b/src/components/Testimonials/client.ts
index 6d415ab40..aa284b804 100644
--- a/src/components/Testimonials/client.ts
+++ b/src/components/Testimonials/client.ts
@@ -6,9 +6,9 @@
import EmblaCarousel, { type EmblaOptionsType, type EmblaCarouselType } from 'embla-carousel'
import Autoplay from 'embla-carousel-autoplay'
-import { LoadableScript, type TriggerEvent } from '../Scripts/loader/@types/loader'
-import { handleScriptError, addScriptBreadcrumb } from '@components/Scripts/errors'
-import { addButtonEventListeners } from '@components/Scripts/elementListeners'
+import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
+import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
+import { addButtonEventListeners } from '@components/scripts/elementListeners'
/**
* Testimonials carousel using LoadableScript pattern with instance-specific approach
diff --git a/src/components/Testimonials/index.astro b/src/components/Testimonials/index.astro
index e962e27bf..998af13df 100644
--- a/src/components/Testimonials/index.astro
+++ b/src/components/Testimonials/index.astro
@@ -123,8 +123,8 @@ const testimonials = allTestimonials
}
-
\ No newline at end of file
diff --git a/src/components/scripts/bootstrap/__tests__/index.spec.ts b/src/components/scripts/bootstrap/__tests__/index.spec.ts
index 1b45633d2..433e5159b 100644
--- a/src/components/scripts/bootstrap/__tests__/index.spec.ts
+++ b/src/components/scripts/bootstrap/__tests__/index.spec.ts
@@ -19,6 +19,7 @@ vi.mock('@components/scripts/store', () => ({
// Mock store modules that are imported by bootstrap
vi.mock('@components/scripts/consent', () => ({
initConsentSideEffects: vi.fn(),
+ initStateSideEffects: vi.fn(),
}))
vi.mock('@components/scripts/theme', () => ({
@@ -38,7 +39,7 @@ vi.mock('@components/scripts/errors', () => ({
// Import the mocked functions after setting up mocks
import { initConsentFromCookies } from '@components/scripts/store'
-import { initConsentSideEffects } from '@components/scripts/consent'
+import { initConsentSideEffects, initStateSideEffects } from '@components/scripts/consent'
import { initThemeSideEffects } from '@components/scripts/theme'
describe('AppBootstrap', () => {
@@ -51,18 +52,21 @@ describe('AppBootstrap', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initStateSideEffects).mockReturnValue(undefined)
AppBootstrap.init()
expect(initConsentFromCookies).toHaveBeenCalledTimes(1)
expect(initConsentSideEffects).toHaveBeenCalledTimes(1)
expect(initThemeSideEffects).toHaveBeenCalledTimes(1)
+ expect(initStateSideEffects).toHaveBeenCalledTimes(1)
})
it('should add breadcrumbs for successful initialization', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initStateSideEffects).mockReturnValue(undefined)
AppBootstrap.init()
@@ -74,6 +78,14 @@ describe('AppBootstrap', () => {
scriptName: 'AppBootstrap',
operation: 'initConsentFromCookies'
})
+ expect(addScriptBreadcrumb).toHaveBeenCalledWith({
+ scriptName: 'AppBootstrap',
+ operation: 'initConsentSideEffects'
+ })
+ expect(addScriptBreadcrumb).toHaveBeenCalledWith({
+ scriptName: 'AppBootstrap',
+ operation: 'initThemeSideEffects'
+ })
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
scriptName: 'AppBootstrap',
operation: 'initStateSideEffects'
@@ -84,6 +96,7 @@ describe('AppBootstrap', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initStateSideEffects).mockReturnValue(undefined)
expect(() => AppBootstrap.init()).not.toThrow()
})
@@ -128,6 +141,7 @@ describe('AppBootstrap', () => {
})
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initStateSideEffects).mockReturnValue(undefined)
try {
AppBootstrap.init()
@@ -137,6 +151,7 @@ describe('AppBootstrap', () => {
expect(initConsentSideEffects).not.toHaveBeenCalled()
expect(initThemeSideEffects).not.toHaveBeenCalled()
+ expect(initStateSideEffects).not.toHaveBeenCalled()
})
it('should wrap non-Error objects in ClientScriptError', () => {
@@ -159,6 +174,7 @@ describe('AppBootstrap', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initStateSideEffects).mockReturnValue(undefined)
AppBootstrap.init()
AppBootstrap.init()
@@ -166,6 +182,7 @@ describe('AppBootstrap', () => {
expect(initConsentFromCookies).toHaveBeenCalledTimes(2)
expect(initConsentSideEffects).toHaveBeenCalledTimes(2)
expect(initThemeSideEffects).toHaveBeenCalledTimes(2)
+ expect(initStateSideEffects).toHaveBeenCalledTimes(2)
})
})
})
diff --git a/src/components/scripts/bootstrap/index.ts b/src/components/scripts/bootstrap/index.ts
index a5169ec30..b3914fac1 100644
--- a/src/components/scripts/bootstrap/index.ts
+++ b/src/components/scripts/bootstrap/index.ts
@@ -4,57 +4,43 @@
* This MUST run before any other scripts that depend on state
*/
import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
-import { addScriptBreadcrumb, handleScriptError } from '@components/scripts/errors'
-import { $hasFunctionalConsent, initConsentFromCookies } from '@components/scripts/store'
-import { initConsentSideEffects } from '@components/scripts/consent'
-import { initThemeSideEffects } from '@components/scripts/theme'
-import { clearEmbedCache } from '@components/scripts/store/socialEmbeds'
-
-/**
- * Setup side effects - call once during app initialization
- * This is like Redux middleware or RTK's createAsyncThunk
- */
-function initStateSideEffects(): void {
- // Initialize all module-specific side effects
- initConsentSideEffects()
- initThemeSideEffects()
-
- // Side Effect: Clear localStorage when functional consent is revoked
- $hasFunctionalConsent.subscribe((hasConsent) => {
- if (!hasConsent) {
- try {
- // Clear theme from localStorage
- localStorage.removeItem('theme')
-
- // Clear Mastodon instances from localStorage
- localStorage.removeItem('mastodonInstances')
- localStorage.removeItem('mastodonCurrentInstance')
-
- // Clear embed cache
- clearEmbedCache()
- } catch (error) {
- handleScriptError(error, {
- scriptName: 'AppBootstrap',
- operation: 'clearLocalStorageOnConsentRevoke',
- })
- }
- }
- })
-}
+import { addScriptBreadcrumb } from '@components/scripts/errors'
+import { initConsentFromCookies } from '@components/scripts/store'
+import { initConsentSideEffects, initStateSideEffects } from '@components/scripts/consent'
+import { initThemeSideEffects, setInitialTheme } from '@components/scripts/theme'
+import { SentryBootstrap } from '@components/scripts/sentry/client'
+import { PUBLIC_SENTRY_DSN } from 'astro:env/client'
export class AppBootstrap {
static init(): void {
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'init' })
try {
+ /** Script to set theme name on . This should be inlined by Astro so */
+ /** that it runs first. Do not add other scripts in this
diff --git a/src/components/Cookies/Consent/selectors.ts b/src/components/Consent/Banner/selectors.ts
similarity index 100%
rename from src/components/Cookies/Consent/selectors.ts
rename to src/components/Consent/Banner/selectors.ts
diff --git a/src/components/Cookies/Consent/state.ts b/src/components/Consent/Banner/state.ts
similarity index 100%
rename from src/components/Cookies/Consent/state.ts
rename to src/components/Consent/Banner/state.ts
diff --git a/src/components/Cookies/OVERVIEW.md b/src/components/Consent/OVERVIEW.md
similarity index 93%
rename from src/components/Cookies/OVERVIEW.md
rename to src/components/Consent/OVERVIEW.md
index 9d03e35e0..f8090a9a2 100644
--- a/src/components/Cookies/OVERVIEW.md
+++ b/src/components/Consent/OVERVIEW.md
@@ -2,7 +2,7 @@
## How the Components Work Together
-### 1. Cookie Consent Modal (`src/components/Cookies/Consent/`)
+### 1. Cookie Consent Banner (`src/components/Consent/Banner/`)
**Purpose:** First-time visitor prompt that appears at the bottom of the page asking for cookie consent.
@@ -17,8 +17,8 @@
**User Actions:**
-1. **"Allow All" button** → Calls `allowAllConsentCookies()` which sets all 4 consent cookies to `granted`, hides modal
-2. **"Customize" button** → Opens the Customize modal (detailed preference center)
+1. **"Allow All" button** → Calls `allowAllConsentCookies()` which sets all 4 consent cookies to `granted`, hides banner
+2. **"Customize" button** → Opens the Preferences modal (detailed preference center)
3. **Close button** → Just hides the modal without changing consent (keeps `unknown` state)
**State Storage:**
@@ -28,7 +28,7 @@
---
-### 2. Cookie Customize Modal (`src/components/Cookies/Customize/`)
+### 2. Cookie Preferences Modal (`src/components/Consent/Preferences/`)
**Purpose:** Detailed preference center where users can toggle individual cookie categories.
@@ -90,7 +90,7 @@ To prevent cookies from being set when opted out, you need to:
1. **Before loading any analytics/advertising script**, check consent:
```typescript
-import { getConsentCookie } from '@components/Cookies/Consent/cookies'
+import { getConsentCookie } from '@components/Consent/Banner/cookies'
const analyticsConsent = getConsentCookie('analytics')
if (analyticsConsent === 'granted') {
diff --git a/src/components/Cookies/Customize/__tests__/client.spec.ts b/src/components/Consent/Preferences/__tests__/client.spec.ts
similarity index 99%
rename from src/components/Cookies/Customize/__tests__/client.spec.ts
rename to src/components/Consent/Preferences/__tests__/client.spec.ts
index 6f68bf521..cf9e1d0bc 100644
--- a/src/components/Cookies/Customize/__tests__/client.spec.ts
+++ b/src/components/Consent/Preferences/__tests__/client.spec.ts
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
-import { CookieCustomize } from '@components/Cookies/Customize/client'
+import { CookieCustomize } from '@components/Consent/Preferences/client'
import { AppBootstrap } from '@components/scripts/bootstrap'
import { $consent } from '@components/scripts/store'
diff --git a/src/components/Cookies/Customize/client.ts b/src/components/Consent/Preferences/client.ts
similarity index 100%
rename from src/components/Cookies/Customize/client.ts
rename to src/components/Consent/Preferences/client.ts
diff --git a/src/components/Cookies/Customize/index.astro b/src/components/Consent/Preferences/index.astro
similarity index 99%
rename from src/components/Cookies/Customize/index.astro
rename to src/components/Consent/Preferences/index.astro
index 7ef3468ec..16e2afdef 100644
--- a/src/components/Cookies/Customize/index.astro
+++ b/src/components/Consent/Preferences/index.astro
@@ -199,7 +199,7 @@ import logoSvg from '@assets/images/site/logo.svg'
diff --git a/src/components/ContactForm/client.ts b/src/components/ContactForm/client.ts
index e8999299e..eb5d44ffa 100644
--- a/src/components/ContactForm/client.ts
+++ b/src/components/ContactForm/client.ts
@@ -1,10 +1,8 @@
/**
* Contact Form Handler
* Manages form submission, validation, and user interactions for the contact form
- * Uses LoadableScript pattern for proper initialization timing
*/
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
@@ -41,18 +39,16 @@ export interface ContactFormConfig {
}
/**
- * Contact Form component using LoadableScript pattern with instance-specific approach
+ * Contact Form component with instance-specific approach
* Manages form submission, validation, and user interactions
*/
-export class ContactForm extends LoadableScript {
- static override scriptName = 'ContactForm'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class ContactForm {
+ static scriptName = 'ContactForm'
private elements: ContactFormElements | null = null
private config: ContactFormConfig
private constructor() {
- super()
this.config = {
maxCharacters: 2000,
warningThreshold: 1500,
@@ -374,9 +370,9 @@ export class ContactForm extends LoadableScript {
}
/**
- * LoadableScript static methods
+ * Static initialization method
*/
- static override init(): void {
+ static init(): void {
const context = { scriptName: ContactForm.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -391,15 +387,15 @@ export class ContactForm extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
// Contact form doesn't need pause functionality during visibility changes
}
- static override resume(): void {
+ static resume(): void {
// Contact form doesn't need resume functionality during visibility changes
}
- static override reset(): void {
+ static reset(): void {
// Clean up any global state if needed for View Transitions
// Remove any event listeners or reset form state if necessary
}
diff --git a/src/components/ContactForm/index.astro b/src/components/ContactForm/index.astro
index 5f9800a24..79d605e74 100644
--- a/src/components/ContactForm/index.astro
+++ b/src/components/ContactForm/index.astro
@@ -429,9 +429,9 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
diff --git a/src/components/Head/client.ts b/src/components/Head/client.ts
index 61fb09264..62b039a26 100644
--- a/src/components/Head/client.ts
+++ b/src/components/Head/client.ts
@@ -1,18 +1,14 @@
/**
- * Head Theme Setup - LoadableScript Implementation
+ * Head Theme Setup
* Runs early in to avoid flash of un-themed styling (FOUS)
* Sets up theme on document element and initializes metaColors global
*/
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
-
/**
- * HeadThemeSetup component using LoadableScript pattern
- * Initializes theme colors for meta theme-color element
+ * HeadThemeSetup - Initializes theme colors for meta theme-color element
*/
-class HeadThemeSetup extends LoadableScript {
- static override scriptName = 'HeadThemeSetup'
- static override eventType: TriggerEvent = 'astro:page-load'
+class HeadThemeSetup {
+ static scriptName = 'HeadThemeSetup'
/**
* Initialize meta colors global variable
@@ -36,19 +32,19 @@ class HeadThemeSetup extends LoadableScript {
}
}
- static override init(): void {
+ static init(): void {
HeadThemeSetup.initializeMetaColors()
}
- static override pause(): void {
+ static pause(): void {
// No pause functionality needed for HeadThemeSetup
}
- static override resume(): void {
+ static resume(): void {
// No resume functionality needed for HeadThemeSetup
}
- static override reset(): void {
+ static reset(): void {
// No reset functionality needed for HeadThemeSetup
}
}
diff --git a/src/components/Hero/client.ts b/src/components/Hero/client.ts
index d19bc8754..c3b25c09a 100644
--- a/src/components/Hero/client.ts
+++ b/src/components/Hero/client.ts
@@ -1,4 +1,3 @@
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader'
import { gsap } from 'gsap'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
import { onAnimationEvent, AnimationLifecycleEvent } from '@components/scripts/events/animationLifecycle'
@@ -28,9 +27,8 @@ const Anticipate = {
},
} as const
-export class HeroLoader extends LoadableScript {
- static override scriptName = 'HeroLoader'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class HeroLoader {
+ static scriptName = 'HeroLoader'
private static instance: HeroLoader | null = null
private timeline?: Timeline
@@ -38,7 +36,6 @@ export class HeroLoader extends LoadableScript {
private overlayClosedCleanup?: () => void
constructor() {
- super()
HeroLoader.instance = this
}
@@ -400,7 +397,7 @@ export class HeroLoader extends LoadableScript {
}
}
- static override init() {
+ static init() {
const context = { scriptName: HeroLoader.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -430,7 +427,7 @@ export class HeroLoader extends LoadableScript {
}
}
- static override pause() {
+ static pause() {
const context = { scriptName: HeroLoader.scriptName, operation: 'pause' }
addScriptBreadcrumb(context)
@@ -443,7 +440,7 @@ export class HeroLoader extends LoadableScript {
}
}
- static override resume() {
+ static resume() {
const context = { scriptName: HeroLoader.scriptName, operation: 'resume' }
addScriptBreadcrumb(context)
@@ -456,7 +453,7 @@ export class HeroLoader extends LoadableScript {
}
}
- static override reset() {
+ static reset() {
const context = { scriptName: HeroLoader.scriptName, operation: 'reset' }
addScriptBreadcrumb(context)
diff --git a/src/components/Hero/home/image.astro b/src/components/Hero/home/image.astro
index 2fcd06672..0fd347da5 100644
--- a/src/components/Hero/home/image.astro
+++ b/src/components/Hero/home/image.astro
@@ -13,8 +13,9 @@ import heroSvg from '@assets/images/site/hero.svg?raw'
diff --git a/src/components/Navigation/Menu.astro b/src/components/Navigation/Menu.astro
index bf8743ac4..8d883b59b 100644
--- a/src/components/Navigation/Menu.astro
+++ b/src/components/Navigation/Menu.astro
@@ -269,9 +269,9 @@ const activeMenuItem = (url: string) => {
diff --git a/src/components/Navigation/__tests__/navigation.spec.ts b/src/components/Navigation/__tests__/navigation.spec.ts
index b2a310c95..38c396887 100644
--- a/src/components/Navigation/__tests__/navigation.spec.ts
+++ b/src/components/Navigation/__tests__/navigation.spec.ts
@@ -156,7 +156,6 @@ describe(`Toggle button works`, () => {
describe('Navigation LoadableScript implementation', () => {
test('should have correct static properties', () => {
expect(Navigation.scriptName).toBe('Navigation')
- expect(Navigation.eventType).toBe('astro:page-load')
})
test('should initialize navigation when static init is called', async () => {
diff --git a/src/components/Navigation/client.ts b/src/components/Navigation/client.ts
index 9217273b8..b0ef6f016 100644
--- a/src/components/Navigation/client.ts
+++ b/src/components/Navigation/client.ts
@@ -1,7 +1,6 @@
import { createFocusTrap } from 'focus-trap'
import type { FocusTrap } from 'focus-trap'
import { navigate } from 'astro:transitions/client'
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import {
getHeaderElement,
getMobileSplashElement,
@@ -22,12 +21,11 @@ export const CLASSES = {
}
/**
- * Navigation component using LoadableScript pattern with instance-specific approach
+ * Navigation component with instance-specific approach
* Uses Astro View Transitions for navigation
*/
-export class Navigation extends LoadableScript {
- static override scriptName = 'Navigation'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class Navigation {
+ static scriptName = 'Navigation'
focusTrap: FocusTrap
isMenuOpen: boolean
@@ -46,7 +44,6 @@ export class Navigation extends LoadableScript {
togglePosition!: DOMRect
constructor() {
- super()
this.isMenuOpen = false
try {
@@ -203,22 +200,22 @@ export class Navigation extends LoadableScript {
}
/**
- * LoadableScript static methods
+ * Static initialization method
*/
- static override init(): void {
+ static init(): void {
const navigation = new Navigation()
navigation.bindEvents()
}
- static override pause(): void {
+ static pause(): void {
// Navigation doesn't need pause functionality during visibility changes
}
- static override resume(): void {
+ static resume(): void {
// Navigation doesn't need resume functionality during visibility changes
}
- static override reset(): void {
+ static reset(): void {
// Clean up any global state if needed for View Transitions
// Remove any event listeners or reset focus traps if necessary
}
diff --git a/src/components/Testimonials/__tests__/client.spec.ts b/src/components/Testimonials/__tests__/client.spec.ts
index 4807a0886..bf9436cc9 100644
--- a/src/components/Testimonials/__tests__/client.spec.ts
+++ b/src/components/Testimonials/__tests__/client.spec.ts
@@ -64,7 +64,6 @@ describe('TestimonialsCarousel LoadableScript', () => {
describe('LoadableScript implementation', () => {
it('should have correct static properties', () => {
expect(TestimonialsCarousel.scriptName).toBe('TestimonialsCarousel')
- expect(TestimonialsCarousel.eventType).toBe('astro:page-load')
})
it('should implement all LoadableScript methods', () => {
diff --git a/src/components/Testimonials/client.ts b/src/components/Testimonials/client.ts
index fcd895b6d..ffb18413a 100644
--- a/src/components/Testimonials/client.ts
+++ b/src/components/Testimonials/client.ts
@@ -1,29 +1,25 @@
/**
* Testimonials Carousel Setup
* Initializes Embla Carousel for testimonials display
- * Uses LoadableScript pattern for proper initialization timing
*/
import EmblaCarousel, { type EmblaOptionsType, type EmblaCarouselType } from 'embla-carousel'
import Autoplay from 'embla-carousel-autoplay'
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
import { addButtonEventListeners } from '@components/scripts/elementListeners'
/**
- * Testimonials carousel using LoadableScript pattern with instance-specific approach
+ * Testimonials carousel with instance-specific approach
* Uses Embla Carousel with autoplay for testimonials display
*/
-export class TestimonialsCarousel extends LoadableScript {
- static override scriptName = 'TestimonialsCarousel'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class TestimonialsCarousel {
+ static scriptName = 'TestimonialsCarousel'
private emblaNode: HTMLElement | null = null
private viewportNode: HTMLElement | null = null
private emblaApi: EmblaCarouselType | null = null
constructor() {
- super()
this.findElements()
}
@@ -184,9 +180,9 @@ export class TestimonialsCarousel extends LoadableScript {
}
/**
- * LoadableScript static methods
+ * Static initialization method
*/
- static override init(): void {
+ static init(): void {
const context = { scriptName: TestimonialsCarousel.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -199,15 +195,15 @@ export class TestimonialsCarousel extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
// Carousel doesn't need pause functionality during visibility changes
}
- static override resume(): void {
+ static resume(): void {
// Carousel doesn't need resume functionality during visibility changes
}
- static override reset(): void {
+ static reset(): void {
// Clean up any global state if needed for View Transitions
// Embla API will be cleaned up automatically when the DOM changes
}
diff --git a/src/components/Testimonials/index.astro b/src/components/Testimonials/index.astro
index 998af13df..613e8d4b4 100644
--- a/src/components/Testimonials/index.astro
+++ b/src/components/Testimonials/index.astro
@@ -123,9 +123,9 @@ const testimonials = allTestimonials
}
diff --git a/src/components/scripts/bootstrap/consent/NOTES.md b/src/components/scripts/bootstrap/consent/NOTES.md
index a446b3f9f..34dbfa367 100644
--- a/src/components/scripts/bootstrap/consent/NOTES.md
+++ b/src/components/scripts/bootstrap/consent/NOTES.md
@@ -4,21 +4,26 @@
Components using 'astro:page-load':
-- CallToAction/Newsletter
-- Carousel
-- ContactForm
-- Cookies/Consent
-- Cookies/Customize
-- Forms/Download
-- Head
-- Hero
-- Navigation
-- Social/Embed
-- Social/Highlighter
-- Social/Mastodon
-- Social/Shares
-- Testimonials
-- Toasts/NetworkStatus
+Singleton Pattern Components (will refactor now):
+
+✅ CarouselManager
+✅ ContactForm
+✅ Hero
+✅ TestimonialsCarousel
+✅ Navigation
+✅ HeadThemeSetup
+
+Instance Pattern Components (skip for now):
+
+❌ NewsletterForm - Creates instances per form element
+❌ CookieConsent - Instance-based with View Transitions handling
+❌ CookieCustomize - Instance-based
+❌ DownloadForm - Instance-based
+❌ SocialEmbed - Manager with multiple instances
+❌ Highlighter - Web Component
+❌ MastodonModal - Instance-based
+❌ SocialShares - Instance-based
+❌ NetworkStatus - Singleton but uses getInstance pattern
## Categories
diff --git a/src/components/scripts/events/animationLifecycle.ts b/src/components/scripts/events/animationLifecycle.ts
index 78f7063e1..71d02ef1e 100644
--- a/src/components/scripts/events/animationLifecycle.ts
+++ b/src/components/scripts/events/animationLifecycle.ts
@@ -3,6 +3,23 @@
* Controls background animations when overlays/modals open and close
*/
+// @TODO: refactor to pubsub-js
+// @TODO: Use a nanostore backing in a store file. Where does the logic to update the store live? In the event system? This is what will be called by a component that needs to pause or resume animations. But is there a danger of dispatching two events for the same update, if we have an event system and the useStore() hook or .subscribe() to a store for changes?
+
+/*
+Use Nanostores for State Management:
+
+Use Nanostores for data that represents the current state of your application (e.g., shopping cart count, user authentication status, theme setting).
+
+Subscribe to changes: Components can listen() to these stores or use framework-specific hooks (useStore()) to reactively update the UI when the state changes.
+
+Use a dedicated Pub/Sub library for Events:
+
+For transient events or actions that don't represent a persistent state, a dedicated library like pubsub-js or a simple built-in event emitter (or even just callbacks) is a better fit.
+
+Example events: "user logged in", "modal closed", "item dragged", "form submitted successfully". These are actions that happen at a specific point in time, and you only care about notifying active listeners at that moment.
+*/
+
/* eslint-disable no-unused-vars */
export enum AnimationLifecycleEvent {
/** Fired when an overlay/modal opens that should pause background animations */
From 5c1c089eb67c315407394d1b0293a77ede209afa Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Fri, 7 Nov 2025 03:14:25 +0300
Subject: [PATCH 022/341] Refactor NewsletterForm, CookieConsent,
CookieCustomize, and DownloadForm client scripts to not use ClientLoader
---
.../Newsletter/__tests__/client.spec.ts | 1 -
.../CallToAction/Newsletter/client.ts | 19 ++++++++-----------
.../CallToAction/Newsletter/index.astro | 7 ++++---
src/components/Consent/Banner/client.ts | 18 +++++++-----------
src/components/Consent/Banner/index.astro | 7 +++++--
.../Preferences/__tests__/client.spec.ts | 4 ----
src/components/Consent/Preferences/client.ts | 19 ++++++++-----------
.../Consent/Preferences/index.astro | 7 ++++---
src/components/Forms/Download/client.ts | 18 +++++++-----------
src/components/Forms/Download/index.astro | 7 ++++---
.../scripts/bootstrap/consent/NOTES.md | 14 +++++---------
11 files changed, 52 insertions(+), 69 deletions(-)
diff --git a/src/components/CallToAction/Newsletter/__tests__/client.spec.ts b/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
index 62e6f4ee1..3c5b213f4 100644
--- a/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
+++ b/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
@@ -257,7 +257,6 @@ describe('NewsletterForm class works', () => {
describe('NewsletterForm LoadableScript implementation', () => {
test('has correct static properties', () => {
expect(NewsletterForm.scriptName).toBe('NewsletterForm')
- expect(NewsletterForm.eventType).toBe('astro:page-load')
})
test('static init method works', async () => {
diff --git a/src/components/CallToAction/Newsletter/client.ts b/src/components/CallToAction/Newsletter/client.ts
index c5934b736..5868ae3e3 100644
--- a/src/components/CallToAction/Newsletter/client.ts
+++ b/src/components/CallToAction/Newsletter/client.ts
@@ -1,18 +1,16 @@
/**
- * Newsletter subscription form client-side logic using LoadableScript pattern
+ * Newsletter subscription form client-side logic
* Manages form validation, submission, and UI state changes
*/
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
- * Newsletter form component using LoadableScript pattern with instance-specific approach
+ * Newsletter form component with instance-specific approach
*/
-export class NewsletterForm extends LoadableScript {
- static override scriptName = 'NewsletterForm'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class NewsletterForm {
+ static scriptName = 'NewsletterForm'
// Email validation pattern (matches server-side)
private readonly emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -29,7 +27,6 @@ export class NewsletterForm extends LoadableScript {
private originalButtonText = 'Subscribe'
constructor() {
- super()
this.initializeElements()
}
@@ -292,7 +289,7 @@ export class NewsletterForm extends LoadableScript {
/**
* LoadableScript static methods
*/
- static override init(): void {
+ static init(): void {
const context = { scriptName: NewsletterForm.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -304,15 +301,15 @@ export class NewsletterForm extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
// Newsletter form doesn't need pause functionality during visibility changes
}
- static override resume(): void {
+ static resume(): void {
// Newsletter form doesn't need resume functionality during visibility changes
}
- static override reset(): void {
+ static reset(): void {
// Clean up any global state if needed for View Transitions
// This could be enhanced to unbind events if we tracked instances
}
diff --git a/src/components/CallToAction/Newsletter/index.astro b/src/components/CallToAction/Newsletter/index.astro
index 6060e2ecc..b6ed1f1af 100644
--- a/src/components/CallToAction/Newsletter/index.astro
+++ b/src/components/CallToAction/Newsletter/index.astro
@@ -147,9 +147,10 @@ const {
diff --git a/src/components/Consent/Banner/client.ts b/src/components/Consent/Banner/client.ts
index 2874649d7..760e22283 100644
--- a/src/components/Consent/Banner/client.ts
+++ b/src/components/Consent/Banner/client.ts
@@ -1,4 +1,3 @@
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import {
addButtonEventListeners,
addWrapperEventListeners,
@@ -16,12 +15,11 @@ import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
- * Cookie Consent component using LoadableScript pattern with instance-specific approach
+ * Cookie Consent component with instance-specific approach
* Manages the cookie consent modal display and user interactions across View Transitions
*/
-export class CookieConsent extends LoadableScript {
- static override scriptName = 'CookieConsent'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class CookieConsent {
+ static scriptName = 'CookieConsent'
/** Modal wrapper element */
wrapper: HTMLDivElement
@@ -39,8 +37,6 @@ export class CookieConsent extends LoadableScript {
private static viewTransitionsSetup = false
constructor() {
- super()
-
try {
this.wrapper = getCookieConsentWrapper()
this.closeBtn = getCookieConsentCloseBtn()
@@ -320,20 +316,20 @@ export class CookieConsent extends LoadableScript {
/**
* LoadableScript static methods
*/
- static override init(): void {
+ static init(): void {
const cookieConsent = new CookieConsent()
cookieConsent.showModal()
}
- static override pause(): void {
+ static pause(): void {
// Cookie consent doesn't need pause functionality during visibility changes
}
- static override resume(): void {
+ static resume(): void {
// Cookie consent doesn't need resume functionality during visibility changes
}
- static override reset(): void {
+ static reset(): void {
// Preserve modal visibility state during View Transitions
// This method is called before View Transitions swap content
const currentModal = document.getElementById('cookie-modal-id')
diff --git a/src/components/Consent/Banner/index.astro b/src/components/Consent/Banner/index.astro
index c06a4a429..d31605e1d 100644
--- a/src/components/Consent/Banner/index.astro
+++ b/src/components/Consent/Banner/index.astro
@@ -65,7 +65,10 @@ import CookieSvg from '@assets/images/site/cookie.svg'
diff --git a/src/components/Consent/Preferences/__tests__/client.spec.ts b/src/components/Consent/Preferences/__tests__/client.spec.ts
index cf9e1d0bc..d3343325a 100644
--- a/src/components/Consent/Preferences/__tests__/client.spec.ts
+++ b/src/components/Consent/Preferences/__tests__/client.spec.ts
@@ -63,10 +63,6 @@ describe('CookieCustomize', () => {
expect(CookieCustomize.scriptName).toBe('CookieCustomize')
})
- it('should have correct eventType', () => {
- expect(CookieCustomize.eventType).toBe('astro:page-load')
- })
-
it('should initialize without errors when DOM elements exist', () => {
// Create actual DOM elements
const mockModal = mockDiv('cookie-customize-modal-id')
diff --git a/src/components/Consent/Preferences/client.ts b/src/components/Consent/Preferences/client.ts
index 5d4d119d2..7568b2cac 100644
--- a/src/components/Consent/Preferences/client.ts
+++ b/src/components/Consent/Preferences/client.ts
@@ -1,10 +1,9 @@
/**
- * Cookie Preferences Management using LoadableScript pattern
+ * Cookie Preferences Management
* Handles cookie preference functionality and modal interactions
* Now uses centralized state management from lib/state
*/
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { isDivElement, isButtonElement } from '@components/scripts/assertions/elements'
import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
import { addButtonEventListeners } from '@components/scripts/elementListeners'
@@ -19,11 +18,10 @@ export interface CookiePreferences {
}
/**
- * Cookie Preferences component using LoadableScript pattern
+ * Cookie Preferences component
*/
-export class CookieCustomize extends LoadableScript {
- static override scriptName = 'CookieCustomize'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class CookieCustomize {
+ static scriptName = 'CookieCustomize'
private modal: HTMLDivElement
private closeBtn: HTMLButtonElement
@@ -31,7 +29,6 @@ export class CookieCustomize extends LoadableScript {
private saveBtn: HTMLButtonElement | null
constructor() {
- super()
this.modal = this.getCookieCustomizeModal()
this.closeBtn = this.getCookieCustomizeCloseBtn()
this.allowAllBtn = document.getElementById('cookie-allow-all') as HTMLButtonElement
@@ -242,21 +239,21 @@ export class CookieCustomize extends LoadableScript {
/**
* LoadableScript static methods
*/
- static override init(): void {
+ static init(): void {
const cookieCustomize = new CookieCustomize()
cookieCustomize.bindEvents()
cookieCustomize.loadPreferences()
}
- static override pause(): void {
+ static pause(): void {
// Cookie preferences don't need pause functionality
}
- static override resume(): void {
+ static resume(): void {
// Cookie preferences don't need resume functionality
}
- static override reset(): void {
+ static reset(): void {
// Clean up any global state if needed for View Transitions
}
}
diff --git a/src/components/Consent/Preferences/index.astro b/src/components/Consent/Preferences/index.astro
index 16e2afdef..e1467d057 100644
--- a/src/components/Consent/Preferences/index.astro
+++ b/src/components/Consent/Preferences/index.astro
@@ -198,9 +198,10 @@ import logoSvg from '@assets/images/site/logo.svg'
diff --git a/src/components/Forms/Download/client.ts b/src/components/Forms/Download/client.ts
index 248e39c6d..7b1645969 100644
--- a/src/components/Forms/Download/client.ts
+++ b/src/components/Forms/Download/client.ts
@@ -1,4 +1,3 @@
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { logger } from '@lib/logger'
import {
getDownloadFormElement,
@@ -10,12 +9,11 @@ import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
- * DownloadForm component using LoadableScript pattern
+ * DownloadForm component
* Handles form submission for gated download resources
*/
-export class DownloadForm extends LoadableScript {
- static override scriptName = 'DownloadForm'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class DownloadForm {
+ static scriptName = 'DownloadForm'
private form: HTMLFormElement
private submitButton: HTMLButtonElement
@@ -23,8 +21,6 @@ export class DownloadForm extends LoadableScript {
private downloadButtonWrapper: HTMLElement
constructor() {
- super()
-
const context = { scriptName: DownloadForm.scriptName, operation: 'constructor' }
addScriptBreadcrumb(context)
@@ -141,7 +137,7 @@ export class DownloadForm extends LoadableScript {
/**
* LoadableScript static methods
*/
- static override init(): void {
+ static init(): void {
const context = { scriptName: DownloadForm.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -159,15 +155,15 @@ export class DownloadForm extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
// DownloadForm doesn't need pause functionality
}
- static override resume(): void {
+ static resume(): void {
// DownloadForm doesn't need resume functionality
}
- static override reset(): void {
+ static reset(): void {
// Clean up any global state if needed for View Transitions
}
}
diff --git a/src/components/Forms/Download/index.astro b/src/components/Forms/Download/index.astro
index 1b8e4fe44..b0afb2c9c 100644
--- a/src/components/Forms/Download/index.astro
+++ b/src/components/Forms/Download/index.astro
@@ -198,9 +198,10 @@ const downloadUrl = `/downloads/${fileName}`
diff --git a/src/components/scripts/bootstrap/consent/NOTES.md b/src/components/scripts/bootstrap/consent/NOTES.md
index 34dbfa367..29c05bd82 100644
--- a/src/components/scripts/bootstrap/consent/NOTES.md
+++ b/src/components/scripts/bootstrap/consent/NOTES.md
@@ -4,15 +4,6 @@
Components using 'astro:page-load':
-Singleton Pattern Components (will refactor now):
-
-✅ CarouselManager
-✅ ContactForm
-✅ Hero
-✅ TestimonialsCarousel
-✅ Navigation
-✅ HeadThemeSetup
-
Instance Pattern Components (skip for now):
❌ NewsletterForm - Creates instances per form element
@@ -25,6 +16,11 @@ Instance Pattern Components (skip for now):
❌ SocialShares - Instance-based
❌ NetworkStatus - Singleton but uses getInstance pattern
+9 instance pattern components skipped for future work:
+
+NewsletterForm, CookieConsent, CookieCustomize, DownloadForm
+SocialEmbed, Highlighter, MastodonModal, SocialShares, NetworkStatus
+
## Categories
### 'necessary' - Infrastructure only, NO actual usage
From 5ccf06ffa4a43b9ef940323ae292f89f6cba4575 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Fri, 7 Nov 2025 03:38:26 +0300
Subject: [PATCH 023/341] Refactor SocialEmbed, Highlighter, MastodonModal,
SocialShares,and NetworkStatus client scripts to not use ClientLoader
---
.../Social/Embed/__tests__/client.spec.ts | 1 -
src/components/Social/Embed/client.ts | 15 ++++++---------
src/components/Social/Embed/index.astro | 7 ++++---
.../Social/Highlighter/Highlighter.astro | 7 +++++--
.../Highlighter/__tests__/client.spec.ts | 1 -
src/components/Social/Highlighter/client.ts | 16 +++++++---------
src/components/Social/Mastodon/Modal.astro | 6 ++++--
src/components/Social/Mastodon/client.ts | 17 +++++++----------
src/components/Social/Shares/Shares.astro | 6 ++++--
src/components/Social/Shares/client.ts | 17 +++++++----------
.../NetworkStatus/__tests__/client.spec.ts | 4 ----
src/components/Toasts/NetworkStatus/client.ts | 18 +++++++-----------
.../Toasts/NetworkStatus/index.astro | 7 ++++---
13 files changed, 55 insertions(+), 67 deletions(-)
diff --git a/src/components/Social/Embed/__tests__/client.spec.ts b/src/components/Social/Embed/__tests__/client.spec.ts
index 42211e040..8592693b2 100644
--- a/src/components/Social/Embed/__tests__/client.spec.ts
+++ b/src/components/Social/Embed/__tests__/client.spec.ts
@@ -64,7 +64,6 @@ describe('EmbedManager', () => {
describe('LoadableScript Interface', () => {
it('should implement LoadableScript interface', () => {
expect(EmbedManager.scriptName).toBe('EmbedManager')
- expect(EmbedManager.eventType).toBe('astro:page-load')
expect(typeof EmbedManager.init).toBe('function')
expect(typeof EmbedManager.pause).toBe('function')
expect(typeof EmbedManager.resume).toBe('function')
diff --git a/src/components/Social/Embed/client.ts b/src/components/Social/Embed/client.ts
index 71556a1e5..9d9cd23fa 100644
--- a/src/components/Social/Embed/client.ts
+++ b/src/components/Social/Embed/client.ts
@@ -1,4 +1,3 @@
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { cacheEmbed, getCachedEmbed } from '@components/scripts/store'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
@@ -39,15 +38,14 @@ interface OEmbedResponse {
* Manager class for all social embeds using singleton pattern
* Can be registered directly: registerScript(EmbedManager)
*/
-export class EmbedManager extends LoadableScript {
+export class EmbedManager {
private static instance: EmbedManager | null = null
private embeds: Map = new Map()
private initialized = false
- static override scriptName = 'EmbedManager'
- static override eventType: TriggerEvent = 'astro:page-load'
+ static scriptName = 'EmbedManager'
- static override init(): void {
+ static init(): void {
const context = { scriptName: EmbedManager.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -67,11 +65,11 @@ export class EmbedManager extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
EmbedManager.getInstance().pauseAll()
}
- static override resume(): void {
+ static resume(): void {
EmbedManager.getInstance().resumeAll()
}
@@ -83,7 +81,6 @@ export class EmbedManager extends LoadableScript {
}
private constructor() {
- super()
}
private discoverNewEmbeds(): void {
@@ -183,7 +180,7 @@ export class EmbedManager extends LoadableScript {
}
}
- static override reset(): void {
+ static reset(): void {
const instance = EmbedManager.instance
if (instance) {
instance.cleanup()
diff --git a/src/components/Social/Embed/index.astro b/src/components/Social/Embed/index.astro
index 68aef621b..771c92ac9 100644
--- a/src/components/Social/Embed/index.astro
+++ b/src/components/Social/Embed/index.astro
@@ -92,9 +92,10 @@ const maxWidthClass = platformMaxWidth[detectedPlatform] || 'max-w-[550px]'
diff --git a/src/components/Social/Highlighter/Highlighter.astro b/src/components/Social/Highlighter/Highlighter.astro
index 27a394c0a..aad470eb3 100644
--- a/src/components/Social/Highlighter/Highlighter.astro
+++ b/src/components/Social/Highlighter/Highlighter.astro
@@ -39,7 +39,10 @@ const { ariaLabel, class: className } = Astro.props
\ No newline at end of file
diff --git a/src/components/Social/Highlighter/__tests__/client.spec.ts b/src/components/Social/Highlighter/__tests__/client.spec.ts
index e3adfa33e..da7a9b4fe 100644
--- a/src/components/Social/Highlighter/__tests__/client.spec.ts
+++ b/src/components/Social/Highlighter/__tests__/client.spec.ts
@@ -88,7 +88,6 @@ describe('Highlighter LoadableScript', () => {
describe('LoadableScript Interface', () => {
it('should implement LoadableScript interface', () => {
expect(Highlighter.scriptName).toBe('Highlighter')
- expect(Highlighter.eventType).toBe('astro:page-load')
expect(typeof Highlighter.init).toBe('function')
expect(typeof Highlighter.pause).toBe('function')
expect(typeof Highlighter.resume).toBe('function')
diff --git a/src/components/Social/Highlighter/client.ts b/src/components/Social/Highlighter/client.ts
index fc9305550..29590ac57 100644
--- a/src/components/Social/Highlighter/client.ts
+++ b/src/components/Social/Highlighter/client.ts
@@ -4,7 +4,6 @@
* Uses LoadableScript pattern for optimized loading
*/
-import { LoadableScript } from '@components/scripts/loader'
import type { ShareData } from '@components/Social/common'
import { platforms, copyToClipboard, nativeShare } from '@components/Social/common'
import { MastodonModal } from '@components/Social/Mastodon/client'
@@ -381,13 +380,12 @@ class HighlighterElement extends HTMLElement {
}
/**
- * Highlighter LoadableScript for delayed initialization
+ * Highlighter for delayed initialization
*/
-export class Highlighter extends LoadableScript {
- static override scriptName = 'Highlighter'
- static override eventType = 'astro:page-load' as const
+export class Highlighter {
+ static scriptName = 'Highlighter'
- static override init(): void {
+ static init(): void {
const context = { scriptName: Highlighter.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -402,15 +400,15 @@ export class Highlighter extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
// No persistent state to pause
}
- static override resume(): void {
+ static resume(): void {
// No persistent state to resume
}
- static override reset(): void {
+ static reset(): void {
// No persistent state to reset
}
}
diff --git a/src/components/Social/Mastodon/Modal.astro b/src/components/Social/Mastodon/Modal.astro
index 6e509d016..8e4b10670 100644
--- a/src/components/Social/Mastodon/Modal.astro
+++ b/src/components/Social/Mastodon/Modal.astro
@@ -148,8 +148,10 @@ const { id = 'mastodon-modal' } = Astro.props
diff --git a/src/components/Social/Mastodon/client.ts b/src/components/Social/Mastodon/client.ts
index 09a8e6372..c402a7bde 100644
--- a/src/components/Social/Mastodon/client.ts
+++ b/src/components/Social/Mastodon/client.ts
@@ -1,5 +1,5 @@
/**
- * Mastodon Share Modal - LoadableScript Implementation
+ * Mastodon Share Modal
*
* Provides a modal interface for sharing content to Mastodon instances.
* Handles instance detection, validation, and saved instance management.
@@ -9,7 +9,6 @@
*/
import { createFocusTrap, type FocusTrap } from 'focus-trap'
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { isMastodonInstance, normalizeURL, getUrlDomain } from './detector'
import { buildShareUrl } from './config'
import {
@@ -65,9 +64,8 @@ declare global {
/**
* MastodonModal - Main controller for the Mastodon share modal
*/
-export class MastodonModal extends LoadableScript {
- static override scriptName = 'MastodonModal'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class MastodonModal {
+ static scriptName = 'MastodonModal'
private static instance: MastodonModal | null = null
@@ -91,7 +89,6 @@ export class MastodonModal extends LoadableScript {
private unsubscribeSavedInstances: (() => void) | null = null
constructor() {
- super()
}
/**
@@ -383,7 +380,7 @@ export class MastodonModal extends LoadableScript {
/**
* LoadableScript static methods
*/
- static override init(): void {
+ static init(): void {
const context = { scriptName: MastodonModal.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -396,19 +393,19 @@ export class MastodonModal extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
if (MastodonModal.instance) {
MastodonModal.instance.pause()
}
}
- static override resume(): void {
+ static resume(): void {
if (MastodonModal.instance) {
MastodonModal.instance.resume()
}
}
- static override reset(): void {
+ static reset(): void {
if (MastodonModal.instance) {
MastodonModal.instance.pause()
if (MastodonModal.instance.unsubscribeSavedInstances) {
diff --git a/src/components/Social/Shares/Shares.astro b/src/components/Social/Shares/Shares.astro
index 018310522..6fa0bde17 100644
--- a/src/components/Social/Shares/Shares.astro
+++ b/src/components/Social/Shares/Shares.astro
@@ -122,8 +122,10 @@ const containerClasses = [
\ No newline at end of file
diff --git a/src/components/Social/Shares/client.ts b/src/components/Social/Shares/client.ts
index 451a75594..1e78c4619 100644
--- a/src/components/Social/Shares/client.ts
+++ b/src/components/Social/Shares/client.ts
@@ -1,10 +1,8 @@
/**
* Social Share Client-side functionality
* Handles click tracking, Mastodon modal, and Web Share API integration
- * Uses LoadableScript pattern for optimized loading
*/
-import { LoadableScript } from '@components/scripts/loader'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
/**
@@ -66,15 +64,14 @@ function handleShareButtonClick(event: Event): void {
}
/**
- * Social Share LoadableScript for delayed initialization
+ * Social Share for delayed initialization
*/
-export class SocialShare extends LoadableScript {
- static override scriptName = 'SocialShare'
- static override eventType = 'astro:page-load' as const
+export class SocialShare {
+ static scriptName = 'SocialShare'
private static listeners = new WeakMap()
- static override init(): void {
+ static init(): void {
const context = { scriptName: SocialShare.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -96,7 +93,7 @@ export class SocialShare extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
const context = { scriptName: SocialShare.scriptName, operation: 'pause' }
addScriptBreadcrumb(context)
@@ -118,11 +115,11 @@ export class SocialShare extends LoadableScript {
}
}
- static override resume(): void {
+ static resume(): void {
this.init()
}
- static override reset(): void {
+ static reset(): void {
this.pause()
this.listeners = new WeakMap()
}
diff --git a/src/components/Toasts/NetworkStatus/__tests__/client.spec.ts b/src/components/Toasts/NetworkStatus/__tests__/client.spec.ts
index a387097f7..b207e467e 100644
--- a/src/components/Toasts/NetworkStatus/__tests__/client.spec.ts
+++ b/src/components/Toasts/NetworkStatus/__tests__/client.spec.ts
@@ -44,10 +44,6 @@ describe('NetworkStatus LoadableScript', () => {
expect(NetworkStatus.scriptName).toBe('NetworkStatus')
})
- test('should have correct eventType', () => {
- expect(NetworkStatus.eventType).toBe('astro:page-load')
- })
-
test('should initialize without errors', () => {
expect(() => new NetworkStatus()).not.toThrow()
})
diff --git a/src/components/Toasts/NetworkStatus/client.ts b/src/components/Toasts/NetworkStatus/client.ts
index 494f4e2c0..eb638de2a 100644
--- a/src/components/Toasts/NetworkStatus/client.ts
+++ b/src/components/Toasts/NetworkStatus/client.ts
@@ -1,18 +1,14 @@
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
-
/**
- * NetworkStatus component using LoadableScript pattern
+ * NetworkStatus component
* Monitors online/offline status and displays notifications
*/
-export class NetworkStatus extends LoadableScript {
- static override scriptName = 'NetworkStatus'
- static override eventType: TriggerEvent = 'astro:page-load'
+export class NetworkStatus {
+ static scriptName = 'NetworkStatus'
private static instance: NetworkStatus | null = null
private hideTimeout: ReturnType | null = null
constructor() {
- super()
}
bindEvents() {
@@ -99,24 +95,24 @@ export class NetworkStatus extends LoadableScript {
/**
* LoadableScript static methods
*/
- static override init(): void {
+ static init(): void {
NetworkStatus.instance = new NetworkStatus()
NetworkStatus.instance.bindEvents()
}
- static override pause(): void {
+ static pause(): void {
if (NetworkStatus.instance) {
NetworkStatus.instance.pause()
}
}
- static override resume(): void {
+ static resume(): void {
if (NetworkStatus.instance) {
NetworkStatus.instance.resume()
}
}
- static override reset(): void {
+ static reset(): void {
if (NetworkStatus.instance) {
NetworkStatus.instance.pause()
NetworkStatus.instance = null
diff --git a/src/components/Toasts/NetworkStatus/index.astro b/src/components/Toasts/NetworkStatus/index.astro
index e0ffeb90d..200bd5022 100644
--- a/src/components/Toasts/NetworkStatus/index.astro
+++ b/src/components/Toasts/NetworkStatus/index.astro
@@ -83,9 +83,10 @@
From a0a548c7d5d4c4eadd50a81b5adcaf5ebe8d426b Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Fri, 7 Nov 2025 03:49:44 +0300
Subject: [PATCH 024/341] Refactor Footer client script to not use ClientLoader
and remove loader
---
.cache/pages.json | 11 +-
src/components/Footer/client.ts | 16 +-
src/components/Footer/index.astro | 6 +-
src/components/Hero/__tests__/client.spec.ts | 22 +-
.../scripts/bootstrap/consent/NOTES.md | 21 -
src/components/scripts/loader/@types/index.ts | 81 ---
src/components/scripts/loader/LOAD_EVENTS.md | 96 ---
.../scripts/loader/__tests__/loader.spec.ts | 578 ------------------
src/components/scripts/loader/index.ts | 444 --------------
9 files changed, 20 insertions(+), 1255 deletions(-)
delete mode 100644 src/components/scripts/loader/@types/index.ts
delete mode 100644 src/components/scripts/loader/LOAD_EVENTS.md
delete mode 100644 src/components/scripts/loader/__tests__/loader.spec.ts
delete mode 100644 src/components/scripts/loader/index.ts
diff --git a/.cache/pages.json b/.cache/pages.json
index 4844728a4..b8605f6d4 100644
--- a/.cache/pages.json
+++ b/.cache/pages.json
@@ -32,10 +32,15 @@
"offline",
"privacy",
{
- "services": ["create-custom-font-sets", "overview"]
+ "services": [
+ "create-custom-font-sets",
+ "overview"
+ ]
},
{
- "social-shares": ["template"]
+ "social-shares": [
+ "template"
+ ]
},
{
"tags": [
@@ -50,4 +55,4 @@
"typescript"
]
}
-]
+]
\ No newline at end of file
diff --git a/src/components/Footer/client.ts b/src/components/Footer/client.ts
index fbf79bc37..6286c1bdf 100644
--- a/src/components/Footer/client.ts
+++ b/src/components/Footer/client.ts
@@ -1,13 +1,11 @@
/**
- * Script for the Footer component using LoadableScript pattern
+ * Script for the Footer component
*/
-import { LoadableScript, type TriggerEvent } from '@components/scripts/loader/@types'
import { getHireMeAnchorElement } from '@components/Footer/selectors'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
-class Footer extends LoadableScript {
- static override scriptName = 'Footer'
- static override eventType: TriggerEvent = 'astro:page-load'
+class Footer {
+ static scriptName = 'Footer'
private static getMonthName(date: Date): string {
const monthDate = new Date(date)
@@ -15,7 +13,7 @@ class Footer extends LoadableScript {
return monthDate.toLocaleString('en-US', { month: 'long' })
}
- static override init(): void {
+ static init(): void {
const context = { scriptName: Footer.scriptName, operation: 'init' }
addScriptBreadcrumb(context)
@@ -32,15 +30,15 @@ class Footer extends LoadableScript {
}
}
- static override pause(): void {
+ static pause(): void {
// No pause functionality needed for Footer
}
- static override resume(): void {
+ static resume(): void {
// No resume functionality needed for Footer
}
- static override reset(): void {
+ static reset(): void {
// No reset functionality needed for Footer
}
}
diff --git a/src/components/Footer/index.astro b/src/components/Footer/index.astro
index 78e5c962a..c89b41c50 100644
--- a/src/components/Footer/index.astro
+++ b/src/components/Footer/index.astro
@@ -145,8 +145,10 @@ const year = new Date().getFullYear()
diff --git a/src/components/Hero/__tests__/client.spec.ts b/src/components/Hero/__tests__/client.spec.ts
index 17cbfe409..5f279c1ab 100644
--- a/src/components/Hero/__tests__/client.spec.ts
+++ b/src/components/Hero/__tests__/client.spec.ts
@@ -1,9 +1,8 @@
-import { describe, it, expect, beforeEach, vi, afterEach, type Mock } from 'vitest'
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { JSDOM } from 'jsdom'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { HeroLoader } from '@components/Hero/client'
-import { registerScript, resetDelayedLoader } from '@components/scripts/loader'
import { gsap } from 'gsap'
// Setup function for DOM with hero SVG
@@ -33,18 +32,6 @@ vi.mock('gsap', () => ({
},
}))
-vi.mock('@components/scripts/loader', () => {
- class LoadableScript {
- static scriptName = 'MockLoadableScript'
- static eventType = 'astro:page-load'
- }
- return {
- registerScript: vi.fn(),
- resetDelayedLoader: vi.fn(),
- LoadableScript,
- }
-})
-
describe('HeroLoader', () => {
let gsapMock: any
let timelineMock: any
@@ -72,7 +59,6 @@ describe('HeroLoader', () => {
vi.restoreAllMocks()
// Clears the HeroLoader singleton instance setup by 'new HeroLoader()' in init() calls via static property access
;(HeroLoader as any).instance = null
- resetDelayedLoader()
})
describe('HeroLoader instantiation and initialization', () => {
@@ -81,12 +67,6 @@ describe('HeroLoader', () => {
expect(loader).toBeInstanceOf(HeroLoader)
})
- it('should register with loader', () => {
- ;(registerScript as Mock).mockClear()
- registerScript(HeroLoader)
- expect(registerScript).toHaveBeenCalledWith(HeroLoader)
- })
-
it('should start animation when init is called', () => {
setupHeroDOM()
HeroLoader.init()
diff --git a/src/components/scripts/bootstrap/consent/NOTES.md b/src/components/scripts/bootstrap/consent/NOTES.md
index 29c05bd82..636f99d7a 100644
--- a/src/components/scripts/bootstrap/consent/NOTES.md
+++ b/src/components/scripts/bootstrap/consent/NOTES.md
@@ -1,26 +1,5 @@
# Consent Category Usage Summary
-## OT: loader
-
-Components using 'astro:page-load':
-
-Instance Pattern Components (skip for now):
-
-❌ NewsletterForm - Creates instances per form element
-❌ CookieConsent - Instance-based with View Transitions handling
-❌ CookieCustomize - Instance-based
-❌ DownloadForm - Instance-based
-❌ SocialEmbed - Manager with multiple instances
-❌ Highlighter - Web Component
-❌ MastodonModal - Instance-based
-❌ SocialShares - Instance-based
-❌ NetworkStatus - Singleton but uses getInstance pattern
-
-9 instance pattern components skipped for future work:
-
-NewsletterForm, CookieConsent, CookieCustomize, DownloadForm
-SocialEmbed, Highlighter, MastodonModal, SocialShares, NetworkStatus
-
## Categories
### 'necessary' - Infrastructure only, NO actual usage
diff --git a/src/components/scripts/loader/@types/index.ts b/src/components/scripts/loader/@types/index.ts
deleted file mode 100644
index 71ada9ebd..000000000
--- a/src/components/scripts/loader/@types/index.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-// eslint-disable
-/**
- * Type definitions for the generic event-driven loader.
- * Contains interfaces and types for script initialization based on DOM and user interaction events.
- */
-
-/** The events that qualify as user interaction and should trigger executing the action. */
-export type UserInteractionEvent =
- | 'keydown'
- | 'mousemove'
- | 'wheel'
- | 'touchmove'
- | 'touchstart'
- | 'touchend'
-
-/** All supported trigger events for script execution */
-/*
-Components using 'astro:page-load':
-
-- CallToAction/Newsletter
-- Carousel
-- ContactForm
-- Cookies/Consent
-- Cookies/Customize
-- Forms/Download
-- Head
-- Hero
-- Navigation
-- Social/Embed
-- Social/Highlighter
-- Social/Mastodon
-- Social/Shares
-- Testimonials
-- Toasts/NetworkStatus
-*/
-export type TriggerEvent =
- | 'delayed'
- | 'visible'
- | 'consent-gated'
- | 'astro:before-preparation'
- | 'astro:after-preparation'
- | 'astro:before-swap'
- | 'astro:after-swap'
- | 'astro:page-load'
-
-/** Metadata for consent-gated scripts */
-export type ConsentMetadata = {
- /** The consent category required for this script to execute */
- consentCategory?: 'necessary' | 'analytics' | 'advertising' | 'functional'
- /** Additional metadata for future extensibility */
- [key: string]: unknown
-}
-
-/** Abstract class for scripts that can be loaded by the Loader */
-export abstract class LoadableScript {
- /** Name identifier for the script */
- public static scriptName: string
- /** The event that should trigger this script's initialization */
- public static eventType: TriggerEvent
- /** Target element selector for 'visible' event type (optional) */
- public static targetSelector?: string
- /** Metadata for conditional script loading (e.g., consent requirements) */
- public static meta: ConsentMetadata = {}
-
- /** Initialize the script */
- public static init(): void {
- throw new Error('Init method not implemented.')
- }
- /** Pause script execution */
- public static pause(): void {
- throw new Error('Pause method not implemented.')
- }
- /** Resume script execution */
- public static resume(): void {
- throw new Error('Resume method not implemented.')
- }
- /** Reset script execution for use on SPA page navigation */
- public static reset(): void {
- throw new Error('Reset method not implemented.')
- }
-}
diff --git a/src/components/scripts/loader/LOAD_EVENTS.md b/src/components/scripts/loader/LOAD_EVENTS.md
deleted file mode 100644
index 22fb2de0e..000000000
--- a/src/components/scripts/loader/LOAD_EVENTS.md
+++ /dev/null
@@ -1,96 +0,0 @@
-# Load Events
-
-Shown in order of execution:
-
-## astro:before-preparation
-
-A signal dispatched by the Astro View Transitions router at the start of a page navigation. It runs before any new page content is loaded, allowing you to perform actions like displaying a loading spinner, preventing the navigation (e.g., for unsaved form changes), or overriding default behavior with the loader() function, which allows you to manually set the `event.newDocument` property. Correlates to the `navigation` native event.
-
-## astro:after-preparation
-
-Dispatched on the `window.document` after Astro has finished preparing and linking the new page's content but before the content is swapped into the document. For most purposes, the native event you would use to react after the page content is loaded is the DOMContentLoaded event. However, when using Astro's View Transitions, the DOMContentLoaded event is not triggered during client-side navigation.
-
-## astro:before-swap
-
-A lifecycle event in Astro's View Transitions router that fires right before the new page content is swapped into the DOM. It allows you to run code just before the visual transition happens, giving you a chance to customize the swap or modify the new document before it's displayed. This event is particularly useful for setting up page-specific styles or animations on the new content. It is the last chance for developers to interact with a page navigation just before the new content is swapped into the DOM.
-
-## astro:after-swap
-
-A lifecycle event in the Astro framework that is dispatched after the contents of a page have been replaced during a client-side navigation, but before the view transition is complete. This event is part of Astro's View Transitions API, which provides a single-page application (SPA)-like feel to multi-page apps (MPAs). Key uses:
-
-- Re-attaching event listeners: For websites that use JavaScript for interactivity, the event listeners on the old page are lost when the DOM is replaced. The astro:after-swap event allows you to re-attach these listeners to the elements on the newly swapped-in page.
-- Managing state across transitions: It can be used to persist settings like dark mode across page navigation. You can set up a function to run on this event that checks a user's theme preference and applies the correct theme to the new page.
-- Integrating with other libraries: Libraries like HTMX, which rely on the DOM to be processed, can be re-initialized using this event to ensure they continue to function correctly after a navigation.
-
-## astro:page-load
-
-A custom event dispatched by Astro's View Transitions router. It serves a similar purpose to the browser's native DOMContentLoaded event but is specifically designed to handle script execution and state re-initialization during client-side navigation facilitated by View Transitions. It is specifically designed to re-run client-side scripts after navigation within a Single-Page App (SPA)-like experience, which is enabled by Astro's View Transitions. In a standard Astro application (Multi-Page App), client-side JavaScript only runs once on the initial page load. This event is an alternative to DOMContentLoaded for use with View Transitions.
-
-## delayed
-
-This is a virtual event that waits for the first user interaction or five seconds to pass. It is intended for scripts that result in a layout shift and should be delayed in execution so as not to affect LCF layout in Lightouse.
-
-## visible
-
-This is a virtual event that triggers when an element scrolls into or near the viewport, using the Intersection Observer API. It fires when the target element is 500px below the viewport (before it becomes visible to the user), allowing for preloading or lazy initialization of components.
-
-**Configuration:**
-
-- **Root:** `document.body` - Observes relative to the document body
-- **Root Margin:** `0px 0px 500px 0px` - Triggers 500px before element enters viewport
-- **Threshold:** `0.01` - Triggers when 1% of element is visible
-- **Delay:** `100ms` - Throttles observer callbacks to once per 100ms
-
-**Usage:**
-
-Scripts using the 'visible' event type must either:
-
-1. Provide a `targetSelector` property pointing to the element(s) to observe
-2. Or ensure elements in the DOM have a `data-script` attribute matching the script name
-
-This event is ideal for:
-
-- Lazy loading social media embeds (fetch oEmbed data before visible)
-- Deferring heavy component initialization
-- Preloading content just before user sees it
-- Optimizing initial page load performance
-
-**Browser Support:** Requires IntersectionObserver API (95%+ browsers, IE11 needs polyfill)
-
-## consent-gated
-
-This is a virtual event for scripts that require user consent before execution, primarily for GDPR compliance. Scripts using this event type will check the user's cookie consent preferences before loading.
-
-**Current Implementation:** Temporarily behaves as `astro:page-load` event. Full consent checking logic is planned for future implementation.
-
-**Configuration:**
-
-Scripts using the 'consent-gated' event type must provide a `meta` object with:
-
-- **consentCategory** (required): One of `'necessary'`, `'analytics'`, `'advertising'`, or `'functional'`
-
-**Usage Example:**
-
-```typescript
-import { LoadableScript, type TriggerEvent } from '@components/Scripts/loader'
-
-class AnalyticsScript extends LoadableScript {
- static override scriptName = 'Analytics'
- static override eventType: TriggerEvent = 'consent-gated'
- static override meta = {
- consentCategory: 'analytics'
- }
-
- static override init(): void {
- // Initialize analytics only if user has consented
- }
-}
-```
-
-**Use Cases:**
-
-- Analytics and tracking scripts (Google Analytics, Plausible, etc.)
-- Advertising and marketing pixels (Google Ads, Facebook Pixel, etc.)
-- Third-party embeds that collect user data
-- Error monitoring with PII collection (Sentry with Session Replay)
-- Any script that processes personal data under GDPR
diff --git a/src/components/scripts/loader/__tests__/loader.spec.ts b/src/components/scripts/loader/__tests__/loader.spec.ts
deleted file mode 100644
index 2960b3e69..000000000
--- a/src/components/scripts/loader/__tests__/loader.spec.ts
+++ /dev/null
@@ -1,578 +0,0 @@
-/**
- * Unit tests for generic script loader with LoadableScript interface
- */
-
-// @vitest-environment happy-dom
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
-import {
- registerScript,
- resetDelayedLoader,
- userInteractionEvents,
- autoLoadDuration,
-} from '@components/scripts/loader/index'
-import type { TriggerEvent } from '@components/scripts/loader/@types'
-
-/**
- * Helper function to create mock LoadableScript classes for testing
- */
-function createMockScript(eventType: TriggerEvent, name = 'MockScript') {
- return class {
- static name = name
- static scriptName = name
- static eventType = eventType
- static meta = {}
- static paused = false
- static executed = false
- static mockInit = vi.fn()
-
- static init(): void {
- this.executed = true
- this.mockInit()
- }
-
- static pause(): void {
- this.paused = true
- }
-
- static resume(): void {
- this.paused = false
- }
-
- static reset(): void {
- this.paused = false
- this.executed = false
- this.mockInit.mockClear()
- }
- }
-}
-
-describe('Generic Script Loader', () => {
- beforeEach(() => {
- resetDelayedLoader()
- vi.clearAllMocks()
- })
-
- afterEach(() => {
- resetDelayedLoader()
- })
-
- describe('registerScript with LoadableScript interface', () => {
- it('should register and execute delayed scripts after timeout', async () => {
- const DelayedScript = createMockScript('delayed', 'DelayedScript')
-
- // Mock setTimeout and clearTimeout
- const mockSetTimeout = vi.spyOn(global, 'setTimeout')
- const mockClearTimeout = vi.spyOn(global, 'clearTimeout')
-
- mockSetTimeout.mockImplementation((callback: () => void) => {
- callback()
- return 1 as unknown as NodeJS.Timeout
- })
-
- registerScript(DelayedScript)
-
- expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), autoLoadDuration)
- expect(DelayedScript.mockInit).toHaveBeenCalled()
- expect(DelayedScript.executed).toBe(true)
-
- mockSetTimeout.mockRestore()
- mockClearTimeout.mockRestore()
- })
-
- it('should execute delayed scripts on user interaction', () => {
- const DelayedScript = createMockScript('delayed', 'DelayedScript')
-
- registerScript(DelayedScript)
-
- // Simulate user interaction
- document.dispatchEvent(new KeyboardEvent('keydown'))
-
- expect(DelayedScript.mockInit).toHaveBeenCalled()
- expect(DelayedScript.executed).toBe(true)
- })
-
- it('should handle Astro View Transition events', () => {
- const BeforePrepScript = createMockScript('astro:before-preparation', 'BeforePrepScript')
-
- registerScript(BeforePrepScript)
-
- // Script should not execute immediately
- expect(BeforePrepScript.mockInit).not.toHaveBeenCalled()
-
- // Simulate Astro view transition event
- document.dispatchEvent(new Event('astro:before-preparation'))
- expect(BeforePrepScript.mockInit).toHaveBeenCalled()
- })
-
- it('should handle Astro lifecycle events', () => {
- const PageLoadScript = createMockScript('astro:page-load', 'PageLoadScript')
-
- registerScript(PageLoadScript)
-
- // Script should not execute immediately
- expect(PageLoadScript.mockInit).not.toHaveBeenCalled()
-
- document.dispatchEvent(new Event('astro:page-load'))
- expect(PageLoadScript.mockInit).toHaveBeenCalled()
- })
-
- it('should handle multiple event types correctly', () => {
- const DelayedScript = createMockScript('delayed', 'DelayedScript')
- const BeforePrepScript = createMockScript('astro:before-preparation', 'BeforePrepScript')
- const PageLoadScript = createMockScript('astro:page-load', 'PageLoadScript')
-
- registerScript(DelayedScript)
- registerScript(BeforePrepScript)
- registerScript(PageLoadScript)
-
- // Trigger user interaction (should only execute delayed script)
- document.dispatchEvent(new KeyboardEvent('keydown'))
- expect(DelayedScript.mockInit).toHaveBeenCalledTimes(1)
- expect(BeforePrepScript.mockInit).not.toHaveBeenCalled()
- expect(PageLoadScript.mockInit).not.toHaveBeenCalled()
-
- // Trigger before-preparation (should execute beforePrep script)
- document.dispatchEvent(new Event('astro:before-preparation'))
- expect(BeforePrepScript.mockInit).toHaveBeenCalledTimes(1)
- expect(PageLoadScript.mockInit).not.toHaveBeenCalled()
-
- // Trigger page-load (should execute page load script)
- document.dispatchEvent(new Event('astro:page-load'))
- expect(PageLoadScript.mockInit).toHaveBeenCalledTimes(1)
- })
- })
-
- describe('pause/resume functionality', () => {
- it('should pause executed scripts when page becomes hidden', () => {
- const PageLoadScript = createMockScript('astro:page-load', 'PageLoadScript')
-
- registerScript(PageLoadScript)
-
- // Execute the script first
- document.dispatchEvent(new Event('astro:page-load'))
-
- expect(PageLoadScript.executed).toBe(true)
-
- // Create a spy to verify pause was called
- const pauseSpy = vi.spyOn(PageLoadScript, 'pause')
-
- // Simulate page becoming hidden using visibilityState
- Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true })
- document.dispatchEvent(new Event('visibilitychange'))
-
- expect(pauseSpy).toHaveBeenCalled()
- })
-
- it('should resume executed scripts when page becomes visible', () => {
- const PageLoadScript = createMockScript('astro:page-load', 'PageLoadScript')
-
- registerScript(PageLoadScript)
-
- // Execute the script first
- document.dispatchEvent(new Event('astro:page-load'))
-
- expect(PageLoadScript.executed).toBe(true)
-
- // Create spies to verify methods were called
- const pauseSpy = vi.spyOn(PageLoadScript, 'pause')
- const resumeSpy = vi.spyOn(PageLoadScript, 'resume')
-
- // Pause first
- Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true })
- document.dispatchEvent(new Event('visibilitychange'))
- expect(pauseSpy).toHaveBeenCalled()
-
- // Then resume
- Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true })
- document.dispatchEvent(new Event('visibilitychange'))
-
- expect(resumeSpy).toHaveBeenCalled()
- })
- })
-
- describe('resetDelayedLoader', () => {
- it('should reset the singleton instance', () => {
- const DelayedScript1 = createMockScript('delayed', 'DelayedScript1')
- const DelayedScript2 = createMockScript('delayed', 'DelayedScript2')
-
- // Register and trigger first script
- registerScript(DelayedScript1)
- document.dispatchEvent(new KeyboardEvent('keydown'))
- expect(DelayedScript1.mockInit).toHaveBeenCalledTimes(1)
-
- // Reset the loader
- resetDelayedLoader()
-
- // Register second script after reset
- registerScript(DelayedScript2)
- document.dispatchEvent(new KeyboardEvent('keydown'))
- expect(DelayedScript2.mockInit).toHaveBeenCalledTimes(1)
- })
-
- it('should handle reset when no instance exists', () => {
- // Should not throw an error
- expect(() => resetDelayedLoader()).not.toThrow()
- })
- })
-
- describe('userInteractionEvents constant', () => {
- it('should contain all expected event types', () => {
- expect(userInteractionEvents).toContain('keydown')
- expect(userInteractionEvents).toContain('mousemove')
- expect(userInteractionEvents).toContain('wheel')
- expect(userInteractionEvents).toContain('touchmove')
- expect(userInteractionEvents).toContain('touchstart')
- expect(userInteractionEvents).toContain('touchend')
- })
-
- it('should have correct length', () => {
- expect(userInteractionEvents).toHaveLength(6)
- })
- })
-
- describe('autoLoadDuration constant', () => {
- it('should be 5 seconds in milliseconds', () => {
- expect(autoLoadDuration).toBe(5000)
- })
- })
-
- describe('visible event with Intersection Observer', () => {
- type MockObserverInstance = {
- observe: ReturnType
- unobserve: ReturnType
- disconnect: ReturnType
- takeRecords: ReturnType
- root: Element | Document | null
- rootMargin: string
- thresholds: number[]
- }
-
- type MockVisibleScript = ReturnType & {
- targetSelector?: string
- }
-
- let mockIntersectionObserver: typeof IntersectionObserver
- let observerCallback: IntersectionObserverCallback
- let observerInstances: MockObserverInstance[] = []
-
- const createMockEntry = (target: Element, isIntersecting: boolean): IntersectionObserverEntry => {
- const boundingRect: DOMRectReadOnly = {
- x: 0,
- y: 0,
- width: 0,
- height: 0,
- top: 0,
- right: 0,
- bottom: 0,
- left: 0,
- toJSON: () => ({}),
- }
- const intersectionRect: DOMRectReadOnly = {
- x: 0,
- y: 0,
- width: 0,
- height: 0,
- top: 0,
- right: 0,
- bottom: 0,
- left: 0,
- toJSON: () => ({}),
- }
- const mockEntry: IntersectionObserverEntry = {
- target,
- isIntersecting,
- intersectionRatio: isIntersecting ? 0.5 : 0,
- boundingClientRect: boundingRect,
- intersectionRect: intersectionRect,
- rootBounds: null,
- time: Date.now(),
- }
- return mockEntry
- }
-
- beforeEach(() => {
- // Mock IntersectionObserver as a spyable constructor
- observerInstances = []
-
- const MockIntersectionObserverClass = vi.fn(function(
- this: IntersectionObserver,
- callback: IntersectionObserverCallback,
- options?: IntersectionObserverInit
- ) {
- observerCallback = callback
- const instance: MockObserverInstance = {
- observe: vi.fn(),
- unobserve: vi.fn(),
- disconnect: vi.fn(),
- takeRecords: vi.fn(() => []),
- root: options?.root ?? null,
- rootMargin: options?.rootMargin ?? '0px',
- thresholds: Array.isArray(options?.threshold) ? options.threshold : [options?.threshold ?? 0],
- }
- observerInstances.push(instance)
- return instance as unknown as IntersectionObserver
- }) as unknown as typeof IntersectionObserver
-
- mockIntersectionObserver = MockIntersectionObserverClass
-
- // Replace global IntersectionObserver
- global.IntersectionObserver = mockIntersectionObserver
- })
-
- afterEach(() => {
- // Clean up
- Reflect.deleteProperty(global, 'IntersectionObserver')
- })
-
- it('should initialize IntersectionObserver with correct options', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.test-element'
-
- // Create a mock element
- const mockElement = document.createElement('div')
- mockElement.className = 'test-element'
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript)
-
- expect(mockIntersectionObserver).toHaveBeenCalledWith(
- expect.any(Function),
- expect.objectContaining({
- root: document.body,
- rootMargin: '0px 0px 500px 0px',
- threshold: 0.01,
- })
- )
-
- // Clean up
- document.body.removeChild(mockElement)
- })
-
- it('should observe elements with custom targetSelector', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.custom-selector'
-
- const mockElement = document.createElement('div')
- mockElement.className = 'custom-selector'
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript)
-
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- expect(firstInstance.observe).toHaveBeenCalledWith(mockElement)
- }
-
- // Clean up
- document.body.removeChild(mockElement)
- })
-
- it('should observe elements with data-script attribute when no targetSelector provided', () => {
- const VisibleScript = createMockScript('visible', 'TestScript')
-
- const mockElement = document.createElement('div')
- mockElement.setAttribute('data-script', 'TestScript')
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript)
-
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- expect(firstInstance.observe).toHaveBeenCalledWith(mockElement)
- }
-
- // Clean up
- document.body.removeChild(mockElement)
- })
-
- it('should execute script when element becomes visible', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.lazy-element'
-
- const mockElement = document.createElement('div')
- mockElement.className = 'lazy-element'
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript)
-
- // Simulate intersection
- const mockEntry = createMockEntry(mockElement, true)
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- observerCallback([mockEntry], firstInstance as unknown as IntersectionObserver)
-
- expect(VisibleScript.mockInit).toHaveBeenCalledTimes(1)
- expect(firstInstance.unobserve).toHaveBeenCalledWith(mockElement)
- }
-
- // Clean up
- document.body.removeChild(mockElement)
- })
-
- it('should not execute script when element is not intersecting', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.lazy-element'
-
- const mockElement = document.createElement('div')
- mockElement.className = 'lazy-element'
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript)
-
- // Simulate non-intersection
- const mockEntry = createMockEntry(mockElement, false)
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- observerCallback([mockEntry], firstInstance as unknown as IntersectionObserver)
-
- expect(VisibleScript.mockInit).not.toHaveBeenCalled()
- expect(firstInstance.unobserve).not.toHaveBeenCalled()
- }
-
- // Clean up
- document.body.removeChild(mockElement)
- })
-
- it('should handle multiple elements with same selector', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.multi-element'
-
- const elements = [
- document.createElement('div'),
- document.createElement('div'),
- document.createElement('div'),
- ]
-
- elements.forEach(el => {
- el.className = 'multi-element'
- document.body.appendChild(el)
- })
-
- registerScript(VisibleScript)
-
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- expect(firstInstance.observe).toHaveBeenCalledTimes(3)
- elements.forEach(el => {
- expect(firstInstance.observe).toHaveBeenCalledWith(el)
- })
- }
-
- // Clean up
- elements.forEach(el => document.body.removeChild(el))
- })
-
- it('should execute script only once per element', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.once-element'
-
- const mockElement = document.createElement('div')
- mockElement.className = 'once-element'
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript)
-
- const mockEntry = createMockEntry(mockElement, true)
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- // Trigger first time
- observerCallback([mockEntry], firstInstance as unknown as IntersectionObserver)
- expect(VisibleScript.mockInit).toHaveBeenCalledTimes(1)
-
- // Trigger again (should not execute again)
- observerCallback([mockEntry], firstInstance as unknown as IntersectionObserver)
- expect(VisibleScript.mockInit).toHaveBeenCalledTimes(1)
- }
-
- // Clean up
- document.body.removeChild(mockElement)
- })
-
- it('should handle case when no elements found for selector', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.nonexistent-element'
-
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
-
- registerScript(VisibleScript)
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'No elements found for selector: .nonexistent-element'
- )
-
- consoleSpy.mockRestore()
- })
-
- it('should handle case when IntersectionObserver is not supported', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.test-element'
-
- // Remove IntersectionObserver
- Reflect.deleteProperty(global, 'IntersectionObserver')
-
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
-
- resetDelayedLoader()
- registerScript(VisibleScript)
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'IntersectionObserver is not supported in this browser'
- )
- expect(VisibleScript.mockInit).toHaveBeenCalledTimes(1)
-
- consoleSpy.mockRestore()
-
- // Restore IntersectionObserver for other tests
- global.IntersectionObserver = mockIntersectionObserver
- })
-
- it('should disconnect observer on reset', () => {
- const VisibleScript = createMockScript('visible', 'VisibleScript') as MockVisibleScript
- VisibleScript.targetSelector = '.test-element'
-
- const mockElement = document.createElement('div')
- mockElement.className = 'test-element'
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript)
-
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- expect(firstInstance.disconnect).not.toHaveBeenCalled()
-
- resetDelayedLoader()
-
- expect(firstInstance.disconnect).toHaveBeenCalledTimes(1)
- }
-
- // Clean up
- document.body.removeChild(mockElement)
- })
-
- it('should handle multiple scripts for same element', () => {
- const VisibleScript1 = createMockScript('visible', 'VisibleScript1') as MockVisibleScript
- const VisibleScript2 = createMockScript('visible', 'VisibleScript2') as MockVisibleScript
- VisibleScript1.targetSelector = '.shared-element'
- VisibleScript2.targetSelector = '.shared-element'
-
- const mockElement = document.createElement('div')
- mockElement.className = 'shared-element'
- document.body.appendChild(mockElement)
-
- registerScript(VisibleScript1)
- registerScript(VisibleScript2)
-
- const mockEntry = createMockEntry(mockElement, true)
- const firstInstance = observerInstances[0]
- if (firstInstance) {
- observerCallback([mockEntry], firstInstance as unknown as IntersectionObserver)
-
- expect(VisibleScript1.mockInit).toHaveBeenCalledTimes(1)
- expect(VisibleScript2.mockInit).toHaveBeenCalledTimes(1)
- }
-
- // Clean up
- document.body.removeChild(mockElement)
- })
- })
-})
\ No newline at end of file
diff --git a/src/components/scripts/loader/index.ts b/src/components/scripts/loader/index.ts
deleted file mode 100644
index 65057d0b8..000000000
--- a/src/components/scripts/loader/index.ts
+++ /dev/null
@@ -1,444 +0,0 @@
-/**
- * Generic event-driven loader for component scripts.
- * Manages script initialization based on various DOM and user interaction events.
- * Supports delayed execution to optimize performance metrics like LCP (Largest Contentful Paint).
- *
- * Features:
- * - Automatic pause/resume based on document visibility changes
- * - Event-driven script execution (DOM ready, user interaction, etc.)
- * - Singleton pattern for centralized script management
- * - Consent-gated script loading with automatic reload on consent change
- */
-
-import type {
- ConsentMetadata,
- TriggerEvent,
- UserInteractionEvent,
-} from './@types'
-import { LoadableScript } from './@types'
-import {
- $hasAdvertisingConsent,
- $hasFunctionalConsent,
- $hasAnalyticsConsent,
- type ConsentCategory,
-} from '@components/scripts/store'
-import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
-
-export const userInteractionEvents: UserInteractionEvent[] = [
- 'keydown',
- 'mousemove',
- 'wheel',
- 'touchmove',
- 'touchstart',
- 'touchend',
-]
-
-/**
- * The maximum period of time in milliseconds that execution should delay while
- * waiting for user interaction.
- */
-/** Duration before automatically executing scripts (5 seconds) */
-export const autoLoadDuration = 5000
-
-/** @deprecated Use userInteractionEvents instead */
-export const eventList = userInteractionEvents
-
-// Re-export types and the LoadableScript class for convenience
-export type { UserInteractionEvent, TriggerEvent, ConsentMetadata }
-export { LoadableScript }
-
-/**
- * Generic singleton class that manages component script execution based on various trigger events.
- * Supports immediate execution, delayed execution, and DOM event-based execution.
- */
-class Loader {
- private static instance: Loader | null = null
- private eventQueues: Map> = new Map()
- private executedEvents: Set = new Set()
- private executedScripts: Set = new Set()
- private timeoutID: number | null = null
- private isDelayedInitialized = false
- private isVisibilityListenerInitialized = false
- private intersectionObserver: IntersectionObserver | null = null
- private observedElements: Map> = new Map()
- private consentUnsubscribers: Map void> = new Map()
- private consentGatedScripts: Map> = new Map()
-
- /**
- * Private constructor to enforce singleton pattern
- */
- private constructor() {
- this.initializeDOMEventListeners()
- this.initializeVisibilityListener()
- this.initializeIntersectionObserver()
- }
-
- /**
- * Get the singleton instance
- */
- public static getInstance(): Loader {
- if (!Loader.instance) {
- Loader.instance = new Loader()
- }
- return Loader.instance
- }
-
- /**
- * Register a script to be executed when its trigger event occurs
- * @param script - The script to register
- */
- public registerScript(script: typeof LoadableScript): void {
- const eventType = script.eventType
-
- if (this.executedEvents.has(eventType)) {
- // Event already fired, execute immediately
- this.executeScript(script)
- return
- }
-
- // Add to queue for this event type
- if (!this.eventQueues.has(eventType)) {
- this.eventQueues.set(eventType, [])
- }
- this.eventQueues.get(eventType)!.push(script)
-
- // Special handling for delayed execution
- if (eventType === 'delayed' && !this.isDelayedInitialized) {
- this.initializeDelayedExecution()
- }
-
- // Special handling for visible event
- if (eventType === 'visible') {
- this.initializeVisibleEvent(script)
- }
-
- // Special handling for consent-gated scripts
- if (eventType === 'consent-gated') {
- this.initializeConsentGatedExecution(script)
- }
- }
-
- /**
- * Initialize DOM event listeners for supported events
- */
- private initializeDOMEventListeners(): void {
- const domEvents: TriggerEvent[] = [
- 'astro:before-preparation',
- 'astro:after-preparation',
- 'astro:before-swap',
- 'astro:after-swap',
- 'astro:page-load',
- ]
-
- domEvents.forEach(eventType => {
- const listener = () => {
- this.executeEvent(eventType)
- // Re-register listeners for View Transitions support
- // astro:page-load should fire on every navigation
- if (eventType === 'astro:page-load' || eventType === 'astro:after-swap') {
- // Remove this event from executedEvents so it can run again
- this.executedEvents.delete(eventType)
- // Re-register this specific listener
- document.addEventListener(eventType, listener, { once: true })
- }
- }
- document.addEventListener(eventType, listener, { once: true })
- })
- }
-
- /**
- * Remove user interaction event listeners
- * @param skipEvent - Optional event type to skip removal
- */
- private removeUserInteractionListeners(skipEvent?: UserInteractionEvent): void {
- userInteractionEvents
- .filter(_eventName => _eventName !== skipEvent)
- .forEach(_eventName => {
- // Note: We can't remove the exact listener without a reference, but since
- // we use { once: true }, they'll be automatically removed after firing
- }) // Clear timeout if it exists
- if (this.timeoutID !== null) {
- clearTimeout(this.timeoutID)
- this.timeoutID = null
- }
- }
-
- /**
- * Execute all scripts registered for a specific event
- * @param eventType - The event type to execute
- */
- private executeEvent(eventType: TriggerEvent): void {
- if (this.executedEvents.has(eventType)) {
- return // Already executed
- }
-
- this.executedEvents.add(eventType)
-
- const queue = this.eventQueues.get(eventType)
- if (!queue || queue.length === 0) {
- return
- }
-
- // Execute all scripts in the queue
- queue.forEach(script => {
- this.executeScript(script)
- })
-
- // For View Transitions: Keep the queue and allow re-execution for these events
- if (eventType !== 'astro:page-load' && eventType !== 'astro:after-swap') {
- // Clear the queue for one-time events
- this.eventQueues.delete(eventType)
- }
- }
-
- /**
- * Execute a script with error handling and logging
- * @param script - The script to execute
- */
- private executeScript(script: typeof LoadableScript): void {
- const context = { scriptName: script.scriptName, operation: 'init' }
- addScriptBreadcrumb(context)
-
- try {
- script.init()
- // Track executed scripts for pause/resume functionality
- this.executedScripts.add(script)
- } catch (error) {
- handleScriptError(error, context)
- }
- }
-
- /**
- * Reset the singleton instance (useful for testing)
- */
- public static reset(): void {
- if (Loader.instance) {
- if (Loader.instance.timeoutID !== null) {
- clearTimeout(Loader.instance.timeoutID)
- }
- // Disconnect intersection observer
- if (Loader.instance.intersectionObserver) {
- Loader.instance.intersectionObserver.disconnect()
- }
- // Unsubscribe from all consent listeners
- Loader.instance.consentUnsubscribers.forEach(unsubscribe => unsubscribe())
- Loader.instance.consentUnsubscribers.clear()
- // Clear executed scripts tracking
- Loader.instance.executedScripts.clear()
- Loader.instance.observedElements.clear()
- Loader.instance.consentGatedScripts.clear()
- Loader.instance = null
- }
- }
-
- /**
- * Initialize delayed execution with user interaction detection
- */
- private initializeDelayedExecution(): void {
- this.isDelayedInitialized = true
-
- const userInteractionListener = (event: Event) => {
- this.executeEvent('delayed')
- this.removeUserInteractionListeners(event.type as UserInteractionEvent)
- }
-
- // Set timeout for auto-execution
- this.timeoutID = window.setTimeout(() => {
- this.executeEvent('delayed')
- this.removeUserInteractionListeners()
- }, autoLoadDuration)
-
- // Add user interaction listeners
- const listenerOptions = { once: true, passive: true }
- userInteractionEvents.forEach(eventName => {
- document.addEventListener(eventName, userInteractionListener, listenerOptions)
- })
- }
-
- /**
- * Initialize visibility change listener for pause/resume functionality
- */
- private initializeVisibilityListener(): void {
- if (this.isVisibilityListenerInitialized) {
- return
- }
-
- this.isVisibilityListenerInitialized = true
-
- const handleVisibilityChange = () => {
- if (document.visibilityState === 'visible') {
- // Resume all executed scripts
- this.executedScripts.forEach(script => {
- try {
- script.resume()
- } catch (error) {
- console.error(`Error resuming script ${script.scriptName}:`, error)
- }
- })
- } else {
- // Pause all executed scripts
- this.executedScripts.forEach(script => {
- try {
- script.pause()
- } catch (error) {
- console.error(`Error pausing script ${script.scriptName}:`, error)
- }
- })
- }
- }
-
- document.addEventListener('visibilitychange', handleVisibilityChange)
- }
-
- /**
- * Initialize Intersection Observer for 'visible' event type
- */
- private initializeIntersectionObserver(): void {
- if (typeof IntersectionObserver === 'undefined') {
- console.warn('IntersectionObserver is not supported in this browser')
- return
- }
-
- const options: IntersectionObserverInit = {
- root: document.body,
- rootMargin: '0px 0px 500px 0px',
- threshold: 0.01,
- }
-
- this.intersectionObserver = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- const scripts = this.observedElements.get(entry.target)
- if (scripts) {
- scripts.forEach(script => {
- this.executeScript(script)
- })
- // Stop observing this element
- this.intersectionObserver?.unobserve(entry.target)
- this.observedElements.delete(entry.target)
- }
- }
- })
- }, options)
- }
-
- /**
- * Initialize visible event for a specific script
- * @param script - The script to initialize visible event for
- */
- private initializeVisibleEvent(script: typeof LoadableScript): void {
- if (!this.intersectionObserver) {
- console.warn('IntersectionObserver not available, executing script immediately')
- this.executeScript(script)
- return
- }
-
- const selector = script.targetSelector || `[data-script="${script.scriptName}"]`
- const elements = document.querySelectorAll(selector)
-
- if (elements.length === 0) {
- console.warn(`No elements found for selector: ${selector}`)
- return
- }
-
- elements.forEach(element => {
- if (!this.observedElements.has(element)) {
- this.observedElements.set(element, [])
- }
- this.observedElements.get(element)!.push(script)
- this.intersectionObserver!.observe(element)
- })
- }
-
- /**
- * Initialize consent-gated execution for a specific script
- * Subscribes to consent changes and automatically loads/unloads scripts
- * @param script - The script to initialize consent-gated execution for
- */
- private initializeConsentGatedExecution(script: typeof LoadableScript): void {
- const consentCategory = script.meta?.consentCategory as ConsentCategory | undefined
-
- if (!consentCategory) {
- console.warn(
- `Script ${script.scriptName} is consent-gated but missing meta.consentCategory. Skipping.`
- )
- return
- }
-
- // Track this script for the consent category
- if (!this.consentGatedScripts.has(consentCategory)) {
- this.consentGatedScripts.set(consentCategory, new Set())
- }
- this.consentGatedScripts.get(consentCategory)!.add(script)
-
- // Get the appropriate consent store
- const consentStore = this.getConsentStore(consentCategory)
-
- // Subscribe to consent changes
- const unsubscribe = consentStore.subscribe(hasConsent => {
- if (hasConsent) {
- // Consent granted - load script if not already executed
- if (!this.executedScripts.has(script)) {
- this.executeScript(script)
- }
- } else {
- // Consent revoked - pause script if it's running
- if (this.executedScripts.has(script)) {
- try {
- script.pause()
- // Optionally remove from executed set if you want it to be able to restart
- // this.executedScripts.delete(script)
- } catch (error) {
- console.error(`Error pausing script ${script.scriptName}:`, error)
- }
- }
- }
- })
-
- // Store unsubscribe function for cleanup
- this.consentUnsubscribers.set(`${consentCategory}-${script.scriptName}`, unsubscribe)
- }
-
- /**
- * Get the appropriate consent store for a category
- * @param category - The consent category
- * @returns The consent store for the category
- */
- private getConsentStore(category: ConsentCategory) {
- switch (category) {
- case 'analytics':
- return $hasAnalyticsConsent
- case 'functional':
- return $hasFunctionalConsent
- case 'advertising':
- return $hasAdvertisingConsent
- case 'necessary':
- // Necessary scripts don't need consent - return a store that's always true
- return {
- subscribe: (callback: (_value: boolean) => void) => {
- callback(true)
- return () => {} // No-op unsubscribe
- },
- get: () => true,
- }
- default:
- throw new Error(`Unknown consent category: ${category}`)
- }
- }
-}
-
-/**
- * Register a script to be executed when its trigger event occurs
- * @param script - The script to register
- */
-export const registerScript = (script: typeof LoadableScript): void => {
- const loader = Loader.getInstance()
- loader.registerScript(script)
-}
-
-/**
- * Reset the loader (useful for testing)
- */
-export const resetDelayedLoader = (): void => {
- Loader.reset()
-}
From b2c034c30b0a62079dc2cc7fb0578554fbc41aea Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Fri, 7 Nov 2025 17:01:44 +0300
Subject: [PATCH 025/341] Move styles in theme picker component to Tailwind
classes in web component
---
FRAMEWORK_CHOICE.md | 97 +++++++++++++
src/components/ThemePicker/Themes.astro | 132 ------------------
src/components/ThemePicker/_example.ts | 85 +++++++++++
src/components/ThemePicker/index.astro | 125 ++++++++++++++++-
.../scripts/bootstrap/consent/NOTES.md | 4 +
5 files changed, 308 insertions(+), 135 deletions(-)
create mode 100644 FRAMEWORK_CHOICE.md
delete mode 100644 src/components/ThemePicker/Themes.astro
create mode 100644 src/components/ThemePicker/_example.ts
diff --git a/FRAMEWORK_CHOICE.md b/FRAMEWORK_CHOICE.md
new file mode 100644
index 000000000..2aa5bb9d2
--- /dev/null
+++ b/FRAMEWORK_CHOICE.md
@@ -0,0 +1,97 @@
+# Analysis: Web Components vs Preact vs React
+
+Project Context Summary
+
+- Current state: 16 components recently refactored from LoadableScript to direct initialization
+- ThemePicker: Successfully migrated to Web Component (proof of concept)
+- Key requirements:
+ - View Transitions persistence (transition:persist)
+Nanostores integration (already in place)
+Animation lifecycle management (pause/resume)
+Cookie consent gating system
+GSAP (Hero) and Embla (Carousel) library integration
+Option 1: Web Components (Custom Elements) ⭐ RECOMMENDED
+Pros for Your Project:
+Perfect View Transitions fit: Native transition:persist support - your ThemePicker proof-of-concept already demonstrates this works seamlessly
+Zero bundle overhead: No framework bytes, aligns with current lightweight approach
+Lifecycle hooks match your needs:
+connectedCallback() - perfect for astro:page-load replacement
+disconnectedCallback() - clean cleanup
+adoptedCallback() - works with View Transitions DOM moves
+Nanostores integration: Already proven in ThemePicker - $themePickerOpen, $theme work perfectly
+Animation control: Can directly implement pause/resume via methods
+Progressive enhancement: Works without JavaScript, gracefully degrades
+No build complexity: No compat layers, no React wrappers
+Cons for Your Project:
+No Embla React component: Need to use vanilla Embla API (but you already are)
+No GSAP React hooks: Must use vanilla GSAP (but you already are)
+Verbose syntax: More boilerplate than framework components
+TypeScript complexity: Defining element types requires more setup
+Option 2: Preact
+Pros for Your Project:
+Smaller than React: 3KB vs 40KB+
+Nanostores hooks: @nanostores/preact provides useStore() without compat
+Component model: Familiar JSX, easier than Web Components for some
+Cons for Your Project:
+GSAP requires compat: @gsap/react needs preact/compat library (+overhead)
+No Embla Preact component: embla-carousel-react won't work, need compat layer
+View Transitions friction: transition:persist requires client:only directive, loses SSR benefits
+Bundle size: Still adds framework weight (even if small)
+Complexity mismatch: Your components are mostly imperative DOM manipulation, not reactive state
+Option 3: React
+Pros for Your Project:
+Embla component: embla-carousel-react available natively
+GSAP hooks: @gsap/react available (though useGSAP hook is for convenience, not required)
+Ecosystem: Largest component ecosystem
+Cons for Your Project:
+Massive bundle: 40KB+ gzipped - conflicts with your performance focus
+Same View Transitions friction as Preact
+Astro mismatch: Astro is explicitly optimized for minimal JS, React conflicts with this philosophy
+Overkill: Your components don't need React's reactivity model - they're mostly event-driven DOM manipulation
+Recommendation: Web Components (Custom Elements)
+Rationale:
+Your ThemePicker is the perfect template: It proves Web Components work excellently with:
+
+Nanostores ($themePickerOpen, $theme)
+View Transitions persistence
+Modal state management
+Your error handling system
+Migration path is proven:
+
+
+// Current patterndocument.addEventListener('astro:page-load', () => Component.init())// Web Component pattern (cleaner)customElements.define('component-name', ComponentElement)// in template - automatically initializes/cleans up
+GSAP and Embla don't need React:
+
+Your Hero already uses vanilla GSAP effectively
+Your Carousel uses vanilla Embla - the React wrapper is just convenience
+Web Components can call these libraries directly in connectedCallback()
+Consent gating fits naturally:
+
+
+connectedCallback() { const hasConsent = $hasAnalyticsConsent.get() if (hasConsent) { this.startTracking() } // Listen for consent changes this.unsubscribe = $hasAnalyticsConsent.subscribe(...)}
+Animation lifecycle integrates cleanly:
+
+
+class AnimatedComponent extends HTMLElement { pause() { this.timeline?.pause() } resume() { this.timeline?.play() } connectedCallback() { onAnimationEvent(OVERLAY_OPENED, () => this.pause()) onAnimationEvent(OVERLAY_CLOSED, () => this.resume()) }}
+Implementation Strategy:
+Phase 1 (Start with simple components):
+
+Footer, NetworkStatus, SocialShare - minimal logic, good learning
+Phase 2 (Medium complexity):
+
+CookieConsent, CookieCustomize - modal management like ThemePicker
+Navigation - event handling, state management
+Phase 3 (Complex):
+
+Carousel - Embla integration, multiple instances
+Hero - GSAP timeline management
+EmbedManager - lazy loading, intersection observer
+Template Pattern:
+
+export class ComponentName extends HTMLElement { private cleanup: Array<() => void> = [] connectedCallback() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.initialize()) } else { this.initialize() } } private initialize() { // Nanostores this.cleanup.push($store.subscribe(val => this.update(val))) // Animation events this.cleanup.push(onAnimationEvent(OVERLAY_OPENED, () => this.pause())) // DOM events this.button.addEventListener('click', this.handleClick) this.cleanup.push(() => this.button.removeEventListener('click', this.handleClick)) } disconnectedCallback() { this.cleanup.forEach(fn => fn()) }}
+Why Not Frameworks Here:
+Your components are imperative (DOM manipulation, timelines, observers) not declarative (state → UI)
+Adding React/Preact brings reactive overhead for components that don't need it
+transition:persist works natively with Web Components, requires workarounds with frameworks
+Your codebase is already "vanilla-first" - Web Components continue this philosophy
+Bottom line: Web Components are the natural evolution of your current architecture, proven by ThemePicker success, with zero philosophical or technical conflicts.
\ No newline at end of file
diff --git a/src/components/ThemePicker/Themes.astro b/src/components/ThemePicker/Themes.astro
deleted file mode 100644
index 8ce77fa59..000000000
--- a/src/components/ThemePicker/Themes.astro
+++ /dev/null
@@ -1,132 +0,0 @@
----
-import { themes } from '@components/ThemePicker/server'
-import Sprite from '@components/Sprite/index.astro'
-import type { SpriteName } from '@components/Sprite/sprites'
-
-export interface Props {
- closeComponent?: string
- label?: string
-}
-
-const { closeComponent = 'close', label = 'Select theme' } = Astro.props
----
-
-
-
-
-
-
- {label}
-
-
-
-
-
-
-
-
-
diff --git a/src/components/ThemePicker/_example.ts b/src/components/ThemePicker/_example.ts
new file mode 100644
index 000000000..96d1a915c
--- /dev/null
+++ b/src/components/ThemePicker/_example.ts
@@ -0,0 +1,85 @@
+import { LitElement } from 'lit'
+import { StoreController } from '@nanostores/lit'
+import { $theme, $themePickerOpen, setTheme, type ThemeId } from '@components/scripts/store'
+
+/**
+ * Lit-based controller for ThemePicker
+ * Uses existing Astro-rendered DOM (Light DOM), just adds reactive behavior
+ */
+export class ThemePickerController extends LitElement {
+ // Render to Light DOM instead of Shadow DOM
+ createRenderRoot() {
+ return this // This is the key - no shadow DOM!
+ }
+
+ // Reactive store bindings
+ private themePickerOpen = new StoreController(this, $themePickerOpen)
+ private theme = new StoreController(this, $theme)
+
+ // Cache DOM elements
+ private pickerModal!: HTMLDivElement
+ private toggleBtn!: HTMLButtonElement
+ private closeBtn!: HTMLButtonElement
+ private themeSelectBtns!: NodeListOf
+
+ firstUpdated() {
+ // Find elements in Light DOM (rendered by Astro)
+ this.pickerModal = this.querySelector('[data-theme-modal]')!
+ this.toggleBtn = document.querySelector('[data-theme-toggle]')!
+ this.closeBtn = this.querySelector('[data-theme-close]')!
+ this.themeSelectBtns = this.querySelectorAll('[data-theme]')!
+
+ // Bind event handlers
+ this.toggleBtn.addEventListener('click', () => this.handleToggle())
+ this.closeBtn.addEventListener('click', () => this.handleClose())
+
+ this.themeSelectBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const themeId = btn.dataset.theme as ThemeId
+ setTheme(themeId)
+ })
+ })
+ }
+
+ // Lit's reactive update cycle handles store changes
+ updated() {
+ const isOpen = this.themePickerOpen.value
+ const currentTheme = this.theme.value
+
+ // Update modal visibility
+ if (isOpen) {
+ this.pickerModal.removeAttribute('hidden')
+ this.pickerModal.classList.add('is-open')
+ this.toggleBtn.setAttribute('aria-expanded', 'true')
+ } else {
+ this.pickerModal.classList.remove('is-open')
+ this.toggleBtn.setAttribute('aria-expanded', 'false')
+ setTimeout(() => {
+ if (!this.themePickerOpen.value) {
+ this.pickerModal.setAttribute('hidden', '')
+ }
+ }, 400) // Match CSS transition duration
+ }
+
+ // Update active theme button
+ this.themeSelectBtns.forEach(btn => {
+ if (btn.dataset.theme === currentTheme) {
+ btn.parentElement?.classList.add('is-active')
+ } else {
+ btn.parentElement?.classList.remove('is-active')
+ }
+ })
+ }
+
+ private handleToggle() {
+ $themePickerOpen.set(!this.themePickerOpen.value)
+ }
+
+ private handleClose() {
+ $themePickerOpen.set(false)
+ }
+
+ // No render() method needed - Astro already rendered the DOM!
+}
+
+customElements.define('theme-picker', ThemePickerController)
diff --git a/src/components/ThemePicker/index.astro b/src/components/ThemePicker/index.astro
index 3be1c741f..8ba11aa90 100644
--- a/src/components/ThemePicker/index.astro
+++ b/src/components/ThemePicker/index.astro
@@ -4,9 +4,128 @@
* Uses Web Components for clean, self-contained functionality
* Works seamlessly with Astro View Transitions
*/
+import { themes } from '@components/ThemePicker/server'
+import Sprite from '@components/Sprite/index.astro'
+import type { SpriteName } from '@components/Sprite/sprites'
-// Import the actual theme picker component
-import Themes from '@components/ThemePicker/Themes.astro'
+export interface Props {
+ closeComponent?: string
+ label?: string
+}
+
+const { closeComponent = 'close', label = 'Select theme' } = Astro.props
---
-
\ No newline at end of file
+
+
+
+
+
+ {label}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/scripts/bootstrap/consent/NOTES.md b/src/components/scripts/bootstrap/consent/NOTES.md
index 636f99d7a..15f677ee7 100644
--- a/src/components/scripts/bootstrap/consent/NOTES.md
+++ b/src/components/scripts/bootstrap/consent/NOTES.md
@@ -1,5 +1,9 @@
# Consent Category Usage Summary
+## OT: Frameworks Notes
+
+View Transitions friction: transition:persist requires client:only directive, loses SSR benefits
+
## Categories
### 'necessary' - Infrastructure only, NO actual usage
From 81188a5959029fa473237bf8cd50fb670a3a4a6a Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Fri, 7 Nov 2025 23:14:50 +0300
Subject: [PATCH 026/341] Migrate theme picker component to Lit web component
and fix bugs
---
.cache/pages.json | 11 +-
eslint.config.ts | 15 +-
package-lock.json | 59 ++++
package.json | 2 +
src/components/Head/ThemeInit.astro | 34 +++
src/components/Head/index.astro | 3 +
.../ThemePicker/__fixtures__/domHelpers.ts | 158 ----------
.../ThemePicker/__fixtures__/mockData.ts | 2 +-
src/components/ThemePicker/_example.ts | 85 ------
.../{theme-picker-element.ts => client.ts} | 289 ++++++++----------
src/components/ThemePicker/index.astro | 2 +-
src/components/ThemePicker/selectors.ts | 63 ++++
src/components/ThemePicker/server.ts | 8 +-
.../scripts/bootstrap/__tests__/index.spec.ts | 38 ++-
.../scripts/bootstrap/consent/NOTES.md | 61 ++++
src/components/scripts/bootstrap/index.ts | 12 +-
.../scripts/bootstrap/theme/index.ts | 2 +-
.../scripts/store/__tests__/themes.spec.ts | 4 +-
src/components/scripts/store/index.ts | 1 +
src/components/scripts/store/themes.ts | 130 +++++++-
20 files changed, 505 insertions(+), 474 deletions(-)
create mode 100644 src/components/Head/ThemeInit.astro
delete mode 100644 src/components/ThemePicker/__fixtures__/domHelpers.ts
delete mode 100644 src/components/ThemePicker/_example.ts
rename src/components/ThemePicker/{theme-picker-element.ts => client.ts} (50%)
create mode 100644 src/components/ThemePicker/selectors.ts
diff --git a/.cache/pages.json b/.cache/pages.json
index b8605f6d4..4844728a4 100644
--- a/.cache/pages.json
+++ b/.cache/pages.json
@@ -32,15 +32,10 @@
"offline",
"privacy",
{
- "services": [
- "create-custom-font-sets",
- "overview"
- ]
+ "services": ["create-custom-font-sets", "overview"]
},
{
- "social-shares": [
- "template"
- ]
+ "social-shares": ["template"]
},
{
"tags": [
@@ -55,4 +50,4 @@
"typescript"
]
}
-]
\ No newline at end of file
+]
diff --git a/eslint.config.ts b/eslint.config.ts
index 075e0a32a..d932fe28d 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -84,16 +84,11 @@ export default [
},
],
'import/no-webpack-loader-syntax': level,
- 'import/extensions': [
- level,
- 'ignorePackages',
- {
- ts: 'never',
- tsx: 'never',
- js: 'never',
- jsx: 'never',
- },
- ],
+ 'import/extensions': [level, "never", {
+ "astro": "always",
+ "json": "always",
+ "svg": "always"
+ }],
'import/order': 'off',
'jsdoc/check-indentation': level,
'jsdoc/check-line-alignment': level,
diff --git a/package-lock.json b/package-lock.json
index 1d893d867..460b3b81c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@astrojs/vercel": "^9.0.0",
"@axe-core/playwright": "^4.11.0",
"@glidejs/glide": "^3.7.1",
+ "@nanostores/lit": "^0.2.3",
"@nanostores/persistent": "^1.2.0",
"@sentry/astro": "^10.22.0",
"@sentry/browser": "^10.22.0",
@@ -37,6 +38,7 @@
"gsap": "^3.13.0",
"js-cookie": "^3.0.5",
"libphonenumber-js": "1.12.25",
+ "lit": "^3.3.1",
"nanostores": "^1.0.1",
"postcss": "8.5.6",
"postcss-html": "1.8.0",
@@ -4461,6 +4463,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@lit-labs/ssr-dom-shim": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz",
+ "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@lit/reactive-element": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz",
+ "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.4.0"
+ }
+ },
"node_modules/@mapbox/node-pre-gyp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz",
@@ -4519,6 +4536,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/@nanostores/lit": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@nanostores/lit/-/lit-0.2.3.tgz",
+ "integrity": "sha512-hIUKzdNrgKXkXCsppzQxmYYYMTn96cncijWhzcYZKn3Yimqj7B6YK94qN/KObjBNEe0xR3JLgMo96ew5fVtW2w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "lit": "^2.6.0 || ^3.0.0",
+ "nanostores": ">=0.7 < 1.0.0 || ^1.0.0"
+ }
+ },
"node_modules/@nanostores/persistent": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@nanostores/persistent/-/persistent-1.2.0.tgz",
@@ -15199,6 +15226,38 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lit": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
+ "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
+ "license": "BSD-3-Clause",
+ "peer": true,
+ "dependencies": {
+ "@lit/reactive-element": "^2.1.0",
+ "lit-element": "^4.2.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-element": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz",
+ "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.4.0",
+ "@lit/reactive-element": "^2.1.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-html": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz",
+ "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
"node_modules/load-bmfont": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz",
diff --git a/package.json b/package.json
index 012876e65..6568879b1 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"@astrojs/vercel": "^9.0.0",
"@axe-core/playwright": "^4.11.0",
"@glidejs/glide": "^3.7.1",
+ "@nanostores/lit": "^0.2.3",
"@nanostores/persistent": "^1.2.0",
"@sentry/astro": "^10.22.0",
"@sentry/browser": "^10.22.0",
@@ -78,6 +79,7 @@
"gsap": "^3.13.0",
"js-cookie": "^3.0.5",
"libphonenumber-js": "1.12.25",
+ "lit": "^3.3.1",
"nanostores": "^1.0.1",
"postcss": "8.5.6",
"postcss-html": "1.8.0",
diff --git a/src/components/Head/ThemeInit.astro b/src/components/Head/ThemeInit.astro
new file mode 100644
index 000000000..8ec0c53dc
--- /dev/null
+++ b/src/components/Head/ThemeInit.astro
@@ -0,0 +1,34 @@
+---
+/**
+ * Theme Initialization - Inline Script
+ *
+ * CRITICAL: This script MUST be inlined (is:inline) and run as early as possible
+ * to prevent Flash of Unstyled Content (FOUC).
+ *
+ * Purpose: Set data-theme attribute on from localStorage before page renders
+ *
+ * Keep this script minimal - it runs synchronously and blocks rendering.
+ * Full theme system initialization happens later in AppBootstrap.
+ */
+---
+
+
diff --git a/src/components/Head/index.astro b/src/components/Head/index.astro
index 8f3ff4301..aadbdd1a2 100644
--- a/src/components/Head/index.astro
+++ b/src/components/Head/index.astro
@@ -1,5 +1,6 @@
---
import Meta from '@components/Head/Meta.astro'
+import ThemeInit from '@components/Head/ThemeInit.astro'
export interface Props {
pageTitle: string
@@ -34,6 +35,8 @@ const {
{/* hoisting issues related to and elements */}
{pageTitle}
+{/* Inline script to set theme ASAP - prevents FOUC */}
+
{/* Site tags, Open Graph social tags, and favicon, PWA manifest, */}
{/* RSS, canonical, pingback, and webmention links */}
{
- return `
-
-
- ${themeId.toUpperCase()}
-
-
-
-
-
-
-
-
-
-`
-}
-
-/**
- * Generates header HTML with theme picker toggle button
- */
-export const getHeader = (): string => {
- return `
-
-`
-}
-
-/**
- * Generates the complete theme picker modal HTML structure
- */
-export const getThemePickerModalHTML = (): string => {
- const themeItems = mockThemes.map(getThemePickerItemCard).join('')
-
- return `
-
-`
-}
-
-/**
- * Setup DOM for testing theme picker
- */
-export function setupThemePickerDOM(): void {
- document.body.innerHTML = getHeader() + getThemePickerModalHTML()
-}
-
-/**
- * Clean up DOM after tests
- */
-export function cleanupThemePickerDOM(): void {
- document.body.innerHTML = ''
-}
-
-/**
- * Setup localStorage mock
- */
-export function setupLocalStorageMock(): void {
- const localStorageMock = {
- getItem: vi.fn(),
- setItem: vi.fn(),
- removeItem: vi.fn(),
- clear: vi.fn(),
- }
-
- Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
- writable: true,
- })
-}
-
-/**
- * Setup matchMedia mock for system theme detection
- */
-export function setupMatchMediaMock(matches: boolean = false): void {
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query: string) => ({
- matches,
- media: query,
- onchange: null,
- addListener: vi.fn(), // deprecated
- removeListener: vi.fn(), // deprecated
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- })
-}
-
-/**
- * Helper to get specific DOM elements for testing
- */
-export const getDOMElements = () => ({
- header: document.querySelector('#header'),
- toggleBtn: document.querySelector('.themepicker-toggle__toggle-btn'),
- pickerModal: document.querySelector('.themepicker'),
- closeBtn: document.querySelector('.themepicker__closeBtn'),
- themeSelectBtns: document.querySelectorAll('.themepicker__selectBtn'),
-})
-
-/**
- * Helper to simulate user interactions
- */
-export const userInteractions = {
- clickToggleButton: () => {
- const toggleBtn = document.querySelector('.themepicker-toggle__toggle-btn')
- if (toggleBtn) {
- toggleBtn.click()
- }
- },
-
- clickCloseButton: () => {
- const closeBtn = document.querySelector('.themepicker__closeBtn')
- if (closeBtn) {
- closeBtn.click()
- }
- },
-
- clickThemeButton: (themeId: string) => {
- const themeBtn = document.querySelector(`[data-theme="${themeId}"]`)
- if (themeBtn) {
- themeBtn.click()
- }
- },
-}
diff --git a/src/components/ThemePicker/__fixtures__/mockData.ts b/src/components/ThemePicker/__fixtures__/mockData.ts
index 72b5cd91f..a3b702340 100644
--- a/src/components/ThemePicker/__fixtures__/mockData.ts
+++ b/src/components/ThemePicker/__fixtures__/mockData.ts
@@ -7,7 +7,7 @@ import type { ThemeId } from '@components/scripts/store'
/**
* Mock themes for testing
*/
-export const mockThemes: ThemeId[] = ['default', 'dark', 'holiday']
+export const mockThemes: ThemeId[] = ['light', 'dark', 'holiday']
/**
* Mock meta colors object
diff --git a/src/components/ThemePicker/_example.ts b/src/components/ThemePicker/_example.ts
deleted file mode 100644
index 96d1a915c..000000000
--- a/src/components/ThemePicker/_example.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { LitElement } from 'lit'
-import { StoreController } from '@nanostores/lit'
-import { $theme, $themePickerOpen, setTheme, type ThemeId } from '@components/scripts/store'
-
-/**
- * Lit-based controller for ThemePicker
- * Uses existing Astro-rendered DOM (Light DOM), just adds reactive behavior
- */
-export class ThemePickerController extends LitElement {
- // Render to Light DOM instead of Shadow DOM
- createRenderRoot() {
- return this // This is the key - no shadow DOM!
- }
-
- // Reactive store bindings
- private themePickerOpen = new StoreController(this, $themePickerOpen)
- private theme = new StoreController(this, $theme)
-
- // Cache DOM elements
- private pickerModal!: HTMLDivElement
- private toggleBtn!: HTMLButtonElement
- private closeBtn!: HTMLButtonElement
- private themeSelectBtns!: NodeListOf
-
- firstUpdated() {
- // Find elements in Light DOM (rendered by Astro)
- this.pickerModal = this.querySelector('[data-theme-modal]')!
- this.toggleBtn = document.querySelector('[data-theme-toggle]')!
- this.closeBtn = this.querySelector('[data-theme-close]')!
- this.themeSelectBtns = this.querySelectorAll('[data-theme]')!
-
- // Bind event handlers
- this.toggleBtn.addEventListener('click', () => this.handleToggle())
- this.closeBtn.addEventListener('click', () => this.handleClose())
-
- this.themeSelectBtns.forEach(btn => {
- btn.addEventListener('click', () => {
- const themeId = btn.dataset.theme as ThemeId
- setTheme(themeId)
- })
- })
- }
-
- // Lit's reactive update cycle handles store changes
- updated() {
- const isOpen = this.themePickerOpen.value
- const currentTheme = this.theme.value
-
- // Update modal visibility
- if (isOpen) {
- this.pickerModal.removeAttribute('hidden')
- this.pickerModal.classList.add('is-open')
- this.toggleBtn.setAttribute('aria-expanded', 'true')
- } else {
- this.pickerModal.classList.remove('is-open')
- this.toggleBtn.setAttribute('aria-expanded', 'false')
- setTimeout(() => {
- if (!this.themePickerOpen.value) {
- this.pickerModal.setAttribute('hidden', '')
- }
- }, 400) // Match CSS transition duration
- }
-
- // Update active theme button
- this.themeSelectBtns.forEach(btn => {
- if (btn.dataset.theme === currentTheme) {
- btn.parentElement?.classList.add('is-active')
- } else {
- btn.parentElement?.classList.remove('is-active')
- }
- })
- }
-
- private handleToggle() {
- $themePickerOpen.set(!this.themePickerOpen.value)
- }
-
- private handleClose() {
- $themePickerOpen.set(false)
- }
-
- // No render() method needed - Astro already rendered the DOM!
-}
-
-customElements.define('theme-picker', ThemePickerController)
diff --git a/src/components/ThemePicker/theme-picker-element.ts b/src/components/ThemePicker/client.ts
similarity index 50%
rename from src/components/ThemePicker/theme-picker-element.ts
rename to src/components/ThemePicker/client.ts
index 4a95f0f12..7cd9400bb 100644
--- a/src/components/ThemePicker/theme-picker-element.ts
+++ b/src/components/ThemePicker/client.ts
@@ -1,12 +1,20 @@
/**
- * ThemePicker Web Component
- * Manages theme selection and persistence using native Custom Elements API
+ * ThemePicker Web Component (Lit + Nanostores)
+ * Manages theme selection and persistence using Lit and Nanostores
* Works seamlessly with Astro View Transitions
*/
+import { LitElement } from 'lit'
+import { StoreController } from '@nanostores/lit'
import { $theme, $themePickerOpen, setTheme, type ThemeId } from '@components/scripts/store'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
import { addButtonEventListeners } from '@components/scripts/elementListeners'
+import {
+ getThemePickerModal,
+ getThemePickerToggleBtn,
+ getThemePickerCloseBtn,
+ getThemeSelectBtns,
+} from './selectors'
export const CLASSES = {
isOpen: 'is-open',
@@ -27,32 +35,38 @@ declare global {
}
/**
- * ThemePicker Custom Element
- * Handles theme selection, modal interactions, and persistence
+ * ThemePicker Custom Element (Lit-based)
+ * Uses Light DOM (no Shadow DOM) with Astro-rendered templates
+ * Lit provides reactive store integration via StoreController
*/
-export class ThemePickerElement extends HTMLElement {
- private isModalOpen = false
- private activeTheme: ThemeId
+export class ThemePickerElement extends LitElement {
+ // Render to Light DOM instead of Shadow DOM
+ override createRenderRoot() {
+ return this // No shadow DOM - works with Astro templates!
+ }
+
+ // Reactive store bindings - Lit auto-updates when these change
+ private themeStore = new StoreController(this, $theme)
+ private themePickerOpenStore = new StoreController(this, $themePickerOpen)
+
+ // Cache DOM elements
private pickerModal!: HTMLDivElement
private toggleBtn!: HTMLButtonElement
private closeBtn!: HTMLButtonElement
private themeSelectBtns!: NodeListOf
- constructor() {
- super()
- this.activeTheme = this.getInitialActiveTheme()
- }
+ // Track View Transitions
+ private isTransitioning = false
/**
- * Called when element is added to DOM
- * This is the Web Component lifecycle method
+ * Lit lifecycle: called when element is connected
*/
- connectedCallback(): void {
+ override connectedCallback(): void {
+ super.connectedCallback()
const context = { scriptName: 'ThemePickerElement', operation: 'connectedCallback' }
addScriptBreadcrumb(context)
// Wait for DOM to be ready before initializing
- // This ensures the toggle button in Header exists
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.initialize())
} else {
@@ -77,38 +91,29 @@ export class ThemePickerElement extends HTMLElement {
return
}
- // Sync active theme with what's already set in DOM by HEAD script
- const domTheme = document.documentElement.dataset['theme'] as ThemeId | undefined
- if (domTheme && domTheme !== this.activeTheme) {
- this.activeTheme = domTheme
- }
-
- this.setActiveItem()
this.bindEvents()
+ this.setViewTransitionHandlers()
- // Restore modal state from store (for View Transitions persistence)
- const wasOpen = $themePickerOpen.get()
- if (wasOpen) {
- // Set open state without animation (for initial page load/navigation)
- this.isModalOpen = true
- this.pickerModal.removeAttribute('hidden')
- this.pickerModal.classList.add(CLASSES.isOpen)
- this.toggleBtn.setAttribute('aria-expanded', 'true')
- // Sync store (already true, but ensures consistency)
- $themePickerOpen.set(true)
- }
+ // Initial update (Lit + StoreController will handle all reactivity automatically)
+ this.requestUpdate()
} catch (error) {
handleScriptError(error, context)
}
}
/**
- * Called when element is removed from DOM
- * Cleanup event listeners
+ * Lit lifecycle: called after properties change
+ * Lit automatically calls this when stores update via StoreController
*/
- disconnectedCallback(): void {
- // Event listeners are automatically cleaned up when element is removed
- // but we can do additional cleanup here if needed
+ override updated(): void {
+ const isOpen = this.themePickerOpenStore.value
+ const currentTheme = this.themeStore.value
+
+ // Update modal visibility
+ this.updateModalVisibility(isOpen)
+
+ // Update active theme button
+ this.updateActiveTheme(currentTheme)
}
/**
@@ -117,77 +122,10 @@ export class ThemePickerElement extends HTMLElement {
private findElements(): void {
const context = { scriptName: 'ThemePickerElement', operation: 'findElements' }
addScriptBreadcrumb(context)
-
- try {
- // Use this.querySelector to scope to this component instance
- this.pickerModal = this.querySelector('[data-theme-modal]') as HTMLDivElement
- this.toggleBtn = document.querySelector('[data-theme-toggle]') as HTMLButtonElement
- this.closeBtn = this.querySelector('[data-theme-close]') as HTMLButtonElement
- this.themeSelectBtns = this.querySelectorAll('[data-theme]') as NodeListOf
-
- if (!this.pickerModal || !this.toggleBtn || !this.closeBtn) {
- throw new Error('Required theme picker elements not found')
- }
- } catch (error) {
- throw new Error(
- `ThemePicker: Failed to find required DOM elements - ${error instanceof Error ? error.message : 'Unknown error'}`
- )
- }
- }
-
- /**
- * Get the initial theme based on stored preference or system preference
- */
- private getInitialActiveTheme(): ThemeId {
- const context = { scriptName: 'ThemePickerElement', operation: 'getInitialTheme' }
- addScriptBreadcrumb(context)
-
- try {
- const storedPreference = this.getStoredPreference()
- const systemPreference = this.getSystemPreference()
-
- if (storedPreference) {
- return storedPreference
- } else if (systemPreference) {
- return systemPreference
- } else {
- return 'default'
- }
- } catch (error) {
- handleScriptError(error, context)
- return 'default'
- }
- }
-
- /**
- * Get theme preference from state store
- */
- private getStoredPreference(): ThemeId | false {
- try {
- // Check if theme is actually stored in localStorage (not just default initialization)
- const hasStoredTheme = localStorage.getItem('theme') !== null
- if (!hasStoredTheme) {
- return false
- }
- const storedTheme = $theme.get() as ThemeId
- return storedTheme || false
- } catch {
- return false
- }
- }
-
- /**
- * Get system theme preference
- */
- private getSystemPreference(): ThemeId | false {
- try {
- if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
- return 'dark'
- }
- return false
- } catch {
- return false
- }
+ this.pickerModal = getThemePickerModal(this)
+ this.toggleBtn = getThemePickerToggleBtn()
+ this.closeBtn = getThemePickerCloseBtn(this)
+ this.themeSelectBtns = getThemeSelectBtns(this)
}
/**
@@ -199,9 +137,10 @@ export class ThemePickerElement extends HTMLElement {
try {
// Toggle button
- addButtonEventListeners(this.toggleBtn, () => this.togglePicker(), this)
+ addButtonEventListeners(this.toggleBtn, () => this.handleToggle(), this)
+
// Close button
- addButtonEventListeners(this.closeBtn, () => this.togglePicker(false), this)
+ addButtonEventListeners(this.closeBtn, () => this.handleClose(), this)
// Theme selection buttons
this.themeSelectBtns.forEach((button) => {
@@ -225,35 +164,21 @@ export class ThemePickerElement extends HTMLElement {
// Close on Escape key
document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && this.isModalOpen) {
- this.togglePicker(false)
+ if (e.key === 'Escape' && this.themePickerOpenStore.value) {
+ this.handleClose()
}
})
- // Track if we're in a View Transition to prevent closing modal during navigation
- let isTransitioning = false
-
- document.addEventListener('astro:before-preparation', () => {
- isTransitioning = true
- // Modal state is persisted via $themePickerOpen store
- // Don't close the modal - it should stay open across navigation
- })
-
- document.addEventListener('astro:after-swap', () => {
- isTransitioning = false
- })
-
// Close on click outside (but not during View Transitions)
document.addEventListener('click', (e) => {
- if (this.isModalOpen && !isTransitioning) {
+ if (this.themePickerOpenStore.value && !this.isTransitioning) {
const target = e.target as HTMLElement
- // Check if click is on or inside the modal, toggle button, or any theme button
const isInsideModal = this.pickerModal.contains(target)
const isToggleButton = this.toggleBtn.contains(target)
const isThemeButton = Array.from(this.themeSelectBtns).some(btn => btn.contains(target))
if (!isInsideModal && !isToggleButton && !isThemeButton) {
- this.togglePicker(false)
+ this.handleClose()
}
}
})
@@ -263,15 +188,58 @@ export class ThemePickerElement extends HTMLElement {
}
/**
- * Set active theme button styling
+ * Set up View Transition handlers
+ */
+ private setViewTransitionHandlers(): void {
+ document.addEventListener('astro:before-preparation', () => {
+ this.isTransitioning = true
+ // Modal state persisted via $themePickerOpen store
+ })
+
+ document.addEventListener('astro:after-swap', () => {
+ this.isTransitioning = false
+ })
+ }
+
+ /**
+ * Update modal visibility (called by Lit's updated lifecycle)
+ */
+ private updateModalVisibility(isOpen: boolean): void {
+ if (isOpen) {
+ // Remove hidden first, then add class after browser paints
+ this.pickerModal.removeAttribute('hidden')
+ this.toggleBtn.setAttribute('aria-expanded', 'true')
+
+ // Wait for next frame so browser processes max-height: 0 before animating to 14em
+ requestAnimationFrame(() => {
+ this.pickerModal.classList.add(CLASSES.isOpen)
+ })
+ } else {
+ this.pickerModal.classList.remove(CLASSES.isOpen)
+
+ // Wait for animation before hiding
+ const transitionHandler = () => {
+ if (!this.themePickerOpenStore.value) {
+ this.pickerModal.setAttribute('hidden', '')
+ }
+ this.pickerModal.removeEventListener('transitionend', transitionHandler)
+ }
+ this.pickerModal.addEventListener('transitionend', transitionHandler, { once: true })
+
+ this.toggleBtn.setAttribute('aria-expanded', 'false')
+ }
+ }
+
+ /**
+ * Update active theme button styling (called by Lit's updated lifecycle)
*/
- private setActiveItem(): void {
+ private updateActiveTheme(currentTheme: ThemeId): void {
try {
this.themeSelectBtns.forEach((button) => {
const themeId = button.dataset['theme']
const parentLi = button.closest('li')
- if (themeId === this.activeTheme) {
+ if (themeId === currentTheme) {
button.setAttribute('aria-checked', 'true')
if (parentLi) {
parentLi.classList.add(CLASSES.active)
@@ -284,53 +252,39 @@ export class ThemePickerElement extends HTMLElement {
}
})
} catch (error) {
- handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'setActiveItem' })
+ handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'updateActiveTheme' })
}
}
/**
- * Toggle theme picker modal
+ * Handle toggle button click
*/
- private togglePicker(open?: boolean): void {
- const context = { scriptName: 'ThemePickerElement', operation: 'togglePicker' }
+ private handleToggle(): void {
+ const context = { scriptName: 'ThemePickerElement', operation: 'handleToggle' }
addScriptBreadcrumb(context)
try {
- this.isModalOpen = open !== undefined ? open : !this.isModalOpen
-
- // Sync with store for View Transitions persistence
- $themePickerOpen.set(this.isModalOpen)
-
- if (this.isModalOpen) {
- // Remove hidden first, then add class after browser paints
- this.pickerModal.removeAttribute('hidden')
- this.toggleBtn.setAttribute('aria-expanded', 'true')
-
- // Wait for next frame so browser processes max-height: 0 before animating to 14em
- requestAnimationFrame(() => {
- this.pickerModal.classList.add(CLASSES.isOpen)
- })
- } else {
- this.pickerModal.classList.remove(CLASSES.isOpen)
-
- // Wait for animation before hiding
- const transitionHandler = () => {
- this.pickerModal.setAttribute('hidden', '')
- this.pickerModal.removeEventListener('transitionend', transitionHandler)
- }
- this.pickerModal.addEventListener('transitionend', transitionHandler, { once: true })
-
- this.toggleBtn.setAttribute('aria-expanded', 'false')
- this.toggleBtn.focus()
- }
+ $themePickerOpen.set(!this.themePickerOpenStore.value)
} catch (error) {
handleScriptError(error, context)
}
}
/**
- * Set theme and close modal
+ * Handle close button click
*/
+ private handleClose(): void {
+ const context = { scriptName: 'ThemePickerElement', operation: 'handleClose' }
+ addScriptBreadcrumb(context)
+
+ try {
+ $themePickerOpen.set(false)
+ this.toggleBtn.focus()
+ } catch (error) {
+ handleScriptError(error, context)
+ }
+ }
+
/**
* Apply theme to document
*/
@@ -339,17 +293,12 @@ export class ThemePickerElement extends HTMLElement {
addScriptBreadcrumb(context)
try {
- this.activeTheme = themeId
-
// Update document attribute
document.documentElement.dataset['theme'] = themeId
- // Update state store
+ // Update state store (Lit will reactively update via StoreController)
setTheme(themeId)
- // Update active item styling
- this.setActiveItem()
-
// Update meta theme-color
this.updateMetaThemeColor(themeId)
} catch (error) {
diff --git a/src/components/ThemePicker/index.astro b/src/components/ThemePicker/index.astro
index 8ba11aa90..987d5f7bf 100644
--- a/src/components/ThemePicker/index.astro
+++ b/src/components/ThemePicker/index.astro
@@ -127,5 +127,5 @@ const { closeComponent = 'close', label = 'Select theme' } = Astro.props
\ No newline at end of file
diff --git a/src/components/ThemePicker/selectors.ts b/src/components/ThemePicker/selectors.ts
new file mode 100644
index 000000000..173e5f60c
--- /dev/null
+++ b/src/components/ThemePicker/selectors.ts
@@ -0,0 +1,63 @@
+/**
+ * Selectors for the theme picker elements
+ */
+import { isButtonElement, isDivElement } from '@components/scripts/assertions/elements'
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
+
+/**
+ * Gets the theme picker modal element
+ * @param scope - Element to query within (defaults to document)
+ */
+export const getThemePickerModal = (scope: ParentNode = document) => {
+ const modal = scope.querySelector('[data-theme-modal]')
+ if (!isDivElement(modal)) {
+ throw new ClientScriptError(`Theme picker modal with selector '[data-theme-modal]' not found`)
+ }
+ return modal
+}
+
+/**
+ * Gets the theme picker toggle button (in header)
+ */
+export const getThemePickerToggleBtn = () => {
+ const toggleBtn = document.querySelector('[data-theme-toggle]')
+ if (!isButtonElement(toggleBtn)) {
+ throw new ClientScriptError(
+ `Theme picker toggle button with selector '[data-theme-toggle]' not found`
+ )
+ }
+ return toggleBtn
+}
+
+/**
+ * Gets the theme picker close button
+ * @param scope - Element to query within (defaults to document)
+ */
+export const getThemePickerCloseBtn = (scope: ParentNode = document) => {
+ const closeBtn = scope.querySelector('[data-theme-close]')
+ if (!isButtonElement(closeBtn)) {
+ throw new ClientScriptError(
+ `Theme picker close button with selector '[data-theme-close]' not found`
+ )
+ }
+ return closeBtn
+}
+
+/**
+ * Gets all theme selection buttons
+ * @param scope - Element to query within (defaults to document)
+ */
+export const getThemeSelectBtns = (scope: ParentNode = document) => {
+ const buttons = scope.querySelectorAll('[data-theme]')
+ if (buttons.length === 0) {
+ throw new ClientScriptError(`Theme selection buttons with selector '[data-theme]' not found`)
+ }
+
+ // Validate all buttons are actually button elements
+ const buttonArray = Array.from(buttons)
+ if (!buttonArray.every(isButtonElement)) {
+ throw new ClientScriptError(`Some theme selection elements are not button elements`)
+ }
+
+ return buttons as NodeListOf
+}
diff --git a/src/components/ThemePicker/server.ts b/src/components/ThemePicker/server.ts
index e2977e2a4..c6f00c5b5 100644
--- a/src/components/ThemePicker/server.ts
+++ b/src/components/ThemePicker/server.ts
@@ -41,7 +41,7 @@ export interface DefaultTheme {
*/
export const themes: Theme[] = [
{
- id: 'default',
+ id: 'light',
name: 'Light',
description: 'Clean light theme with blue accents',
category: 'core',
@@ -66,7 +66,7 @@ export const themes: Theme[] = [
* Default theme configuration
*/
export const defaultTheme: DefaultTheme = {
- id: 'default',
+ id: 'light',
prefersDarkScheme: false,
}
@@ -109,11 +109,11 @@ export function themeExists(themeId: string): boolean {
* Get the next theme in the list (useful for theme cycling)
*/
export function getNextTheme(currentThemeId: string): string {
- if (themes.length === 0) return 'default'
+ if (themes.length === 0) return 'light'
const currentIndex = themes.findIndex(theme => theme.id === currentThemeId)
const nextIndex = (currentIndex + 1) % themes.length
const nextTheme = themes[nextIndex]
- return nextTheme?.id || themes[0]?.id || 'default'
+ return nextTheme?.id || themes[0]?.id || 'light'
}
/**
diff --git a/src/components/scripts/bootstrap/__tests__/index.spec.ts b/src/components/scripts/bootstrap/__tests__/index.spec.ts
index fb8958363..da8c48107 100644
--- a/src/components/scripts/bootstrap/__tests__/index.spec.ts
+++ b/src/components/scripts/bootstrap/__tests__/index.spec.ts
@@ -22,9 +22,19 @@ vi.mock('@components/scripts/bootstrap/consent', () => ({
initStateSideEffects: vi.fn(),
}))
-vi.mock('@components/scripts/bootstrap/theme', () => ({
- initThemeSideEffects: vi.fn(),
- setInitialTheme: vi.fn(),
+vi.mock('@components/scripts/store/themes', () => ({
+ initThemeSystem: vi.fn(),
+ $theme: {
+ get: vi.fn(() => 'light'),
+ set: vi.fn(),
+ subscribe: vi.fn(),
+ },
+ $themePickerOpen: {
+ get: vi.fn(() => false),
+ set: vi.fn(),
+ subscribe: vi.fn(),
+ },
+ setTheme: vi.fn(),
}))
vi.mock('@components/scripts/store/socialEmbeds', () => ({
@@ -40,7 +50,7 @@ vi.mock('@components/scripts/errors', () => ({
// Import the mocked functions after setting up mocks
import { initConsentFromCookies } from '@components/scripts/store'
import { initConsentSideEffects, initStateSideEffects } from '@components/scripts/bootstrap/consent'
-import { initThemeSideEffects } from '@components/scripts/bootstrap/theme'
+import { initThemeSystem } from '@components/scripts/store/themes'
describe('AppBootstrap', () => {
beforeEach(() => {
@@ -51,21 +61,21 @@ describe('AppBootstrap', () => {
it('should call initConsentFromCookies and initialize side effects', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
- vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initThemeSystem).mockReturnValue(undefined)
vi.mocked(initStateSideEffects).mockReturnValue(undefined)
AppBootstrap.init()
expect(initConsentFromCookies).toHaveBeenCalledTimes(1)
expect(initConsentSideEffects).toHaveBeenCalledTimes(1)
- expect(initThemeSideEffects).toHaveBeenCalledTimes(1)
+ expect(initThemeSystem).toHaveBeenCalledTimes(1)
expect(initStateSideEffects).toHaveBeenCalledTimes(1)
})
it('should add breadcrumbs for successful initialization', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
- vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initThemeSystem).mockReturnValue(undefined)
vi.mocked(initStateSideEffects).mockReturnValue(undefined)
AppBootstrap.init()
@@ -84,7 +94,7 @@ describe('AppBootstrap', () => {
})
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
scriptName: 'AppBootstrap',
- operation: 'initThemeSideEffects'
+ operation: 'initThemeSystem'
})
expect(addScriptBreadcrumb).toHaveBeenCalledWith({
scriptName: 'AppBootstrap',
@@ -95,7 +105,7 @@ describe('AppBootstrap', () => {
it('should not throw error when initialization succeeds', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
- vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initThemeSystem).mockReturnValue(undefined)
vi.mocked(initStateSideEffects).mockReturnValue(undefined)
expect(() => AppBootstrap.init()).not.toThrow()
@@ -140,7 +150,7 @@ describe('AppBootstrap', () => {
throw new Error('Cookie initialization failed')
})
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
- vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initThemeSystem).mockReturnValue(undefined)
vi.mocked(initStateSideEffects).mockReturnValue(undefined)
try {
@@ -150,7 +160,7 @@ describe('AppBootstrap', () => {
}
expect(initConsentSideEffects).not.toHaveBeenCalled()
- expect(initThemeSideEffects).not.toHaveBeenCalled()
+ expect(initThemeSystem).not.toHaveBeenCalled()
expect(initStateSideEffects).not.toHaveBeenCalled()
})
@@ -173,7 +183,7 @@ describe('AppBootstrap', () => {
it('should successfully initialize on repeated calls', () => {
vi.mocked(initConsentFromCookies).mockReturnValue(undefined)
vi.mocked(initConsentSideEffects).mockReturnValue(undefined)
- vi.mocked(initThemeSideEffects).mockReturnValue(undefined)
+ vi.mocked(initThemeSystem).mockReturnValue(undefined)
vi.mocked(initStateSideEffects).mockReturnValue(undefined)
AppBootstrap.init()
@@ -181,8 +191,8 @@ describe('AppBootstrap', () => {
expect(initConsentFromCookies).toHaveBeenCalledTimes(2)
expect(initConsentSideEffects).toHaveBeenCalledTimes(2)
- expect(initThemeSideEffects).toHaveBeenCalledTimes(2)
+ expect(initThemeSystem).toHaveBeenCalledTimes(2)
expect(initStateSideEffects).toHaveBeenCalledTimes(2)
})
})
-})
+})
\ No newline at end of file
diff --git a/src/components/scripts/bootstrap/consent/NOTES.md b/src/components/scripts/bootstrap/consent/NOTES.md
index 15f677ee7..8ef4014b2 100644
--- a/src/components/scripts/bootstrap/consent/NOTES.md
+++ b/src/components/scripts/bootstrap/consent/NOTES.md
@@ -4,6 +4,67 @@
View Transitions friction: transition:persist requires client:only directive, loses SSR benefits
+```typescript
+// In themes.ts
+export const $theme = persistentAtom('theme', 'default', {
+ // ... encode/decode ...
+})
+
+// In HEAD inline script
+
+```
+
+```html
+
+
+
+ My Page
+
+
+
+
+
+
+
+
+
+```
+
+```typescript
+let domReady = (cb) => {
+ document.readyState === 'interactive' || document.readyState === 'complete'
+ ? cb()
+ : document.addEventListener('DOMContentLoaded', cb);
+};
+
+domReady(() => {
+ // Display body when DOM is loaded
+ document.body.style.visibility = 'visible';
+})
+```
+
## Categories
### 'necessary' - Infrastructure only, NO actual usage
diff --git a/src/components/scripts/bootstrap/index.ts b/src/components/scripts/bootstrap/index.ts
index 8254fcc66..3ec229650 100644
--- a/src/components/scripts/bootstrap/index.ts
+++ b/src/components/scripts/bootstrap/index.ts
@@ -7,7 +7,7 @@ import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
import { addScriptBreadcrumb } from '@components/scripts/errors'
import { initConsentFromCookies } from '@components/scripts/store'
import { initConsentSideEffects, initStateSideEffects } from '@components/scripts/bootstrap/consent'
-import { initThemeSideEffects, setInitialTheme } from '@components/scripts/bootstrap/theme'
+import { initThemeSystem } from '@components/scripts/store/themes'
import { SentryBootstrap } from '@components/scripts/sentry/client'
import { PUBLIC_SENTRY_DSN } from 'astro:env/client'
@@ -16,10 +16,6 @@ export class AppBootstrap {
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'init' })
try {
- /** Script to set theme name on . This should be inlined by Astro so */
- /** that it runs first. Do not add other scripts in this
diff --git a/src/components/scripts/bootstrap/consent/NOTES.md b/src/components/scripts/bootstrap/consent/NOTES.md
index 8ef4014b2..15f677ee7 100644
--- a/src/components/scripts/bootstrap/consent/NOTES.md
+++ b/src/components/scripts/bootstrap/consent/NOTES.md
@@ -4,67 +4,6 @@
View Transitions friction: transition:persist requires client:only directive, loses SSR benefits
-```typescript
-// In themes.ts
-export const $theme = persistentAtom('theme', 'default', {
- // ... encode/decode ...
-})
-
-// In HEAD inline script
-
-```
-
-```html
-
-
-
- My Page
-
-
-
-
-
-
-
-
-
-```
-
-```typescript
-let domReady = (cb) => {
- document.readyState === 'interactive' || document.readyState === 'complete'
- ? cb()
- : document.addEventListener('DOMContentLoaded', cb);
-};
-
-domReady(() => {
- // Display body when DOM is loaded
- document.body.style.visibility = 'visible';
-})
-```
-
## Categories
### 'necessary' - Infrastructure only, NO actual usage
From 069864dd9c6b81b97b67d62bdde5c7a96aef3abe Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Fri, 7 Nov 2025 23:44:09 +0300
Subject: [PATCH 028/341] Modify theme picker component to not move body
content after it has scrolled down, on page navigation when theme picker
modal is kept open
---
src/components/ThemePicker/client.ts | 59 ++++++++++++++++++++++------
1 file changed, 46 insertions(+), 13 deletions(-)
diff --git a/src/components/ThemePicker/client.ts b/src/components/ThemePicker/client.ts
index 7cd9400bb..5da61f900 100644
--- a/src/components/ThemePicker/client.ts
+++ b/src/components/ThemePicker/client.ts
@@ -57,6 +57,7 @@ export class ThemePickerElement extends LitElement {
// Track View Transitions
private isTransitioning = false
+ private isInitialized = false
/**
* Lit lifecycle: called when element is connected
@@ -82,6 +83,11 @@ export class ThemePickerElement extends LitElement {
addScriptBreadcrumb(context)
try {
+ // Skip if already initialized (for View Transitions)
+ if (this.isInitialized) {
+ return
+ }
+
// Find elements within this component
this.findElements()
@@ -93,6 +99,7 @@ export class ThemePickerElement extends LitElement {
this.bindEvents()
this.setViewTransitionHandlers()
+ this.isInitialized = true
// Initial update (Lit + StoreController will handle all reactivity automatically)
this.requestUpdate()
@@ -136,8 +143,11 @@ export class ThemePickerElement extends LitElement {
addScriptBreadcrumb(context)
try {
- // Toggle button
- addButtonEventListeners(this.toggleBtn, () => this.handleToggle(), this)
+ // Toggle button - stop propagation to prevent "click outside" handler
+ addButtonEventListeners(this.toggleBtn, (e) => {
+ e.stopPropagation()
+ this.handleToggle()
+ }, this)
// Close button
addButtonEventListeners(this.closeBtn, () => this.handleClose(), this)
@@ -170,18 +180,21 @@ export class ThemePickerElement extends LitElement {
})
// Close on click outside (but not during View Transitions)
+ // Use bubble phase (default) so button handlers run first and can stopPropagation
document.addEventListener('click', (e) => {
- if (this.themePickerOpenStore.value && !this.isTransitioning) {
- const target = e.target as HTMLElement
- const isInsideModal = this.pickerModal.contains(target)
- const isToggleButton = this.toggleBtn.contains(target)
- const isThemeButton = Array.from(this.themeSelectBtns).some(btn => btn.contains(target))
-
- if (!isInsideModal && !isToggleButton && !isThemeButton) {
- this.handleClose()
- }
+ // Skip if transitioning
+ if (this.isTransitioning) return
+
+ const target = e.target as HTMLElement
+ const isInsideModal = this.pickerModal.contains(target)
+ const isToggleButton = this.toggleBtn.contains(target)
+
+ // Close if clicking outside modal and not on toggle button
+ // This runs in bubble phase, after button handlers, so stopPropagation works
+ if (this.themePickerOpenStore.value && !isInsideModal && !isToggleButton) {
+ this.handleClose()
}
- })
+ }) // Default bubble phase - button handlers run first
} catch (error) {
handleScriptError(error, context)
}
@@ -193,11 +206,31 @@ export class ThemePickerElement extends LitElement {
private setViewTransitionHandlers(): void {
document.addEventListener('astro:before-preparation', () => {
this.isTransitioning = true
- // Modal state persisted via $themePickerOpen store
})
document.addEventListener('astro:after-swap', () => {
this.isTransitioning = false
+
+ // Re-query DOM elements after swap since HTML was replaced
+ // But DON'T re-bind events since this element persists
+ try {
+ this.findElements()
+
+ // CRITICAL: If modal should be open, apply classes immediately
+ // to prevent visual jump (HTML was swapped without the is-open class)
+ const isOpen = this.themePickerOpenStore.value
+ if (isOpen) {
+ // Apply state synchronously before browser paints
+ this.pickerModal.removeAttribute('hidden')
+ this.pickerModal.classList.add(CLASSES.isOpen)
+ this.toggleBtn.setAttribute('aria-expanded', 'true')
+ }
+
+ // Force Lit to update with new DOM references
+ this.requestUpdate()
+ } catch (error) {
+ handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'after-swap' })
+ }
})
}
From a10bb4a077e71ebe3cf9ab8b1cc7db35aeced9ec Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 00:20:30 +0300
Subject: [PATCH 029/341] Troubleshoot theme picker component mobile modal open
/ close. Problem still exists, this commit has a troubleshooting doc of steps
taken so far.
---
src/components/ThemePicker/MOBILE_PROBLEMS.md | 236 ++++++++++++++++++
.../ThemePicker/__fixtures__/mockData.ts | 19 --
src/components/ThemePicker/client.ts | 40 ++-
3 files changed, 269 insertions(+), 26 deletions(-)
create mode 100644 src/components/ThemePicker/MOBILE_PROBLEMS.md
delete mode 100644 src/components/ThemePicker/__fixtures__/mockData.ts
diff --git a/src/components/ThemePicker/MOBILE_PROBLEMS.md b/src/components/ThemePicker/MOBILE_PROBLEMS.md
new file mode 100644
index 000000000..096a1ae79
--- /dev/null
+++ b/src/components/ThemePicker/MOBILE_PROBLEMS.md
@@ -0,0 +1,236 @@
+# ThemePicker Mobile Issues
+
+## Current Status: UNRESOLVED
+
+Date: November 8, 2025
+Component: `src/components/ThemePicker/client.ts`
+
+## Symptoms
+
+### Issue 1: First Tap Causes Modal to Flash Open/Closed
+
+- **When**: First touch of the toggle button after page load
+- **Behavior**: Theme picker modal briefly opens then immediately closes
+- **Expected**: Modal should stay open on first tap
+
+### Issue 2: Second Tap Opens but Cannot Close
+
+- **When**: Second touch of the toggle button
+- **Behavior**: Modal opens successfully
+- **Expected**: This is correct behavior
+
+### Issue 3: Toggle Button Non-Functional After Opening
+
+- **When**: Tapping toggle button after modal is open
+- **Behavior**: No effect - modal stays open, cannot close via toggle
+- **Expected**: Toggle button should close the modal
+
+## Root Cause Analysis
+
+### Event Flow on Mobile (from Chrome DevTools debugging)
+
+```bash
+[1762548393148] touchstart reached document {from: 'svg', propagationStopped: false, modalOpen: false}
+[1762548393148] touchstart on toggleBtn {propagationStopped: false, modalOpen: false}
+[1762548393273] touchend reached document {from: 'svg', propagationStopped: false, modalOpen: false}
+[1762548393273] touchend on toggleBtn {propagationStopped: false, modalOpen: false}
+[1762548393276] click reached document {from: 'svg', propagationStopped: false, modalOpen: false}
+[1762548393276] click on toggleBtn {propagationStopped: false, modalOpen: false}
+
+Modal state after tap: {hidden: true, isOpen: true}
+```
+
+### Key Findings
+
+1. **Event target is SVG, not button**: User taps the icon SVG element inside the button
+2. **Document listener runs BEFORE button handler**: Events reach document in capture phase before button handlers
+3. **stopPropagation() never called**: All events show `propagationStopped: false`
+4. **Contradictory state**: Modal has `hidden: true` AND `isOpen: true` class simultaneously
+5. **State not updating synchronously**: `modalOpen: false` persists even after toggle should open it
+
+### Technical Details
+
+- **Component**: LitElement-based Web Component with Light DOM
+- **Event handling**: Uses `addButtonEventListeners()` helper which adds `click`, `keyup`, and `touchend` listeners
+- **State management**: Nanostores `$themePickerOpen` persistentAtom
+- **Document listeners**: Added in bubble phase (default) for "click outside to close" functionality
+
+## Attempted Fixes (All Failed)
+
+### Attempt 1: Event Propagation Control
+
+**Approach**: Add `e.stopPropagation()` to toggle button handler
+
+```typescript
+addButtonEventListeners(this.toggleBtn, (e) => {
+ e.stopPropagation()
+ this.handleToggle()
+}, this)
+```
+
+**Result**: Failed - `stopPropagation()` never called (events show `propagationStopped: false`)
+
+### Attempt 2: Capture Phase on Document Listener
+
+**Approach**: Use capture phase `addEventListener(..., true)` to run document listener first
+**Result**: Failed - Made problem worse, document listener saw old state
+
+### Attempt 3: Bubble Phase + stopPropagation
+
+**Approach**: Use default bubble phase, rely on button's `stopPropagation()`
+**Result**: Failed - Button handler's `stopPropagation()` not executing
+
+### Attempt 4: Timestamp-Based Debounce (50ms)
+
+**Approach**: Track `justToggledTimestamp`, skip document listeners within 50ms
+
+```typescript
+private justToggledTimestamp = 0
+// In handleToggle:
+this.justToggledTimestamp = Date.now()
+// In document listeners:
+if (Date.now() - this.justToggledTimestamp < 50) return
+```
+
+**Result**: Failed - 50ms too short for event sequence
+
+### Attempt 5: Increased Debounce (150ms)
+
+**Approach**: Increased timeout to 150ms
+**Result**: Failed - Still not enough time
+
+### Attempt 6: Pointer Events Blocking
+
+**Approach**: Set `document.body.style.pointerEvents = 'none'` for 100ms when opening
+
+```typescript
+document.body.style.pointerEvents = 'none'
+this.pickerModal.style.pointerEvents = 'auto'
+this.toggleBtn.style.pointerEvents = 'auto'
+setTimeout(() => {
+ document.body.style.pointerEvents = ''
+ // ...
+}, 100)
+```
+
+**Result**: Failed - Blocked toggle button itself on subsequent taps (couldn't close modal)
+**Evidence**: Console showed `body pointerEvents: none` but button still blocked
+
+### Attempt 7: Boolean Flag (isTogglingViaButton)
+
+**Approach**: Set flag during toggle, document listeners skip if flag is true
+
+```typescript
+private isTogglingViaButton = false
+// In handleToggle:
+this.isTogglingViaButton = true
+setTimeout(() => { this.isTogglingViaButton = false }, 200)
+// In document listeners:
+if (this.isTogglingViaButton) return
+```
+
+**Result**: Failed - Same behavior persists
+
+## Desktop Behavior
+
+**Status**: ✅ WORKING CORRECTLY
+
+The desktop (mouse) implementation works perfectly:
+
+- First click opens modal
+- Second click on toggle closes modal
+- Click outside closes modal
+- Modal persists correctly across View Transitions navigation
+
+## Proposed Solutions (Not Yet Attempted)
+
+### Option A: Remove Document Listeners, Use Backdrop Element
+
+Instead of document-level click listeners, add a transparent backdrop element:
+
+```html
+
+```
+
+- Show backdrop when modal opens
+- Click on backdrop closes modal
+- Backdrop is a sibling of modal, easier to control event flow
+
+### Option B: Use requestAnimationFrame for State Sync
+
+Delay document listener checks until next animation frame to ensure state updates:
+
+```typescript
+requestAnimationFrame(() => {
+ // Check state here after it's guaranteed to update
+})
+```
+
+### Option C: Completely Remove "Click Outside to Close"
+
+Simplify mobile UX - require explicit close button tap or theme selection to close modal
+
+### Option D: Mobile-Specific Event Handling
+
+Detect mobile and use completely different event handling:
+
+```typescript
+const isMobile = 'ontouchstart' in window
+if (isMobile) {
+ // Use touchstart/touchend only, different logic
+} else {
+ // Use click events
+}
+```
+
+### Option E: Use focus-trap Library
+
+Implement proper focus management to handle modal open/close, which includes backdrop click handling
+
+## Related Files
+
+- `src/components/ThemePicker/client.ts` - Main component logic
+- `src/components/ThemePicker/index.astro` - Template (Light DOM)
+- `src/components/ThemePicker/selectors.ts` - DOM query helpers
+- `src/components/scripts/store/themes.ts` - State management (`$themePickerOpen`)
+- `src/components/scripts/elementListeners/index.ts` - `addButtonEventListeners` helper
+
+## Desktop Fix Applied (Working)
+
+### View Transitions Scroll Jump Fix
+
+**Issue**: When modal was open and user navigated to new page, content would scroll down
+**Solution**: Apply modal state immediately after DOM swap in `astro:after-swap` handler
+
+```typescript
+document.addEventListener('astro:after-swap', () => {
+ this.findElements()
+
+ const isOpen = this.themePickerOpenStore.value
+ if (isOpen) {
+ // Apply state synchronously before browser paints
+ this.pickerModal.removeAttribute('hidden')
+ this.pickerModal.classList.add(CLASSES.isOpen)
+ this.toggleBtn.setAttribute('aria-expanded', 'true')
+ }
+
+ this.requestUpdate()
+})
+```
+
+**Result**: ✅ WORKING - Content no longer jumps on navigation
+
+## Next Steps
+
+1. Implement Option A (backdrop element) - most likely to succeed
+2. If Option A fails, try Option D (mobile-specific handling)
+3. Consider if "click outside to close" is necessary UX on mobile
+4. Add comprehensive unit tests to catch regressions
+
+## Notes for Future Developer
+
+- The `addButtonEventListeners` helper wraps handlers but DOES pass the event object
+- The SVG icon inside the button is the actual event target, not the button itself
+- `toggleBtn.contains(target)` should catch SVG clicks but doesn't prevent issues
+- Nanostores updates may not be synchronous enough for rapid event sequences
+- Mobile touch events fire in sequence: touchstart → touchend → click (with ~3ms delay)
diff --git a/src/components/ThemePicker/__fixtures__/mockData.ts b/src/components/ThemePicker/__fixtures__/mockData.ts
deleted file mode 100644
index a3b702340..000000000
--- a/src/components/ThemePicker/__fixtures__/mockData.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Mock data for ThemePicker component testing
- */
-
-import type { ThemeId } from '@components/scripts/store'
-
-/**
- * Mock themes for testing
- */
-export const mockThemes: ThemeId[] = ['light', 'dark', 'holiday']
-
-/**
- * Mock meta colors object
- */
-export const mockMetaColors = {
- default: '#ffffff',
- dark: '#000000',
- holiday: '#ff0000',
-}
diff --git a/src/components/ThemePicker/client.ts b/src/components/ThemePicker/client.ts
index 5da61f900..36a6d7e69 100644
--- a/src/components/ThemePicker/client.ts
+++ b/src/components/ThemePicker/client.ts
@@ -58,6 +58,7 @@ export class ThemePickerElement extends LitElement {
// Track View Transitions
private isTransitioning = false
private isInitialized = false
+ private isTogglingViaButton = false
/**
* Lit lifecycle: called when element is connected
@@ -182,12 +183,13 @@ export class ThemePickerElement extends LitElement {
// Close on click outside (but not during View Transitions)
// Use bubble phase (default) so button handlers run first and can stopPropagation
document.addEventListener('click', (e) => {
- // Skip if transitioning
+ // Skip if transitioning or if toggle button is actively being clicked
if (this.isTransitioning) return
+ if (this.isTogglingViaButton) return
const target = e.target as HTMLElement
const isInsideModal = this.pickerModal.contains(target)
- const isToggleButton = this.toggleBtn.contains(target)
+ const isToggleButton = this.toggleBtn.contains(target) || target === this.toggleBtn
// Close if clicking outside modal and not on toggle button
// This runs in bubble phase, after button handlers, so stopPropagation works
@@ -195,14 +197,29 @@ export class ThemePickerElement extends LitElement {
this.handleClose()
}
}) // Default bubble phase - button handlers run first
+
+ // Mobile-specific: Handle touchend separately since it fires before click
+ // and can have different event targets due to SVG children
+ document.addEventListener('touchend', (e) => {
+ // Skip if transitioning or if toggle button is actively being clicked
+ if (this.isTransitioning) return
+ if (this.isTogglingViaButton) return
+
+ const target = e.target as HTMLElement
+ const isInsideModal = this.pickerModal.contains(target)
+ const isToggleButton = this.toggleBtn.contains(target) || target === this.toggleBtn
+
+ // Close only if touching outside both modal and toggle button
+ if (this.themePickerOpenStore.value && !isInsideModal && !isToggleButton) {
+ this.handleClose()
+ }
+ })
} catch (error) {
handleScriptError(error, context)
}
- }
-
- /**
- * Set up View Transition handlers
- */
+ } /**
+ * Set up View Transition handlers
+ */
private setViewTransitionHandlers(): void {
document.addEventListener('astro:before-preparation', () => {
this.isTransitioning = true
@@ -297,7 +314,16 @@ export class ThemePickerElement extends LitElement {
addScriptBreadcrumb(context)
try {
+ // Set flag to indicate we're toggling via button, not document listener
+ this.isTogglingViaButton = true
+
+ // Update state
$themePickerOpen.set(!this.themePickerOpenStore.value)
+
+ // Clear flag after a delay to allow document listeners to see it
+ setTimeout(() => {
+ this.isTogglingViaButton = false
+ }, 200)
} catch (error) {
handleScriptError(error, context)
}
From a3cacd3441e38abd5707ed91f5853d006ee3f9fe Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 00:35:44 +0300
Subject: [PATCH 030/341] Add unit tests to theme picker component
---
.cache/pages.json | 11 +-
.github/instructions/general.instructions.md | 20 +-
.../__fixtures__/client.fixture.astro | 25 ++
.../ThemePicker/__tests__/client.spec.ts | 217 ++++++++++++++++++
4 files changed, 267 insertions(+), 6 deletions(-)
create mode 100644 src/components/ThemePicker/__fixtures__/client.fixture.astro
create mode 100644 src/components/ThemePicker/__tests__/client.spec.ts
diff --git a/.cache/pages.json b/.cache/pages.json
index 4844728a4..b8605f6d4 100644
--- a/.cache/pages.json
+++ b/.cache/pages.json
@@ -32,10 +32,15 @@
"offline",
"privacy",
{
- "services": ["create-custom-font-sets", "overview"]
+ "services": [
+ "create-custom-font-sets",
+ "overview"
+ ]
},
{
- "social-shares": ["template"]
+ "social-shares": [
+ "template"
+ ]
},
{
"tags": [
@@ -50,4 +55,4 @@
"typescript"
]
}
-]
+]
\ No newline at end of file
diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md
index ba63302bd..7262221c5 100644
--- a/.github/instructions/general.instructions.md
+++ b/.github/instructions/general.instructions.md
@@ -21,19 +21,33 @@ applyTo: "**"
# Testing Standards
-- NEVER use manual HTML strings in test files. They get out of sync with templates and are worse than no test at all.
-- Always use Astro's Container API to create fixtures from actual .astro templates.
+## Astro Component Testing - Container API (MANDATORY)
+
+- **NEVER use manual HTML strings in test files or fixtures.** They get out of sync with templates and are worse than no test at all.
+- **ALWAYS use Astro's Container API** to create fixtures from actual .astro templates. See: https://docs.astro.build/en/reference/container-reference/
+- **Test fixtures MUST import actual components**, not duplicate HTML. Example:
+ ```astro
+ ---
+ import MyComponent from '@components/MyComponent/index.astro'
+ const { testProp } = Astro.props
+ ---
+
+ ```
+- **Hard-coded HTML fixtures are FORBIDDEN.** If you find yourself writing HTML in a fixture, STOP and use the actual component instead.
- Reference the working example in src/components/Test/container.astro and its test file.
- Use experimental_AstroContainer.create() to instantiate the container.
- Use container.renderToString(Component) to get rendered HTML from actual Astro components.
- For DOM unit testing with Container API: use `// @vitest-environment happy-dom` for better DOM compatibility than jsdom or node.
- Configure Vitest with getViteConfig() from 'astro/config' to support Astro Container API.
- Test files should follow a client.spec.ts naming pattern or similar.
-- Fixture files should follow a componentName.fixture.ts naming pattern (e.g., newsletter.fixture.ts).
+- Fixture files should follow a componentName.fixture.astro naming pattern (e.g., newsletter.fixture.astro).
- Use `// @vitest-environment happy-dom` as the first line of test files that need DOM support with Container API. Never include Vitest directives inside JSDoc comments.
- happy-dom provides proper document, window, and localStorage globals without manual mocking.
- JavaScript loading warnings from happy-dom are silenced in vitest.setup.ts for clean test output.
- A working example test using the Container API is available at /home/kevin/Repos/Webstack Builders/Corporate Website/astro.webstackbuilders.com/src/components/Test/container.spec.ts
+
+## E2E Testing Standards
+
- **NEVER hard-code content slugs in e2e tests** (e.g., `/articles/typescript-best-practices`, `/services/web-development`). Content can be deleted or renamed. Always dynamically fetch the first available item from listing pages (articles, services, case-studies, etc.) and navigate to it. This prevents test breakage when content changes.
- **Playwright E2E Tests**: ALWAYS run with `DEBUG=1` environment variable (e.g., `DEBUG=1 npx playwright test`). This prevents the Playwright test runner from launching its own dev server. The user maintains a running dev server for development.
- **NEVER run the full e2e test suite** unless explicitly requested by the user. The full suite is very resource intensive and takes over 10 minutes to run. Only run specific e2e test files when verification is needed (e.g., `DEBUG=1 npx playwright test test/e2e/specific-file.spec.ts`).
diff --git a/src/components/ThemePicker/__fixtures__/client.fixture.astro b/src/components/ThemePicker/__fixtures__/client.fixture.astro
new file mode 100644
index 000000000..77c4c1fb6
--- /dev/null
+++ b/src/components/ThemePicker/__fixtures__/client.fixture.astro
@@ -0,0 +1,25 @@
+---
+/**
+ * Test fixture for ThemePicker Container API testing
+ * Uses actual ThemePicker component to ensure tests stay in sync with implementation
+ */
+import ThemePicker from '@components/ThemePicker/index.astro'
+import ThemeButton from '@components/ThemePicker/ThemeButton.astro'
+
+export interface Props {
+ /** Override default label for testing */
+ label?: string
+ /** Override close component for testing */
+ closeComponent?: string
+}
+
+const { label = 'Select theme', closeComponent = 'close' } = Astro.props
+---
+
+
+
+
+
+
diff --git a/src/components/ThemePicker/__tests__/client.spec.ts b/src/components/ThemePicker/__tests__/client.spec.ts
new file mode 100644
index 000000000..05a0f62bf
--- /dev/null
+++ b/src/components/ThemePicker/__tests__/client.spec.ts
@@ -0,0 +1,217 @@
+// @vitest-environment happy-dom
+/**
+ * ThemePicker Web Component Unit Tests
+ * Tests the LitElement-based theme picker functionality
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { experimental_AstroContainer as AstroContainer } from 'astro/container'
+import ThemePickerFixture from '@components/ThemePicker/__fixtures__/client.fixture.astro'
+
+describe('ThemePicker Component', () => {
+ let container: AstroContainer
+ let html: string
+
+ beforeEach(async () => {
+ // Suppress expected async errors from Lit component initialization
+ // The component tries to update DOM elements that don't exist in tests
+ const ignoreError = (err: unknown) => {
+ if (err instanceof TypeError && String(err).includes('Cannot read properties of undefined')) {
+ return // Suppress expected Lit component errors
+ }
+ throw err // Re-throw unexpected errors
+ }
+ process.on('unhandledRejection', ignoreError)
+
+ container = await AstroContainer.create()
+ html = await container.renderToString(ThemePickerFixture)
+ document.body.innerHTML = html
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ''
+ vi.restoreAllMocks()
+ })
+
+ describe('Component Structure', () => {
+ it('should render theme-picker custom element', () => {
+ const themePicker = document.querySelector('theme-picker')
+ expect(themePicker).toBeTruthy()
+ })
+
+ it('should render toggle button in header', () => {
+ const toggleBtn = document.querySelector('.themepicker-toggle__toggle-btn')
+ expect(toggleBtn).toBeTruthy()
+ expect(toggleBtn?.getAttribute('aria-label')).toBe('toggle theme switcher')
+ expect(toggleBtn?.getAttribute('aria-haspopup')).toBe('true')
+ })
+
+ it('should render modal with correct attributes', () => {
+ const modal = document.querySelector('[data-theme-modal]')
+ expect(modal).toBeTruthy()
+ expect(modal?.hasAttribute('hidden')).toBe(true)
+ expect(modal?.classList.contains('themepicker')).toBe(true)
+ })
+
+ it('should render theme selection buttons', () => {
+ const themeButtons = document.querySelectorAll('[data-theme]')
+ expect(themeButtons.length).toBeGreaterThan(0)
+
+ // Should have at least light and dark (holiday is seasonal and may be commented out)
+ const themes = Array.from(themeButtons).map(btn => btn.getAttribute('data-theme'))
+ expect(themes).toContain('light')
+ expect(themes).toContain('dark')
+ })
+
+ it('should render close button', () => {
+ const closeBtn = document.querySelector('[data-theme-close]')
+ expect(closeBtn).toBeTruthy()
+ expect(closeBtn?.getAttribute('aria-label')).toBe('Close theme picker dialog')
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have proper ARIA attributes on toggle button', () => {
+ const toggleBtn = document.querySelector('.themepicker-toggle__toggle-btn')
+ expect(toggleBtn?.getAttribute('aria-expanded')).toBe('false')
+ expect(toggleBtn?.getAttribute('aria-owns')).toBe('theme-menu')
+ expect(toggleBtn?.getAttribute('aria-haspopup')).toBe('true')
+ })
+
+ it('should have accessible labels on theme buttons', () => {
+ const themeButtons = document.querySelectorAll('[data-theme]')
+ themeButtons.forEach(button => {
+ const label = button.getAttribute('aria-label')
+ expect(label).toBeTruthy()
+ expect(label).toMatch(/select color theme/i)
+ })
+ })
+
+ it('should have role="banner" on header', () => {
+ const header = document.querySelector('#header')
+ expect(header?.getAttribute('role')).toBe('banner')
+ })
+ })
+
+ describe('Component Props', () => {
+ it('should accept custom label prop', async () => {
+ const customHtml = await container.renderToString(ThemePickerFixture, {
+ props: { label: 'Custom Theme Label' }
+ })
+ expect(customHtml).toContain('Custom Theme Label')
+ })
+
+ it('should use default label when not provided', () => {
+ const heading = document.querySelector('.themepicker h3')
+ expect(heading?.textContent?.trim()).toBe('Select theme')
+ })
+ })
+
+ describe('CSS Classes', () => {
+ it('should have Tailwind utility classes on modal', () => {
+ const modal = document.querySelector('[data-theme-modal]')
+ expect(modal?.classList.contains('bg-bg-offset')).toBe(true)
+ expect(modal?.classList.contains('block')).toBe(true)
+ expect(modal?.classList.contains('overflow-hidden')).toBe(true)
+ expect(modal?.classList.contains('relative')).toBe(true)
+ expect(modal?.classList.contains('w-full')).toBe(true)
+ })
+
+ it('should have hover classes on theme items', () => {
+ const items = document.querySelectorAll('.themepicker__item')
+ items.forEach(item => {
+ expect(item.classList.contains('hover:shadow-md')).toBe(true)
+ expect(item.classList.contains('hover:scale-105')).toBe(true)
+ })
+ })
+ })
+
+ describe('Data Attributes', () => {
+ it('should mark modal with data-nosnippet', () => {
+ const modal = document.querySelector('[data-theme-modal]')
+ expect(modal?.hasAttribute('data-nosnippet')).toBe(true)
+ })
+
+ it('should have data-theme attributes on selection buttons', () => {
+ const buttons = document.querySelectorAll('.themepicker__selectBtn')
+ buttons.forEach(button => {
+ expect(button.hasAttribute('data-theme')).toBe(true)
+ const theme = button.getAttribute('data-theme')
+ expect(['light', 'dark', 'holiday']).toContain(theme)
+ })
+ })
+ })
+
+ describe('Theme List Structure', () => {
+ it('should render theme list with correct ID', () => {
+ const list = document.querySelector('#theme-menu')
+ expect(list).toBeTruthy()
+ expect(list?.classList.contains('themepicker__list')).toBe(true)
+ })
+
+ it('should render theme items as list elements', () => {
+ const items = document.querySelectorAll('.themepicker__item')
+ items.forEach(item => {
+ expect(item.tagName).toBe('LI')
+ })
+ })
+
+ it('should have proper button structure in each theme item', () => {
+ const items = document.querySelectorAll('.themepicker__item')
+ items.forEach(item => {
+ const button = item.querySelector('button')
+ expect(button).toBeTruthy()
+ expect(button?.classList.contains('themepicker__selectBtn')).toBe(true)
+ })
+ })
+ })
+
+ describe('Color Palette Display', () => {
+ it('should display color hues for each theme', () => {
+ const items = document.querySelectorAll('.themepicker__item')
+ items.forEach(item => {
+ const hues = item.querySelectorAll('.themepicker__hue')
+ // Each theme should show 5 color hues
+ expect(hues.length).toBe(5)
+ })
+ })
+
+ it('should have correct Tailwind color classes on hues', () => {
+ const firstItem = document.querySelector('.themepicker__item')
+ const hues = firstItem?.querySelectorAll('.themepicker__hue')
+
+ expect(hues?.[0].classList.contains('bg-primary')).toBe(true)
+ expect(hues?.[1].classList.contains('bg-secondary')).toBe(true)
+ expect(hues?.[2].classList.contains('bg-border')).toBe(true)
+ expect(hues?.[3].classList.contains('bg-text-offset')).toBe(true)
+ expect(hues?.[4].classList.contains('bg-text')).toBe(true)
+ })
+ })
+
+ describe('Close Button', () => {
+ it('should render sprite/icon for close button', () => {
+ const closeBtn = document.querySelector('[data-theme-close]')
+ const svg = closeBtn?.querySelector('svg')
+ expect(svg).toBeTruthy()
+ })
+
+ it('should have proper styling classes on close button', () => {
+ const closeBtn = document.querySelector('[data-theme-close]')
+ expect(closeBtn?.classList.contains('bg-secondary')).toBe(true)
+ expect(closeBtn?.classList.contains('text-text')).toBe(true)
+ expect(closeBtn?.classList.contains('absolute')).toBe(true)
+ })
+ })
+
+ describe('Responsive Design', () => {
+ it('should have horizontal scroll on theme list', () => {
+ const list = document.querySelector('.themepicker__list')
+ expect(list?.classList.contains('overflow-x-auto')).toBe(true)
+ expect(list?.classList.contains('overflow-y-hidden')).toBe(true)
+ })
+
+ it('should have whitespace-nowrap for horizontal layout', () => {
+ const list = document.querySelector('.themepicker__list')
+ expect(list?.classList.contains('whitespace-nowrap')).toBe(true)
+ })
+ })
+})
From a8d914169d89cfa3e00319cd4210f91463cb7e66 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 01:00:56 +0300
Subject: [PATCH 031/341] Consolidate theme picker component e2e test files to
one and update for selecting wrong data-theme value
---
.../theme-picker-web-component.spec.ts | 63 ---
.../specs/04-components/theme-picker.spec.ts | 415 ++++++++++++------
.../theme-initialization.spec.ts | 296 -------------
.../theme-picker-view-transitions.spec.ts | 153 -------
4 files changed, 283 insertions(+), 644 deletions(-)
delete mode 100644 test/e2e/specs/04-components/theme-picker-web-component.spec.ts
delete mode 100644 test/e2e/specs/11-regression/theme-initialization.spec.ts
delete mode 100644 test/e2e/specs/11-regression/theme-picker-view-transitions.spec.ts
diff --git a/test/e2e/specs/04-components/theme-picker-web-component.spec.ts b/test/e2e/specs/04-components/theme-picker-web-component.spec.ts
deleted file mode 100644
index 80e7d64a8..000000000
--- a/test/e2e/specs/04-components/theme-picker-web-component.spec.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { test, expect } from '@playwright/test'
-import { setupTestPage } from '@test/e2e/helpers/cookieHelper'
-
-test.describe('ThemePicker Web Component', () => {
- test('theme picker modal opens and closes', async ({ page }) => {
- await setupTestPage(page, '/')
-
- // Wait for page to load
- await page.waitForLoadState('networkidle') // Find the theme toggle button
- const toggleBtn = page.locator('[data-theme-toggle]')
- await expect(toggleBtn).toBeVisible()
-
- // Click to open theme picker
- await toggleBtn.click()
-
- // Wait a bit for animation
- await page.waitForTimeout(100)
-
- // Check if modal is visible
- const modal = page.locator('[data-theme-modal]')
- await expect(modal).toBeVisible()
-
- // Check if themes are visible
- const themeButtons = page.locator('[data-theme]')
- await expect(themeButtons.first()).toBeVisible()
-
- // Click close button
- const closeBtn = page.locator('[data-theme-close]')
- await closeBtn.click()
-
- // Wait for animation
- await page.waitForTimeout(500)
-
- // Check if modal is hidden
- await expect(modal).toBeHidden()
-
- console.log('✅ ThemePicker Web Component test passed!')
- })
-
- test('can select a theme', async ({ page }) => {
- await setupTestPage(page, '/')
- await page.waitForLoadState('networkidle')
-
- // Open theme picker
- await page.click('[data-theme-toggle]')
- await page.waitForTimeout(100)
-
- // Click on dark theme button
- await page.click('[data-theme="dark"]')
-
- // Wait for theme to apply
- await page.waitForTimeout(100)
-
- // Check if theme attribute is set on document
- const themeAttr = await page.evaluate(() => {
- return document.documentElement.dataset['theme']
- })
-
- expect(themeAttr).toBe('dark')
-
- console.log('✅ Theme selection test passed!')
- })
-})
diff --git a/test/e2e/specs/04-components/theme-picker.spec.ts b/test/e2e/specs/04-components/theme-picker.spec.ts
index 809462fc2..4920fe061 100644
--- a/test/e2e/specs/04-components/theme-picker.spec.ts
+++ b/test/e2e/specs/04-components/theme-picker.spec.ts
@@ -1,6 +1,6 @@
/**
* Theme Picker Component Tests
- * Tests for theme switching functionality (light/dark mode)
+ * Comprehensive tests for theme switching functionality
* @see src/components/ThemePicker/
*/
import {
@@ -12,189 +12,340 @@ import {
test,
} from '@test/e2e/helpers'
+/**
+ * Helper function to handle mobile navigation
+ */
+async function navigateWithMobileSupport(page: import('@playwright/test').Page, selector: string) {
+ const viewport = page.viewportSize()
+ const isMobile = viewport && viewport.width < 768
+
+ if (isMobile) {
+ // Open mobile menu first
+ await page.locator('button[aria-label="toggle menu"]').click()
+ await page.waitForTimeout(600) // Wait for mobile menu animation
+ }
+
+ // Click the first navigation link
+ await page.locator(selector).first().click()
+
+ // Wait for navigation to complete
+ await page.waitForLoadState('networkidle')
+
+ // On mobile, the menu should automatically close after navigation
+ // But let's ensure it's closed by checking and closing if needed
+ if (isMobile) {
+ const menuToggle = page.locator('button[aria-label="toggle menu"]')
+ const isExpanded = await menuToggle.getAttribute('aria-expanded')
+ if (isExpanded === 'true') {
+ await menuToggle.click()
+ await page.waitForTimeout(600) // Wait for close animation
+ }
+ }
+}
test.describe('Theme Picker Component', () => {
- test.beforeEach(async ({ page }, testInfo) => {
- // Skip setup for tests that need custom media emulation
- if (testInfo.title.includes('prefers-color-scheme')) {
- return
- }
- // Most tests need clean state
- await setupCleanTestPage(page)
- })
+ test.describe('Core Functionality', () => {
+ test.beforeEach(async ({ page }, testInfo) => {
+ // Skip setup for tests that need custom media emulation
+ if (testInfo.title.includes('prefers-color-scheme')) {
+ return
+ }
+ await setupCleanTestPage(page)
+ })
- test('@ready theme picker is visible', async ({ page }) => {
- // Expected: Theme picker button should be visible in header
- const themePicker = getThemePickerToggle(page)
- await expect(themePicker).toBeVisible()
- })
+ test('@ready theme picker is visible', async ({ page }) => {
+ // Expected: Theme picker button should be visible in header
+ const themePicker = getThemePickerToggle(page)
+ await expect(themePicker).toBeVisible()
+ })
- test('@ready can toggle to dark theme', async ({ page }) => {
- // Expected: Clicking theme picker should open modal and allow selecting dark theme
- await selectTheme(page, 'dark')
+ test('@ready can toggle to dark theme', async ({ page }) => {
+ // Expected: Clicking theme picker should open modal and allow selecting dark theme
+ await selectTheme(page, 'dark')
- // Check if dark theme is applied
- const htmlElement = page.locator('html')
- const dataTheme = await htmlElement.getAttribute('data-theme')
+ // Check if dark theme is applied
+ const htmlElement = page.locator('html')
+ const dataTheme = await htmlElement.getAttribute('data-theme')
- expect(dataTheme).toBe('dark')
- })
+ expect(dataTheme).toBe('dark')
+ })
- test('@ready can toggle back to light theme', async ({ page }) => {
- // Expected: Toggling twice should return to default theme
+ test('@ready can toggle back to light theme', async ({ page }) => {
+ // Expected: Toggling twice should return to light theme
- // Toggle to dark
- await selectTheme(page, 'dark')
+ // Toggle to dark
+ await selectTheme(page, 'dark')
- // Toggle back to default (light)
- await selectTheme(page, 'default')
+ // Toggle back to light
+ await selectTheme(page, 'light')
- const htmlElement = page.locator('html')
- const dataTheme = await htmlElement.getAttribute('data-theme')
+ const htmlElement = page.locator('html')
+ const dataTheme = await htmlElement.getAttribute('data-theme')
- expect(dataTheme).toBe('default')
- })
+ expect(dataTheme).toBe('light')
+ })
+
+ test('@ready theme picker has accessible label', async ({ page }) => {
+ // Expected: Theme picker button should have appropriate ARIA label
+ const themePicker = getThemePickerToggle(page)
+
+ const ariaLabel = await themePicker.getAttribute('aria-label')
+ expect(ariaLabel).toBeTruthy()
+ expect(ariaLabel?.toLowerCase()).toContain('theme')
+ })
+
+ test('@ready theme picker shows current theme state', async ({ page }) => {
+ // Expected: Theme picker modal should show active state for current theme
+ const themePicker = getThemePickerToggle(page)
+
+ // Open the theme picker modal
+ await themePicker.click()
+ await page.waitForTimeout(300)
+
+ // Check that light theme button's parent has is-active class
+ const lightThemeButton = page.locator('button[data-theme="light"]')
+ const lightParentLi = lightThemeButton.locator('..')
- test('@ready theme preference persists across page reloads', async ({ page }) => {
- // Expected: Theme selection should be stored in localStorage
+ // Verify light theme is initially active
+ const lightClasses = await lightParentLi.getAttribute('class')
+ expect(lightClasses).toContain('is-active')
- // Toggle to dark theme
- await selectTheme(page, 'dark')
+ // Select dark theme (which closes the modal)
+ const darkThemeButton = page.locator('button[data-theme="dark"]')
+ await darkThemeButton.click()
+ await page.waitForTimeout(300)
- // Reload page
- await page.reload()
- await page.waitForTimeout(300)
+ // Reopen modal
+ await themePicker.click()
+ await page.waitForTimeout(300)
- // Verify dark theme persisted
- const htmlElement = page.locator('html')
- const dataTheme = await htmlElement.getAttribute('data-theme')
+ // Dark theme should now be active
+ const darkParentLi = darkThemeButton.locator('..')
+ const darkClasses = await darkParentLi.getAttribute('class')
+ expect(darkClasses).toContain('is-active')
- expect(dataTheme).toBe('dark')
+ // And light should no longer be active
+ const lightClassesAfter = await lightParentLi.getAttribute('class')
+ expect(lightClassesAfter).not.toContain('is-active')
+ })
})
- test('@ready theme preference persists across navigation', async ({ page }) => {
- // Expected: Theme should persist when navigating between pages
+ test.describe('Persistence', () => {
+ test.beforeEach(async ({ page }) => {
+ await setupCleanTestPage(page)
+ })
- // This test needs its own setup without localStorage clearing
- await setupTestPage(page)
+ test('@ready theme preference persists across page reloads', async ({ page }) => {
+ // Expected: Theme selection should be stored in localStorage
- // Select dark theme using helper
- await selectTheme(page, 'dark')
+ // Toggle to dark theme
+ await selectTheme(page, 'dark')
- // Navigate to another page
- await page.goto('/about')
- await page.waitForTimeout(300)
+ // Reload page
+ await page.reload()
+ await page.waitForTimeout(300)
- // Verify dark theme persisted
- const htmlElement = page.locator('html')
- const dataTheme = await htmlElement.getAttribute('data-theme')
+ // Verify dark theme persisted
+ const htmlElement = page.locator('html')
+ const dataTheme = await htmlElement.getAttribute('data-theme')
- expect(dataTheme).toBe('dark')
- })
+ expect(dataTheme).toBe('dark')
+ })
+
+ test('@ready theme preference persists across navigation', async ({ page }) => {
+ // Expected: Theme should persist when navigating between pages
+
+ // This test needs its own setup without localStorage clearing
+ await setupTestPage(page)
+
+ // Select dark theme using helper
+ await selectTheme(page, 'dark')
- test('@ready theme picker has accessible label', async ({ page }) => {
- // Expected: Theme picker button should have appropriate ARIA label
- const themePicker = getThemePickerToggle(page)
+ // Navigate to another page
+ await page.goto('/about')
+ await page.waitForTimeout(300)
- const ariaLabel = await themePicker.getAttribute('aria-label')
- expect(ariaLabel).toBeTruthy()
- expect(ariaLabel?.toLowerCase()).toContain('theme')
+ // Verify dark theme persisted
+ const htmlElement = page.locator('html')
+ const dataTheme = await htmlElement.getAttribute('data-theme')
+
+ expect(dataTheme).toBe('dark')
+ })
})
- test('@ready theme picker shows current theme state', async ({ page }) => {
- // Expected: Theme picker modal should show active state for current theme
- const themePicker = getThemePickerToggle(page)
+ test.describe('System Preferences', () => {
+ test('@ready respects prefers-color-scheme on first visit', async ({ page }) => {
+ // Expected: Should use system preference if no stored preference
+ // Simulate dark mode preference BEFORE navigation
+ await page.context().clearCookies()
- // Open the theme picker modal
- await themePicker.click()
- await page.waitForTimeout(300)
+ await page.emulateMedia({ colorScheme: 'dark' })
- // Check that default theme button's parent has is-active class
- const defaultThemeButton = page.locator('button[data-theme="default"]')
- const defaultParentLi = defaultThemeButton.locator('..')
+ // Visit site for "first time" with dark mode preference
+ await page.goto('/', { waitUntil: 'domcontentloaded' })
- // Verify default theme is initially active
- const defaultClasses = await defaultParentLi.getAttribute('class')
- expect(defaultClasses).toContain('is-active')
+ const htmlElement = page.locator('html')
+ const dataTheme = await htmlElement.getAttribute('data-theme')
- // Select dark theme (which closes the modal)
- const darkThemeButton = page.locator('button[data-theme="dark"]')
- await darkThemeButton.click()
- await page.waitForTimeout(300)
+ expect(dataTheme).toBe('dark')
+ })
- // Reopen modal
- await themePicker.click()
- await page.waitForTimeout(300)
+ test('@ready manual selection overrides system preference', async ({ page }) => {
+ // Expected: User selection should override system preference
+ await page.emulateMedia({ colorScheme: 'dark' })
+ await page.goto('/')
+ await page.waitForTimeout(300)
- // Dark theme should now be active
- const darkParentLi = darkThemeButton.locator('..')
- const darkClasses = await darkParentLi.getAttribute('class')
- expect(darkClasses).toContain('is-active')
+ // Manually switch to light theme
+ await selectTheme(page, 'light')
- // And default should no longer be active
- const defaultClassesAfter = await defaultParentLi.getAttribute('class')
- expect(defaultClassesAfter).not.toContain('is-active')
+ const htmlElement = page.locator('html')
+ const dataTheme = await htmlElement.getAttribute('data-theme')
+
+ expect(dataTheme).toBe('light')
+
+ // Verify it persists after reload
+ await page.reload()
+ await page.waitForTimeout(300)
+
+ const dataThemeAfterReload = await htmlElement.getAttribute('data-theme')
+ expect(dataThemeAfterReload).toBe('light')
+ })
})
- test('@ready respects prefers-color-scheme on first visit', async ({ page }) => {
- // Expected: Should use system preference if no stored preference
- // Simulate dark mode preference BEFORE navigation
- await page.context().clearCookies()
+ test.describe('View Transitions - Regression', () => {
+ test.beforeEach(async ({ page }) => {
+ // Clear localStorage and dismiss cookie modal before each test
+ await setupTestPage(page, '/')
+ await page.evaluate(() => localStorage.clear())
+ await page.reload()
+ })
- await page.emulateMedia({ colorScheme: 'dark' })
+ test('theme picker button works after View Transition navigation', async ({ page }) => {
+ // Issue: Theme picker button stopped working after navigating to another page
+ // Root Cause: Scripts didn't properly reinitialize on View Transitions
+ // Fix: Migrated to Web Component pattern with connectedCallback/disconnectedCallback lifecycle
- // Visit site for "first time" with dark mode preference
- await page.goto('/', { waitUntil: 'domcontentloaded' })
+ // 1. Verify theme picker works on initial page load
+ const themeToggleBtn = page.locator('.theme-toggle-btn').first()
+ await expect(themeToggleBtn).toBeVisible()
- const htmlElement = page.locator('html')
- const dataTheme = await htmlElement.getAttribute('data-theme')
+ // Click to open theme picker
+ await themeToggleBtn.click()
+ await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'true')
- expect(dataTheme).toBe('dark')
- })
+ // Close theme picker
+ const closeBtn = page.locator('.themepicker__closeBtn').first()
+ await closeBtn.click()
+ await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'false')
+
+ // 2. Navigate to another page using an internal link
+ await navigateWithMobileSupport(page, '.main-nav-item a')
- test('@ready manual selection overrides system preference', async ({ page }) => {
- // Expected: User selection should override system preference
- await page.emulateMedia({ colorScheme: 'dark' })
- await page.goto('/')
- await page.waitForTimeout(300)
+ // 3. Verify theme picker button still works after navigation
+ const themeToggleBtnAfterNav = page.locator('.theme-toggle-btn').first()
+ await expect(themeToggleBtnAfterNav).toBeVisible()
- // Manually switch to default (light) theme
- await selectTheme(page, 'default')
+ // Click to open theme picker - this was broken before the fix
+ await themeToggleBtnAfterNav.click()
+ await expect(themeToggleBtnAfterNav).toHaveAttribute('aria-expanded', 'true')
- const htmlElement = page.locator('html')
- const dataTheme = await htmlElement.getAttribute('data-theme')
+ // Verify theme picker modal is actually open
+ const themePicker = page.locator('.themepicker').first()
+ await expect(themePicker).toHaveClass(/is-open/)
+ })
- expect(dataTheme).toBe('default')
+ test('theme picker can select themes after View Transition navigation', async ({ page }) => {
+ // Navigate to another page first using mobile-aware helper
+ await navigateWithMobileSupport(page, '.main-nav-item a')
- // Verify it persists after reload
- await page.reload()
- await page.waitForTimeout(300)
+ // 2. Open theme picker
+ const themeToggleBtn = page.locator('.theme-toggle-btn').first()
+ await themeToggleBtn.click()
+ await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'true')
- const dataThemeAfterReload = await htmlElement.getAttribute('data-theme')
- expect(dataThemeAfterReload).toBe('default')
- })
+ // 3. Select dark theme
+ const darkThemeBtn = page.locator('.themepicker__selectBtn[data-theme="dark"]').first()
+ await darkThemeBtn.click()
- test('@ready theme transition is smooth', async ({ page }) => {
- // Expected: Theme change should not cause flash of unstyled content
- const themePicker = getThemePickerToggle(page)
- const htmlElement = page.locator('html')
+ // 4. Verify dark theme is applied
+ const htmlElement = page.locator('html')
+ await expect(htmlElement).toHaveAttribute('data-theme', 'dark')
+
+ // 5. Verify the button has the active class
+ const darkThemeItem = darkThemeBtn.locator('..')
+ await expect(darkThemeItem).toHaveClass(/is-active/)
+ })
- // Select dark theme
- await themePicker.click()
- await page.waitForTimeout(100)
- await page.click('[data-theme="dark"]')
+ test('theme preference persists across View Transitions', async ({ page }) => {
+ // 1. Select dark theme on home page
+ const themeToggleBtn = page.locator('.theme-toggle-btn').first()
+ await themeToggleBtn.click()
- // Verify theme changed immediately (no flash)
- await page.waitForTimeout(100)
+ const darkThemeBtn = page.locator('.themepicker__selectBtn[data-theme="dark"]').first()
+ await darkThemeBtn.click()
- const dataTheme = await htmlElement.getAttribute('data-theme')
- expect(dataTheme).toBe('dark')
+ // Wait for theme to be applied and picker to close
+ await page.waitForTimeout(500)
- // Verify the theme is visually applied by checking a CSS variable
- const bgColor = await htmlElement.evaluate((el) => {
- return window.getComputedStyle(el).getPropertyValue('--color-bg')
+ // 2. Navigate to another page using mobile-aware helper
+ await navigateWithMobileSupport(page, '.main-nav-item a')
+
+ // 3. Verify dark theme is still applied
+ const htmlElement = page.locator('html')
+ await expect(htmlElement).toHaveAttribute('data-theme', 'dark')
+
+ // 4. Verify theme picker modal stays open after selecting theme and navigating
+ // Note: The modal stays open because $themePickerOpen persists the state
+ const themePickerAfterNav = page.locator('.themepicker').first()
+ await expect(themePickerAfterNav).toHaveClass(/is-open/)
})
+ })
- expect(bgColor).toBeTruthy()
+ test.describe('HTML Attributes - Regression', () => {
+ test.beforeEach(async ({ page }) => {
+ await setupTestPage(page, '/')
+ await page.evaluate(() => localStorage.clear())
+ await page.reload()
+ })
+
+ test('preserves lang attribute across navigation', async ({ page }) => {
+ // Bug: Astro View Transitions removes lang attribute from
+ // Verify lang attribute on home page
+ let langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+
+ // Navigate to another page using View Transitions
+ await page.goto('/about')
+
+ // Verify lang attribute is still present (not removed by View Transitions)
+ langAttr = await page.getAttribute('html', 'lang')
+ expect(langAttr).toBe('en')
+ })
+
+ test('preserves lang attribute with theme changes', async ({ page }) => {
+ // Set theme
+ await page.evaluate(() => {
+ localStorage.setItem('theme', 'dark')
+ })
+
+ await page.reload()
+
+ // Verify both attributes are present
+ const langAttr = await page.getAttribute('html', 'lang')
+ const themeAttr = await page.getAttribute('html', 'data-theme')
+
+ expect(langAttr).toBe('en')
+ expect(themeAttr).toBe('dark')
+
+ // Navigate and verify both persist
+ await page.goto('/articles')
+
+ const langAttrAfter = await page.getAttribute('html', 'lang')
+ const themeAttrAfter = await page.getAttribute('html', 'data-theme')
+
+ expect(langAttrAfter).toBe('en')
+ expect(themeAttrAfter).toBe('dark')
+ })
})
})
diff --git a/test/e2e/specs/11-regression/theme-initialization.spec.ts b/test/e2e/specs/11-regression/theme-initialization.spec.ts
deleted file mode 100644
index 56563d6c9..000000000
--- a/test/e2e/specs/11-regression/theme-initialization.spec.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-/**
- * Regression Tests for Theme Initialization
- *
- * Tests theme persistence across page navigation and ensures the lang attribute
- * is not removed by Astro View Transitions.
- *
- * Related:
- * - src/components/scripts/theme/index.ts (setInitialTheme)
- * - Bug: Astro View Transitions removes lang attribute from
- */
-
-import { BasePage, test, describe, expect } from '@test/e2e/helpers'
-
-describe('Theme Initialization - Regression Tests', () => {
- test.beforeEach(async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
- // Clear localStorage and dismiss cookie modal
- await page.goto('/')
- await page.evaluate(() => localStorage.clear())
- await playwrightPage.reload()
- })
-
- describe('localStorage theme persistence', () => {
- test('should persist theme across page navigation', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Set theme to dark in localStorage
- await page.evaluate(() => {
- localStorage.setItem('theme', 'dark')
- })
-
- await playwrightPage.reload()
-
- // Verify dark theme is applied
- let themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('dark')
-
- // Navigate to another page (dynamically fetch first article)
- await page.goto('/articles')
- const firstArticleLink = playwrightPage.locator('a[href^="/articles/"]').first()
- await firstArticleLink.click()
- await playwrightPage.waitForLoadState('networkidle')
-
- // Verify theme persists after navigation
- themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('dark')
- })
-
- test('should persist light theme across navigation', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Set theme to light in localStorage
- await page.evaluate(() => {
- localStorage.setItem('theme', 'light')
- })
-
- await playwrightPage.reload()
-
- // Verify light theme is applied
- let themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('light')
-
- // Navigate to services page
- await page.goto('/services')
-
- // Navigate to first service (dynamically fetch)
- const firstServiceLink = playwrightPage.locator('a[href^="/services/"]').first()
- await firstServiceLink.click()
- await playwrightPage.waitForLoadState('networkidle')
-
- // Verify theme persists
- themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('light')
- })
-
- test('should maintain theme when navigating back and forth', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Set custom theme
- await page.evaluate(() => {
- localStorage.setItem('theme', 'dark')
- })
-
- await playwrightPage.reload()
-
- // Navigate forward to case studies
- await page.goto('/case-studies')
-
- let themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('dark')
-
- // Navigate back
- await playwrightPage.goBack()
- await playwrightPage.waitForLoadState('networkidle')
-
- themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('dark')
-
- // Navigate forward again
- await playwrightPage.goForward()
- await playwrightPage.waitForLoadState('networkidle')
-
- themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('dark')
- })
- })
-
- describe('lang attribute preservation', () => {
- test('should preserve lang="en" attribute on initial load', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
- await page.goto('/')
-
- const langAttr = await page.getAttribute('html', 'lang')
- expect(langAttr).toBe('en')
- })
-
- test('should restore lang attribute after View Transition navigation', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
- await page.goto('/')
-
- // Verify lang attribute on home page
- let langAttr = await page.getAttribute('html', 'lang')
- expect(langAttr).toBe('en')
-
- // Navigate to another page using View Transitions
- await page.goto('/about')
-
- // Verify lang attribute is still present (not removed by View Transitions)
- langAttr = await page.getAttribute('html', 'lang')
- expect(langAttr).toBe('en')
- })
-
- test('should preserve lang attribute across multiple navigations', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
- const pages = ['/', '/articles', '/services', '/case-studies', '/contact']
-
- for (const pagePath of pages) {
- await page.goto(pagePath)
-
- const langAttr = await page.getAttribute('html', 'lang')
- expect(langAttr).toBe('en')
- }
- })
-
- test('should preserve lang attribute with theme changes', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Set theme
- await page.evaluate(() => {
- localStorage.setItem('theme', 'dark')
- })
-
- await playwrightPage.reload()
-
- // Verify both attributes are present
- const langAttr = await page.getAttribute('html', 'lang')
- const themeAttr = await page.getAttribute('html', 'data-theme')
-
- expect(langAttr).toBe('en')
- expect(themeAttr).toBe('dark')
-
- // Navigate and verify both persist
- await page.goto('/articles')
-
- const langAttrAfter = await page.getAttribute('html', 'lang')
- const themeAttrAfter = await page.getAttribute('html', 'data-theme')
-
- expect(langAttrAfter).toBe('en')
- expect(themeAttrAfter).toBe('dark')
- })
- })
-
- describe('system preference fallback', () => {
- test('should apply dark theme when system prefers dark and no stored theme', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Emulate dark color scheme preference
- await playwrightPage.emulateMedia({ colorScheme: 'dark' })
-
- // Clear any stored theme
- await page.evaluate(() => localStorage.removeItem('theme'))
-
- await playwrightPage.reload()
-
- const themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('dark')
- })
-
- test('should not override stored theme with system preference', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Set light theme in storage
- await page.evaluate(() => {
- localStorage.setItem('theme', 'light')
- })
-
- // Emulate dark system preference
- await playwrightPage.emulateMedia({ colorScheme: 'dark' })
-
- await playwrightPage.reload()
-
- // Stored theme should take precedence
- const themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('light')
- })
- })
-
- describe('error handling', () => {
- test('should still set lang attribute even when localStorage throws error', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Make localStorage throw errors
- await playwrightPage.addInitScript(() => {
- const originalGetItem = Storage.prototype.getItem
- Storage.prototype.getItem = function() {
- throw new Error('localStorage access denied')
- }
-
- // Restore after a short delay to allow theme script to run
- setTimeout(() => {
- Storage.prototype.getItem = originalGetItem
- }, 100)
- })
-
- await playwrightPage.reload()
-
- // Even with localStorage error, lang attribute should be set
- const langAttr = await page.getAttribute('html', 'lang')
- expect(langAttr).toBe('en')
- })
-
- test('should gracefully handle missing localStorage', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Mock missing localStorage
- await playwrightPage.addInitScript(() => {
- // @ts-expect-error - deliberately breaking localStorage
- delete window.localStorage
- })
-
- await playwrightPage.reload()
-
- // Should still function and set lang attribute
- const langAttr = await page.getAttribute('html', 'lang')
- expect(langAttr).toBe('en')
- })
- })
-
- describe('FOUC prevention', () => {
- test('should apply theme before page renders (no flash)', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- // Set dark theme
- await page.evaluate(() => {
- localStorage.setItem('theme', 'dark')
- })
-
- // Reload and immediately check theme (before full page load)
- await page.goto('/')
-
- // Check theme is set during DOMContentLoaded (before full render)
- const themeAttr = await page.getAttribute('html', 'data-theme')
- expect(themeAttr).toBe('dark')
- })
-
- test('should apply theme synchronously on page load', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
-
- await page.evaluate(() => {
- localStorage.setItem('theme', 'dark')
- })
-
- // Monitor when theme attribute is set
- const themeSetTime = await page.evaluate(() => {
- return new Promise((resolve) => {
- const observer = new MutationObserver(() => {
- const theme = document.documentElement.getAttribute('data-theme')
- if (theme) {
- resolve(performance.now().toString())
- }
- })
-
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ['data-theme']
- })
-
- // Timeout after 1 second
- setTimeout(() => resolve('timeout'), 1000)
- })
- })
-
- expect(themeSetTime).not.toBe('timeout')
- })
- })
-})
diff --git a/test/e2e/specs/11-regression/theme-picker-view-transitions.spec.ts b/test/e2e/specs/11-regression/theme-picker-view-transitions.spec.ts
deleted file mode 100644
index 970a11711..000000000
--- a/test/e2e/specs/11-regression/theme-picker-view-transitions.spec.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * Regression Tests for Theme Picker with View Transitions
- *
- * Issue: Theme picker button stopped working after navigating to another page
- * Root Cause: Scripts didn't properly reinitialize on View Transitions
- * Fix: Migrated to Web Component pattern with connectedCallback/disconnectedCallback lifecycle
- *
- * @see src/components/ThemePicker/theme-picker-element.ts
- */
-
-import { test, expect } from '@test/e2e/helpers'
-import { setupTestPage } from '@test/e2e/helpers/cookieHelper'
-
-/**
- * Helper function to handle mobile navigation
- */
-async function navigateWithMobileSupport(page: import('@playwright/test').Page, selector: string) {
- const viewport = page.viewportSize()
- const isMobile = viewport && viewport.width < 768
-
- if (isMobile) {
- // Open mobile menu first
- await page.locator('button[aria-label="toggle menu"]').click()
- await page.waitForTimeout(600) // Wait for mobile menu animation
- }
-
- // Click the first navigation link
- await page.locator(selector).first().click()
-
- // Wait for navigation to complete
- await page.waitForLoadState('networkidle')
-
- // On mobile, the menu should automatically close after navigation
- // But let's ensure it's closed by checking and closing if needed
- if (isMobile) {
- const menuToggle = page.locator('button[aria-label="toggle menu"]')
- const isExpanded = await menuToggle.getAttribute('aria-expanded')
- if (isExpanded === 'true') {
- await menuToggle.click()
- await page.waitForTimeout(600) // Wait for close animation
- }
- }
-}
-
-test.describe('Theme Picker - View Transitions Regression', () => {
- test.beforeEach(async ({ page }) => {
- // Clear localStorage and dismiss cookie modal before each test
- await setupTestPage(page, '/')
- await page.evaluate(() => localStorage.clear())
- await page.reload()
- })
-
- test('theme picker button works after View Transition navigation', async ({ page }) => {
- // 1. Verify theme picker works on initial page load
- const themeToggleBtn = page.locator('.theme-toggle-btn').first()
- await expect(themeToggleBtn).toBeVisible()
-
- // Click to open theme picker
- await themeToggleBtn.click()
- await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'true')
-
- // Close theme picker
- const closeBtn = page.locator('.themepicker__closeBtn').first()
- await closeBtn.click()
- await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'false')
-
- // 2. Navigate to another page using an internal link
- // Navigate using mobile-aware helper
- await navigateWithMobileSupport(page, '.main-nav-item a')
-
- // 3. Verify theme picker button still works after navigation
- const themeToggleBtnAfterNav = page.locator('.theme-toggle-btn').first()
- await expect(themeToggleBtnAfterNav).toBeVisible()
-
- // Click to open theme picker - this was broken before the fix
- await themeToggleBtnAfterNav.click()
- await expect(themeToggleBtnAfterNav).toHaveAttribute('aria-expanded', 'true')
-
- // Verify theme picker modal is actually open
- const themePicker = page.locator('.themepicker').first()
- await expect(themePicker).toHaveClass(/is-open/)
- })
-
- test('theme picker can select themes after View Transition navigation', async ({ page }) => {
- // Navigate to another page first using mobile-aware helper
- await navigateWithMobileSupport(page, '.main-nav-item a')
-
- // 2. Open theme picker
- const themeToggleBtn = page.locator('.theme-toggle-btn').first()
- await themeToggleBtn.click()
- await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'true')
-
- // 3. Select dark theme
- const darkThemeBtn = page.locator('.themepicker__selectBtn[data-theme="dark"]').first()
- await darkThemeBtn.click()
-
- // 4. Verify dark theme is applied
- const htmlElement = page.locator('html')
- await expect(htmlElement).toHaveAttribute('data-theme', 'dark')
-
- // 5. Verify the button has the active class
- const darkThemeItem = darkThemeBtn.locator('..')
- await expect(darkThemeItem).toHaveClass(/is-active/)
- })
-
- test('theme picker works after multiple navigations', async ({ page }) => {
- // Navigate through multiple pages to ensure script reinitializes each time
- const navLinks = page.locator('.main-nav-item a')
- const linkCount = await navLinks.count()
-
- // Navigate to first 3 links (or all if less than 3)
- const navigationsToTest = Math.min(3, linkCount)
-
- for (let i = 0; i < navigationsToTest; i++) {
- // Use mobile-aware navigation helper
- await navigateWithMobileSupport(page, `.main-nav-item a >> nth=${i}`)
-
- // Verify theme picker still works
- const themeToggleBtn = page.locator('.theme-toggle-btn').first()
- await expect(themeToggleBtn).toBeVisible()
- await themeToggleBtn.click()
- await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'true')
-
- // Modal now stays open when selecting themes - close it manually before next iteration
- await themeToggleBtn.click()
- await expect(themeToggleBtn).toHaveAttribute('aria-expanded', 'false')
- }
- })
-
- test('theme preference persists across View Transitions', async ({ page }) => {
- // 1. Select dark theme on home page
- const themeToggleBtn = page.locator('.theme-toggle-btn').first()
- await themeToggleBtn.click()
-
- const darkThemeBtn = page.locator('.themepicker__selectBtn[data-theme="dark"]').first()
- await darkThemeBtn.click()
-
- // Wait for theme to be applied and picker to close
- await page.waitForTimeout(500)
-
- // 2. Navigate to another page using mobile-aware helper
- await navigateWithMobileSupport(page, '.main-nav-item a')
-
- // 3. Verify dark theme is still applied
- const htmlElement = page.locator('html')
- await expect(htmlElement).toHaveAttribute('data-theme', 'dark')
-
- // 4. Verify theme picker modal stays open after selecting theme and navigating
- // Note: The modal stays open because $themePickerOpen persists the state
- const themePickerAfterNav = page.locator('.themepicker').first()
- await expect(themePickerAfterNav).toHaveClass(/is-open/)
- })
-})
From 3eee80d61448c447a88ca3bda1f0dd658eabb4e8 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 02:07:49 +0300
Subject: [PATCH 032/341] Note problems with theme picker component and todo
list, and update view-transitions-persistence test to check if theme picker
state is maintained across page transitions
---
src/components/Test/ViewTransitionTest.astro | 5 --
.../{MOBILE_PROBLEMS.md => PROBLEMS.md} | 84 ++++++++++++++++++-
src/layouts/BaseLayout.astro | 18 +---
.../specs/04-components/theme-picker.spec.ts | 24 +++++-
...mepicker-view-transitions-persist.spec.ts} | 77 +++++++++++------
5 files changed, 156 insertions(+), 52 deletions(-)
delete mode 100644 src/components/Test/ViewTransitionTest.astro
rename src/components/ThemePicker/{MOBILE_PROBLEMS.md => PROBLEMS.md} (76%)
rename test/e2e/specs/11-regression/{view-transitions-persist.spec.ts => themepicker-view-transitions-persist.spec.ts} (58%)
diff --git a/src/components/Test/ViewTransitionTest.astro b/src/components/Test/ViewTransitionTest.astro
deleted file mode 100644
index 11ebd4dd4..000000000
--- a/src/components/Test/ViewTransitionTest.astro
+++ /dev/null
@@ -1,5 +0,0 @@
----
-// For use in regression test on Astro Page Transitions
----
-
-
\ No newline at end of file
diff --git a/src/components/ThemePicker/MOBILE_PROBLEMS.md b/src/components/ThemePicker/PROBLEMS.md
similarity index 76%
rename from src/components/ThemePicker/MOBILE_PROBLEMS.md
rename to src/components/ThemePicker/PROBLEMS.md
index 096a1ae79..e5317e038 100644
--- a/src/components/ThemePicker/MOBILE_PROBLEMS.md
+++ b/src/components/ThemePicker/PROBLEMS.md
@@ -1,8 +1,86 @@
-# ThemePicker Mobile Issues
+# ThemePicker Issues & TODO
-## Current Status: UNRESOLVED
+## Current Status Summary
+
+- **Mobile Touch Issues**: UNRESOLVED (documented below)
+- **Desktop**: ✅ WORKING
+- **System Preference Detection**: 🔍 INVESTIGATING (media query works, but regression in initial theme)
+- **E2E Tests**: ✅ 14/14 PASSING (Chromium)
+- **Unit Tests**: ✅ 23/23 PASSING
+
+Last Updated: November 8, 2025
+
+---
+
+## System Preference Detection Investigation
+
+### Debug Testing Results (November 8, 2025)
+
+Created `/src/pages/debug-prefers-color-scheme.html` to test `window.matchMedia('(prefers-color-scheme: dark)')`.
+
+**Chrome Settings Tested (all in incognito):**
+
+1. Light theme
+2. Dark theme
+3. System theme
+
+**Results:** Media query works correctly in all cases:
+
+- Chrome Light → `prefers-color-scheme: dark` = `false`, `light` = `true`
+- Chrome Dark → Same as light (unexpected!)
+- Chrome System → Same as light
+
+**Conclusion:** The media query itself works. The regression might be:
+
+1. Timing issue (script runs before system preference available)
+2. Different issue causing FOUC
+3. Need to investigate git diff changes more carefully
+
+**Git Diff Analysis (81188a5 → 315d7b0):**
+
+- Added `else if (!stored)` block to ThemeInit.astro
+- Logic: Check `window.matchMedia('(prefers-color-scheme: dark)').matches`
+- Should work but causes dark theme on first visit
+
+**Status:** Paused - needs deeper investigation of timing/FOUC
+
+---
+
+## TODO Items
+
+### High Priority
+
+- [ ] **Fix position of theme picker toggle icon on mobile view**
+ - Current: Icon slides down vertically when modal opens
+ - Expected: Should slide RIGHT to align under hamburger menu icon
+ - Issue: Looks awkward spaced off right margin
+ - Required: Reverse position animation on modal close
+
+### Medium Priority
+
+- [ ] **Fix system preference regression** (from investigation above)
+ - Debug why first-time visitors always get dark theme
+ - Eliminate FOUC (light theme flash before dark)
+ - Verify fix across all system preference settings
+
+- [ ] **Add more themes**
+ - Current: Only light and dark
+ - Expand theme options for users
+
+- [ ] **Add carousel to theme picker modal**
+ - Needed when: More themes than fit in viewport
+ - Allows scrolling through theme options
+
+### Low Priority
+
+- [ ] **Resolve mobile touch issues** (see below for details)
+
+---
+
+## Mobile Touch Issues
+
+### Current Status: UNRESOLVED
-Date: November 8, 2025
Component: `src/components/ThemePicker/client.ts`
## Symptoms
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 8daf2b1ca..16fef55f8 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -8,7 +8,6 @@ import Header from '@components/Header/index.astro'
import Content from '@components/Layout/Content/index.astro'
import PwaTitleBar from '@components/PwaTitleBar/index.astro'
import Skip from '@components/Layout/Skip/index.astro'
-import ViewTransitionTest from '@components/Test/ViewTransitionTest.astro'
import ThemePicker from '@components/ThemePicker/index.astro'
import { ClientRouter } from 'astro:transitions'
@@ -69,24 +68,15 @@ const {
- {/* Cookie Consent Overlay - Only persist in production mode */}
- {import.meta.env.PROD ? (
-
- ) : (
-
- )}
+ {/* Cookie Consent Overlay */}
+
{/* Skip to main content link for accessibility */}
{/* Sets Window Controls Overlay API for PWA title bar */}
{/* Site header and main content */}
- {/* Theme Picker Modal - uses transition:persist in production for View Transitions API */}
- {import.meta.env.PROD ? (
-
- ) : (
-
- )}
+
{/* Main page content */}
@@ -96,7 +86,5 @@ const {
{/* Site footer */}
- {/* View Transition Test Element for E2E Regression Tests */}
-
diff --git a/test/e2e/specs/04-components/theme-picker.spec.ts b/test/e2e/specs/04-components/theme-picker.spec.ts
index 4920fe061..15d8dc7cf 100644
--- a/test/e2e/specs/04-components/theme-picker.spec.ts
+++ b/test/e2e/specs/04-components/theme-picker.spec.ts
@@ -176,16 +176,32 @@ test.describe('Theme Picker Component', () => {
test.describe('System Preferences', () => {
test('@ready respects prefers-color-scheme on first visit', async ({ page }) => {
// Expected: Should use system preference if no stored preference
- // Simulate dark mode preference BEFORE navigation
- await page.context().clearCookies()
+ // First, visit the page to establish the domain context
+ await page.goto('/')
+
+ // Clear localStorage to simulate first visit
+ await page.evaluate(() => localStorage.clear())
+
+ // Set up media emulation for dark color scheme
await page.emulateMedia({ colorScheme: 'dark' })
- // Visit site for "first time" with dark mode preference
- await page.goto('/', { waitUntil: 'domcontentloaded' })
+ // Reload to apply the preference with no stored theme
+ await page.reload({ waitUntil: 'domcontentloaded' })
+
+ // Debug: Check what's in localStorage
+ const storedTheme = await page.evaluate(() => localStorage.getItem('theme'))
+ console.log('Stored theme after reload:', storedTheme)
+
+ // Debug: Check media query
+ const prefersDark = await page.evaluate(() =>
+ window.matchMedia('(prefers-color-scheme: dark)').matches
+ )
+ console.log('Prefers dark:', prefersDark)
const htmlElement = page.locator('html')
const dataTheme = await htmlElement.getAttribute('data-theme')
+ console.log('Actual data-theme:', dataTheme)
expect(dataTheme).toBe('dark')
})
diff --git a/test/e2e/specs/11-regression/view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
similarity index 58%
rename from test/e2e/specs/11-regression/view-transitions-persist.spec.ts
rename to test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
index 26b07ae55..215b8afd7 100644
--- a/test/e2e/specs/11-regression/view-transitions-persist.spec.ts
+++ b/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
@@ -4,22 +4,28 @@
* Verifies that elements with transition:persist maintain their DOM identity
* across page navigations when using Astro's View Transitions API.
*
+ * Tests specifically focus on Web Components (like ThemePicker) to verify
+ * whether Astro properly handles transition:persist for custom elements.
+ *
* Related:
- * - src/layouts/BaseLayout.astro (view-transition test element)
+ * - src/layouts/BaseLayout.astro (ThemePicker with transition:persist)
+ * - src/components/ThemePicker/index.astro (Web Component implementation)
* - Astro View Transitions API documentation
*/
import { BasePage, test, describe, expect } from '@test/e2e/helpers'
-describe('View Transitions - transition:persist', () => {
- test('should persist DOM element identity across navigation', async ({ page: playwrightPage }) => {
+describe('View Transitions - transition:persist on Web Components', () => {
+ test('should persist ThemePicker web component identity across navigation', async ({
+ page: playwrightPage,
+ }) => {
const page = new BasePage(playwrightPage)
await page.goto('/')
- // Set a unique identifier on the element and store a custom property
+ // Set a unique identifier on the ThemePicker web component and store a custom property
const initialData = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
- if (!element) throw new Error('view-transition element not found')
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
// Create a unique identifier
const uniqueId = `test-${Date.now()}-${Math.random()}`
@@ -31,9 +37,13 @@ describe('View Transitions - transition:persist', () => {
// This is more definitive proof of DOM identity persistence
;(element as any).__testProperty = uniqueId
+ // Store a counter to verify the element isn't recreated
+ ;(element as any).__navigationCounter = 0
+
return {
uniqueId,
initialTimestamp: Date.now(),
+ tagName: element.tagName.toLowerCase(),
}
})
@@ -42,30 +52,40 @@ describe('View Transitions - transition:persist', () => {
// Verify the element still has the same unique identifier
const afterNavigationData = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
- if (!element) throw new Error('view-transition element not found after navigation')
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found after navigation')
+
+ // Increment counter to prove it's the same element
+ const counter = (element as any).__navigationCounter
+ ;(element as any).__navigationCounter = counter + 1
return {
dataAttribute: element.getAttribute('data-unique-id'),
customProperty: (element as any).__testProperty,
+ navigationCounter: counter,
elementExists: !!element,
+ tagName: element.tagName.toLowerCase(),
}
})
// Assertions proving DOM identity persistence
expect(afterNavigationData.elementExists).toBe(true)
+ expect(afterNavigationData.tagName).toBe('theme-picker')
expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
+ expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
})
- test.skip('should maintain custom properties across multiple navigations', async ({ page: playwrightPage }) => {
+ test.skip('should maintain custom properties across multiple navigations', async ({
+ page: playwrightPage,
+ }) => {
const page = new BasePage(playwrightPage)
await page.goto('/')
- // Set multiple custom properties on the element
+ // Set multiple custom properties on the ThemePicker web component
const testData = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
- if (!element) throw new Error('view-transition element not found')
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
const data = {
id: `id-${Math.random()}`,
@@ -88,7 +108,7 @@ describe('View Transitions - transition:persist', () => {
// Verify the custom property persists
const currentData = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ const element = document.querySelector('theme-picker') as HTMLElement
return (element as any).__testData
})
@@ -96,14 +116,16 @@ describe('View Transitions - transition:persist', () => {
}
})
- test.skip('should preserve event listener state across navigation', async ({ page: playwrightPage }) => {
+ test.skip('should preserve internal state of web component across navigation', async ({
+ page: playwrightPage,
+ }) => {
const page = new BasePage(playwrightPage)
await page.goto('/')
- // Add an event listener and track if it fires
+ // Modify internal state of the ThemePicker component
await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
- if (!element) throw new Error('view-transition element not found')
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
let clickCount = 0
@@ -112,6 +134,8 @@ describe('View Transitions - transition:persist', () => {
;(element as any).__clickCount = clickCount
}
+ // Note: This is for testing DOM persistence, not production code
+ // In production, we use addButtonEventListeners from elementListeners
element.addEventListener('click', handleClick)
// Store initial state
@@ -122,11 +146,11 @@ describe('View Transitions - transition:persist', () => {
// Navigate to another page
await page.goto('/about')
- // Click the element and verify the listener still works
- await playwrightPage.locator('[data-testid="view-transition"]').click()
+ // Click the element and verify internal state persists
+ await playwrightPage.locator('theme-picker').click()
const listenerData = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ const element = document.querySelector('theme-picker') as HTMLElement
return {
listenerAttached: (element as any).__listenerAttached,
clickCount: (element as any).__clickCount,
@@ -141,10 +165,10 @@ describe('View Transitions - transition:persist', () => {
const page = new BasePage(playwrightPage)
await page.goto('/')
- // Store a WeakMap reference test - more advanced verification
+ // Store a reference test on ThemePicker - advanced verification of identity persistence
const refCheck = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
- if (!element) throw new Error('view-transition element not found')
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
// Create a unique symbol that can only exist on the same object reference
const uniqueSymbol = Symbol('test-identity')
@@ -156,21 +180,24 @@ describe('View Transitions - transition:persist', () => {
return {
symbolKey: uniqueSymbol.toString(),
+ tagName: element.tagName.toLowerCase(),
}
})
// Navigate
await page.goto('/about')
- // Check if the symbol property still exists
+ // Check if the symbol property still exists (proves same object reference)
const afterNav = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="view-transition"]') as HTMLElement
+ const element = document.querySelector('theme-picker') as HTMLElement
return {
symbolKey: (element as any).__symbolKey,
symbolValue: (element as any).__symbolValue,
+ tagName: element.tagName.toLowerCase(),
}
})
+ expect(afterNav.tagName).toBe('theme-picker')
expect(afterNav.symbolKey).toBe(refCheck.symbolKey)
expect(afterNav.symbolValue).toBe('identity-marker')
})
From ccd7408d9cab34353369e091e312f96c92137830 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 04:12:04 +0300
Subject: [PATCH 033/341] Experimental refactor of Footer component to Preact
for investigating Astro View Transitions behavior
---
package-lock.json | 148 +++++-----
package.json | 4 +-
src/components/Footer/FooterPreact.tsx | 115 ++++++++
src/layouts/BaseLayout.astro | 19 +-
.../footer-view-transitions-persist.spec.ts | 264 ++++++++++++++++++
5 files changed, 470 insertions(+), 80 deletions(-)
create mode 100644 src/components/Footer/FooterPreact.tsx
create mode 100644 test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
diff --git a/package-lock.json b/package-lock.json
index 460b3b81c..c4f11eab2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,7 +37,7 @@
"focus-trap": "7.6.6",
"gsap": "^3.13.0",
"js-cookie": "^3.0.5",
- "libphonenumber-js": "1.12.25",
+ "libphonenumber-js": "1.12.26",
"lit": "^3.3.1",
"nanostores": "^1.0.1",
"postcss": "8.5.6",
@@ -119,7 +119,7 @@
"typescript-eslint": "8.46.3",
"unist-util-inspect": "^8.1.0",
"unist-util-visit": "^5.0.0",
- "vitest": "4.0.7",
+ "vitest": "4.0.8",
"vitest-axe": "0.1.0"
},
"engines": {
@@ -8380,17 +8380,17 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.7.tgz",
- "integrity": "sha512-jGRG6HghnJDjljdjYIoVzX17S6uCVCBRFnsgdLGJ6CaxfPh8kzUKe/2n533y4O/aeZ/sIr7q7GbuEbeGDsWv4Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz",
+ "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.0.7",
- "@vitest/utils": "4.0.7",
- "chai": "^6.0.1",
+ "@vitest/spy": "4.0.8",
+ "@vitest/utils": "4.0.8",
+ "chai": "^6.2.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
@@ -8398,9 +8398,9 @@
}
},
"node_modules/@vitest/expect/node_modules/@vitest/pretty-format": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.7.tgz",
- "integrity": "sha512-YY//yxqTmk29+/pK+Wi1UB4DUH3lSVgIm+M10rAJ74pOSMgT7rydMSc+vFuq9LjZLhFvVEXir8EcqMke3SVM6Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz",
+ "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8411,13 +8411,13 @@
}
},
"node_modules/@vitest/expect/node_modules/@vitest/utils": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.7.tgz",
- "integrity": "sha512-HNrg9CM/Z4ZWB6RuExhuC6FPmLipiShKVMnT9JlQvfhwR47JatWLChA6mtZqVHqypE6p/z6ofcjbyWpM7YLxPQ==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz",
+ "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.7",
+ "@vitest/pretty-format": "4.0.8",
"tinyrainbow": "^3.0.3"
},
"funding": {
@@ -8425,15 +8425,15 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.7.tgz",
- "integrity": "sha512-OsDwLS7WnpuNslOV6bJkXVYVV/6RSc4eeVxV7h9wxQPNxnjRvTTrIikfwCbMyl8XJmW6oOccBj2Q07YwZtQcCw==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz",
+ "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.0.7",
+ "@vitest/spy": "4.0.8",
"estree-walker": "^3.0.3",
- "magic-string": "^0.30.19"
+ "magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -8465,13 +8465,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.7.tgz",
- "integrity": "sha512-orU1lsu4PxLEcDWfjVCNGIedOSF/YtZ+XMrd1PZb90E68khWCNzD8y1dtxtgd0hyBIQk8XggteKN/38VQLvzuw==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz",
+ "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.0.7",
+ "@vitest/utils": "4.0.8",
"pathe": "^2.0.3"
},
"funding": {
@@ -8479,9 +8479,9 @@
}
},
"node_modules/@vitest/runner/node_modules/@vitest/pretty-format": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.7.tgz",
- "integrity": "sha512-YY//yxqTmk29+/pK+Wi1UB4DUH3lSVgIm+M10rAJ74pOSMgT7rydMSc+vFuq9LjZLhFvVEXir8EcqMke3SVM6Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz",
+ "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8492,13 +8492,13 @@
}
},
"node_modules/@vitest/runner/node_modules/@vitest/utils": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.7.tgz",
- "integrity": "sha512-HNrg9CM/Z4ZWB6RuExhuC6FPmLipiShKVMnT9JlQvfhwR47JatWLChA6mtZqVHqypE6p/z6ofcjbyWpM7YLxPQ==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz",
+ "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.7",
+ "@vitest/pretty-format": "4.0.8",
"tinyrainbow": "^3.0.3"
},
"funding": {
@@ -8506,14 +8506,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.7.tgz",
- "integrity": "sha512-xJL+Nkw0OjaUXXQf13B8iKK5pI9QVtN9uOtzNHYuG/o/B7fIEg0DQ+xOe0/RcqwDEI15rud1k7y5xznBKGUXAA==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz",
+ "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.7",
- "magic-string": "^0.30.19",
+ "@vitest/pretty-format": "4.0.8",
+ "magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
@@ -8521,9 +8521,9 @@
}
},
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.7.tgz",
- "integrity": "sha512-YY//yxqTmk29+/pK+Wi1UB4DUH3lSVgIm+M10rAJ74pOSMgT7rydMSc+vFuq9LjZLhFvVEXir8EcqMke3SVM6Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz",
+ "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8534,9 +8534,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.7.tgz",
- "integrity": "sha512-FW4X8hzIEn4z+HublB4hBF/FhCVaXfIHm8sUfvlznrcy1MQG7VooBgZPMtVCGZtHi0yl3KESaXTqsKh16d8cFg==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz",
+ "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -14964,9 +14964,9 @@
}
},
"node_modules/libphonenumber-js": {
- "version": "1.12.25",
- "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.25.tgz",
- "integrity": "sha512-u90tUu/SEF8b+RaDKCoW7ZNFDakyBtFlX1ex3J+VH+ElWes/UaitJLt/w4jGu8uAE41lltV/s+kMVtywcMEg7g==",
+ "version": "1.12.26",
+ "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.26.tgz",
+ "integrity": "sha512-MagMOuqEXB2Pa90cWE+BoCmcKJx+de5uBIicaUkQ+uiEslZ0OBMNOkSZT/36syXNHu68UeayTxPm3DYM2IHoLQ==",
"license": "MIT"
},
"node_modules/lightningcss": {
@@ -15397,9 +15397,9 @@
}
},
"node_modules/magic-string": {
- "version": "0.30.19",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
- "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -19770,9 +19770,9 @@
"license": "MIT"
},
"node_modules/std-env": {
- "version": "3.9.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
- "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
@@ -22140,27 +22140,27 @@
}
},
"node_modules/vitest": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.7.tgz",
- "integrity": "sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz",
+ "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "@vitest/expect": "4.0.7",
- "@vitest/mocker": "4.0.7",
- "@vitest/pretty-format": "4.0.7",
- "@vitest/runner": "4.0.7",
- "@vitest/snapshot": "4.0.7",
- "@vitest/spy": "4.0.7",
- "@vitest/utils": "4.0.7",
+ "@vitest/expect": "4.0.8",
+ "@vitest/mocker": "4.0.8",
+ "@vitest/pretty-format": "4.0.8",
+ "@vitest/runner": "4.0.8",
+ "@vitest/snapshot": "4.0.8",
+ "@vitest/spy": "4.0.8",
+ "@vitest/utils": "4.0.8",
"debug": "^4.4.3",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
- "magic-string": "^0.30.19",
+ "magic-string": "^0.30.21",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
- "std-env": "^3.9.0",
+ "std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.15",
@@ -22181,10 +22181,10 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.0.7",
- "@vitest/browser-preview": "4.0.7",
- "@vitest/browser-webdriverio": "4.0.7",
- "@vitest/ui": "4.0.7",
+ "@vitest/browser-playwright": "4.0.8",
+ "@vitest/browser-preview": "4.0.8",
+ "@vitest/browser-webdriverio": "4.0.8",
+ "@vitest/ui": "4.0.8",
"happy-dom": "*",
"jsdom": "*"
},
@@ -22250,9 +22250,9 @@
}
},
"node_modules/vitest/node_modules/@vitest/pretty-format": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.7.tgz",
- "integrity": "sha512-YY//yxqTmk29+/pK+Wi1UB4DUH3lSVgIm+M10rAJ74pOSMgT7rydMSc+vFuq9LjZLhFvVEXir8EcqMke3SVM6Q==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz",
+ "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -22263,13 +22263,13 @@
}
},
"node_modules/vitest/node_modules/@vitest/utils": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.7.tgz",
- "integrity": "sha512-HNrg9CM/Z4ZWB6RuExhuC6FPmLipiShKVMnT9JlQvfhwR47JatWLChA6mtZqVHqypE6p/z6ofcjbyWpM7YLxPQ==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz",
+ "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.7",
+ "@vitest/pretty-format": "4.0.8",
"tinyrainbow": "^3.0.3"
},
"funding": {
diff --git a/package.json b/package.json
index 6568879b1..a1ce43bd8 100644
--- a/package.json
+++ b/package.json
@@ -78,7 +78,7 @@
"focus-trap": "7.6.6",
"gsap": "^3.13.0",
"js-cookie": "^3.0.5",
- "libphonenumber-js": "1.12.25",
+ "libphonenumber-js": "1.12.26",
"lit": "^3.3.1",
"nanostores": "^1.0.1",
"postcss": "8.5.6",
@@ -160,7 +160,7 @@
"typescript-eslint": "8.46.3",
"unist-util-inspect": "^8.1.0",
"unist-util-visit": "^5.0.0",
- "vitest": "4.0.7",
+ "vitest": "4.0.8",
"vitest-axe": "0.1.0"
},
"optionalDependencies": {
diff --git a/src/components/Footer/FooterPreact.tsx b/src/components/Footer/FooterPreact.tsx
new file mode 100644
index 000000000..e49ecb50d
--- /dev/null
+++ b/src/components/Footer/FooterPreact.tsx
@@ -0,0 +1,115 @@
+/**
+ * Footer Component - Preact Version
+ * For testing transition:persist with UI framework components
+ */
+import { useEffect, useState } from 'preact/hooks'
+import type { FunctionalComponent } from 'preact'
+import contactData from '@content/contact.json'
+import { formatPhoneNumber } from '@components/Footer/server'
+
+interface FooterProps {
+ year: number
+ siteUrl: string
+ authorAvatar: string
+}
+
+const Footer: FunctionalComponent = ({ year, siteUrl, authorAvatar }) => {
+ const [hireMeText, setHireMeText] = useState('')
+ const [isVisible, setIsVisible] = useState(false)
+
+ useEffect(() => {
+ // Set "Hire Me" text with current month/year
+ const date = new Date()
+ const monthDate = new Date(date)
+ monthDate.setMonth(monthDate.getMonth() - 1)
+ const month = monthDate.toLocaleString('en-US', { month: 'long' })
+ const year = date.getFullYear()
+ setHireMeText(`Available ${month}, ${year}. Hire Me Now`)
+ setIsVisible(true)
+ }, [])
+
+ return (
+
+ )
+}
+
+export default Footer
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 16fef55f8..6d5c56f72 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -2,7 +2,7 @@
import Breadcrumbs from '@components/Breadcrumbs/index.astro'
import Container from '@components/Layout/Container/index.astro'
import CookieConsent from '@components/Consent/Banner/index.astro'
-import Footer from '@components/Footer/index.astro'
+import FooterPreact from '@components/Footer/FooterPreact'
import HeadContent from '@components/Head/index.astro'
import Header from '@components/Header/index.astro'
import Content from '@components/Layout/Content/index.astro'
@@ -10,9 +10,13 @@ import PwaTitleBar from '@components/PwaTitleBar/index.astro'
import Skip from '@components/Layout/Skip/index.astro'
import ThemePicker from '@components/ThemePicker/index.astro'
import { ClientRouter } from 'astro:transitions'
+import VtBotDebug from 'astro-vtbot/components/VtBotDebug.astro'
+import Linter from 'astro-vtbot/components/Linter.astro'
+import AuthorAvatar from '@assets/images/avatars/kevin-brown.webp'
import '@styles/index.css'
import '@styles/sprites.css'
+import '@components/Footer/footer.css'
export interface Props {
pageTitle: string
@@ -45,6 +49,11 @@ const {
image,
noindex,
} = Astro.props
+
+const year = new Date().getFullYear()
+const siteUrl = Astro.site?.toString() || 'https://webstackbuilders.com'
+// Get the image src path for Preact
+const avatarSrc = AuthorAvatar.src
---
@@ -65,7 +74,9 @@ const {
/>
{/* Client-side router for Astro pages (enables partial page reloads) */}
{/* Must be placed at the end of the to avoid blocking page rendering */}
+
+
{/* Cookie Consent Overlay */}
@@ -76,15 +87,15 @@ const {
{/* Site header and main content */}
-
+
{/* Main page content */}
- {/* Site footer */}
-
+ {/* Site footer - Preact component with transition:persist for testing */}
+
diff --git a/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
new file mode 100644
index 000000000..2a7a24a2f
--- /dev/null
+++ b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
@@ -0,0 +1,264 @@
+/**
+ * Regression Tests for Astro View Transitions - transition:persist directive
+ *
+ * Verifies that elements with transition:persist maintain their DOM identity
+ * across page navigations when using Astro's View Transitions API.
+ *
+ * Tests specifically focus on Web Components (like ThemePicker) to verify
+ * whether Astro properly handles transition:persist for custom elements.
+ *
+ * Related:
+ * - src/layouts/BaseLayout.astro (ThemePicker with transition:persist)
+ * - src/components/ThemePicker/index.astro (Web Component implementation)
+ * - Astro View Transitions API documentation
+ */
+
+import { BasePage, test, describe, expect } from '@test/e2e/helpers'
+
+describe('View Transitions - transition:persist on Web Components', () => {
+ test('should persist ThemePicker web component identity across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Set a unique identifier on the ThemePicker web component and store a custom property
+ const initialData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ // Create a unique identifier
+ const uniqueId = `test-${Date.now()}-${Math.random()}`
+
+ // Set it as a data attribute
+ element.setAttribute('data-unique-id', uniqueId)
+
+ // Also set a custom property directly on the DOM element
+ // This is more definitive proof of DOM identity persistence
+ ;(element as any).__testProperty = uniqueId
+
+ // Store a counter to verify the element isn't recreated
+ ;(element as any).__navigationCounter = 0
+
+ return {
+ uniqueId,
+ initialTimestamp: Date.now(),
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Navigate to a different page
+ await page.goto('/about')
+
+ // Verify the element still has the same unique identifier
+ const afterNavigationData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found after navigation')
+
+ // Increment counter to prove it's the same element
+ const counter = (element as any).__navigationCounter
+ ;(element as any).__navigationCounter = counter + 1
+
+ return {
+ dataAttribute: element.getAttribute('data-unique-id'),
+ customProperty: (element as any).__testProperty,
+ navigationCounter: counter,
+ elementExists: !!element,
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Assertions proving DOM identity persistence
+ expect(afterNavigationData.elementExists).toBe(true)
+ expect(afterNavigationData.tagName).toBe('theme-picker')
+ expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
+ expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
+ expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
+ })
+
+ test.skip('should maintain custom properties across multiple navigations', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Set multiple custom properties on the ThemePicker web component
+ const testData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ const data = {
+ id: `id-${Math.random()}`,
+ counter: 42,
+ timestamp: Date.now(),
+ nested: { value: 'test' },
+ }
+
+ // Store various types of data on the element
+ ;(element as any).__testData = data
+
+ return data
+ })
+
+ // Navigate through multiple pages
+ const pages = ['/about', '/articles', '/services', '/contact']
+
+ for (const pagePath of pages) {
+ await page.goto(pagePath)
+
+ // Verify the custom property persists
+ const currentData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ return (element as any).__testData
+ })
+
+ expect(currentData).toEqual(testData)
+ }
+ })
+
+ test.skip('should preserve internal state of web component across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Modify internal state of the ThemePicker component
+ await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ let clickCount = 0
+
+ const handleClick = () => {
+ clickCount++
+ ;(element as any).__clickCount = clickCount
+ }
+
+ // Note: This is for testing DOM persistence, not production code
+ // In production, we use addButtonEventListeners from elementListeners
+ element.addEventListener('click', handleClick)
+
+ // Store initial state
+ ;(element as any).__listenerAttached = true
+ ;(element as any).__clickCount = 0
+ })
+
+ // Navigate to another page
+ await page.goto('/about')
+
+ // Click the element and verify internal state persists
+ await playwrightPage.locator('theme-picker').click()
+
+ const listenerData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ return {
+ listenerAttached: (element as any).__listenerAttached,
+ clickCount: (element as any).__clickCount,
+ }
+ })
+
+ expect(listenerData.listenerAttached).toBe(true)
+ expect(listenerData.clickCount).toBe(1) // Should have incremented from the click
+ })
+
+ test('should maintain element reference across navigation', async ({ page: playwrightPage }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Store a reference test on ThemePicker - advanced verification of identity persistence
+ const refCheck = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ // Create a unique symbol that can only exist on the same object reference
+ const uniqueSymbol = Symbol('test-identity')
+ ;(element as any)[uniqueSymbol] = 'identity-marker'
+
+ // Store the symbol key as a string for later comparison
+ ;(element as any).__symbolKey = uniqueSymbol.toString()
+ ;(element as any).__symbolValue = 'identity-marker'
+
+ return {
+ symbolKey: uniqueSymbol.toString(),
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Navigate
+ await page.goto('/about')
+
+ // Check if the symbol property still exists (proves same object reference)
+ const afterNav = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ return {
+ symbolKey: (element as any).__symbolKey,
+ symbolValue: (element as any).__symbolValue,
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ expect(afterNav.tagName).toBe('theme-picker')
+ expect(afterNav.symbolKey).toBe(refCheck.symbolKey)
+ expect(afterNav.symbolValue).toBe('identity-marker')
+ })
+
+ test('should persist Preact Footer component identity across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new BasePage(playwrightPage)
+ await page.goto('/')
+
+ // Set a unique identifier on the Preact Footer component and store a custom property
+ const initialData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="footer-preact"]') as HTMLElement
+ if (!element) throw new Error('footer-preact component not found')
+
+ // Create a unique identifier
+ const uniqueId = `test-${Date.now()}-${Math.random()}`
+
+ // Set it as a data attribute
+ element.setAttribute('data-unique-id', uniqueId)
+
+ // Also set a custom property directly on the DOM element
+ // This is more definitive proof of DOM identity persistence
+ ;(element as any).__testProperty = uniqueId
+
+ // Store a counter to verify the element isn't recreated
+ ;(element as any).__navigationCounter = 0
+
+ return {
+ uniqueId,
+ initialTimestamp: Date.now(),
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Navigate to a different page
+ await page.goto('/about')
+
+ // Verify the element still has the same unique identifier
+ const afterNavigationData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="footer-preact"]') as HTMLElement
+ if (!element) throw new Error('footer-preact component not found after navigation')
+
+ // Increment counter to prove it's the same element
+ const counter = (element as any).__navigationCounter
+ ;(element as any).__navigationCounter = counter + 1
+
+ return {
+ dataAttribute: element.getAttribute('data-unique-id'),
+ customProperty: (element as any).__testProperty,
+ navigationCounter: counter,
+ elementExists: !!element,
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Assertions proving DOM identity persistence
+ expect(afterNavigationData.elementExists).toBe(true)
+ expect(afterNavigationData.tagName).toBe('footer')
+ expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
+ expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
+ expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
+ })
+})
From 649aa43660214fec6955ad37d2e779610736db63 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 17:39:39 +0300
Subject: [PATCH 034/341] Add unit tests for Newsletter component. Need to
refactor GDPR Consent component to a web component before we can finish the
Newsletter units.
---
.../__fixtures__/client.fixture.astro | 33 +++
.../__fixtures__/newsletter.fixture.ts | 61 -----
.../__fixtures__/newsletterFixture.ts | 0
.../Newsletter/__tests__/client.spec.ts | 250 ++++++++++--------
.../CallToAction/Newsletter/client.ts | 175 ++++++------
.../CallToAction/Newsletter/index.astro | 154 ++++++-----
.../scripts/bootstrap/consent/NOTES.md | 4 -
7 files changed, 343 insertions(+), 334 deletions(-)
create mode 100644 src/components/CallToAction/Newsletter/__fixtures__/client.fixture.astro
delete mode 100644 src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts
delete mode 100644 src/components/CallToAction/Newsletter/__fixtures__/newsletterFixture.ts
diff --git a/src/components/CallToAction/Newsletter/__fixtures__/client.fixture.astro b/src/components/CallToAction/Newsletter/__fixtures__/client.fixture.astro
new file mode 100644
index 000000000..77abab19a
--- /dev/null
+++ b/src/components/CallToAction/Newsletter/__fixtures__/client.fixture.astro
@@ -0,0 +1,33 @@
+---
+/**
+ * Test fixture for Newsletter Container API testing
+ * Uses actual Newsletter component to ensure tests stay in sync with implementation
+ */
+import Newsletter from '@components/CallToAction/Newsletter/index.astro'
+
+export interface Props {
+ /** Override title for testing */
+ title?: string
+ /** Override description for testing */
+ description?: string
+ /** Override placeholder for testing */
+ placeholder?: string
+ /** Override button text for testing */
+ buttonText?: string
+}
+
+const {
+ title = 'Test Newsletter',
+ description = 'Test description for newsletter signup',
+ placeholder = 'test@example.com',
+ buttonText = 'Subscribe',
+} = Astro.props
+---
+
+
+
diff --git a/src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts b/src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts
deleted file mode 100644
index ec010b4ed..000000000
--- a/src/components/CallToAction/Newsletter/__fixtures__/newsletter.fixture.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * Newsletter component test fixture using Astro Container API
- * Uses the actual Newsletter/index.astro component instead of hard-coded HTML
- */
-import { experimental_AstroContainer as AstroContainer } from 'astro/container'
-
-import NewsletterComponent from '@components/CallToAction/Newsletter/index.astro'
-
-export interface NewsletterProps {
- title?: string
- description?: string
- placeholder?: string
- buttonText?: string
-}
-
-/**
- * Create Newsletter component DOM using Astro Container API
- * @param props - Props to pass to the Newsletter component
- * @returns Promise - Rendered HTML from the actual Astro component
- */
-export async function createNewsletterDOM(props: NewsletterProps = {}): Promise {
- const container = await AstroContainer.create()
-
- const defaultProps: NewsletterProps = {
- title: 'Test Newsletter',
- description: 'Test description for newsletter signup',
- placeholder: 'test@example.com',
- buttonText: 'Subscribe',
- }
-
- const finalProps = { ...defaultProps, ...props }
-
- return await container.renderToString(NewsletterComponent, {
- props: finalProps,
- })
-}
-
-/**
- * Setup Newsletter DOM in document.body using the actual Astro component
- * @param props - Props to pass to the Newsletter component
- */
-export async function setupNewsletterDOM(props: NewsletterProps = {}): Promise {
- const html = await createNewsletterDOM(props)
- document.body.innerHTML = html
-}
-
-/**
- * Helper function to get form elements after DOM setup
- */
-export function getFormElements() {
- return {
- form: document.getElementById('newsletter-form') as HTMLFormElement,
- emailInput: document.getElementById('newsletter-email') as HTMLInputElement,
- consentCheckbox: document.getElementById('newsletter-gdpr-consent') as HTMLInputElement,
- submitButton: document.getElementById('newsletter-submit') as HTMLButtonElement,
- buttonText: document.getElementById('button-text') as HTMLSpanElement,
- buttonArrow: document.getElementById('button-arrow') as unknown as SVGSVGElement,
- buttonSpinner: document.getElementById('button-spinner') as unknown as SVGSVGElement,
- message: document.getElementById('newsletter-message') as HTMLParagraphElement,
- }
-}
diff --git a/src/components/CallToAction/Newsletter/__fixtures__/newsletterFixture.ts b/src/components/CallToAction/Newsletter/__fixtures__/newsletterFixture.ts
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/components/CallToAction/Newsletter/__tests__/client.spec.ts b/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
index 3c5b213f4..fc86b15c7 100644
--- a/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
+++ b/src/components/CallToAction/Newsletter/__tests__/client.spec.ts
@@ -1,111 +1,148 @@
// @vitest-environment happy-dom
/**
- * Tests for Newsletter form component using Container API pattern with happy-dom
+ * Tests for Newsletter form web component using Container API pattern with happy-dom
*/
import { beforeAll, beforeEach, describe, expect, test, vi, afterEach } from 'vitest'
-import { NewsletterForm } from '@components/CallToAction/Newsletter/client'
-import { setupNewsletterDOM, getFormElements } from '@components/CallToAction/Newsletter/__fixtures__/newsletter.fixture'
+import { experimental_AstroContainer as AstroContainer } from 'astro/container'
+import { NewsletterFormElement } from '@components/CallToAction/Newsletter/client'
+import NewsletterFixture from '@components/CallToAction/Newsletter/__fixtures__/client.fixture.astro'
+
+/**
+ * Helper function to get form elements after DOM setup
+ */
+function getFormElements() {
+ return {
+ form: document.getElementById('newsletter-form') as HTMLFormElement,
+ emailInput: document.getElementById('newsletter-email') as HTMLInputElement,
+ consentCheckbox: document.getElementById('newsletter-gdpr-consent') as HTMLInputElement,
+ submitButton: document.getElementById('newsletter-submit') as HTMLButtonElement,
+ buttonText: document.getElementById('button-text') as HTMLSpanElement,
+ buttonArrow: document.getElementById('button-arrow') as unknown as SVGSVGElement,
+ buttonSpinner: document.getElementById('button-spinner') as unknown as SVGSVGElement,
+ message: document.getElementById('newsletter-message') as HTMLParagraphElement,
+ }
+}
// Mock fetch for API testing
const mockFetch = vi.fn()
global.fetch = mockFetch
-beforeAll(() => {
- vi.useFakeTimers()
-})
+describe('NewsletterFormElement web component', () => {
+ let container: AstroContainer
+ let html: string
-beforeEach(() => {
- vi.clearAllMocks()
- // Reset fetch mock
- mockFetch.mockClear()
-})
+ beforeAll(() => {
+ vi.useFakeTimers()
+ })
-afterEach(() => {
- // Clean up DOM
- document.body.innerHTML = ''
-})
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ mockFetch.mockClear()
-describe('NewsletterForm class works', () => {
- test('LoadableScript init initializes', async () => {
- await setupNewsletterDOM()
+ container = await AstroContainer.create()
+ html = await container.renderToString(NewsletterFixture)
+
+ console.log('=== RENDERED HTML ===')
+ console.log(html.substring(0, 1000))
+ console.log('=== END HTML ===')
+
+ document.body.innerHTML = html
+
+ // Get the web component and manually initialize it
+ // Don't trigger global DOMContentLoaded as it causes issues with other components
+ const newsletterElement = document.querySelector('newsletter-form') as NewsletterFormElement
+
+ console.log('Newsletter element found:', !!newsletterElement)
+ console.log('Custom element defined:', !!customElements.get('newsletter-form'))
+
+ if (newsletterElement) {
+ newsletterElement.initialize()
+ }
+
+ // Wait for initialization to complete
+ await new Promise(resolve => setTimeout(resolve, 0))
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ''
+ vi.restoreAllMocks()
+ })
+ test('custom element is defined', () => {
+ // Verify custom element is registered
+ expect(customElements.get('newsletter-form')).toBeDefined()
+ })
+
+ test('web component renders with required elements', () => {
+ const newsletterElement = document.querySelector('newsletter-form')
+ expect(newsletterElement).toBeTruthy()
// Verify required elements exist
const elements = getFormElements()
expect(elements.form).toBeTruthy()
expect(elements.emailInput).toBeTruthy()
expect(elements.submitButton).toBeTruthy()
-
- // Initialize the component
- NewsletterForm.init()
-
- // Verify initialization doesn't throw
- expect(() => new NewsletterForm()).not.toThrow()
})
- test('validates email addresses correctly', async () => {
- await setupNewsletterDOM()
+ test('validates email addresses correctly', () => {
+ const newsletterElement = document.querySelector('newsletter-form') as NewsletterFormElement
+ expect(newsletterElement).toBeTruthy()
- const newsletter = new NewsletterForm()
+ // Access private method via any type for testing
+ const element = newsletterElement as any
// Test valid emails
- expect(newsletter.validateEmail('test@example.com')).toBe(true)
- expect(newsletter.validateEmail('user.name+tag@domain.co.uk')).toBe(true)
+ expect(element.validateEmail('test@example.com')).toBe(true)
+ expect(element.validateEmail('user.name+tag@domain.co.uk')).toBe(true)
// Test invalid emails
- expect(newsletter.validateEmail('')).toBe(false)
- expect(newsletter.validateEmail('invalid')).toBe(false)
- expect(newsletter.validateEmail('test@')).toBe(false)
- expect(newsletter.validateEmail('@domain.com')).toBe(false)
+ expect(element.validateEmail('')).toBe(false)
+ expect(element.validateEmail('invalid')).toBe(false)
+ expect(element.validateEmail('test@')).toBe(false)
+ expect(element.validateEmail('@domain.com')).toBe(false)
})
- test('displays messages with correct styling', async () => {
- await setupNewsletterDOM()
-
- const newsletter = new NewsletterForm()
+ test('displays messages with correct styling', () => {
+ const newsletterElement = document.querySelector('newsletter-form') as NewsletterFormElement
+ const element = newsletterElement as any
const elements = getFormElements()
// Test success message
- newsletter.showMessage('Success message', 'success')
+ element.showMessage('Success message', 'success')
expect(elements.message.textContent).toBe('Success message')
expect(elements.message.classList.contains('text-[var(--color-success)]')).toBe(true)
// Test error message
- newsletter.showMessage('Error message', 'error')
+ element.showMessage('Error message', 'error')
expect(elements.message.textContent).toBe('Error message')
expect(elements.message.classList.contains('text-[var(--color-danger)]')).toBe(true)
// Test info message
- newsletter.showMessage('Info message', 'info')
+ element.showMessage('Info message', 'info')
expect(elements.message.textContent).toBe('Info message')
expect(elements.message.classList.contains('text-[var(--color-text-offset)]')).toBe(true)
})
- test('handles loading state correctly', async () => {
- await setupNewsletterDOM()
-
- const newsletter = new NewsletterForm()
+ test('handles loading state correctly', () => {
+ const newsletterElement = document.querySelector('newsletter-form') as NewsletterFormElement
+ const element = newsletterElement as any
const elements = getFormElements()
// Test loading state
- newsletter.setLoading(true)
+ element.setLoading(true)
expect(elements.submitButton.disabled).toBe(true)
expect(elements.buttonText.textContent).toBe('Subscribing...')
expect(elements.buttonArrow.classList.contains('hidden')).toBe(true)
expect(elements.buttonSpinner.classList.contains('hidden')).toBe(false)
// Test non-loading state
- newsletter.setLoading(false)
+ element.setLoading(false)
expect(elements.submitButton.disabled).toBe(false)
expect(elements.buttonText.textContent).toBe('Subscribe')
expect(elements.buttonArrow.classList.contains('hidden')).toBe(false)
expect(elements.buttonSpinner.classList.contains('hidden')).toBe(true)
})
- test('validates empty email on form submit', async () => {
- await setupNewsletterDOM()
-
- const newsletter = new NewsletterForm()
- newsletter.bindEvents()
+ test('validates empty email on form submit', () => {
const elements = getFormElements()
// Submit empty form
@@ -116,11 +153,7 @@ describe('NewsletterForm class works', () => {
expect(elements.message.classList.contains('text-[var(--color-danger)]')).toBe(true)
})
- test('validates invalid email on form submit', async () => {
- await setupNewsletterDOM()
-
- const newsletter = new NewsletterForm()
- newsletter.bindEvents()
+ test('validates invalid email on form submit', () => {
const elements = getFormElements()
// Enter invalid email and submit
@@ -133,16 +166,12 @@ describe('NewsletterForm class works', () => {
})
test('handles successful API response', async () => {
- await setupNewsletterDOM()
-
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, message: 'Subscribed successfully!' }),
})
- const newsletter = new NewsletterForm()
- newsletter.bindEvents()
const elements = getFormElements()
// Enter valid email, check consent, and submit
@@ -164,16 +193,12 @@ describe('NewsletterForm class works', () => {
})
test('handles API error response', async () => {
- await setupNewsletterDOM()
-
// Mock error API response
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ success: false, error: 'Subscription failed' }),
})
- const newsletter = new NewsletterForm()
- newsletter.bindEvents()
const elements = getFormElements()
// Enter valid email, check consent, and submit
@@ -189,13 +214,9 @@ describe('NewsletterForm class works', () => {
})
test('handles network error', async () => {
- await setupNewsletterDOM()
-
// Mock network error
mockFetch.mockRejectedValueOnce(new Error('Network error'))
- const newsletter = new NewsletterForm()
- newsletter.bindEvents()
const elements = getFormElements()
// Enter valid email, check consent, and submit
@@ -210,11 +231,7 @@ describe('NewsletterForm class works', () => {
expect(fetch).toHaveBeenCalled()
})
- test('validates email on blur event', async () => {
- await setupNewsletterDOM()
-
- const newsletter = new NewsletterForm()
- newsletter.bindEvents()
+ test('validates email on blur event', () => {
const elements = getFormElements()
// Enter invalid email and blur
@@ -233,63 +250,70 @@ describe('NewsletterForm class works', () => {
expect(elements.message.classList.contains('text-[var(--color-text-offset)]')).toBe(true)
})
- test('unbinds events correctly', async () => {
- await setupNewsletterDOM()
-
- const newsletter = new NewsletterForm()
+ test('web component lifecycle works correctly', () => {
+ const newsletterElement = document.querySelector('newsletter-form') as NewsletterFormElement
const elements = getFormElements()
- // Bind events
- newsletter.bindEvents()
-
- // Spy on removeEventListener
- const formSpy = vi.spyOn(elements.form, 'removeEventListener')
- const inputSpy = vi.spyOn(elements.emailInput, 'removeEventListener')
-
- // Unbind events
- newsletter.unbindEvents()
+ // Verify element is initialized
+ expect(newsletterElement).toBeTruthy()
- expect(formSpy).toHaveBeenCalledWith('submit', expect.any(Function))
- expect(inputSpy).toHaveBeenCalledWith('blur', expect.any(Function))
+ // Verify events are bound (submit should trigger validation)
+ const submitEvent = new Event('submit')
+ elements.form.dispatchEvent(submitEvent)
+ expect(elements.message.textContent).toBe('Please enter your email address.')
})
})
-describe('NewsletterForm LoadableScript implementation', () => {
- test('has correct static properties', () => {
- expect(NewsletterForm.scriptName).toBe('NewsletterForm')
- })
-
- test('static init method works', async () => {
- await setupNewsletterDOM()
-
- expect(() => NewsletterForm.init()).not.toThrow()
+describe('Web component lifecycle and behavior', () => {
+ test('custom element auto-initializes', () => {
+ const newsletterElement = document.querySelector('newsletter-form')
+ expect(newsletterElement).toBeTruthy()
+ expect(newsletterElement?.tagName.toLowerCase()).toBe('newsletter-form')
})
- test('static methods exist and are callable', () => {
- expect(typeof NewsletterForm.pause).toBe('function')
- expect(typeof NewsletterForm.resume).toBe('function')
- expect(typeof NewsletterForm.reset).toBe('function')
+ test('handles re-initialization gracefully', () => {
+ // Component should handle multiple setups without errors
+ const newsletterElement = document.querySelector('newsletter-form') as NewsletterFormElement
+ const element = newsletterElement as any
- expect(() => NewsletterForm.pause()).not.toThrow()
- expect(() => NewsletterForm.resume()).not.toThrow()
- expect(() => NewsletterForm.reset()).not.toThrow()
+ // Calling initialize multiple times should be safe
+ expect(() => element.initialize()).not.toThrow()
})
})
describe('Edge cases and error handling', () => {
- test('throws error for missing DOM elements', () => {
- // Set up DOM without required elements
+ test('handles missing DOM elements gracefully', () => {
+ // Set up DOM without newsletter-form element
document.body.innerHTML = 'No newsletter form
'
- // Newsletter is a critical component (Phase 1), should throw when instantiated
- expect(() => new NewsletterForm()).toThrow('NewsletterForm: Required DOM elements not found')
+ // Web component won't be present
+ const newsletterElement = document.querySelector('newsletter-form')
+ expect(newsletterElement).toBeNull()
})
- test('throws error for partially missing elements', async () => {
- // Form exists but missing required input
- document.body.innerHTML = ''
+ test('handles missing required elements inside web component', async () => {
+ // Create web component but with empty content
+ document.body.innerHTML = ' '
- // Missing email input and other required elements should throw
- expect(() => new NewsletterForm()).toThrow('NewsletterForm: Required DOM elements not found')
+ const newsletterElement = document.querySelector('newsletter-form')
+ expect(newsletterElement).toBeTruthy()
+
+ // Form elements won't exist, but component should handle gracefully
+ const elements = getFormElements()
+ expect(elements.form).toBeNull()
+ })
+
+ test('validates consent checkbox requirement', async () => {
+ const elements = getFormElements()
+
+ // Enter valid email but don't check consent
+ elements.emailInput.value = 'test@example.com'
+ elements.consentCheckbox.checked = false
+
+ const submitEvent = new Event('submit')
+ elements.form.dispatchEvent(submitEvent)
+
+ expect(elements.message.textContent).toBe('Please consent to receive marketing communications.')
+ expect(elements.message.classList.contains('text-[var(--color-danger)]')).toBe(true)
})
})
diff --git a/src/components/CallToAction/Newsletter/client.ts b/src/components/CallToAction/Newsletter/client.ts
index 5868ae3e3..8209b47fe 100644
--- a/src/components/CallToAction/Newsletter/client.ts
+++ b/src/components/CallToAction/Newsletter/client.ts
@@ -1,51 +1,94 @@
/**
- * Newsletter subscription form client-side logic
- * Manages form validation, submission, and UI state changes
+ * Newsletter Form Web Component (Lit-based)
+ * Manages newsletter subscription form with validation and submission
+ * Works seamlessly with Astro View Transitions
*/
-import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
+import { LitElement } from 'lit'
import { handleScriptError, addScriptBreadcrumb } from '@components/scripts/errors'
+import { ClientScriptError } from '@components/scripts/errors/ClientScriptError'
/**
- * Newsletter form component with instance-specific approach
+ * Newsletter Form Custom Element (Lit-based)
+ * Uses Light DOM (no Shadow DOM) with Astro-rendered templates
*/
-export class NewsletterForm {
- static scriptName = 'NewsletterForm'
+export class NewsletterFormElement extends LitElement {
+ // Render to Light DOM instead of Shadow DOM
+ override createRenderRoot() {
+ return this // No shadow DOM - works with Astro templates!
+ }
// Email validation pattern (matches server-side)
private readonly emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// DOM elements
- private form: HTMLFormElement | null = null
- private emailInput: HTMLInputElement | null = null
- private consentCheckbox: HTMLInputElement | null = null
- private submitButton: HTMLButtonElement | null = null
- private buttonText: HTMLSpanElement | null = null
- private buttonArrow: SVGSVGElement | null = null
- private buttonSpinner: SVGSVGElement | null = null
- private message: HTMLParagraphElement | null = null
+ private form!: HTMLFormElement
+ private emailInput!: HTMLInputElement
+ private consentCheckbox!: HTMLInputElement
+ private submitButton!: HTMLButtonElement
+ private buttonText!: HTMLSpanElement
+ private buttonArrow!: SVGSVGElement
+ private buttonSpinner!: SVGSVGElement
+ private message!: HTMLParagraphElement
private originalButtonText = 'Subscribe'
- constructor() {
- this.initializeElements()
+ // Track initialization
+ private isInitialized = false
+
+ /**
+ * Lit lifecycle: called when element is connected
+ */
+ override connectedCallback(): void {
+ super.connectedCallback()
+ const context = { scriptName: 'NewsletterFormElement', operation: 'connectedCallback' }
+ addScriptBreadcrumb(context)
+
+ // Wait for DOM to be ready before initializing
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => this.initialize())
+ } else {
+ this.initialize()
+ }
+ }
+
+ /**
+ * Initialize the newsletter form after DOM is ready
+ * Public for testing purposes
+ */
+ public initialize(): void {
+ const context = { scriptName: 'NewsletterFormElement', operation: 'initialize' }
+ addScriptBreadcrumb(context)
+
+ try {
+ // Skip if already initialized (for View Transitions)
+ if (this.isInitialized) {
+ return
+ }
+
+ this.findElements()
+ this.bindEvents()
+ this.isInitialized = true
+ } catch (error) {
+ handleScriptError(error, context)
+ }
}
/**
- * Initialize DOM element references
+ * Find and cache DOM elements
*/
- private initializeElements(): void {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'initializeElements' }
+ private findElements(): void {
+ const context = { scriptName: 'NewsletterFormElement', operation: 'findElements' }
addScriptBreadcrumb(context)
try {
- this.form = document.getElementById('newsletter-form') as HTMLFormElement | null
- this.emailInput = document.getElementById('newsletter-email') as HTMLInputElement | null
- this.consentCheckbox = document.getElementById('newsletter-gdpr-consent') as HTMLInputElement | null
- this.submitButton = document.getElementById('newsletter-submit') as HTMLButtonElement | null
- this.buttonText = document.getElementById('button-text') as HTMLSpanElement | null
- this.buttonArrow = document.getElementById('button-arrow') as SVGSVGElement | null
- this.buttonSpinner = document.getElementById('button-spinner') as SVGSVGElement | null
- this.message = document.getElementById('newsletter-message') as HTMLParagraphElement | null
+ this.form = this.querySelector('#newsletter-form') as HTMLFormElement
+ this.emailInput = this.querySelector('#newsletter-email') as HTMLInputElement
+ this.consentCheckbox = this.querySelector('#newsletter-gdpr-consent') as HTMLInputElement
+ this.submitButton = this.querySelector('#newsletter-submit') as HTMLButtonElement
+ this.buttonText = this.querySelector('#button-text') as HTMLSpanElement
+ this.buttonArrow = this.querySelector('#button-arrow') as SVGSVGElement
+ this.buttonSpinner = this.querySelector('#button-spinner') as SVGSVGElement
+ this.message = this.querySelector('#newsletter-message') as HTMLParagraphElement
if (
!this.form ||
@@ -58,23 +101,19 @@ export class NewsletterForm {
!this.message
) {
throw new ClientScriptError(
- 'NewsletterForm: Required DOM elements not found. Form requires all elements to function.'
+ 'NewsletterFormElement: Required DOM elements not found'
)
}
- // Save original button text (recoverable)
- try {
- this.originalButtonText = this.buttonText.textContent || 'Subscribe'
- this.submitButton.setAttribute('data-original-text', this.originalButtonText)
- } catch (error) {
- handleScriptError(error, { scriptName: NewsletterForm.scriptName, operation: 'saveButtonText' })
- }
+ // Save original button text
+ this.originalButtonText = this.buttonText.textContent || 'Subscribe'
+ this.submitButton.setAttribute('data-original-text', this.originalButtonText)
} catch (error) {
if (error instanceof ClientScriptError) {
throw error
}
throw new ClientScriptError(
- `NewsletterForm: Failed to initialize elements - ${error instanceof Error ? error.message : 'Unknown error'}`
+ `NewsletterFormElement: Failed to find elements - ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -82,8 +121,8 @@ export class NewsletterForm {
/**
* Validate email address format
*/
- public validateEmail(email: string): boolean {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'validateEmail' }
+ private validateEmail(email: string): boolean {
+ const context = { scriptName: 'NewsletterFormElement', operation: 'validateEmail' }
addScriptBreadcrumb(context)
try {
@@ -97,8 +136,8 @@ export class NewsletterForm {
/**
* Display a message to the user
*/
- public showMessage(text: string, type: 'success' | 'error' | 'info'): void {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'showMessage' }
+ private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
+ const context = { scriptName: 'NewsletterFormElement', operation: 'showMessage' }
addScriptBreadcrumb(context)
try {
@@ -126,8 +165,8 @@ export class NewsletterForm {
/**
* Set the loading state of the submit button
*/
- public setLoading(loading: boolean): void {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'setLoading' }
+ private setLoading(loading: boolean): void {
+ const context = { scriptName: 'NewsletterFormElement', operation: 'setLoading' }
addScriptBreadcrumb(context)
try {
@@ -155,7 +194,7 @@ export class NewsletterForm {
* Handle form submission
*/
private handleSubmit = async (e: Event): Promise => {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'handleSubmit' }
+ const context = { scriptName: 'NewsletterFormElement', operation: 'handleSubmit' }
addScriptBreadcrumb(context)
try {
@@ -197,7 +236,7 @@ export class NewsletterForm {
},
body: JSON.stringify({
email,
- consentGiven
+ consentGiven,
}),
})
@@ -213,7 +252,7 @@ export class NewsletterForm {
this.showMessage(data.error || 'Failed to subscribe. Please try again.', 'error')
}
} catch (error) {
- handleScriptError(error, { scriptName: NewsletterForm.scriptName, operation: 'apiSubmission' })
+ handleScriptError(error, { scriptName: 'NewsletterFormElement', operation: 'apiSubmission' })
this.showMessage('Network error. Please check your connection and try again.', 'error')
} finally {
this.setLoading(false)
@@ -229,7 +268,7 @@ export class NewsletterForm {
* Handle email input blur for real-time validation
*/
private handleEmailBlur = (): void => {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'handleEmailBlur' }
+ const context = { scriptName: 'NewsletterFormElement', operation: 'handleEmailBlur' }
addScriptBreadcrumb(context)
try {
@@ -250,8 +289,8 @@ export class NewsletterForm {
/**
* Bind event listeners
*/
- bindEvents(): void {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'bindEvents' }
+ private bindEvents(): void {
+ const context = { scriptName: 'NewsletterFormElement', operation: 'bindEvents' }
addScriptBreadcrumb(context)
try {
@@ -268,13 +307,15 @@ export class NewsletterForm {
}
/**
- * Remove event listeners for cleanup
+ * Lit lifecycle: called when element is disconnected
*/
- unbindEvents(): void {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'unbindEvents' }
+ override disconnectedCallback(): void {
+ super.disconnectedCallback()
+ const context = { scriptName: 'NewsletterFormElement', operation: 'disconnectedCallback' }
addScriptBreadcrumb(context)
try {
+ // Clean up event listeners
if (this.form) {
this.form.removeEventListener('submit', this.handleSubmit)
}
@@ -285,32 +326,10 @@ export class NewsletterForm {
handleScriptError(error, context)
}
}
+}
- /**
- * LoadableScript static methods
- */
- static init(): void {
- const context = { scriptName: NewsletterForm.scriptName, operation: 'init' }
- addScriptBreadcrumb(context)
-
- try {
- const newsletterForm = new NewsletterForm()
- newsletterForm.bindEvents()
- } catch (error) {
- handleScriptError(error, context)
- }
- }
-
- static pause(): void {
- // Newsletter form doesn't need pause functionality during visibility changes
- }
-
- static resume(): void {
- // Newsletter form doesn't need resume functionality during visibility changes
- }
-
- static reset(): void {
- // Clean up any global state if needed for View Transitions
- // This could be enhanced to unbind events if we tracked instances
- }
+// Register the custom element
+if (!customElements.get('newsletter-form')) {
+ customElements.define('newsletter-form', NewsletterFormElement)
}
+
diff --git a/src/components/CallToAction/Newsletter/index.astro b/src/components/CallToAction/Newsletter/index.astro
index b6ed1f1af..2834b2737 100644
--- a/src/components/CallToAction/Newsletter/index.astro
+++ b/src/components/CallToAction/Newsletter/index.astro
@@ -65,92 +65,90 @@ const {
-
+
diff --git a/src/components/scripts/bootstrap/consent/NOTES.md b/src/components/scripts/bootstrap/consent/NOTES.md
index 15f677ee7..636f99d7a 100644
--- a/src/components/scripts/bootstrap/consent/NOTES.md
+++ b/src/components/scripts/bootstrap/consent/NOTES.md
@@ -1,9 +1,5 @@
# Consent Category Usage Summary
-## OT: Frameworks Notes
-
-View Transitions friction: transition:persist requires client:only directive, loses SSR benefits
-
## Categories
### 'necessary' - Infrastructure only, NO actual usage
From 3aaf97de02d6d85681fe8dcd231a62c8d0dc5040 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 17:40:33 +0300
Subject: [PATCH 035/341] Troubleshoot component persistence with Astro View
Transitions API. Update e2e tests for component persistence.
---
TODO.md | 4 +
.../ThemePicker/__tests__/client.spec.ts | 16 ++--
src/components/ThemePicker/client.ts | 5 +
src/components/ThemePicker/index.astro | 2 +-
src/layouts/BaseLayout.astro | 4 +-
test/e2e/helpers/consoleCapture.ts | 96 +++++++++++++++++++
test/e2e/helpers/index.ts | 4 +
test/e2e/helpers/pageObjectModels/BasePage.ts | 52 ++++++++++
.../footer-view-transitions-persist.spec.ts | 6 +-
...emepicker-view-transitions-persist.spec.ts | 25 ++++-
10 files changed, 195 insertions(+), 19 deletions(-)
create mode 100644 test/e2e/helpers/consoleCapture.ts
diff --git a/TODO.md b/TODO.md
index c98da0dea..ffe018ccb 100644
--- a/TODO.md
+++ b/TODO.md
@@ -15,6 +15,10 @@ Axe accessibility (2) - axe-core integration
\[color:var\(--color-(.*?)\)\]
+## !!! IMPORTANT !!!
+
+We need to update all tests to use Astro's View Transitions navigate() method instead of Playwright's goto() method. Navigate will trigger Astro events, while goto does full page reloads.
+
## Color vars
brand primary: #001733
diff --git a/src/components/ThemePicker/__tests__/client.spec.ts b/src/components/ThemePicker/__tests__/client.spec.ts
index 05a0f62bf..1e8a10575 100644
--- a/src/components/ThemePicker/__tests__/client.spec.ts
+++ b/src/components/ThemePicker/__tests__/client.spec.ts
@@ -12,19 +12,15 @@ describe('ThemePicker Component', () => {
let html: string
beforeEach(async () => {
- // Suppress expected async errors from Lit component initialization
- // The component tries to update DOM elements that don't exist in tests
- const ignoreError = (err: unknown) => {
- if (err instanceof TypeError && String(err).includes('Cannot read properties of undefined')) {
- return // Suppress expected Lit component errors
- }
- throw err // Re-throw unexpected errors
- }
- process.on('unhandledRejection', ignoreError)
-
container = await AstroContainer.create()
html = await container.renderToString(ThemePickerFixture)
document.body.innerHTML = html
+
+ // Wait for all async operations (including Lit component initialization) to complete
+ await (window as any).happyDOM.whenAsyncComplete()
+
+ // Additional tick to ensure event handlers are bound
+ await new Promise(resolve => setTimeout(resolve, 0))
})
afterEach(() => {
diff --git a/src/components/ThemePicker/client.ts b/src/components/ThemePicker/client.ts
index 36a6d7e69..3a49bb858 100644
--- a/src/components/ThemePicker/client.ts
+++ b/src/components/ThemePicker/client.ts
@@ -114,6 +114,11 @@ export class ThemePickerElement extends LitElement {
* Lit automatically calls this when stores update via StoreController
*/
override updated(): void {
+ // Guard: Don't update until component is initialized
+ if (!this.isInitialized) {
+ return
+ }
+
const isOpen = this.themePickerOpenStore.value
const currentTheme = this.themeStore.value
diff --git a/src/components/ThemePicker/index.astro b/src/components/ThemePicker/index.astro
index 987d5f7bf..22d48b125 100644
--- a/src/components/ThemePicker/index.astro
+++ b/src/components/ThemePicker/index.astro
@@ -76,7 +76,7 @@ const { closeComponent = 'close', label = 'Select theme' } = Astro.props
}
-
+
to avoid blocking page rendering */}
-
+
{/* Cookie Consent Overlay */}
@@ -87,7 +87,7 @@ const avatarSrc = AuthorAvatar.src
{/* Site header and main content */}
-
+
{/* Main page content */}
diff --git a/test/e2e/helpers/consoleCapture.ts b/test/e2e/helpers/consoleCapture.ts
new file mode 100644
index 000000000..44ec480bd
--- /dev/null
+++ b/test/e2e/helpers/consoleCapture.ts
@@ -0,0 +1,96 @@
+/**
+ * Console Capture Helper for E2E Tests
+ *
+ * Captures all console output from the browser during tests,
+ * including nested group content and arguments.
+ *
+ * Useful for debugging View Transitions and other browser events.
+ */
+
+import type { Page, ConsoleMessage } from '@playwright/test'
+
+export interface CapturedConsoleMessage {
+ type: string
+ text: string
+ args: string[]
+ location?: string
+}
+
+/**
+ * Sets up console message capture on a Playwright page
+ *
+ * @param page - The Playwright page to capture console from
+ * @param enableLogging - Whether to print messages to test output in real-time
+ * @returns Array that will be populated with captured messages
+ *
+ * @example
+ * ```ts
+ * const messages = setupConsoleCapture(page, true)
+ * // ... perform actions ...
+ * printCapturedMessages(messages)
+ * ```
+ */
+export function setupConsoleCapture(
+ page: Page,
+ enableLogging = false
+): CapturedConsoleMessage[] {
+ const messages: CapturedConsoleMessage[] = []
+
+ page.on('console', async (msg: ConsoleMessage) => {
+ const type = msg.type()
+ const text = msg.text()
+
+ // Get all arguments (for grouped messages)
+ const args: string[] = []
+ for (const arg of msg.args()) {
+ try {
+ const value = await arg.jsonValue().catch(() => arg.toString())
+ args.push(String(value))
+ } catch {
+ args.push(arg.toString())
+ }
+ }
+
+ messages.push({
+ type,
+ text,
+ args,
+ location: msg.location().url,
+ })
+
+ // Print to test output for real-time debugging
+ if (enableLogging) {
+ console.log(`[${type}] ${text}`)
+ if (args.length > 0 && args.join('') !== text) {
+ console.log(` Args: ${args.join(', ')}`)
+ }
+ }
+ })
+
+ return messages
+}
+
+/**
+ * Prints all captured console messages to test output
+ *
+ * @param messages - Array of captured console messages
+ * @param title - Optional title for the output section
+ *
+ * @example
+ * ```ts
+ * printCapturedMessages(messages, 'Navigation Console Output')
+ * ```
+ */
+export function printCapturedMessages(
+ messages: CapturedConsoleMessage[],
+ title = 'Console Messages'
+): void {
+ console.log(`\n=== ${title} ===`)
+ messages.forEach((msg, i) => {
+ console.log(`\n[${i}] ${msg.type}: ${msg.text}`)
+ if (msg.args.length > 1) {
+ console.log(` Full args (${msg.args.length}):`, msg.args.slice(0, 5))
+ }
+ })
+ console.log(`=== END ${title.toUpperCase()} ===\n`)
+}
diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts
index f623b4d55..6b3088424 100644
--- a/test/e2e/helpers/index.ts
+++ b/test/e2e/helpers/index.ts
@@ -7,6 +7,10 @@ export {
setupConsoleErrorChecker,
logConsoleErrors,
} from '@test/e2e/helpers/consoleErrors'
+export {
+ setupConsoleCapture,
+ printCapturedMessages,
+} from '@test/e2e/helpers/consoleCapture'
export { clearConsentCookies } from '@test/e2e/helpers/browserState'
export { BasePage } from '@test/e2e/helpers/pageObjectModels/BasePage'
export {
diff --git a/test/e2e/helpers/pageObjectModels/BasePage.ts b/test/e2e/helpers/pageObjectModels/BasePage.ts
index 45412ede1..e93e8e3b3 100644
--- a/test/e2e/helpers/pageObjectModels/BasePage.ts
+++ b/test/e2e/helpers/pageObjectModels/BasePage.ts
@@ -231,11 +231,63 @@ export class BasePage {
/**
* Wait for specified timeout in milliseconds
+ * @deprecated Use event-based waits like waitForPageLoad() instead
*/
async wait(timeout: number): Promise {
await this._page.waitForTimeout(timeout)
}
+ /**
+ * Wait for Astro page load event
+ * Use this instead of waitForTimeout when testing View Transitions
+ *
+ * @example
+ * ```ts
+ * await page.navigateToPage('/articles')
+ * await page.waitForPageLoad()
+ * ```
+ */
+ async waitForPageLoad(): Promise {
+ await this._page.evaluate(() => {
+ return new Promise(resolve => {
+ document.addEventListener('astro:page-load', () => {
+ resolve()
+ }, { once: true })
+ })
+ })
+ }
+
+ /**
+ * Navigate to a page using Astro View Transitions
+ * Clicks a link with the given href to trigger client-side navigation
+ *
+ * @param href - The href of the link to click (e.g., '/articles')
+ *
+ * @example
+ * ```ts
+ * await page.navigateToPage('/articles')
+ * await page.waitForPageLoad()
+ * ```
+ */
+ async navigateToPage(href: string): Promise {
+ await this._page.click(`a[href="${href}"]`)
+ }
+
+ /**
+ * Wait for URL to change to match the expected pattern
+ *
+ * @param urlPattern - Glob pattern or regex to match against URL
+ * @param options - Timeout and other options
+ *
+ * @example
+ * ```ts
+ * await page.waitForURL('/articles')
+ * ```
+ */
+ async waitForURL(urlPattern: string | RegExp, options?: { timeout?: number }): Promise {
+ await this._page.waitForURL(urlPattern, options)
+ }
+
/**
* ================================================================
*
diff --git a/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
index 2a7a24a2f..b3de7b38a 100644
--- a/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
+++ b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
@@ -233,8 +233,10 @@ describe('View Transitions - transition:persist on Web Components', () => {
}
})
- // Navigate to a different page
- await page.goto('/about')
+ // Navigate to a different page using Astro View Transitions
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
// Verify the element still has the same unique identifier
const afterNavigationData = await page.evaluate(() => {
diff --git a/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
index 215b8afd7..94fe62b4a 100644
--- a/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
+++ b/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
@@ -14,12 +14,17 @@
*/
import { BasePage, test, describe, expect } from '@test/e2e/helpers'
+import { setupConsoleCapture, printCapturedMessages } from '@test/e2e/helpers/consoleCapture'
describe('View Transitions - transition:persist on Web Components', () => {
test('should persist ThemePicker web component identity across navigation', async ({
page: playwrightPage,
}) => {
const page = new BasePage(playwrightPage)
+
+ // Capture all console output including nested group content
+ const consoleMessages = setupConsoleCapture(playwrightPage, true)
+
await page.goto('/')
// Set a unique identifier on the ThemePicker web component and store a custom property
@@ -47,8 +52,15 @@ describe('View Transitions - transition:persist on Web Components', () => {
}
})
- // Navigate to a different page
- await page.goto('/about')
+ // Navigate to a different page using Astro's View Transitions
+ // Click the Articles link in the main navigation to trigger client-side navigation
+ await page.navigateToPage('/articles')
+
+ // Wait for the View Transition to complete and URL to change
+ await page.waitForURL('**/articles', { timeout: 5000 })
+
+ // Wait for Astro page load event instead of arbitrary timeout
+ await page.waitForPageLoad()
// Verify the element still has the same unique identifier
const afterNavigationData = await page.evaluate(() => {
@@ -74,6 +86,9 @@ describe('View Transitions - transition:persist on Web Components', () => {
expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
+
+ // Output all captured console messages for analysis
+ printCapturedMessages(consoleMessages, 'ALL CAPTURED CONSOLE MESSAGES')
})
test.skip('should maintain custom properties across multiple navigations', async ({
@@ -184,8 +199,10 @@ describe('View Transitions - transition:persist on Web Components', () => {
}
})
- // Navigate
- await page.goto('/about')
+ // Navigate using View Transitions
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
// Check if the symbol property still exists (proves same object reference)
const afterNav = await page.evaluate(() => {
From 210b04c0ff40ce0d09a58842c3fc420e95307e0a Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 17:42:40 +0300
Subject: [PATCH 036/341] Update AI instructions and organize into a separate
test instruction file
---
.github/instructions/general.instructions.md | 27 ++++++++++++++++
.github/instructions/testing.instructions.md | 33 ++++++++++++++++++++
src/layouts/BaseLayout.astro | 2 +-
3 files changed, 61 insertions(+), 1 deletion(-)
create mode 100644 .github/instructions/testing.instructions.md
diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md
index 7262221c5..09d02b5b2 100644
--- a/.github/instructions/general.instructions.md
+++ b/.github/instructions/general.instructions.md
@@ -19,6 +19,17 @@ applyTo: "**"
- Always create TypeScript files, not JavaScript files.
- Prefer destructured imports over namespace imports when importing specific functions from modules (e.g., `import { resolve } from 'path'` instead of `import * as path from 'path'`).
+# Astro View Transitions Navigation
+
+Components may have behavior dependent on Astro View Transitions navigation events. Choose the appropriate navigation method:
+
+- **Fresh page load**: Use `page.goto(url)` for full browser navigation (no View Transitions, triggers full page lifecycle)
+- **Client-side navigation**: Use `navigateToPage('/path')` for in-site navigation with View Transitions (triggers `astro:page-load` and other View Transition events)
+
+Always use the `navigateToPage()` method for client-side navigation - never ad-hoc `click('a[href]')` calls. This maintains centralized control.
+
+# Personality
+
# Testing Standards
## Astro Component Testing - Container API (MANDATORY)
@@ -53,6 +64,22 @@ applyTo: "**"
- **NEVER run the full e2e test suite** unless explicitly requested by the user. The full suite is very resource intensive and takes over 10 minutes to run. Only run specific e2e test files when verification is needed (e.g., `DEBUG=1 npx playwright test test/e2e/specific-file.spec.ts`).
- **NEVER start a dev server yourself**. The user runs their own dev server for development. When you need a dev server running, notify the user instead of starting one.
+### Astro View Transitions Testing
+
+- **Navigation method matters**: Choose between `page.goto()` and Astro's client-side navigation based on what you're testing:
+ - Use `page.goto(url)` for testing **fresh page loads** (full browser navigation, no View Transitions)
+ - Use `page.click('a[href="/path"]')` or BasePage's `navigateToPage()` for testing **View Transitions** (client-side navigation within the site)
+- **Wait for page load properly**: Use BasePage's `waitForPageLoad()` method to wait for `astro:page-load` event instead of arbitrary timeouts
+- **NEVER use `page.waitForTimeout()`** for waiting on View Transitions - it's unreliable and slows tests. Use event-based waits instead
+- **transition:persist directive**: Must be applied directly to HTML elements (including custom elements), not on Astro component wrappers. Example:
+ ```astro
+
+
+
+
+
+ ```
+
# Personality
- Do not apologize
- Do not flatter me
diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md
new file mode 100644
index 000000000..122b4205e
--- /dev/null
+++ b/.github/instructions/testing.instructions.md
@@ -0,0 +1,33 @@
+---
+applyTo: "**/*.spec.ts"
+---
+
+# Testing Standards
+
+## Astro Container API (Unit Tests)
+
+- **NEVER use manual HTML strings** - use Astro's Container API with actual .astro templates
+- **Test fixtures MUST import actual components**, not duplicate HTML
+- Reference: https://docs.astro.build/en/reference/container-reference/
+- Use `experimental_AstroContainer.create()` to instantiate container
+- Use `container.renderToString(Component)` to render
+- Use `// @vitest-environment happy-dom` for DOM support
+- Configure Vitest with `getViteConfig()` from 'astro/config'
+- Naming: `client.spec.ts` for tests, `componentName.fixture.astro` for fixtures
+- Working example: `src/components/Test/container.spec.ts`
+
+## E2E Testing
+
+- **NEVER hard-code content slugs** - fetch dynamically from listing pages
+- **Always run with `DEBUG=1`** - e.g., `DEBUG=1 npx playwright test test/e2e/file.spec.ts`
+- **NEVER run full e2e suite** unless requested - it takes 10+ minutes
+- **NEVER start dev server** - user maintains running server
+- **Use `BasePage.waitForPageLoad()`** to wait for `astro:page-load` event
+- **NEVER use `waitForTimeout()`** - use event-based waits
+- **transition:persist**: Apply to HTML elements in component definition, not on component usage
+
+## View Transitions Testing
+
+- `page.goto(url)` = Full page reload (no View Transitions)
+- `page.navigateToPage('/path')` = Astro View Transitions (client-side navigation)
+- Always use `navigateToPage()` for consistency - never ad-hoc `click('a[href]')`
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 6e01c5f22..fa8509b53 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -94,7 +94,7 @@ const avatarSrc = AuthorAvatar.src
- {/* Site footer - Preact component with transition:persist for testing */}
+ {/* Site footer - Preact component */}
From 03995f4317fca1902da989038f9e541498db8634 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 18:18:38 +0300
Subject: [PATCH 037/341] Add helper and assertions file for component
persistence tests
---
test/COMPONENT_TESTING.md | 27 +++
test/e2e/assertions/index.ts | 28 ++++
test/e2e/helpers/index.ts | 1 +
.../ComponentPersistencePage.ts | 155 ++++++++++++++++++
.../footer-view-transitions-persist.spec.ts | 58 ++-----
...emepicker-view-transitions-persist.spec.ts | 113 ++-----------
6 files changed, 231 insertions(+), 151 deletions(-)
create mode 100644 test/COMPONENT_TESTING.md
create mode 100644 test/e2e/assertions/index.ts
create mode 100644 test/e2e/helpers/pageObjectModels/ComponentPersistencePage.ts
diff --git a/test/COMPONENT_TESTING.md b/test/COMPONENT_TESTING.md
new file mode 100644
index 000000000..7975128c0
--- /dev/null
+++ b/test/COMPONENT_TESTING.md
@@ -0,0 +1,27 @@
+# Differentiating What to Test in Unit and E2E Test Cases
+
+When testing Web Components with both unit tests and Playwright E2E tests, there should be a clear distinction in focus.
+
+## Unit Tests (for Web Components):
+
+Unit tests should focus on the isolated functionality of each Web Component, ensuring its internal logic and behavior are correct. Key areas to test include:
+
+- **Property and Attribute Handling:** Verify that properties and attributes are correctly set, reflected, and updated within the component. This includes default values, type conversions, and reactivity.
+- **Event Emission and Handling:** Test that the component correctly dispatches custom events and that any internal event listeners function as expected.
+- **Shadow DOM Interaction:** If using Shadow DOM, ensure that elements within the Shadow DOM are correctly rendered, styled, and interact as intended.
+- **Method Logic:** Test the individual methods of the component, ensuring they perform their specific tasks correctly and return expected outputs.
+- **State Management:** If the component manages its own internal state, test that state changes are handled correctly and trigger appropriate updates.
+- **Lifecycle Hooks:** Verify that lifecycle callbacks (e.g., connectedCallback, disconnectedCallback, attributeChangedCallback) are invoked at the correct times and perform their intended actions.
+
+## E2E Tests (with Playwright):
+
+Playwright E2E tests should focus on the overall user experience and how the Web Components interact within the larger application context. This includes:
+
+- **User Flows:** Test critical user journeys and interactions involving multiple components, such as form submissions, navigation, and data display.
+- **Component Integration:** Verify that different Web Components interact correctly with each other and with external services or APIs.
+- **Visual Regression:** Ensure that the components render correctly and consistently across different browsers and viewports.
+- **Accessibility:** Test for accessibility compliance, ensuring that components are usable by individuals with disabilities.
+- **Performance:** Monitor loading times and responsiveness of the application and its components.
+- **Data Persistence:** If applicable, verify that data is correctly stored and retrieved across sessions or interactions.
+
+By clearly separating the concerns, unit tests provide granular feedback on individual component correctness, while E2E tests validate the end-to-end user experience and overall system integration.
diff --git a/test/e2e/assertions/index.ts b/test/e2e/assertions/index.ts
new file mode 100644
index 000000000..08821d562
--- /dev/null
+++ b/test/e2e/assertions/index.ts
@@ -0,0 +1,28 @@
+/**
+ * Type guards for E2E testing
+ */
+
+/**
+ * Type for HTML elements with test properties attached during persistence testing
+ */
+export interface ElementWithTestProperties extends HTMLElement {
+ __testProperty?: string
+ __navigationCounter?: number
+}
+
+/**
+ * Type guard to check if an element has test properties attached
+ */
+export function isElementWithTestProperties(
+ element: unknown
+): element is ElementWithTestProperties {
+ if (
+ element &&
+ typeof element === 'object' &&
+ 'nodeType' in (element as HTMLElement) &&
+ (element as HTMLElement).nodeType === Node.ELEMENT_NODE
+ ) {
+ return true
+ }
+ return false
+}
diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts
index 6b3088424..f6065d0a0 100644
--- a/test/e2e/helpers/index.ts
+++ b/test/e2e/helpers/index.ts
@@ -13,6 +13,7 @@ export {
} from '@test/e2e/helpers/consoleCapture'
export { clearConsentCookies } from '@test/e2e/helpers/browserState'
export { BasePage } from '@test/e2e/helpers/pageObjectModels/BasePage'
+export { ComponentPersistencePage } from '@test/e2e/helpers/pageObjectModels/ComponentPersistencePage'
export {
setupCleanTestPage,
setupTestPage,
diff --git a/test/e2e/helpers/pageObjectModels/ComponentPersistencePage.ts b/test/e2e/helpers/pageObjectModels/ComponentPersistencePage.ts
new file mode 100644
index 000000000..b366e440e
--- /dev/null
+++ b/test/e2e/helpers/pageObjectModels/ComponentPersistencePage.ts
@@ -0,0 +1,155 @@
+/**
+ * Component Persistence Page Object Model
+ *
+ * Specialized page object for testing Astro View Transitions persistence
+ * with web components and framework components.
+ */
+
+import type { Page } from '@playwright/test'
+import { expect } from '@playwright/test'
+import { BasePage } from './BasePage'
+import type { CapturedConsoleMessage } from '@test/e2e/helpers/consoleCapture'
+import type { ElementWithTestProperties } from '@test/e2e/assertions'
+
+export interface PersistenceTestData {
+ uniqueId: string
+ initialTimestamp: number
+ tagName: string
+}
+
+export interface PersistenceVerificationData {
+ dataAttribute: string | null
+ customProperty: string | null
+ navigationCounter: number
+ elementExists: boolean
+ tagName: string
+}
+
+export class ComponentPersistencePage extends BasePage {
+ private consoleMessages: CapturedConsoleMessage[] = []
+
+ constructor(page: Page) {
+ super(page)
+ this.setupConsoleCapture()
+ }
+
+ /**
+ * Set up console message capture
+ */
+ private setupConsoleCapture(): void {
+ this.page.on('console', async msg => {
+ const type = msg.type()
+ const text = msg.text()
+
+ const args: string[] = []
+ for (const arg of msg.args()) {
+ try {
+ const value = await arg.jsonValue().catch(() => arg.toString())
+ args.push(String(value))
+ } catch {
+ args.push(arg.toString())
+ }
+ }
+
+ this.consoleMessages.push({
+ type,
+ text,
+ args,
+ location: msg.location().url,
+ })
+
+ // Print to test output for real-time debugging
+ console.log(`[${type}] ${text}`)
+ if (args.length > 0 && args.join('') !== text) {
+ console.log(` Args: ${args.join(', ')}`)
+ }
+ })
+ }
+
+ /**
+ * Print all captured console messages
+ */
+ printCapturedMessages(title = 'Console Messages'): void {
+ console.log(`\n=== ${title} ===`)
+ this.consoleMessages.forEach((msg, i) => {
+ console.log(`\n[${i}] ${msg.type}: ${msg.text}`)
+ if (msg.args.length > 1) {
+ console.log(` Full args (${msg.args.length}):`, msg.args.slice(0, 5))
+ }
+ })
+ console.log(`=== END ${title.toUpperCase()} ===\n`)
+ }
+
+ /**
+ * Set up persistence test data on an element
+ *
+ * @param selector - CSS selector for the element to test
+ * @returns Initial test data with unique ID and timestamp
+ */
+ async setupPersistenceTest(selector: string): Promise {
+ return await this.evaluate((sel: string) => {
+ const element = document.querySelector(sel) as ElementWithTestProperties | null
+ if (!element) throw new Error(`${sel} element not found`)
+
+ // Create a unique identifier
+ const uniqueId = `test-${Date.now()}-${Math.random()}`
+
+ // Set it as a data attribute
+ element.setAttribute('data-unique-id', uniqueId)
+
+ // Also set a custom property directly on the DOM element
+ element.__testProperty = uniqueId
+
+ // Store a counter to verify the element isn't recreated
+ element.__navigationCounter = 0
+
+ return {
+ uniqueId,
+ initialTimestamp: Date.now(),
+ tagName: element.tagName.toLowerCase(),
+ }
+ }, selector)
+ }
+
+ /**
+ * Verify element persistence after navigation
+ *
+ * @param selector - CSS selector for the element to verify
+ * @returns Verification data showing if element persisted
+ */
+ async verifyPersistence(selector: string): Promise {
+ return await this.evaluate((sel: string) => {
+ const element = document.querySelector(sel) as ElementWithTestProperties | null
+ if (!element) throw new Error(`${sel} element not found after navigation`)
+
+ // Increment counter to prove it's the same element
+ const counter = element.__navigationCounter ?? 0
+ element.__navigationCounter = counter + 1
+
+ return {
+ dataAttribute: element.getAttribute('data-unique-id'),
+ customProperty: element.__testProperty ?? null,
+ navigationCounter: counter,
+ elementExists: !!element,
+ tagName: element.tagName.toLowerCase(),
+ }
+ }, selector)
+ }
+
+ /**
+ * Assert that element persisted across navigation
+ *
+ * @param initialData - Initial test data from setupPersistenceTest
+ * @param verificationData - Verification data from verifyPersistence
+ */
+ assertPersistence(
+ initialData: PersistenceTestData,
+ verificationData: PersistenceVerificationData
+ ): void {
+ expect(verificationData.elementExists).toBe(true)
+ expect(verificationData.tagName).toBe(initialData.tagName)
+ expect(verificationData.dataAttribute).toBe(initialData.uniqueId)
+ expect(verificationData.customProperty).toBe(initialData.uniqueId)
+ expect(verificationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
+ }
+}
diff --git a/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
index b3de7b38a..2f75ec033 100644
--- a/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
+++ b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
@@ -13,7 +13,7 @@
* - Astro View Transitions API documentation
*/
-import { BasePage, test, describe, expect } from '@test/e2e/helpers'
+import { ComponentPersistencePage, BasePage, test, describe, expect } from '@test/e2e/helpers'
describe('View Transitions - transition:persist on Web Components', () => {
test('should persist ThemePicker web component identity across navigation', async ({
@@ -205,62 +205,24 @@ describe('View Transitions - transition:persist on Web Components', () => {
test('should persist Preact Footer component identity across navigation', async ({
page: playwrightPage,
}) => {
- const page = new BasePage(playwrightPage)
+ const page = new ComponentPersistencePage(playwrightPage)
await page.goto('/')
- // Set a unique identifier on the Preact Footer component and store a custom property
- const initialData = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="footer-preact"]') as HTMLElement
- if (!element) throw new Error('footer-preact component not found')
-
- // Create a unique identifier
- const uniqueId = `test-${Date.now()}-${Math.random()}`
-
- // Set it as a data attribute
- element.setAttribute('data-unique-id', uniqueId)
-
- // Also set a custom property directly on the DOM element
- // This is more definitive proof of DOM identity persistence
- ;(element as any).__testProperty = uniqueId
-
- // Store a counter to verify the element isn't recreated
- ;(element as any).__navigationCounter = 0
-
- return {
- uniqueId,
- initialTimestamp: Date.now(),
- tagName: element.tagName.toLowerCase(),
- }
- })
+ // Set up test data on the Preact Footer component
+ const initialData = await page.setupPersistenceTest('[data-testid="footer-preact"]')
// Navigate to a different page using Astro View Transitions
await page.navigateToPage('/articles')
await page.waitForURL('**/articles', { timeout: 5000 })
await page.waitForPageLoad()
- // Verify the element still has the same unique identifier
- const afterNavigationData = await page.evaluate(() => {
- const element = document.querySelector('[data-testid="footer-preact"]') as HTMLElement
- if (!element) throw new Error('footer-preact component not found after navigation')
-
- // Increment counter to prove it's the same element
- const counter = (element as any).__navigationCounter
- ;(element as any).__navigationCounter = counter + 1
+ // Verify the element persisted with the same DOM identity
+ const afterNavigationData = await page.verifyPersistence('[data-testid="footer-preact"]')
- return {
- dataAttribute: element.getAttribute('data-unique-id'),
- customProperty: (element as any).__testProperty,
- navigationCounter: counter,
- elementExists: !!element,
- tagName: element.tagName.toLowerCase(),
- }
- })
+ // Run all persistence assertions
+ page.assertPersistence(initialData, afterNavigationData)
- // Assertions proving DOM identity persistence
- expect(afterNavigationData.elementExists).toBe(true)
- expect(afterNavigationData.tagName).toBe('footer')
- expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
- expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
- expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
+ // Output console messages for debugging
+ page.printCapturedMessages('FOOTER PERSISTENCE TEST CONSOLE')
})
})
diff --git a/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
index 94fe62b4a..8c4f49c60 100644
--- a/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
+++ b/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
@@ -13,82 +13,32 @@
* - Astro View Transitions API documentation
*/
-import { BasePage, test, describe, expect } from '@test/e2e/helpers'
-import { setupConsoleCapture, printCapturedMessages } from '@test/e2e/helpers/consoleCapture'
+import { ComponentPersistencePage, BasePage, test, describe, expect } from '@test/e2e/helpers'
describe('View Transitions - transition:persist on Web Components', () => {
test('should persist ThemePicker web component identity across navigation', async ({
page: playwrightPage,
}) => {
- const page = new BasePage(playwrightPage)
-
- // Capture all console output including nested group content
- const consoleMessages = setupConsoleCapture(playwrightPage, true)
+ const page = new ComponentPersistencePage(playwrightPage)
await page.goto('/')
- // Set a unique identifier on the ThemePicker web component and store a custom property
- const initialData = await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
- if (!element) throw new Error('theme-picker web component not found')
-
- // Create a unique identifier
- const uniqueId = `test-${Date.now()}-${Math.random()}`
-
- // Set it as a data attribute
- element.setAttribute('data-unique-id', uniqueId)
-
- // Also set a custom property directly on the DOM element
- // This is more definitive proof of DOM identity persistence
- ;(element as any).__testProperty = uniqueId
-
- // Store a counter to verify the element isn't recreated
- ;(element as any).__navigationCounter = 0
-
- return {
- uniqueId,
- initialTimestamp: Date.now(),
- tagName: element.tagName.toLowerCase(),
- }
- })
+ // Set up test data on the ThemePicker web component
+ const initialData = await page.setupPersistenceTest('theme-picker')
// Navigate to a different page using Astro's View Transitions
- // Click the Articles link in the main navigation to trigger client-side navigation
await page.navigateToPage('/articles')
-
- // Wait for the View Transition to complete and URL to change
await page.waitForURL('**/articles', { timeout: 5000 })
-
- // Wait for Astro page load event instead of arbitrary timeout
await page.waitForPageLoad()
- // Verify the element still has the same unique identifier
- const afterNavigationData = await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
- if (!element) throw new Error('theme-picker web component not found after navigation')
+ // Verify the element persisted with the same DOM identity
+ const afterNavigationData = await page.verifyPersistence('theme-picker')
- // Increment counter to prove it's the same element
- const counter = (element as any).__navigationCounter
- ;(element as any).__navigationCounter = counter + 1
+ // Run all persistence assertions
+ page.assertPersistence(initialData, afterNavigationData)
- return {
- dataAttribute: element.getAttribute('data-unique-id'),
- customProperty: (element as any).__testProperty,
- navigationCounter: counter,
- elementExists: !!element,
- tagName: element.tagName.toLowerCase(),
- }
- })
-
- // Assertions proving DOM identity persistence
- expect(afterNavigationData.elementExists).toBe(true)
- expect(afterNavigationData.tagName).toBe('theme-picker')
- expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
- expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
- expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
-
- // Output all captured console messages for analysis
- printCapturedMessages(consoleMessages, 'ALL CAPTURED CONSOLE MESSAGES')
+ // Output console messages for debugging
+ page.printCapturedMessages('ALL CAPTURED CONSOLE MESSAGES')
})
test.skip('should maintain custom properties across multiple navigations', async ({
@@ -175,47 +125,4 @@ describe('View Transitions - transition:persist on Web Components', () => {
expect(listenerData.listenerAttached).toBe(true)
expect(listenerData.clickCount).toBe(1) // Should have incremented from the click
})
-
- test('should maintain element reference across navigation', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
- await page.goto('/')
-
- // Store a reference test on ThemePicker - advanced verification of identity persistence
- const refCheck = await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
- if (!element) throw new Error('theme-picker web component not found')
-
- // Create a unique symbol that can only exist on the same object reference
- const uniqueSymbol = Symbol('test-identity')
- ;(element as any)[uniqueSymbol] = 'identity-marker'
-
- // Store the symbol key as a string for later comparison
- ;(element as any).__symbolKey = uniqueSymbol.toString()
- ;(element as any).__symbolValue = 'identity-marker'
-
- return {
- symbolKey: uniqueSymbol.toString(),
- tagName: element.tagName.toLowerCase(),
- }
- })
-
- // Navigate using View Transitions
- await page.navigateToPage('/articles')
- await page.waitForURL('**/articles', { timeout: 5000 })
- await page.waitForPageLoad()
-
- // Check if the symbol property still exists (proves same object reference)
- const afterNav = await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
- return {
- symbolKey: (element as any).__symbolKey,
- symbolValue: (element as any).__symbolValue,
- tagName: element.tagName.toLowerCase(),
- }
- })
-
- expect(afterNav.tagName).toBe('theme-picker')
- expect(afterNav.symbolKey).toBe(refCheck.symbolKey)
- expect(afterNav.symbolValue).toBe('identity-marker')
- })
})
From 246e6963a4fdedf800ad0135a9259d13a6bcd42e Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 22:33:29 +0300
Subject: [PATCH 038/341] Remove obsolete theme bootstrap code
---
src/components/scripts/bootstrap/index.ts | 3 +-
.../bootstrap/theme/__tests__/index.spec.ts | 223 ------------------
.../scripts/bootstrap/theme/index.ts | 145 ------------
3 files changed, 1 insertion(+), 370 deletions(-)
delete mode 100644 src/components/scripts/bootstrap/theme/__tests__/index.spec.ts
delete mode 100644 src/components/scripts/bootstrap/theme/index.ts
diff --git a/src/components/scripts/bootstrap/index.ts b/src/components/scripts/bootstrap/index.ts
index 3ec229650..c14b73485 100644
--- a/src/components/scripts/bootstrap/index.ts
+++ b/src/components/scripts/bootstrap/index.ts
@@ -34,8 +34,7 @@ export class AppBootstrap {
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'initConsentSideEffects' })
initConsentSideEffects()
- // 3. Initialize theme system (replaces old setInitialTheme + initThemeSideEffects)
- // This synchronizes DOM, localStorage, and store state
+ // 3. Initialize theme system - synchronizes DOM, localStorage, and store state
addScriptBreadcrumb({ scriptName: 'AppBootstrap', operation: 'initThemeSystem' })
initThemeSystem()
diff --git a/src/components/scripts/bootstrap/theme/__tests__/index.spec.ts b/src/components/scripts/bootstrap/theme/__tests__/index.spec.ts
deleted file mode 100644
index 2cee00e23..000000000
--- a/src/components/scripts/bootstrap/theme/__tests__/index.spec.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * Unit tests for theme initialization script
- */
-
-// @vitest-environment happy-dom
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
-import { setInitialTheme } from '@components/scripts/bootstrap/theme/index'
-import * as errorHandler from '@components/scripts/errors/handler'
-
-describe('setInitialTheme', () => {
- beforeEach(() => {
- // Clear localStorage before each test
- localStorage.clear()
-
- // Clear all document attributes
- document.documentElement.removeAttribute('data-theme')
- document.documentElement.removeAttribute('lang')
-
- // Reset all mocks
- vi.clearAllMocks()
- })
-
- afterEach(() => {
- localStorage.clear()
- vi.restoreAllMocks()
- })
-
- describe('localStorage theme handling', () => {
- it('should set theme from localStorage when available', () => {
- localStorage.setItem('theme', 'dark')
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
- })
-
- it('should set theme to light when stored in localStorage', () => {
- localStorage.setItem('theme', 'light')
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('data-theme')).toBe('light')
- })
-
- it('should set custom theme from localStorage', () => {
- localStorage.setItem('theme', 'custom-theme')
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('data-theme')).toBe('custom-theme')
- })
-
- it('should handle localStorage errors gracefully', () => {
- const handleScriptErrorSpy = vi.spyOn(errorHandler, 'handleScriptError').mockReturnValue({} as any)
-
- // Make localStorage.getItem throw by overriding the global
- const originalGetItem = window.localStorage.getItem
- Object.defineProperty(window.localStorage, 'getItem', {
- configurable: true,
- value: vi.fn(() => {
- throw new Error('localStorage access denied')
- })
- })
-
- setInitialTheme()
-
- expect(handleScriptErrorSpy).toHaveBeenCalledWith(
- expect.any(Error),
- { scriptName: 'theme-initialization', operation: 'setInitialTheme' }
- )
-
- // Restore original
- Object.defineProperty(window.localStorage, 'getItem', {
- configurable: true,
- value: originalGetItem
- })
- })
- })
-
- describe('system preference fallback', () => {
- it('should set dark theme when system prefers dark and no stored theme', () => {
- // Mock matchMedia to return dark preference
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query) => ({
- matches: query === '(prefers-color-scheme: dark)',
- media: query,
- onchange: null,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- })
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
- })
-
- it('should not override stored theme with system preference', () => {
- localStorage.setItem('theme', 'light')
-
- // Mock matchMedia to return dark preference
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query) => ({
- matches: query === '(prefers-color-scheme: dark)',
- media: query,
- onchange: null,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- })
-
- setInitialTheme()
-
- // Should keep the stored theme, not system preference
- expect(document.documentElement.getAttribute('data-theme')).toBe('light')
- })
-
- it('should not set theme when system prefers light and no stored theme', () => {
- // Mock matchMedia to return light preference
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query) => ({
- matches: false, // doesn't match dark preference
- media: query,
- onchange: null,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- })
-
- setInitialTheme()
-
- // Should not set data-theme (defaults to light in BaseLayout.astro)
- expect(document.documentElement.getAttribute('data-theme')).toBeNull()
- })
- })
-
- describe('lang attribute fix', () => {
- it('should always set lang attribute to "en"', () => {
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('lang')).toBe('en')
- })
-
- it('should restore lang attribute even when localStorage has theme', () => {
- localStorage.setItem('theme', 'dark')
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('lang')).toBe('en')
- })
-
- it('should restore lang attribute even when localStorage throws error', () => {
- vi.spyOn(errorHandler, 'handleScriptError')
- vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
- throw new Error('localStorage error')
- })
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('lang')).toBe('en')
- })
- })
-
- describe('integration scenarios', () => {
- it('should handle complete flow: stored theme + lang attribute', () => {
- localStorage.setItem('theme', 'dark')
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
- expect(document.documentElement.getAttribute('lang')).toBe('en')
- })
-
- it('should handle complete flow: system preference + lang attribute', () => {
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query) => ({
- matches: query === '(prefers-color-scheme: dark)',
- media: query,
- onchange: null,
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- })
-
- setInitialTheme()
-
- expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
- expect(document.documentElement.getAttribute('lang')).toBe('en')
- })
-
- it('should handle error gracefully and still set lang attribute', () => {
- const handleScriptErrorSpy = vi.spyOn(errorHandler, 'handleScriptError').mockReturnValue({} as any)
-
- // Make localStorage.getItem throw
- const originalGetItem = window.localStorage.getItem
- Object.defineProperty(window.localStorage, 'getItem', {
- configurable: true,
- value: vi.fn(() => {
- throw new Error('localStorage unavailable')
- })
- })
-
- setInitialTheme()
-
- expect(handleScriptErrorSpy).toHaveBeenCalled()
- expect(document.documentElement.getAttribute('lang')).toBe('en')
-
- // Restore original
- Object.defineProperty(window.localStorage, 'getItem', {
- configurable: true,
- value: originalGetItem
- })
- })
- })
-})
diff --git a/src/components/scripts/bootstrap/theme/index.ts b/src/components/scripts/bootstrap/theme/index.ts
deleted file mode 100644
index 5b1f731fa..000000000
--- a/src/components/scripts/bootstrap/theme/index.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { handleScriptError } from '@components/scripts/errors/handler'
-import type { ThemeId } from '@components/scripts/store/@types'
-import { $theme } from '@components/scripts/store/themes'
-
-/**
- * Set theme name on element from storage
- * early to prevent flash of unstyled content.
- */
-// @TODO: This will give a flash of the incorrect theme. The theme selector set on will show before this script has a chance to run. Also, the theme is being changed to "default" somewhere which selects for nothing in the current setup.
-// @TODO: Called from components/Head/index.astro
-export const setInitialTheme = () => {
- /** 1. If there is a stored theme in localstorage, it takes priority */
- let themeSet = false
- try {
- const storedTheme = localStorage.getItem('theme')
- if (storedTheme) {
- document.documentElement.setAttribute('data-theme', storedTheme)
- themeSet = true
- }
- } catch (error) {
- handleScriptError(error, { scriptName: 'theme-initialization', operation: 'setInitialTheme' })
- }
- /** 2. If no stored theme and system preference is "dark", set to dark theme */
- if (!themeSet && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
- document.documentElement.setAttribute('data-theme', 'dark')
- }
- /**
- * 3. If no stored theme and no system preference, light theme
- * is set by default in BaseLayout.astro
- */
-
- /**
- * Make sure Astro View Transitions hasn't removed lang tag from
- * after modifying attributes with . This is
- * a known bug with Astro View Transitions.
- */
- document.documentElement.setAttribute('lang', 'en')
-}
-
-// @TODO: Moved from scripts/store/themes.ts
-// ============================================================================
-// SIDE EFFECTS
-// ============================================================================
-
-/**
- * Setup theme-related side effects
- */
-export function initThemeSideEffects(): void {
- // Helper function to apply theme to DOM
- const applyThemeToDom = (themeId: ThemeId) => {
- try {
- // Update DOM attribute
- document.documentElement.setAttribute('data-theme', themeId)
-
- // Sync to localStorage for FOUC prevention (Head/index.astro reads this on page load)
- // NOTE: Don't persist 'default' - it means "use system preference"
- // NOTE: This is a side effect only - nanostore is the source of truth
- try {
- if (themeId === 'default') {
- localStorage.removeItem('theme')
- } else {
- localStorage.setItem('theme', themeId)
- }
- } catch (storageError) {
- handleScriptError(storageError, {
- scriptName: 'themes',
- operation: 'syncThemeToLocalStorage',
- })
- }
-
- // Update meta theme-color
- const metaElement = document.querySelector('meta[name="theme-color"]')
- if (metaElement && window.metaColors) {
- metaElement.setAttribute('content', window.metaColors[themeId] || '')
- }
- } catch (error) {
- handleScriptError(error, {
- scriptName: 'themes',
- operation: 'applyThemeToDom',
- })
- }
- }
-
- // Side Effect: Update DOM and localStorage when theme changes
- // Use .listen() instead of .subscribe() to avoid firing on initial value
- let isInitialized = false
-
- $theme.listen((themeId) => {
- console.log('[Theme] listen fired:', themeId, 'isInit:', isInitialized)
- // Only apply theme changes after initialization completes
- // During init, we need to wait for persistentAtom's restore to complete
- if (isInitialized) {
- applyThemeToDom(themeId)
- }
- })
-
- // Wait for persistentAtom's async restore() to complete before syncing
- // persistentAtom's restore is async and may fire after our init code runs
- // Use setTimeout to ensure we run after restore completes
- setTimeout(() => {
- // Guard against test environment teardown
- if (typeof document === 'undefined' || typeof localStorage === 'undefined') {
- return
- }
-
- // Apply the current theme after restore completes
- // Priority order:
- // 1. User's stored preference (manual selection) - highest priority
- // 2. System preference detected by HEAD script (DOM theme)
- // 3. Store default value
- const domTheme = document.documentElement.getAttribute('data-theme') as ThemeId | null
- const storedTheme = localStorage.getItem('theme') as ThemeId | null
- const storeTheme = $theme.get()
- console.log('[Theme] Init after restore - dom:', domTheme, 'stored:', storedTheme, 'store:', storeTheme)
-
- if (storedTheme) {
- // User has explicitly chosen a theme - apply it
- console.log('[Theme] Applying stored preference:', storedTheme)
- if (storeTheme !== storedTheme) {
- $theme.set(storedTheme as ThemeId)
- }
- applyThemeToDom(storedTheme as ThemeId)
- } else if (domTheme && domTheme !== 'light' && domTheme !== storeTheme) {
- // No stored preference, but HEAD script detected system preference
- // Sync store to match DOM without persisting
- console.log('[Theme] Syncing to system preference:', domTheme)
- $theme.set(domTheme)
- applyThemeToDom(domTheme)
- } else {
- // DOM and store are in sync, just apply current state
- console.log('[Theme] Applying current theme:', storeTheme)
- applyThemeToDom(storeTheme)
- }
-
- // Mark initialization complete - now listen for user theme changes
- isInitialized = true
- console.log('[Theme] Initialization complete')
- }, 100) // Side Effect: Re-apply theme after View Transitions DOM swap
- // The inline script in Head/index.astro applies theme on initial load,
- // but View Transitions replaces the DOM, so we need to re-apply
- document.addEventListener('astro:after-swap', () => {
- const theme = $theme.get()
- applyThemeToDom(theme)
- })
-}
From ea335057023871712082c88744b8c3498956e906 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sat, 8 Nov 2025 22:34:03 +0300
Subject: [PATCH 039/341] Implement view transition persistence test case for
event listeners
---
test/COMPONENT_TESTING.md | 18 +-
.../THEMEPICKER_TEST_NOTES.md} | 0
.../footer-view-transitions-persist.spec.ts | 112 +++++--
.../12-component-persistence/footer.spec.ts | 304 ++++++++++++++++++
.../themepicker.spec.ts} | 88 ++---
5 files changed, 442 insertions(+), 80 deletions(-)
rename test/e2e/specs/{11-regression/NOTES.md => 04-components/THEMEPICKER_TEST_NOTES.md} (100%)
create mode 100644 test/e2e/specs/12-component-persistence/footer.spec.ts
rename test/e2e/specs/{11-regression/themepicker-view-transitions-persist.spec.ts => 12-component-persistence/themepicker.spec.ts} (50%)
diff --git a/test/COMPONENT_TESTING.md b/test/COMPONENT_TESTING.md
index 7975128c0..4b3e5a5b4 100644
--- a/test/COMPONENT_TESTING.md
+++ b/test/COMPONENT_TESTING.md
@@ -1,27 +1,35 @@
# Differentiating What to Test in Unit and E2E Test Cases
-When testing Web Components with both unit tests and Playwright E2E tests, there should be a clear distinction in focus.
+This document outlines the testing strategy for Web Components in this project, distinguishing between unit tests (isolated component logic) and E2E tests (integrated user experience). Following this separation ensures comprehensive coverage while avoiding redundant testing.
-## Unit Tests (for Web Components):
+## Testing Tools
+
+- **Unit Tests:** Vitest with Astro Container API
+- **E2E Tests:** Playwright
+
+## Unit Tests (for Web Components)
Unit tests should focus on the isolated functionality of each Web Component, ensuring its internal logic and behavior are correct. Key areas to test include:
+- **Component Isolation:** Test components in isolation from external dependencies. Use mocks, stubs, or test doubles for external services, APIs, global state, or other components. This ensures tests are fast, deterministic, and only fail when the component itself has issues, not due to external factors.
- **Property and Attribute Handling:** Verify that properties and attributes are correctly set, reflected, and updated within the component. This includes default values, type conversions, and reactivity.
- **Event Emission and Handling:** Test that the component correctly dispatches custom events and that any internal event listeners function as expected.
-- **Shadow DOM Interaction:** If using Shadow DOM, ensure that elements within the Shadow DOM are correctly rendered, styled, and interact as intended.
- **Method Logic:** Test the individual methods of the component, ensuring they perform their specific tasks correctly and return expected outputs.
- **State Management:** If the component manages its own internal state, test that state changes are handled correctly and trigger appropriate updates.
- **Lifecycle Hooks:** Verify that lifecycle callbacks (e.g., connectedCallback, disconnectedCallback, attributeChangedCallback) are invoked at the correct times and perform their intended actions.
+- **Edge Cases and Error Handling:** Test boundary conditions, invalid inputs, null/undefined values, and error states to ensure robust component behavior. Examples include empty strings, negative numbers, maximum length inputs, missing required attributes, and network failures.
-## E2E Tests (with Playwright):
+## E2E Tests (with Playwright)
Playwright E2E tests should focus on the overall user experience and how the Web Components interact within the larger application context. This includes:
- **User Flows:** Test critical user journeys and interactions involving multiple components, such as form submissions, navigation, and data display.
- **Component Integration:** Verify that different Web Components interact correctly with each other and with external services or APIs.
- **Visual Regression:** Ensure that the components render correctly and consistently across different browsers and viewports.
+- **Cross-Browser Compatibility:** Verify consistent behavior across different browsers (Chromium, Firefox, WebKit) and browser versions, catching browser-specific quirks or rendering differences.
- **Accessibility:** Test for accessibility compliance, ensuring that components are usable by individuals with disabilities.
- **Performance:** Monitor loading times and responsiveness of the application and its components.
-- **Data Persistence:** If applicable, verify that data is correctly stored and retrieved across sessions or interactions.
+- **Data Persistence:** Verify that localStorage/sessionStorage maintains data across page reloads, form inputs persist when expected, and state management works correctly across View Transitions. For Astro applications, also verify that session data survives client-side navigation.
+- **View Transitions Persistence:** For components using Astro's transition:persist directive, verify that DOM identity is maintained across page navigations. This includes testing that event listeners survive navigation, custom properties remain attached to the element, and internal component state persists without re-initialization.
By clearly separating the concerns, unit tests provide granular feedback on individual component correctness, while E2E tests validate the end-to-end user experience and overall system integration.
diff --git a/test/e2e/specs/11-regression/NOTES.md b/test/e2e/specs/04-components/THEMEPICKER_TEST_NOTES.md
similarity index 100%
rename from test/e2e/specs/11-regression/NOTES.md
rename to test/e2e/specs/04-components/THEMEPICKER_TEST_NOTES.md
diff --git a/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
index 2f75ec033..322a3ab7e 100644
--- a/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
+++ b/test/e2e/specs/11-regression/footer-view-transitions-persist.spec.ts
@@ -1,25 +1,99 @@
/**
- * Regression Tests for Astro View Transitions - transition:persist directive
+ * Component Persistence Tests for Footer Component
*
- * Verifies that elements with transition:persist maintain their DOM identity
- * across page navigations when using Astro's View Transitions API.
+ * Verifies that the Footer component maintains DOM identity across
+ * page navigations when using Astro's View Transitions API with transition:persist.
*
- * Tests specifically focus on Web Components (like ThemePicker) to verify
- * whether Astro properly handles transition:persist for custom elements.
+ * These tests are implementation-agnostic and should work for both:
+ * - Current Preact implementation ([data-testid="footer-preact"])
+ * - Future Web Component implementation (footer-component or similar)
*
* Related:
- * - src/layouts/BaseLayout.astro (ThemePicker with transition:persist)
- * - src/components/ThemePicker/index.astro (Web Component implementation)
+ * - src/components/Footer (Footer component implementation)
* - Astro View Transitions API documentation
*/
-import { ComponentPersistencePage, BasePage, test, describe, expect } from '@test/e2e/helpers'
+/* eslint-disable custom-rules/enforce-centralized-events -- Test file uses addEventListener to verify DOM persistence */
-describe('View Transitions - transition:persist on Web Components', () => {
- test('should persist ThemePicker web component identity across navigation', async ({
+import { ComponentPersistencePage, test, describe, expect } from '@test/e2e/helpers'
+
+describe('Footer Component Persistence', () => {
+ test('should persist Footer component identity across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Set up test data on the Footer component (works for both Preact and Web Component)
+ const initialData = await page.setupPersistenceTest('[data-testid="footer-preact"]')
+
+ // Navigate to a different page using Astro View Transitions
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
+
+ // Verify the element persisted with the same DOM identity
+ const afterNavigationData = await page.verifyPersistence('[data-testid="footer-preact"]')
+
+ // Run all persistence assertions
+ page.assertPersistence(initialData, afterNavigationData)
+ })
+
+ test('should preserve event listeners and closure state across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Attach an event listener with closure state to the Footer component
+ const initialClickCount = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="footer-preact"]')
+ if (!element) throw new Error('Footer component not found')
+
+ let clickCount = 0
+
+ const handleClick = () => {
+ clickCount++
+ ;(element as any).__clickCount = clickCount
+ }
+
+ element.addEventListener('click', handleClick)
+
+ ;(element as any).__listenerAttached = true
+ ;(element as any).__clickCount = 0
+
+ return clickCount
+ })
+
+ expect(initialClickCount).toBe(0)
+
+ // Navigate using View Transitions
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
+
+ // Dispatch click event to trigger the listener
+ const afterNavigationData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="footer-preact"]')
+ if (!element) throw new Error('Footer component not found after navigation')
+
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ return {
+ listenerAttached: (element as any).__listenerAttached,
+ clickCount: (element as any).__clickCount,
+ }
+ })
+
+ expect(afterNavigationData.listenerAttached).toBe(true)
+ expect(afterNavigationData.clickCount).toBe(1)
+ })
+
+ // Legacy tests - skipped, kept for reference during Preact → Web Component migration
+ test.skip('OLD: should persist ThemePicker web component identity across navigation', async ({
page: playwrightPage,
}) => {
- const page = new BasePage(playwrightPage)
+ const page = new ComponentPersistencePage(playwrightPage)
await page.goto('/')
// Set a unique identifier on the ThemePicker web component and store a custom property
@@ -76,10 +150,10 @@ describe('View Transitions - transition:persist on Web Components', () => {
expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
})
- test.skip('should maintain custom properties across multiple navigations', async ({
+ test.skip('OLD: should maintain custom properties across multiple navigations', async ({
page: playwrightPage,
}) => {
- const page = new BasePage(playwrightPage)
+ const page = new ComponentPersistencePage(playwrightPage)
await page.goto('/')
// Set multiple custom properties on the ThemePicker web component
@@ -116,10 +190,10 @@ describe('View Transitions - transition:persist on Web Components', () => {
}
})
- test.skip('should preserve internal state of web component across navigation', async ({
+ test.skip('OLD: should preserve internal state of web component across navigation', async ({
page: playwrightPage,
}) => {
- const page = new BasePage(playwrightPage)
+ const page = new ComponentPersistencePage(playwrightPage)
await page.goto('/')
// Modify internal state of the ThemePicker component
@@ -161,8 +235,10 @@ describe('View Transitions - transition:persist on Web Components', () => {
expect(listenerData.clickCount).toBe(1) // Should have incremented from the click
})
- test('should maintain element reference across navigation', async ({ page: playwrightPage }) => {
- const page = new BasePage(playwrightPage)
+ test.skip('OLD: should maintain element reference across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
await page.goto('/')
// Store a reference test on ThemePicker - advanced verification of identity persistence
@@ -202,7 +278,7 @@ describe('View Transitions - transition:persist on Web Components', () => {
expect(afterNav.symbolValue).toBe('identity-marker')
})
- test('should persist Preact Footer component identity across navigation', async ({
+ test.skip('OLD: should persist Preact Footer component identity across navigation', async ({
page: playwrightPage,
}) => {
const page = new ComponentPersistencePage(playwrightPage)
diff --git a/test/e2e/specs/12-component-persistence/footer.spec.ts b/test/e2e/specs/12-component-persistence/footer.spec.ts
new file mode 100644
index 000000000..322a3ab7e
--- /dev/null
+++ b/test/e2e/specs/12-component-persistence/footer.spec.ts
@@ -0,0 +1,304 @@
+/**
+ * Component Persistence Tests for Footer Component
+ *
+ * Verifies that the Footer component maintains DOM identity across
+ * page navigations when using Astro's View Transitions API with transition:persist.
+ *
+ * These tests are implementation-agnostic and should work for both:
+ * - Current Preact implementation ([data-testid="footer-preact"])
+ * - Future Web Component implementation (footer-component or similar)
+ *
+ * Related:
+ * - src/components/Footer (Footer component implementation)
+ * - Astro View Transitions API documentation
+ */
+
+/* eslint-disable custom-rules/enforce-centralized-events -- Test file uses addEventListener to verify DOM persistence */
+
+import { ComponentPersistencePage, test, describe, expect } from '@test/e2e/helpers'
+
+describe('Footer Component Persistence', () => {
+ test('should persist Footer component identity across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Set up test data on the Footer component (works for both Preact and Web Component)
+ const initialData = await page.setupPersistenceTest('[data-testid="footer-preact"]')
+
+ // Navigate to a different page using Astro View Transitions
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
+
+ // Verify the element persisted with the same DOM identity
+ const afterNavigationData = await page.verifyPersistence('[data-testid="footer-preact"]')
+
+ // Run all persistence assertions
+ page.assertPersistence(initialData, afterNavigationData)
+ })
+
+ test('should preserve event listeners and closure state across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Attach an event listener with closure state to the Footer component
+ const initialClickCount = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="footer-preact"]')
+ if (!element) throw new Error('Footer component not found')
+
+ let clickCount = 0
+
+ const handleClick = () => {
+ clickCount++
+ ;(element as any).__clickCount = clickCount
+ }
+
+ element.addEventListener('click', handleClick)
+
+ ;(element as any).__listenerAttached = true
+ ;(element as any).__clickCount = 0
+
+ return clickCount
+ })
+
+ expect(initialClickCount).toBe(0)
+
+ // Navigate using View Transitions
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
+
+ // Dispatch click event to trigger the listener
+ const afterNavigationData = await page.evaluate(() => {
+ const element = document.querySelector('[data-testid="footer-preact"]')
+ if (!element) throw new Error('Footer component not found after navigation')
+
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+
+ return {
+ listenerAttached: (element as any).__listenerAttached,
+ clickCount: (element as any).__clickCount,
+ }
+ })
+
+ expect(afterNavigationData.listenerAttached).toBe(true)
+ expect(afterNavigationData.clickCount).toBe(1)
+ })
+
+ // Legacy tests - skipped, kept for reference during Preact → Web Component migration
+ test.skip('OLD: should persist ThemePicker web component identity across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Set a unique identifier on the ThemePicker web component and store a custom property
+ const initialData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ // Create a unique identifier
+ const uniqueId = `test-${Date.now()}-${Math.random()}`
+
+ // Set it as a data attribute
+ element.setAttribute('data-unique-id', uniqueId)
+
+ // Also set a custom property directly on the DOM element
+ // This is more definitive proof of DOM identity persistence
+ ;(element as any).__testProperty = uniqueId
+
+ // Store a counter to verify the element isn't recreated
+ ;(element as any).__navigationCounter = 0
+
+ return {
+ uniqueId,
+ initialTimestamp: Date.now(),
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Navigate to a different page
+ await page.goto('/about')
+
+ // Verify the element still has the same unique identifier
+ const afterNavigationData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found after navigation')
+
+ // Increment counter to prove it's the same element
+ const counter = (element as any).__navigationCounter
+ ;(element as any).__navigationCounter = counter + 1
+
+ return {
+ dataAttribute: element.getAttribute('data-unique-id'),
+ customProperty: (element as any).__testProperty,
+ navigationCounter: counter,
+ elementExists: !!element,
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Assertions proving DOM identity persistence
+ expect(afterNavigationData.elementExists).toBe(true)
+ expect(afterNavigationData.tagName).toBe('theme-picker')
+ expect(afterNavigationData.dataAttribute).toBe(initialData.uniqueId)
+ expect(afterNavigationData.customProperty).toBe(initialData.uniqueId)
+ expect(afterNavigationData.navigationCounter).toBe(0) // Should still be 0 if element persisted
+ })
+
+ test.skip('OLD: should maintain custom properties across multiple navigations', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Set multiple custom properties on the ThemePicker web component
+ const testData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ const data = {
+ id: `id-${Math.random()}`,
+ counter: 42,
+ timestamp: Date.now(),
+ nested: { value: 'test' },
+ }
+
+ // Store various types of data on the element
+ ;(element as any).__testData = data
+
+ return data
+ })
+
+ // Navigate through multiple pages
+ const pages = ['/about', '/articles', '/services', '/contact']
+
+ for (const pagePath of pages) {
+ await page.goto(pagePath)
+
+ // Verify the custom property persists
+ const currentData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ return (element as any).__testData
+ })
+
+ expect(currentData).toEqual(testData)
+ }
+ })
+
+ test.skip('OLD: should preserve internal state of web component across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Modify internal state of the ThemePicker component
+ await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ let clickCount = 0
+
+ const handleClick = () => {
+ clickCount++
+ ;(element as any).__clickCount = clickCount
+ }
+
+ // Note: This is for testing DOM persistence, not production code
+ // In production, we use addButtonEventListeners from elementListeners
+ element.addEventListener('click', handleClick)
+
+ // Store initial state
+ ;(element as any).__listenerAttached = true
+ ;(element as any).__clickCount = 0
+ })
+
+ // Navigate to another page
+ await page.goto('/about')
+
+ // Click the element and verify internal state persists
+ await playwrightPage.locator('theme-picker').click()
+
+ const listenerData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ return {
+ listenerAttached: (element as any).__listenerAttached,
+ clickCount: (element as any).__clickCount,
+ }
+ })
+
+ expect(listenerData.listenerAttached).toBe(true)
+ expect(listenerData.clickCount).toBe(1) // Should have incremented from the click
+ })
+
+ test.skip('OLD: should maintain element reference across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Store a reference test on ThemePicker - advanced verification of identity persistence
+ const refCheck = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ if (!element) throw new Error('theme-picker web component not found')
+
+ // Create a unique symbol that can only exist on the same object reference
+ const uniqueSymbol = Symbol('test-identity')
+ ;(element as any)[uniqueSymbol] = 'identity-marker'
+
+ // Store the symbol key as a string for later comparison
+ ;(element as any).__symbolKey = uniqueSymbol.toString()
+ ;(element as any).__symbolValue = 'identity-marker'
+
+ return {
+ symbolKey: uniqueSymbol.toString(),
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ // Navigate
+ await page.goto('/about')
+
+ // Check if the symbol property still exists (proves same object reference)
+ const afterNav = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker') as HTMLElement
+ return {
+ symbolKey: (element as any).__symbolKey,
+ symbolValue: (element as any).__symbolValue,
+ tagName: element.tagName.toLowerCase(),
+ }
+ })
+
+ expect(afterNav.tagName).toBe('theme-picker')
+ expect(afterNav.symbolKey).toBe(refCheck.symbolKey)
+ expect(afterNav.symbolValue).toBe('identity-marker')
+ })
+
+ test.skip('OLD: should persist Preact Footer component identity across navigation', async ({
+ page: playwrightPage,
+ }) => {
+ const page = new ComponentPersistencePage(playwrightPage)
+ await page.goto('/')
+
+ // Set up test data on the Preact Footer component
+ const initialData = await page.setupPersistenceTest('[data-testid="footer-preact"]')
+
+ // Navigate to a different page using Astro View Transitions
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
+
+ // Verify the element persisted with the same DOM identity
+ const afterNavigationData = await page.verifyPersistence('[data-testid="footer-preact"]')
+
+ // Run all persistence assertions
+ page.assertPersistence(initialData, afterNavigationData)
+
+ // Output console messages for debugging
+ page.printCapturedMessages('FOOTER PERSISTENCE TEST CONSOLE')
+ })
+})
diff --git a/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts b/test/e2e/specs/12-component-persistence/themepicker.spec.ts
similarity index 50%
rename from test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
rename to test/e2e/specs/12-component-persistence/themepicker.spec.ts
index 8c4f49c60..75cb44939 100644
--- a/test/e2e/specs/11-regression/themepicker-view-transitions-persist.spec.ts
+++ b/test/e2e/specs/12-component-persistence/themepicker.spec.ts
@@ -13,7 +13,9 @@
* - Astro View Transitions API documentation
*/
-import { ComponentPersistencePage, BasePage, test, describe, expect } from '@test/e2e/helpers'
+/* eslint-disable custom-rules/enforce-centralized-events -- Test file uses addEventListener to verify DOM persistence */
+
+import { ComponentPersistencePage, test, describe, expect } from '@test/e2e/helpers'
describe('View Transitions - transition:persist on Web Components', () => {
test('should persist ThemePicker web component identity across navigation', async ({
@@ -41,55 +43,16 @@ describe('View Transitions - transition:persist on Web Components', () => {
page.printCapturedMessages('ALL CAPTURED CONSOLE MESSAGES')
})
- test.skip('should maintain custom properties across multiple navigations', async ({
- page: playwrightPage,
- }) => {
- const page = new BasePage(playwrightPage)
- await page.goto('/')
-
- // Set multiple custom properties on the ThemePicker web component
- const testData = await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
- if (!element) throw new Error('theme-picker web component not found')
-
- const data = {
- id: `id-${Math.random()}`,
- counter: 42,
- timestamp: Date.now(),
- nested: { value: 'test' },
- }
-
- // Store various types of data on the element
- ;(element as any).__testData = data
-
- return data
- })
-
- // Navigate through multiple pages
- const pages = ['/about', '/articles', '/services', '/contact']
-
- for (const pagePath of pages) {
- await page.goto(pagePath)
-
- // Verify the custom property persists
- const currentData = await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
- return (element as any).__testData
- })
-
- expect(currentData).toEqual(testData)
- }
- })
-
- test.skip('should preserve internal state of web component across navigation', async ({
+ test('should preserve event listeners and closure state across navigation', async ({
page: playwrightPage,
}) => {
- const page = new BasePage(playwrightPage)
+ const page = new ComponentPersistencePage(playwrightPage)
await page.goto('/')
- // Modify internal state of the ThemePicker component
- await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
+ // Attach an event listener with closure state to the ThemePicker component
+ // This tests whether the DOM element truly persists (event listeners would be lost on recreation)
+ const initialClickCount = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker')
if (!element) throw new Error('theme-picker web component not found')
let clickCount = 0
@@ -99,30 +62,41 @@ describe('View Transitions - transition:persist on Web Components', () => {
;(element as any).__clickCount = clickCount
}
- // Note: This is for testing DOM persistence, not production code
- // In production, we use addButtonEventListeners from elementListeners
+ // Attach listener to the web component
element.addEventListener('click', handleClick)
- // Store initial state
+ // Mark that listener was attached
;(element as any).__listenerAttached = true
;(element as any).__clickCount = 0
+
+ return clickCount
})
- // Navigate to another page
- await page.goto('/about')
+ expect(initialClickCount).toBe(0)
+
+ // Navigate using View Transitions (not full reload)
+ await page.navigateToPage('/articles')
+ await page.waitForURL('**/articles', { timeout: 5000 })
+ await page.waitForPageLoad()
+
+ // Programmatically dispatch a click event to trigger the listener
+ const afterNavigationData = await page.evaluate(() => {
+ const element = document.querySelector('theme-picker')
+ if (!element) throw new Error('theme-picker web component not found after navigation')
- // Click the element and verify internal state persists
- await playwrightPage.locator('theme-picker').click()
+ // Dispatch synthetic click event
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
- const listenerData = await page.evaluate(() => {
- const element = document.querySelector('theme-picker') as HTMLElement
+ // Return the state
return {
listenerAttached: (element as any).__listenerAttached,
clickCount: (element as any).__clickCount,
}
})
- expect(listenerData.listenerAttached).toBe(true)
- expect(listenerData.clickCount).toBe(1) // Should have incremented from the click
+ // If the element was destroyed and recreated, the listener would be lost
+ // and clickCount would be undefined (not 1)
+ expect(afterNavigationData.listenerAttached).toBe(true)
+ expect(afterNavigationData.clickCount).toBe(1)
})
})
From 4c79f684c16ef5f1b7e5ee78b1622b5399307c57 Mon Sep 17 00:00:00 2001
From: Kevin Brown
Date: Sun, 9 Nov 2025 00:08:06 +0300
Subject: [PATCH 040/341] Refactor from var() classes to Tailwind theme classes
in Newletter and ContactForm
---
.cache/pages.json | 11 +-
.../CallToAction/Newsletter/index.astro | 18 +-
src/components/ContactForm/index.astro | 182 +++++++++---------
3 files changed, 102 insertions(+), 109 deletions(-)
diff --git a/.cache/pages.json b/.cache/pages.json
index b8605f6d4..4844728a4 100644
--- a/.cache/pages.json
+++ b/.cache/pages.json
@@ -32,15 +32,10 @@
"offline",
"privacy",
{
- "services": [
- "create-custom-font-sets",
- "overview"
- ]
+ "services": ["create-custom-font-sets", "overview"]
},
{
- "social-shares": [
- "template"
- ]
+ "social-shares": ["template"]
},
{
"tags": [
@@ -55,4 +50,4 @@
"typescript"
]
}
-]
\ No newline at end of file
+]
diff --git a/src/components/CallToAction/Newsletter/index.astro b/src/components/CallToAction/Newsletter/index.astro
index 2834b2737..7a753e02c 100644
--- a/src/components/CallToAction/Newsletter/index.astro
+++ b/src/components/CallToAction/Newsletter/index.astro
@@ -36,15 +36,15 @@ const {
} = Astro.props
---
-
+
-
+
{title}
{description}
@@ -75,7 +75,7 @@ const {
id="newsletter-email"
name="email"
placeholder={placeholder}
- class="flex-1 px-6 py-4 border-2 border-[var(--color-border)] rounded-xl bg-[var(--color-bg)] text-[var(--color-text)] placeholder:text-[var(--color-text-offset)] focus:border-[var(--color-primary)] focus:ring-0 focus:outline-none transition-colors duration-200"
+ class="flex-1 px-6 py-4 border-2 border-border rounded-xl bg-bg text-text placeholder:text-text-offset focus:border-primary focus:ring-0 focus:outline-none transition-colors duration-200"
required
aria-label="Email address for newsletter subscription"
aria-describedby="newsletter-message"
@@ -83,7 +83,7 @@ const {
{buttonText}
diff --git a/src/components/ContactForm/index.astro b/src/components/ContactForm/index.astro
index 79d605e74..220842dd3 100644
--- a/src/components/ContactForm/index.astro
+++ b/src/components/ContactForm/index.astro
@@ -5,34 +5,34 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
-
+
-
+
Tell us about your project
-
+
We'll get back to you within 24 hours with a detailed proposal.
-
-
-
+
+
+
Contact Information
-
Full Name *
-
Email Address *
@@ -57,28 +57,28 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
-
-
-
+
+
+
Project Details
@@ -96,12 +96,12 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
Project Type Project Type
Select project type
New Website
@@ -116,35 +116,33 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
-
- Budget Range
+ Budget Range *
Select budget range
- Under $5,000
$5,000 - $10,000
$10,000 - $25,000
$25,000 - $50,000
- $50,000 - $100,000
- $100,000+
- Let's discuss
+ $50,000+
- Timeline Project Timeline *
Select timeline
ASAP
@@ -157,19 +155,19 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
-
Project Description *
-
@@ -177,25 +175,25 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
-
-
+
+
Project Files (Optional)
-
+
Upload any relevant files like mockups, specifications, existing designs, or reference
materials.
-
+
Supported formats: PDF, DOC, DOCX, JPG, PNG, GIF, WebP, MP3, WAV, MP4,
MOV, ZIP, TXT
-
+
Max file size: 10MB per file • Max files: 5
@@ -204,21 +202,21 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'
-
+
Data Retention: Your contact information will be stored for 90 days to respond to your inquiry.
-
Your Rights: You can request access, correction, or deletion of your data at any time by contacting kevin@webstackbuilders.com .
+
Your Rights: You can request access, correction, or deletion of your data at any time by contacting kevin@webstackbuilders.com .
Send Project Details
@@ -244,16 +242,16 @@ import GDPRConsent from '@components/GDPR/Consent/index.astro'