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

320 lines
8.2 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';
declare global {
interface HTMLElementTagNameMap {
'dees-screensaver': DeesScreensaver;
}
}
// 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('dees-screensaver')
export class DeesScreensaver extends DeesElement {
public static demo = () => html`<dees-screensaver .active=${true}></dees-screensaver>`;
// Instance management
private static instance: DeesScreensaver | null = null;
public static async show(): Promise<DeesScreensaver> {
if (DeesScreensaver.instance) {
DeesScreensaver.instance.active = true;
return DeesScreensaver.instance;
}
const screensaver = new DeesScreensaver();
screensaver.active = true;
document.body.appendChild(screensaver);
DeesScreensaver.instance = screensaver;
return screensaver;
}
public static hide(): void {
if (DeesScreensaver.instance) {
DeesScreensaver.instance.active = false;
}
}
public static destroy(): void {
if (DeesScreensaver.instance) {
DeesScreensaver.instance.remove();
DeesScreensaver.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.8s ease;
}
:host([active]) {
pointer-events: all;
opacity: 1;
}
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: hsl(240 10% 4%);
}
.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;
}
.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;
}
}
`,
];
@property({ type: Boolean, reflect: true })
accessor active = false;
@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;
constructor() {
super();
this.updateTime();
}
public render(): TemplateResult {
return html`
<div class="backdrop" @click=${this.handleClick}></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>
`;
}
public firstUpdated(): void {
this.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement;
}
async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.startAnimation();
this.startTimeUpdate();
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.stopAnimation();
this.stopTimeUpdate();
}
updated(changedProperties: Map<string, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('active')) {
if (this.active) {
this.startAnimation();
this.startTimeUpdate();
} else {
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 handleClick(): void {
this.dispatchEvent(new CustomEvent('screensaver-click'));
// Optionally hide on click
this.active = false;
}
}