import { customElement, DeesElement, type TemplateResult, html, property, css, cssManager, state, } from '@design.estate/dees-element'; import { zIndexLayers } from '../00zindex.js'; import { demo } from './eco-screensaver.demo.js'; declare global { interface HTMLElementTagNameMap { 'eco-screensaver': EcoScreensaver; } } // Subtle shadcn-inspired color palette const colors = [ 'hsl(0 0% 98%)', // zinc-50 'hsl(240 5% 65%)', // zinc-400 'hsl(240 4% 46%)', // zinc-500 'hsl(240 5% 34%)', // zinc-600 'hsl(217 91% 60%)', // blue-500 'hsl(142 71% 45%)', // green-500 ]; @customElement('eco-screensaver') export class EcoScreensaver extends DeesElement { public static demo = demo; // Instance management public static instance: EcoScreensaver | null = null; public static async show(): Promise { if (EcoScreensaver.instance) { EcoScreensaver.instance.active = true; return EcoScreensaver.instance; } const screensaver = new EcoScreensaver(); screensaver.active = true; document.body.appendChild(screensaver); EcoScreensaver.instance = screensaver; return screensaver; } public static hide(): void { if (EcoScreensaver.instance) { EcoScreensaver.instance.active = false; } } public static destroy(): void { if (EcoScreensaver.instance) { EcoScreensaver.instance.remove(); EcoScreensaver.instance = null; } } // Styles public static styles = [ cssManager.defaultStyles, css` :host { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: ${zIndexLayers.overlay.screensaver}; pointer-events: none; opacity: 0; transition: opacity 0.3s ease; } :host([active]) { pointer-events: all; opacity: 1; } .backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: hsl(240 10% 4%); opacity: 0; transition: opacity 1.5s ease 0.5s; } :host([active]) .backdrop { opacity: 1; } .vignette { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient( circle at center, transparent 0%, transparent 5%, hsla(240 10% 4% / 0.2) 20%, hsla(240 10% 4% / 0.6) 40%, hsla(240 10% 4% / 0.9) 60%, hsl(240 10% 4%) 80% ); transform: scale(3); opacity: 0; transition: transform 2s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease; } :host([active]) .vignette { transform: scale(1); opacity: 1; } /* Container for all screensaver visuals - gets masked on dismiss */ .screensaver-content { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .time-container { position: absolute; top: 0; left: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; user-select: none; white-space: nowrap; will-change: transform; opacity: 0; transition: opacity 0.8s ease; transition-delay: 0s; } :host([active]) .time-container { opacity: 1; transition-delay: 1.2s; } .time { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 96px; font-weight: 200; letter-spacing: -2px; line-height: 1; transition: color 1.5s ease; } .date { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 18px; font-weight: 400; letter-spacing: 0.5px; opacity: 0.5; text-transform: uppercase; transition: color 1.5s ease; } @media (max-width: 600px) { .time { font-size: 48px; letter-spacing: -1px; } .date { font-size: 14px; } } .hint { position: fixed; bottom: 32px; left: 50%; transform: translateX(-50%) translateY(20px); padding: 12px 24px; background: hsl(240 6% 15%); border: 1px solid hsl(240 5% 26%); border-radius: 8px; color: hsl(0 0% 90%); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; font-weight: 500; opacity: 0; pointer-events: none; transition: opacity 0.3s ease, transform 0.3s ease; z-index: 10; } .hint.visible { opacity: 1; transform: translateX(-50%) translateY(0); } `, ]; @property({ type: Boolean, reflect: true }) accessor active = false; @property({ type: Number }) accessor delay = 0; // milliseconds before activation (0 = no delay) @state() accessor currentTime = ''; @state() accessor currentDate = ''; @state() accessor currentColor = colors[0]; // Animation state - non-reactive for smooth animation private posX = 100; private posY = 100; private velocityX = 0.3; private velocityY = 0.2; private animationId: number | null = null; private timeUpdateInterval: ReturnType | null = null; private colorIndex = 0; private elementWidth = 280; private elementHeight = 140; private hasBounced = false; private timeContainerEl: HTMLElement | null = null; private vignetteEl: HTMLElement | null = null; private contentEl: HTMLElement | null = null; private delayTimeoutId: ReturnType | null = null; private boundResetDelayTimer: () => void; private boundShowHint: () => void; private hintEl: HTMLElement | null = null; private hintTimeoutId: ReturnType | null = null; private hintVisible = false; constructor() { super(); this.updateTime(); this.boundResetDelayTimer = this.resetDelayTimer.bind(this); this.boundShowHint = this.showHint.bind(this); } public render(): TemplateResult { return html`
${this.currentTime} ${this.currentDate}
Click to exit screensaver
`; } public firstUpdated(): void { this.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement; this.vignetteEl = this.shadowRoot?.querySelector('.vignette') as HTMLElement; this.contentEl = this.shadowRoot?.querySelector('.screensaver-content') as HTMLElement; this.hintEl = this.shadowRoot?.querySelector('.hint') as HTMLElement; } async connectedCallback(): Promise { await super.connectedCallback(); // If delay is set, start the delay timer and listen for activity if (this.delay > 0 && !this.active) { this.startDelayTimer(); this.addActivityListeners(); } else if (this.active) { this.startAnimation(); this.startTimeUpdate(); } } async disconnectedCallback(): Promise { await super.disconnectedCallback(); this.stopAnimation(); this.stopTimeUpdate(); this.stopDelayTimer(); this.removeActivityListeners(); } updated(changedProperties: Map): void { super.updated(changedProperties); if (changedProperties.has('active')) { if (this.active) { // Reset mask for fresh activation if (this.contentEl) { this.contentEl.style.maskImage = ''; this.contentEl.style.webkitMaskImage = ''; } // Hide hint when freshly activated this.hideHint(); // Listen for mouse movement to show hint window.addEventListener('mousemove', this.boundShowHint); this.startAnimation(); this.startTimeUpdate(); } else { window.removeEventListener('mousemove', this.boundShowHint); this.hideHint(); this.stopAnimation(); this.stopTimeUpdate(); } } } private updateTime(): void { const now = new Date(); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); this.currentTime = `${hours}:${minutes}`; // Format date like "Monday, January 6" const options: Intl.DateTimeFormatOptions = { weekday: 'long', month: 'long', day: 'numeric', }; this.currentDate = now.toLocaleDateString('en-US', options); } private startTimeUpdate(): void { if (this.timeUpdateInterval) return; this.updateTime(); this.timeUpdateInterval = setInterval(() => this.updateTime(), 1000); } private stopTimeUpdate(): void { if (this.timeUpdateInterval) { clearInterval(this.timeUpdateInterval); this.timeUpdateInterval = null; } } private startAnimation(): void { if (this.animationId) return; // Initialize position randomly const maxX = window.innerWidth - this.elementWidth; const maxY = window.innerHeight - this.elementHeight; this.posX = Math.random() * Math.max(0, maxX); this.posY = Math.random() * Math.max(0, maxY); // Randomize initial direction - very slow, elegant movement this.velocityX = (Math.random() > 0.5 ? 1 : -1) * (0.2 + Math.random() * 0.15); this.velocityY = (Math.random() > 0.5 ? 1 : -1) * (0.15 + Math.random() * 0.1); // Reset bounce state this.hasBounced = false; const animate = () => { if (!this.active) { this.animationId = null; return; } const maxX = window.innerWidth - this.elementWidth; const maxY = window.innerHeight - this.elementHeight; // Update position this.posX += this.velocityX; this.posY += this.velocityY; // Track if we're currently at a boundary let atBoundary = false; // Bounce off walls if (this.posX <= 0) { this.posX = 0; this.velocityX = Math.abs(this.velocityX); atBoundary = true; } else if (this.posX >= maxX) { this.posX = maxX; this.velocityX = -Math.abs(this.velocityX); atBoundary = true; } if (this.posY <= 0) { this.posY = 0; this.velocityY = Math.abs(this.velocityY); atBoundary = true; } else if (this.posY >= maxY) { this.posY = maxY; this.velocityY = -Math.abs(this.velocityY); atBoundary = true; } // Change color only once per bounce (when entering boundary, not while at it) if (atBoundary && !this.hasBounced) { this.hasBounced = true; this.colorIndex = (this.colorIndex + 1) % colors.length; this.currentColor = colors[this.colorIndex]; } else if (!atBoundary) { this.hasBounced = false; } // Direct DOM manipulation for smooth position updates (no re-render) if (this.timeContainerEl) { this.timeContainerEl.style.transform = `translate(${this.posX}px, ${this.posY}px)`; } this.animationId = requestAnimationFrame(animate); }; this.animationId = requestAnimationFrame(animate); } private stopAnimation(): void { if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; } } private startDelayTimer(): void { this.stopDelayTimer(); this.delayTimeoutId = setTimeout(() => { this.removeActivityListeners(); this.active = true; }, this.delay); } private stopDelayTimer(): void { if (this.delayTimeoutId) { clearTimeout(this.delayTimeoutId); this.delayTimeoutId = null; } } private resetDelayTimer(): void { if (this.delay > 0 && !this.active) { this.startDelayTimer(); } } private addActivityListeners(): void { window.addEventListener('mousemove', this.boundResetDelayTimer); window.addEventListener('keydown', this.boundResetDelayTimer); window.addEventListener('click', this.boundResetDelayTimer); window.addEventListener('touchstart', this.boundResetDelayTimer); window.addEventListener('scroll', this.boundResetDelayTimer); } private removeActivityListeners(): void { window.removeEventListener('mousemove', this.boundResetDelayTimer); window.removeEventListener('keydown', this.boundResetDelayTimer); window.removeEventListener('click', this.boundResetDelayTimer); window.removeEventListener('touchstart', this.boundResetDelayTimer); window.removeEventListener('scroll', this.boundResetDelayTimer); } private showHint(): void { if (!this.active || this.hintVisible) return; this.hintVisible = true; if (this.hintEl) { this.hintEl.classList.add('visible'); } // Auto-hide after 3 seconds if (this.hintTimeoutId) { clearTimeout(this.hintTimeoutId); } this.hintTimeoutId = setTimeout(() => { this.hideHint(); }, 3000); } private hideHint(): void { this.hintVisible = false; if (this.hintEl) { this.hintEl.classList.remove('visible'); } if (this.hintTimeoutId) { clearTimeout(this.hintTimeoutId); this.hintTimeoutId = null; } } private handleClick(event: MouseEvent | TouchEvent): void { // Get click/touch position let x: number, y: number; if (event instanceof TouchEvent && event.changedTouches.length > 0) { x = event.changedTouches[0].clientX; y = event.changedTouches[0].clientY; } else if (event instanceof MouseEvent) { x = event.clientX; y = event.clientY; } else { // Fallback to center x = window.innerWidth / 2; y = window.innerHeight / 2; } this.dispatchEvent(new CustomEvent('screensaver-click', { detail: { x, y } })); // Animate circle reveal from click position if (this.contentEl) { const duration = 700; // ms - slower for dramatic effect const startTime = performance.now(); const maxSize = Math.max(window.innerWidth, window.innerHeight) * 1.5; const startSize = 20; // Start with small visible circle const animate = (currentTime: number) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Ease-in curve: starts slow, picks up speed const eased = progress * progress * progress; const size = startSize + eased * (maxSize - startSize); this.contentEl!.style.maskImage = `radial-gradient(circle ${size}px at ${x}px ${y}px, transparent 100%, black 100%)`; this.contentEl!.style.webkitMaskImage = `radial-gradient(circle ${size}px at ${x}px ${y}px, transparent 100%, black 100%)`; if (progress < 1) { requestAnimationFrame(animate); } else { // Animation complete - remove screensaver this.active = false; EcoScreensaver.destroy(); } }; requestAnimationFrame(animate); } else { this.active = false; } } }