feat: Add festive visual effects (#2252)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Introduces an effects system (fireworks, ghost, hearts, shamrocks,
snow), a new `effect` action, and optional date-based loading effects,
integrated into the card with docs, schema, editor, and tests.
> 
> - **Effects System**:
> - Add `EffectsController` and `advanced-camera-card-effects` host with
lazy-loaded effect modules (`fireworks`, `ghost`, `hearts`, `shamrocks`,
`snow`).
> - New base effect component with fade-in/out; SCSS and z-index
updates.
> - **Actions**:
> - New custom action `advanced_camera_card_action: effect` with
`effect_action` (`start`|`stop`|`toggle`).
>   - Wire into `ActionFactory` and add `EffectAction` executor.
> - **Card Integration**:
> - Mount effects host in `advanced-camera-card` and expose
`getEffectsControllerAPI()` via `CardController`.
> - Loading component can trigger date-based effects (e.g., New Year
fireworks) when enabled.
> - **Configuration & Editor**:
> - Add `performance.features.card_loading_effects` (default `true`)
alongside `card_loading_indicator`.
> - Update schemas, defaults, low-performance profile (disables
effects), and editor toggles.
> - **Docs & Examples**:
>   - Document `effect` action parameters and add menu example.
>   - Update performance docs with new option.
> - **Localization & Tests**:
>   - Update i18n strings for new performance option.
> - Add comprehensive tests for effects controller, action, factory,
config defaults/profiles, and utilities.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
cffd4304fe3bd22340316f9cec53e341379b1756. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Dermot Duffy
2025-12-06 18:21:44 -08:00
committed by GitHub
parent 993e288c14
commit 3279481b0e
58 changed files with 1943 additions and 104 deletions
+53
View File
@@ -0,0 +1,53 @@
import { CSSResultGroup, LitElement, PropertyValues, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import effectBaseStyle from '../../scss/effect-base.scss';
import { forceReflow } from '../../utils/basic';
export abstract class BaseEffectComponent extends LitElement {
@property({ type: Boolean })
public fadeIn = true;
protected firstUpdated(): void {
if (this.fadeIn) {
this._startFadeIn();
}
}
protected updated(changedProps: PropertyValues): void {
// Skip if this is the initial property setting (handled by firstUpdated).
if (changedProps.get('fadeIn') !== undefined) {
if (!this.fadeIn) {
this._setOpacity(1);
} else {
this._startFadeIn();
}
}
}
public startFadeOut(): Promise<void> {
return new Promise((resolve) => {
const handler = (ev: TransitionEvent) => {
if (ev.propertyName === 'opacity') {
this.removeEventListener('transitionend', handler);
resolve();
}
};
this.addEventListener('transitionend', handler);
this._setOpacity(0);
});
}
private _startFadeIn(): void {
this._setOpacity(0);
forceReflow(this);
this._setOpacity(1);
}
private _setOpacity(opacity: number): void {
this.style.opacity = `${opacity}`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(effectBaseStyle);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { LitElement, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { EffectsController } from '../../components-lib/effects/effects-controller';
import { EffectOptions } from '../../components-lib/effects/types';
import effectsStyle from '../../scss/effects.scss';
import { EffectName, EffectsControllerAPI } from '../../types';
@customElement('advanced-camera-card-effects')
export class AdvancedCameraCardEffects
extends LitElement
implements EffectsControllerAPI
{
protected _controller = new EffectsController();
public async startEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
await this._controller.startEffect(effect, options);
}
public stopEffect(effect: EffectName): void {
this._controller.stopEffect(effect);
}
public async toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
await this._controller.toggleEffect(effect, options);
}
protected updated(): void {
this._controller.setContainer(this.renderRoot);
}
static get styles() {
return unsafeCSS(effectsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-effects': AdvancedCameraCardEffects;
}
}
+172
View File
@@ -0,0 +1,172 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import fireworkBurstStyle from '../../scss/firework-burst.scss';
import './firework-particle';
const BASE_PARTICLE_COUNT = 36;
const FIREWORK_COLORS = [
'#ff2222', // Bright Red
'#ffdd00', // Bright Gold
'#22ff22', // Bright Green
'#22aaff', // Bright Blue
'#ff22ff', // Bright Magenta
'#ffffff', // White
'#ffaa00', // Bright Orange
'#22ffff', // Bright Cyan
'#ff66aa', // Pink
'#aaaaff', // Lavender
];
type BurstType = 'standard' | 'ring' | 'palm';
interface ParticleConfig {
id: number;
angle: number;
distance: number;
color: string;
size: string;
duration: string;
delay: string;
gravity: number;
}
@customElement('advanced-camera-card-firework-burst')
export class AdvancedCameraCardFireworkBurst extends LitElement {
@property({ type: String })
public posX = '50%';
@property({ type: String })
public posY = '50%';
@property({ type: String })
public delay = '0s';
@property({ type: Number })
public scale = 1.0;
@property({ type: String })
public burstType: BurstType = 'standard';
private _particles: ParticleConfig[] = [];
private _color: string = '';
private _initialized = false;
private _initializeParticles(): void {
if (this._initialized) {
return;
}
this._initialized = true;
this._color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
switch (this.burstType) {
case 'ring':
this._initializeRingParticles();
break;
case 'palm':
this._initializePalmParticles();
break;
default:
this._initializeStandardParticles();
}
}
private _initializeStandardParticles(): void {
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale);
this._particles = Array.from({ length: particleCount }, (_, i) => {
const baseAngle = (360 / particleCount) * i;
const angleVariation = (Math.random() - 0.5) * 20;
return {
id: i,
angle: baseAngle + angleVariation,
distance: (Math.random() * 80 + 100) * this.scale,
color: this._color,
size: `${(Math.random() * 12 + 18) * this.scale}px`,
duration: `${Math.random() * 0.5 + 1.8}s`,
delay: `${Math.random() * 0.08}s`,
gravity: 0,
};
});
}
private _initializeRingParticles(): void {
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale * 1.5);
const ringDistance = (120 + Math.random() * 40) * this.scale;
this._particles = Array.from({ length: particleCount }, (_, i) => {
const baseAngle = (360 / particleCount) * i;
return {
id: i,
angle: baseAngle,
distance: ringDistance + (Math.random() - 0.5) * 10,
color: this._color,
size: `${(Math.random() * 8 + 14) * this.scale}px`,
duration: `${Math.random() * 0.3 + 1.5}s`,
delay: `${Math.random() * 0.02}s`,
gravity: 0,
};
});
}
private _initializePalmParticles(): void {
const particleCount = Math.round(18 * this.scale);
this._particles = Array.from({ length: particleCount }, (_, i) => {
const baseAngle = (360 / particleCount) * i;
const angleVariation = (Math.random() - 0.5) * 15;
return {
id: i,
angle: baseAngle + angleVariation,
distance: (Math.random() * 60 + 140) * this.scale,
color: this._color,
size: `${(Math.random() * 10 + 20) * this.scale}px`,
duration: `${Math.random() * 0.8 + 2.2}s`,
delay: `${Math.random() * 0.05}s`,
gravity: 80 + Math.random() * 60,
};
});
}
protected render(): TemplateResult {
this._initializeParticles();
return html`
${repeat(
this._particles,
(p) => p.id,
(p) => html`
<advanced-camera-card-firework-particle
.angle=${p.angle}
.distance=${p.distance}
.color=${p.color}
.size=${p.size}
.duration=${p.duration}
.delay=${p.delay}
.gravity=${p.gravity}
></advanced-camera-card-firework-particle>
`,
)}
`;
}
protected updated(): void {
this.style.setProperty('--pos-x', this.posX);
this.style.setProperty('--pos-y', this.posY);
this.style.setProperty('--delay', this.delay);
}
static get styles(): CSSResultGroup {
return unsafeCSS(fireworkBurstStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-firework-burst': AdvancedCameraCardFireworkBurst;
}
}
@@ -0,0 +1,54 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import fireworkParticleStyle from '../../scss/firework-particle.scss';
@customElement('advanced-camera-card-firework-particle')
export class AdvancedCameraCardFireworkParticle extends LitElement {
@property({ type: Number })
public angle = 0;
@property({ type: Number })
public distance = 100;
@property({ type: String })
public color = '#ffcc00';
@property({ type: String })
public size = '4px';
@property({ type: String })
public duration = '1.5s';
@property({ type: String })
public delay = '0s';
@property({ type: Number })
public gravity = 0;
protected render(): TemplateResult {
return html`<span class="spark">✦</span>`;
}
protected updated(): void {
const radians = (this.angle * Math.PI) / 180;
const endX = Math.cos(radians) * this.distance;
const endY = Math.sin(radians) * this.distance + this.gravity;
this.style.setProperty('--end-x', `${endX}px`);
this.style.setProperty('--end-y', `${endY}px`);
this.style.setProperty('--color', this.color);
this.style.setProperty('--size', this.size);
this.style.setProperty('--duration', this.duration);
this.style.setProperty('--delay', this.delay);
}
static get styles(): CSSResultGroup {
return unsafeCSS(fireworkParticleStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-firework-particle': AdvancedCameraCardFireworkParticle;
}
}
+115
View File
@@ -0,0 +1,115 @@
import { html, TemplateResult } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { Timer } from '../../utils/timer';
import { BaseEffectComponent } from './base';
import './firework-burst';
const INITIAL_BURST_COUNT = 3;
const MIN_BURST_COUNT = 2;
const MAX_BURST_COUNT = 5;
const BURST_CYCLE_SECONDS = 2.0;
const MAX_BURST_DELAY_SECONDS = 1.2;
type BurstType = 'standard' | 'ring' | 'palm';
interface BurstConfig {
id: number;
posX: string;
posY: string;
delay: string;
scale: number;
burstType: BurstType;
}
@customElement('advanced-camera-card-effect-fireworks')
export class AdvancedCameraCardEffectFireworks extends BaseEffectComponent {
@state()
private _bursts: BurstConfig[] = [];
private _burstIdCounter = 0;
private _timer = new Timer();
public connectedCallback(): void {
super.connectedCallback();
this._startFireworks();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopFireworks();
}
private _startFireworks(): void {
this._createBursts(INITIAL_BURST_COUNT);
this._timer.startRepeated(BURST_CYCLE_SECONDS, () => {
const count =
Math.floor(Math.random() * (MAX_BURST_COUNT - MIN_BURST_COUNT + 1)) +
MIN_BURST_COUNT;
this._createBursts(count);
});
}
private _stopFireworks(): void {
this._timer.stop();
}
private _createBursts(count: number): void {
const newBursts: BurstConfig[] = [];
for (let i = 0; i < count; i++) {
newBursts.push(this._createBurstConfig());
}
this._bursts = newBursts;
}
private _createBurstConfig(): BurstConfig {
// 20% chance of a big burst (scale 1.5-2.0), otherwise normal (scale 0.8-1.2)
const isBig = Math.random() < 0.2;
const scale = isBig ? Math.random() * 0.5 + 1.5 : Math.random() * 0.4 + 0.8;
// Burst type distribution: 60% standard, 25% ring, 15% palm
const typeRoll = Math.random();
let burstType: BurstType;
if (typeRoll < 0.6) {
burstType = 'standard';
} else if (typeRoll < 0.85) {
burstType = 'ring';
} else {
burstType = 'palm';
}
return {
id: this._burstIdCounter++,
posX: `${Math.random() * 80 + 10}%`,
posY: `${Math.random() * 60 + 20}%`,
delay: `${Math.random() * MAX_BURST_DELAY_SECONDS}s`,
scale,
burstType,
};
}
protected render(): TemplateResult {
return html`
${repeat(
this._bursts,
(burst) => burst.id,
(burst) => html`
<advanced-camera-card-firework-burst
.posX=${burst.posX}
.posY=${burst.posY}
.delay=${burst.delay}
.scale=${burst.scale}
.burstType=${burst.burstType}
></advanced-camera-card-firework-burst>
`,
)}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-effect-fireworks': AdvancedCameraCardEffectFireworks;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { BaseEffectComponent } from './base';
import ghostStyle from '../../scss/ghost.scss';
@customElement('advanced-camera-card-effect-ghost')
export class AdvancedCameraCardEffectGhost extends BaseEffectComponent {
protected render(): TemplateResult {
return html`<span class="ghost">👻</span>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(ghostStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-effect-ghost': AdvancedCameraCardEffectGhost;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import heartStyle from '../../scss/heart.scss';
@customElement('advanced-camera-card-heart')
export class AdvancedCameraCardHeart extends LitElement {
@property({ type: String })
public char = '❤️';
@property({ type: String })
public size = '1em';
@property({ type: Number })
public hue = 340;
@property({ type: Number })
public saturation = 80;
@property({ type: Number })
public lightness = 55;
@property({ type: Number })
public maxOpacity = 1;
@property({ type: String })
public pulseDuration = '3s';
@property({ type: String })
public pulseDelay = '0s';
@property({ type: String })
public startX = '0%';
@property({ type: String })
public startY = '0%';
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener('animationiteration', this._handleAnimationIteration);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('animationiteration', this._handleAnimationIteration);
}
private _handleAnimationIteration = (ev: AnimationEvent): void => {
if (ev.animationName === 'pulse') {
this.startX = `${Math.random() * 100}%`;
this.startY = `${Math.random() * 100}%`;
}
};
protected render(): TemplateResult {
return html`${this.char}`;
}
protected updated(): void {
this.style.setProperty('--size', this.size);
this.style.setProperty('--hue', `${this.hue}`);
this.style.setProperty('--saturation', `${this.saturation}`);
this.style.setProperty('--lightness', `${this.lightness}`);
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
this.style.setProperty('--pulse-duration', this.pulseDuration);
this.style.setProperty('--pulse-delay', this.pulseDelay);
this.style.setProperty('--start-x', this.startX);
this.style.setProperty('--start-y', this.startY);
}
static get styles(): CSSResultGroup {
return unsafeCSS(heartStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-heart': AdvancedCameraCardHeart;
}
}
+83
View File
@@ -0,0 +1,83 @@
import { html, TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { BaseEffectComponent } from './base';
import './heart';
const HEART_CHARS = ['❤️', '💖', '💕', '💗', '💓'];
const HEART_MAX_COUNT = 50;
interface HeartConfig {
id: number;
char: string;
size: string;
hue: number;
saturation: number;
lightness: number;
maxOpacity: number;
pulseDuration: string;
pulseDelay: string;
startX: string;
startY: string;
}
@customElement('advanced-camera-card-effect-hearts')
export class AdvancedCameraCardEffectHearts extends BaseEffectComponent {
private _hearts: HeartConfig[];
constructor() {
super();
this._hearts = Array.from({ length: HEART_MAX_COUNT }, (_, i) => {
const duration = Math.random() * 4 + 4;
const delay = -Math.random() * duration * 0.8;
return {
id: i,
char: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],
size: `${Math.random() * 1.5 + 0.5}em`,
hue: Math.random() * 40 + 320,
saturation: Math.random() * 40 + 60,
lightness: Math.random() * 20 + 45,
maxOpacity: Math.random() * 0.5 + 0.2,
pulseDuration: `${duration}s`,
pulseDelay: `${delay}s`,
startX: `${Math.random() * 100}%`,
startY: `${Math.random() * 100}%`,
};
});
}
protected render(): TemplateResult {
return html`
${repeat(
this._hearts,
(heart) => heart.id,
(heart) => html`
<advanced-camera-card-heart
.char=${heart.char}
.size=${heart.size}
.hue=${heart.hue}
.saturation=${heart.saturation}
.lightness=${heart.lightness}
.maxOpacity=${heart.maxOpacity}
.pulseDuration=${heart.pulseDuration}
.pulseDelay=${heart.pulseDelay}
.startX=${heart.startX}
.startY=${heart.startY}
></advanced-camera-card-heart>
`,
)}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-effect-hearts': AdvancedCameraCardEffectHearts;
}
}
+67
View File
@@ -0,0 +1,67 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import shamrockStyle from '../../scss/shamrock.scss';
@customElement('advanced-camera-card-shamrock')
export class AdvancedCameraCardShamrock extends LitElement {
@property({ type: String })
public char = '☘️';
@property({ type: String })
public size = '1em';
@property({ type: Number })
public maxOpacity = 1;
@property({ type: String })
public pulseDuration = '3s';
@property({ type: String })
public pulseDelay = '0s';
@property({ type: String })
public startX = '0%';
@property({ type: String })
public startY = '0%';
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener('animationiteration', this._handleAnimationIteration);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('animationiteration', this._handleAnimationIteration);
}
private _handleAnimationIteration = (ev: AnimationEvent): void => {
if (ev.animationName === 'shamrock-pulse') {
this.startX = `${Math.random() * 100}%`;
this.startY = `${Math.random() * 100}%`;
}
};
protected render(): TemplateResult {
return html`${this.char}`;
}
protected updated(): void {
this.style.setProperty('--size', this.size);
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
this.style.setProperty('--pulse-duration', this.pulseDuration);
this.style.setProperty('--pulse-delay', this.pulseDelay);
this.style.setProperty('--start-x', this.startX);
this.style.setProperty('--start-y', this.startY);
}
static get styles(): CSSResultGroup {
return unsafeCSS(shamrockStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-shamrock': AdvancedCameraCardShamrock;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { html, TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { BaseEffectComponent } from './base';
import './shamrock';
const SHAMROCK_COUNT = 10;
interface ShamrockConfig {
id: number;
size: string;
maxOpacity: number;
pulseDuration: string;
pulseDelay: string;
startX: string;
startY: string;
}
@customElement('advanced-camera-card-effect-shamrocks')
export class AdvancedCameraCardEffectShamrocks extends BaseEffectComponent {
private _shamrocks: ShamrockConfig[];
constructor() {
super();
this._shamrocks = Array.from({ length: SHAMROCK_COUNT }, (_, i) => {
const duration = Math.random() * 3 + 5;
const delay = -Math.random() * duration * 0.9;
return {
id: i,
size: `${Math.random() * 12 + 8}em`,
maxOpacity: Math.random() * 0.3 + 0.5,
pulseDuration: `${duration}s`,
pulseDelay: `${delay}s`,
startX: `${Math.random() * 80 + 10}%`,
startY: `${Math.random() * 80 + 10}%`,
};
});
}
protected render(): TemplateResult {
return html`
${repeat(
this._shamrocks,
(shamrock) => shamrock.id,
(shamrock) => html`
<advanced-camera-card-shamrock
.size=${shamrock.size}
.maxOpacity=${shamrock.maxOpacity}
.pulseDuration=${shamrock.pulseDuration}
.pulseDelay=${shamrock.pulseDelay}
.startX=${shamrock.startX}
.startY=${shamrock.startY}
></advanced-camera-card-shamrock>
`,
)}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-effect-shamrocks': AdvancedCameraCardEffectShamrocks;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { html, TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { BaseEffectComponent } from './base';
import './snowflake';
const SNOWFLAKE_CHARS = ['❄', '❅', '❆'];
const MAX_SNOWFLAKES = 50;
interface SnowflakeConfig {
id: number;
char: string;
size: string;
maxOpacity: number;
fallDuration: string;
fallDelay: string;
startX: string;
endX: string;
}
@customElement('advanced-camera-card-effect-snow')
export class AdvancedCameraCardEffectSnow extends BaseEffectComponent {
private _snowflakes: SnowflakeConfig[];
constructor() {
super();
this._snowflakes = Array.from({ length: MAX_SNOWFLAKES }, (_, i) => {
const duration = Math.random() * 10 + 10;
const delay = -Math.random() * duration * 0.8;
return {
id: i,
char: SNOWFLAKE_CHARS[Math.floor(Math.random() * SNOWFLAKE_CHARS.length)],
size: `${Math.random() * 1.5 + 0.5}em`,
maxOpacity: Math.random() * 0.5 + 0.5,
fallDuration: `${duration}s`,
fallDelay: `${delay}s`,
startX: `${Math.random() * 100}%`,
endX: `${Math.random() * 100}%`,
};
});
}
protected render(): TemplateResult {
return html`
${repeat(
this._snowflakes,
(snowflake) => snowflake.id,
(snowflake) => html`
<advanced-camera-card-snowflake
.char=${snowflake.char}
.size=${snowflake.size}
.maxOpacity=${snowflake.maxOpacity}
.fallDuration=${snowflake.fallDuration}
.fallDelay=${snowflake.fallDelay}
.startX=${snowflake.startX}
.endX=${snowflake.endX}
></advanced-camera-card-snowflake>
`,
)}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-effect-snow': AdvancedCameraCardEffectSnow;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import snowflakeStyle from '../../scss/snowflake.scss';
@customElement('advanced-camera-card-snowflake')
export class AdvancedCameraCardSnowflake extends LitElement {
@property({ type: String })
public char = '❄';
@property({ type: String })
public size = '1em';
@property({ type: Number })
public maxOpacity = 1;
@property({ type: String })
public fallDuration = '10s';
@property({ type: String })
public fallDelay = '0s';
@property({ type: String })
public startX = '0%';
@property({ type: String })
public endX = '0%';
protected render(): TemplateResult {
return html`${this.char}`;
}
protected updated(): void {
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
this.style.setProperty('--fall-duration', this.fallDuration);
this.style.setProperty('--fall-delay', this.fallDelay);
this.style.setProperty('--start-x', this.startX);
this.style.setProperty('--end-x', this.endX);
this.style.fontSize = this.size;
}
static get styles(): CSSResultGroup {
return unsafeCSS(snowflakeStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-snowflake': AdvancedCameraCardSnowflake;
}
}
+66 -1
View File
@@ -1,11 +1,76 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import loadingStyle from '../scss/loading.scss';
import { EffectName, EffectsControllerAPI } from '../types';
import { getReleaseVersion } from '../utils/diagnostics';
import './icon';
// Map of "MM-DD" to effect name for special dates.
const DATE_EFFECTS: Record<string, EffectName> = {
// New Year's Day
'01-01': 'fireworks',
// Valentine's Day
'02-14': 'hearts',
// St. Patrick's Day
'03-17': 'shamrocks',
// Halloween
'10-31': 'ghost',
// Christmas
'12-25': 'snow',
};
const getDateEffect = (): EffectName | null => {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return DATE_EFFECTS[`${month}-${day}`] ?? null;
};
@customElement('advanced-camera-card-loading')
export class AdvancedCameraCardLoading extends LitElement {
@property({ attribute: false })
public effectsControllerAPI?: EffectsControllerAPI | null;
@property({ type: Boolean, reflect: true })
public loaded = false;
private _effectName: EffectName | null = null;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._stopEffect();
}
protected updated(): void {
const effect = getDateEffect();
if (!effect) {
this._stopEffect();
return;
}
if (!this.loaded) {
this._startEffect(effect);
} else {
this._stopEffect();
}
}
private _startEffect(effect: EffectName): void {
this.effectsControllerAPI?.startEffect(effect, { fadeIn: false });
this._effectName = effect;
}
private _stopEffect(): void {
if (this._effectName) {
this.effectsControllerAPI?.stopEffect(this._effectName);
}
this._effectName = null;
}
protected render(): TemplateResult {
return html`<advanced-camera-card-icon
.icon=${{ icon: 'iris' }}