Files
catalog/ts_web/elements/eco-screensaver/eco-screensaver.ts

545 lines
15 KiB
TypeScript
Raw Normal View History

2026-01-06 02:24:12 +00:00
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';
2026-01-06 02:24:12 +00:00
declare global {
interface HTMLElementTagNameMap {
'eco-screensaver': EcoScreensaver;
2026-01-06 02:24:12 +00:00
}
}
// 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;
2026-01-06 02:24:12 +00:00
// Instance management
public static instance: EcoScreensaver | null = null;
2026-01-06 02:24:12 +00:00
public static async show(): Promise<EcoScreensaver> {
if (EcoScreensaver.instance) {
EcoScreensaver.instance.active = true;
return EcoScreensaver.instance;
2026-01-06 02:24:12 +00:00
}
const screensaver = new EcoScreensaver();
2026-01-06 02:24:12 +00:00
screensaver.active = true;
document.body.appendChild(screensaver);
EcoScreensaver.instance = screensaver;
2026-01-06 02:24:12 +00:00
return screensaver;
}
public static hide(): void {
if (EcoScreensaver.instance) {
EcoScreensaver.instance.active = false;
2026-01-06 02:24:12 +00:00
}
}
public static destroy(): void {
if (EcoScreensaver.instance) {
EcoScreensaver.instance.remove();
EcoScreensaver.instance = null;
2026-01-06 02:24:12 +00:00
}
}
// 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;
2026-01-06 02:24:12 +00:00
}
: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%;
2026-01-06 02:24:12 +00:00
}
.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;
2026-01-06 02:24:12 +00:00
}
.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);
}
2026-01-06 02:24:12 +00:00
`,
];
@property({ type: Boolean, reflect: true })
accessor active = false;
@property({ type: Number })
accessor delay = 0; // milliseconds before activation (0 = no delay)
2026-01-06 02:24:12 +00:00
@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<typeof setInterval> | 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<typeof setTimeout> | null = null;
private boundResetDelayTimer: () => void;
private boundShowHint: () => void;
private hintEl: HTMLElement | null = null;
private hintTimeoutId: ReturnType<typeof setTimeout> | null = null;
private hintVisible = false;
2026-01-06 02:24:12 +00:00
constructor() {
super();
this.updateTime();
this.boundResetDelayTimer = this.resetDelayTimer.bind(this);
this.boundShowHint = this.showHint.bind(this);
2026-01-06 02:24:12 +00:00
}
public render(): TemplateResult {
return html`
<div class="screensaver-content" @click=${this.handleClick}>
<div class="backdrop"></div>
<div class="vignette"></div>
<div class="time-container">
<span class="time" style="color: ${this.currentColor};">${this.currentTime}</span>
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
</div>
2026-01-06 02:24:12 +00:00
</div>
<div class="hint">Click to exit screensaver</div>
2026-01-06 02:24:12 +00:00
`;
}
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;
2026-01-06 02:24:12 +00:00
}
async connectedCallback(): Promise<void> {
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();
}
2026-01-06 02:24:12 +00:00
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.stopAnimation();
this.stopTimeUpdate();
this.stopDelayTimer();
this.removeActivityListeners();
2026-01-06 02:24:12 +00:00
}
updated(changedProperties: Map<string, unknown>): 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);
2026-01-06 02:24:12 +00:00
this.startAnimation();
this.startTimeUpdate();
} else {
window.removeEventListener('mousemove', this.boundShowHint);
this.hideHint();
2026-01-06 02:24:12 +00:00
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;
}
2026-01-06 02:24:12 +00:00
}
}