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 */} 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 ---- - - - - - - - - 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 + + + + + + + \ 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 ` -
  • - -
  • -` -} - -/** - * 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 */}