Add first-visit theme preference dialog and integrate into base layout
- Introduce ThemePreferenceDialog.astro with two‑step flow and reduced motion support. - Modify BaseLayout.astro to render dialog for first‑time visitors. - Ensure dialog respects user preferences and theme toggles. Hubert The Eunuch
This commit is contained in:
parent
861263437c
commit
3326723293
252
src/components/ThemePreferenceDialog.astro
Normal file
252
src/components/ThemePreferenceDialog.astro
Normal file
@ -0,0 +1,252 @@
|
||||
---
|
||||
// First-visit theme preference dialog
|
||||
// Shows only to true first-time visitors
|
||||
// Two-step flow: theme selection → remember preference
|
||||
---
|
||||
|
||||
<div
|
||||
id="theme-preference-dialog"
|
||||
class="fixed bottom-4 right-4 md:bottom-6 md:right-6 z-[100] w-[calc(100vw-2rem)] md:w-auto max-w-md translate-y-[120%] opacity-0 transition-all duration-500 ease-out"
|
||||
style="will-change: transform, opacity;"
|
||||
>
|
||||
<!-- Dialog container -->
|
||||
<div class="relative p-6 border border-[var(--theme-border-primary)] bg-[var(--theme-bg-primary)] shadow-2xl backdrop-blur-sm">
|
||||
<!-- Corner accents (smaller) -->
|
||||
<div class="absolute -top-px -left-px w-6 h-6 border-t-2 border-l-2 border-brand-accent"></div>
|
||||
<div class="absolute -top-px -right-px w-6 h-6 border-t-2 border-r-2 border-brand-accent"></div>
|
||||
<div class="absolute -bottom-px -left-px w-6 h-6 border-b-2 border-l-2 border-brand-accent"></div>
|
||||
<div class="absolute -bottom-px -right-px w-6 h-6 border-b-2 border-r-2 border-brand-accent"></div>
|
||||
|
||||
<!-- Step 1: Theme Selection -->
|
||||
<div id="theme-selection-step" class="transition-opacity duration-300">
|
||||
<!-- Technical header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1.5 h-1.5 bg-brand-accent animate-pulse"></div>
|
||||
<span class="font-mono text-[9px] text-brand-accent uppercase tracking-[0.3em]">
|
||||
THEME_SELECT
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id="close-dialog"
|
||||
class="text-[var(--theme-text-muted)] hover:text-brand-accent transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main heading -->
|
||||
<h2 class="text-xl font-bold uppercase tracking-tight mb-2 text-[var(--theme-text-primary)]">
|
||||
Choose Theme
|
||||
</h2>
|
||||
<p class="text-[var(--theme-text-secondary)] text-sm mb-6">
|
||||
Select your preferred visual mode
|
||||
</p>
|
||||
|
||||
<!-- Theme preview cards -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<!-- Dark Theme Card -->
|
||||
<button
|
||||
type="button"
|
||||
id="select-dark-theme"
|
||||
class="group relative p-4 border border-[var(--theme-border-primary)] hover:border-brand-accent transition-all duration-300 text-left"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-[#0B0D11] border border-white/20"></div>
|
||||
<span class="font-mono text-[10px] uppercase tracking-wide text-[var(--theme-text-primary)]">
|
||||
Dark
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Light Theme Card -->
|
||||
<button
|
||||
type="button"
|
||||
id="select-light-theme"
|
||||
class="group relative p-4 border border-[var(--theme-border-primary)] hover:border-brand-accent transition-all duration-300 text-left"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-[#efefef] border border-black/10"></div>
|
||||
<span class="font-mono text-[10px] uppercase tracking-wide text-[var(--theme-text-primary)]">
|
||||
Light
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Remember Preference -->
|
||||
<div id="remember-preference-step" class="hidden transition-opacity duration-300">
|
||||
<!-- Technical header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1.5 h-1.5 bg-brand-accent animate-pulse"></div>
|
||||
<span class="font-mono text-[9px] text-brand-accent uppercase tracking-[0.3em]">
|
||||
SAVE_PREF
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main heading -->
|
||||
<h2 class="text-xl font-bold uppercase tracking-tight mb-2 text-[var(--theme-text-primary)]">
|
||||
Remember This?
|
||||
</h2>
|
||||
<p class="text-[var(--theme-text-secondary)] text-sm mb-6">
|
||||
Save your preference for future visits?
|
||||
</p>
|
||||
|
||||
<!-- Choice buttons -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="remember-yes"
|
||||
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent transition-all duration-300"
|
||||
>
|
||||
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)] group-hover:text-brand-dark">
|
||||
Save
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="remember-no"
|
||||
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-[var(--theme-text-subtle)] transition-all duration-300"
|
||||
>
|
||||
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)]">
|
||||
Session
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme preference dialog logic
|
||||
const dialog = document.getElementById('theme-preference-dialog');
|
||||
const themeSelectionStep = document.getElementById('theme-selection-step');
|
||||
const rememberPreferenceStep = document.getElementById('remember-preference-step');
|
||||
const selectDarkBtn = document.getElementById('select-dark-theme');
|
||||
const selectLightBtn = document.getElementById('select-light-theme');
|
||||
const rememberYesBtn = document.getElementById('remember-yes');
|
||||
const rememberNoBtn = document.getElementById('remember-no');
|
||||
const closeBtn = document.getElementById('close-dialog');
|
||||
|
||||
let selectedTheme: 'dark' | 'light' = 'dark';
|
||||
|
||||
// Check if user prefers reduced motion
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Check if we should show the dialog
|
||||
function shouldShowDialog(): boolean {
|
||||
const preferenceSet = localStorage.getItem('theme-preference-set');
|
||||
return preferenceSet === null;
|
||||
}
|
||||
|
||||
// Apply theme immediately
|
||||
function applyTheme(theme: 'dark' | 'light') {
|
||||
if (!prefersReducedMotion) {
|
||||
document.documentElement.classList.add('theme-transition');
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Update existing theme toggles if they exist
|
||||
const toggles = document.querySelectorAll('.theme-toggle-group');
|
||||
toggles.forEach(group => {
|
||||
const darkBtn = group.querySelector('.theme-toggle-dark');
|
||||
const lightBtn = group.querySelector('.theme-toggle-light');
|
||||
darkBtn?.classList.remove('is-current-theme');
|
||||
lightBtn?.classList.remove('is-current-theme');
|
||||
if (theme === 'dark') {
|
||||
darkBtn?.classList.add('is-current-theme');
|
||||
} else {
|
||||
lightBtn?.classList.add('is-current-theme');
|
||||
}
|
||||
});
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('theme-transition');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Show dialog with smooth slide-up animation
|
||||
function showDialog() {
|
||||
if (!dialog) return;
|
||||
|
||||
// Remove initial translate
|
||||
dialog.classList.remove('translate-y-[120%]');
|
||||
dialog.classList.add('translate-y-0', 'opacity-100');
|
||||
|
||||
// Set ARIA attributes for accessibility
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dialog.setAttribute('aria-labelledby', 'theme-selection-step');
|
||||
}
|
||||
|
||||
// Hide dialog with smooth slide-down animation
|
||||
function hideDialog() {
|
||||
if (!dialog) return;
|
||||
|
||||
dialog.classList.remove('translate-y-0', 'opacity-100');
|
||||
dialog.classList.add('translate-y-[120%]', 'opacity-0');
|
||||
|
||||
// Remove ARIA attributes
|
||||
dialog.removeAttribute('role');
|
||||
dialog.removeAttribute('aria-modal');
|
||||
dialog.removeAttribute('aria-labelledby');
|
||||
}
|
||||
|
||||
// Show step 2
|
||||
function showRememberStep() {
|
||||
if (!themeSelectionStep || !rememberPreferenceStep) return;
|
||||
themeSelectionStep.classList.add('hidden');
|
||||
rememberPreferenceStep.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Handle theme selection
|
||||
selectDarkBtn?.addEventListener('click', () => {
|
||||
selectedTheme = 'dark';
|
||||
applyTheme('dark');
|
||||
showRememberStep();
|
||||
});
|
||||
|
||||
selectLightBtn?.addEventListener('click', () => {
|
||||
selectedTheme = 'light';
|
||||
applyTheme('light');
|
||||
showRememberStep();
|
||||
});
|
||||
|
||||
// Handle remember preference - YES
|
||||
rememberYesBtn?.addEventListener('click', () => {
|
||||
localStorage.setItem('theme', selectedTheme);
|
||||
localStorage.setItem('theme-preference-set', 'true');
|
||||
hideDialog();
|
||||
});
|
||||
|
||||
// Handle remember preference - NO
|
||||
rememberNoBtn?.addEventListener('click', () => {
|
||||
sessionStorage.setItem('theme', selectedTheme);
|
||||
localStorage.setItem('theme-preference-set', 'true');
|
||||
hideDialog();
|
||||
});
|
||||
|
||||
// Handle close button
|
||||
closeBtn?.addEventListener('click', () => {
|
||||
// Set default dark theme and mark as set
|
||||
localStorage.setItem('theme-preference-set', 'true');
|
||||
hideDialog();
|
||||
});
|
||||
|
||||
// Show dialog on first visit with delay for smooth entrance
|
||||
if (shouldShowDialog()) {
|
||||
setTimeout(() => {
|
||||
showDialog();
|
||||
}, prefersReducedMotion ? 100 : 800);
|
||||
}
|
||||
</script>
|
||||
@ -5,6 +5,7 @@ import Footer from '../components/Footer.astro';
|
||||
import GridOverlay from '../components/GridOverlay.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import CustomCursor from '../components/CustomCursor';
|
||||
import ThemePreferenceDialog from '../components/ThemePreferenceDialog.astro';
|
||||
import { HTML_MARKER, SITE_TITLE, SITE_DESCRIPTION, SOCIAL_LINKS } from '../consts';
|
||||
|
||||
interface Props {
|
||||
@ -69,8 +70,13 @@ const personSchema = {
|
||||
<!-- Theme initialization script - runs before page render to prevent flash -->
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const stored = localStorage.getItem('theme');
|
||||
const theme = (stored === 'light' || stored === 'dark') ? stored : 'dark';
|
||||
// Check localStorage first (persistent), then sessionStorage (current session)
|
||||
const storedLocal = localStorage.getItem('theme');
|
||||
const storedSession = sessionStorage.getItem('theme');
|
||||
const theme =
|
||||
(storedLocal === 'light' || storedLocal === 'dark') ? storedLocal :
|
||||
(storedSession === 'light' || storedSession === 'dark') ? storedSession :
|
||||
'dark'; // Default fallback
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
@ -90,6 +96,9 @@ const personSchema = {
|
||||
</head>
|
||||
|
||||
<body class="antialiased selection:bg-brand-accent selection:text-brand-dark">
|
||||
<!-- First-visit theme preference dialog -->
|
||||
<ThemePreferenceDialog />
|
||||
|
||||
<!-- Only hydrate custom cursor on devices that can actually benefit from it -->
|
||||
<CustomCursor client:media="(pointer: fine) and (hover: hover)" />
|
||||
<GridOverlay />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user