@kongyo2/cards-css 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/holo-card.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { adjust, clamp, round } from "./math.js";
2
- import { Spring, type SpringSetOpts } from "./spring.js";
3
- import { CLASS } from "./dom.js";
2
+ import { Spring, type SpringSetOpts, type SpringOpts, type SpringDynamics } from "./spring.js";
3
+ import { CLASS, applyVars, buildLayerElement, normalizeMask } from "./dom.js";
4
4
  import { getActiveCard, setActiveCard, subscribeActiveCard } from "./active-registry.js";
5
5
  import { resetBaseOrientation, subscribeOrientation, type RelativeOrientation } from "./orientation.js";
6
6
  import { generateTextures, texturesToCssVariables } from "./textures.js";
7
- import type { HoloCardOptions } from "./types.js";
7
+ import type { CssVars, HoloCardOptions, HoloLayerOptions, ShowcaseOptions, VisualOptions } from "./types.js";
8
8
 
9
9
  const requestFrame = (cb: () => void): number =>
10
10
  typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame(cb) : setTimeout(cb, 16);
@@ -21,6 +21,86 @@ const SPRING_INTERACT = { stiffness: 0.066, damping: 0.25 };
21
21
  const SPRING_POPOVER = { stiffness: 0.033, damping: 0.45 };
22
22
  const SNAP_STIFFNESS = 0.01;
23
23
  const SNAP_DAMPING = 0.06;
24
+ const DEFAULT_MAX_TILT = 50 / 3.5;
25
+ const DEFAULT_PRECISION = 0.01;
26
+
27
+ interface BaseDynamics {
28
+ stiffness: number;
29
+ damping: number;
30
+ precision?: number;
31
+ axes?: Record<string, SpringDynamics>;
32
+ }
33
+
34
+ interface MutableDynamics {
35
+ stiffness: number;
36
+ damping: number;
37
+ precision: number;
38
+ axes: Record<string, SpringDynamics> | undefined;
39
+ }
40
+
41
+ const mergeAxes = (
42
+ base: Record<string, SpringDynamics> | undefined,
43
+ override: Record<string, SpringDynamics> | undefined,
44
+ ): Record<string, SpringDynamics> | undefined => {
45
+ if (!base) {
46
+ return override;
47
+ }
48
+ if (!override) {
49
+ return base;
50
+ }
51
+ const out: Record<string, SpringDynamics> = { ...base };
52
+ for (const key of Object.keys(override)) {
53
+ out[key] = { ...base[key], ...override[key] };
54
+ }
55
+ return out;
56
+ };
57
+
58
+ const resolveDynamics = (base: BaseDynamics, override?: SpringOpts): BaseDynamics => {
59
+ const out: BaseDynamics = {
60
+ stiffness: override?.stiffness ?? base.stiffness,
61
+ damping: override?.damping ?? base.damping,
62
+ };
63
+ const precision = override?.precision ?? base.precision;
64
+ if (precision !== undefined) {
65
+ out.precision = precision;
66
+ }
67
+ const axes = mergeAxes(base.axes, override?.axes);
68
+ if (axes !== undefined) {
69
+ out.axes = axes;
70
+ }
71
+ return out;
72
+ };
73
+
74
+ const assignDynamics = (spring: MutableDynamics, dyn: BaseDynamics): void => {
75
+ spring.stiffness = dyn.stiffness;
76
+ spring.damping = dyn.damping;
77
+ spring.precision = dyn.precision ?? DEFAULT_PRECISION;
78
+ spring.axes = dyn.axes;
79
+ };
80
+
81
+ const cssDimension = (value: string | number, unit: string): string =>
82
+ typeof value === "number" ? `${value}${unit}` : value;
83
+
84
+ interface ResolvedShowcase {
85
+ delay: number;
86
+ duration: number;
87
+ loop: boolean;
88
+ speed: number;
89
+ intensity: number;
90
+ dynamics: BaseDynamics;
91
+ }
92
+
93
+ const resolveShowcase = (showcase: boolean | ShowcaseOptions | undefined): ResolvedShowcase => {
94
+ const opts: ShowcaseOptions = typeof showcase === "object" ? showcase : {};
95
+ return {
96
+ delay: opts.delay ?? 2000,
97
+ duration: opts.duration ?? 4000,
98
+ loop: opts.loop ?? false,
99
+ speed: opts.speed ?? 0.05,
100
+ intensity: opts.intensity ?? 25,
101
+ dynamics: resolveDynamics({ stiffness: 0.02, damping: 0.5 }, opts.spring),
102
+ };
103
+ };
24
104
 
25
105
  interface Vec2 {
26
106
  x: number;
@@ -36,16 +116,34 @@ export class HoloCard {
36
116
  readonly element: HTMLElement;
37
117
 
38
118
  private readonly rotator: HTMLElement;
119
+ private frontElement: HTMLElement | null;
120
+ private layersElement: HTMLElement | null = null;
39
121
  private readonly options: Required<
40
122
  Pick<HoloCardOptions, "interactive" | "activateOnClick" | "gyroscope" | "showcase">
41
123
  >;
42
124
 
43
- private readonly springRotate = new Spring<Vec2>({ x: 0, y: 0 }, SPRING_INTERACT);
44
- private readonly springGlare = new Spring<Glare>({ x: 50, y: 50, o: 0 }, SPRING_INTERACT);
45
- private readonly springBackground = new Spring<Vec2>({ x: 50, y: 50 }, SPRING_INTERACT);
46
- private readonly springRotateDelta = new Spring<Vec2>({ x: 0, y: 0 }, SPRING_POPOVER);
47
- private readonly springTranslate = new Spring<Vec2>({ x: 0, y: 0 }, SPRING_POPOVER);
48
- private readonly springScale = new Spring<number>(1, SPRING_POPOVER);
125
+ private readonly springRotate: Spring<Vec2>;
126
+ private readonly springGlare: Spring<Glare>;
127
+ private readonly springBackground: Spring<Vec2>;
128
+ private readonly springPointer: Spring<Vec2>;
129
+ private readonly springRotateDelta: Spring<Vec2>;
130
+ private readonly springTranslate: Spring<Vec2>;
131
+ private readonly springScale: Spring<number>;
132
+
133
+ private readonly liveRotate: BaseDynamics;
134
+ private readonly liveGlare: BaseDynamics;
135
+ private readonly liveBackground: BaseDynamics;
136
+ private readonly livePointer: BaseDynamics;
137
+ private readonly snapDynamics: BaseDynamics;
138
+
139
+ private readonly tiltFactorX: number;
140
+ private readonly tiltFactorY: number;
141
+ private readonly tiltScaleX: number;
142
+ private readonly tiltScaleY: number;
143
+ private readonly parallax: number;
144
+ private readonly glareRange: number;
145
+ private readonly returnDelay: number;
146
+ private readonly showcaseConfig: ResolvedShowcase;
49
147
 
50
148
  private isInteracting = false;
51
149
  private firstPop = true;
@@ -54,7 +152,7 @@ export class HoloCard {
54
152
 
55
153
  private renderScheduled = false;
56
154
  private interactRaf: number | null = null;
57
- private pendingUpdate: { background: Vec2; rotate: Vec2; glare: Glare } | null = null;
155
+ private pendingUpdate: { background: Vec2; rotate: Vec2; glare: Glare; pointer: Vec2 } | null = null;
58
156
 
59
157
  private repositionTimer: ReturnType<typeof setTimeout> | null = null;
60
158
  private endTimer: ReturnType<typeof setTimeout> | null = null;
@@ -73,6 +171,8 @@ export class HoloCard {
73
171
  throw new Error("@kongyo2/cards-css: holo card element is missing its .holo-card__rotator child.");
74
172
  }
75
173
  this.rotator = rotator;
174
+ this.frontElement = element.querySelector<HTMLElement>(`.${CLASS.front}`);
175
+ this.layersElement = element.querySelector<HTMLElement>(`.${CLASS.layers}`);
76
176
 
77
177
  this.options = {
78
178
  interactive: options.interactive ?? true,
@@ -80,7 +180,37 @@ export class HoloCard {
80
180
  gyroscope: options.gyroscope ?? true,
81
181
  showcase: options.showcase ?? false,
82
182
  };
83
- this.showcaseRunning = this.options.showcase;
183
+ this.showcaseRunning = Boolean(options.showcase);
184
+ this.showcaseConfig = resolveShowcase(options.showcase);
185
+
186
+ const physics = options.physics ?? {};
187
+ this.tiltFactorX = (physics.maxTiltX ?? physics.maxTilt ?? DEFAULT_MAX_TILT) / 50;
188
+ this.tiltFactorY = (physics.maxTiltY ?? physics.maxTilt ?? DEFAULT_MAX_TILT) / 50;
189
+ this.tiltScaleX = (physics.maxTiltX ?? physics.maxTilt ?? DEFAULT_MAX_TILT) / DEFAULT_MAX_TILT;
190
+ this.tiltScaleY = (physics.maxTiltY ?? physics.maxTilt ?? DEFAULT_MAX_TILT) / DEFAULT_MAX_TILT;
191
+ this.parallax = physics.parallax ?? 1;
192
+ this.glareRange = physics.glareRange ?? 1;
193
+ this.returnDelay = physics.returnDelay ?? 500;
194
+
195
+ const interactBase = resolveDynamics(SPRING_INTERACT, physics.interactSpring);
196
+ const popoverBase = resolveDynamics(SPRING_POPOVER, physics.popoverSpring);
197
+ this.snapDynamics = resolveDynamics({ stiffness: SNAP_STIFFNESS, damping: SNAP_DAMPING }, physics.snapSpring);
198
+
199
+ this.liveRotate = resolveDynamics(interactBase, physics.springs?.rotate);
200
+ this.liveGlare = resolveDynamics(interactBase, physics.springs?.glare);
201
+ this.liveBackground = resolveDynamics(interactBase, physics.springs?.background);
202
+ this.livePointer = interactBase;
203
+
204
+ this.springRotate = new Spring<Vec2>({ x: 0, y: 0 }, this.liveRotate);
205
+ this.springGlare = new Spring<Glare>({ x: 50, y: 50, o: 0 }, this.liveGlare);
206
+ this.springBackground = new Spring<Vec2>({ x: 50, y: 50 }, this.liveBackground);
207
+ this.springPointer = new Spring<Vec2>({ x: 50, y: 50 }, this.livePointer);
208
+ this.springRotateDelta = new Spring<Vec2>(
209
+ { x: 0, y: 0 },
210
+ resolveDynamics(popoverBase, physics.springs?.rotateDelta),
211
+ );
212
+ this.springTranslate = new Spring<Vec2>({ x: 0, y: 0 }, resolveDynamics(popoverBase, physics.springs?.translate));
213
+ this.springScale = new Spring<number>(1, resolveDynamics(popoverBase, physics.springs?.scale));
84
214
 
85
215
  if (options.effect) {
86
216
  element.dataset.effect = options.effect;
@@ -93,13 +223,35 @@ export class HoloCard {
93
223
  if (typeof options.aspectRatio === "number") {
94
224
  element.style.setProperty("--card-aspect", String(options.aspectRatio));
95
225
  }
96
- if (options.mask) {
97
- element.style.setProperty("--mask", `url(${options.mask})`);
226
+
227
+ const mask = normalizeMask(options.mask);
228
+ if (mask?.image) {
229
+ element.style.setProperty("--mask", `url(${mask.image})`);
230
+ if (mask.size) {
231
+ element.style.setProperty("--mask-size", mask.size);
232
+ }
233
+ if (mask.position) {
234
+ element.style.setProperty("--mask-position", mask.position);
235
+ }
236
+ if (mask.repeat) {
237
+ element.style.setProperty("--mask-repeat", mask.repeat);
238
+ }
98
239
  element.classList.add(CLASS.masked);
240
+ if (mask.mode === "card") {
241
+ element.classList.add(CLASS.maskCard);
242
+ }
99
243
  }
100
244
  if (options.foil) {
101
245
  element.style.setProperty("--foil", `url(${options.foil})`);
102
246
  }
247
+ this.applyVisual(options.visual);
248
+ applyVars(element, options.vars);
249
+
250
+ if (!this.layersElement && options.layers?.length && this.frontElement) {
251
+ for (const layer of options.layers) {
252
+ this.addLayer(layer);
253
+ }
254
+ }
103
255
 
104
256
  this.applyStaticStyles(options.textureSeed);
105
257
 
@@ -107,6 +259,7 @@ export class HoloCard {
107
259
  this.springRotate,
108
260
  this.springGlare,
109
261
  this.springBackground,
262
+ this.springPointer,
110
263
  this.springRotateDelta,
111
264
  this.springTranslate,
112
265
  this.springScale,
@@ -133,6 +286,35 @@ export class HoloCard {
133
286
  }
134
287
  }
135
288
 
289
+ private applyVisual(visual: VisualOptions | undefined): void {
290
+ if (!visual) {
291
+ return;
292
+ }
293
+ const style = this.element.style;
294
+ const setNumber = (property: string, value: number | undefined): void => {
295
+ if (typeof value === "number") {
296
+ style.setProperty(property, String(value));
297
+ }
298
+ };
299
+ setNumber("--hc-brightness", visual.brightness);
300
+ setNumber("--hc-contrast", visual.contrast);
301
+ setNumber("--hc-saturate", visual.saturate);
302
+ setNumber("--hc-glare-opacity", visual.glareOpacity);
303
+ setNumber("--hc-shine-opacity", visual.shineOpacity);
304
+ if (visual.lineSpace !== undefined) {
305
+ style.setProperty("--space", cssDimension(visual.lineSpace, "%"));
306
+ }
307
+ if (visual.lineAngle !== undefined) {
308
+ style.setProperty("--angle", cssDimension(visual.lineAngle, "deg"));
309
+ }
310
+ if (visual.glitterSize !== undefined) {
311
+ style.setProperty("--glittersize", cssDimension(visual.glitterSize, "%"));
312
+ }
313
+ if (visual.imageFit !== undefined) {
314
+ style.setProperty("--imgsize", visual.imageFit);
315
+ }
316
+ }
317
+
136
318
  private applyStaticStyles(seed: number | undefined): void {
137
319
  const seedX = Math.random();
138
320
  const seedY = Math.random();
@@ -162,12 +344,25 @@ export class HoloCard {
162
344
 
163
345
  if (this.options.activateOnClick) {
164
346
  const onClick = (): void => this.toggleActive();
165
- const onBlur = (): void => this.deactivate();
347
+ const onFocusOut = (event: FocusEvent): void => {
348
+ const next = event.relatedTarget;
349
+ if (next instanceof Node && this.element.contains(next)) {
350
+ return;
351
+ }
352
+ this.deactivate();
353
+ };
166
354
  this.rotator.addEventListener("click", onClick);
167
- this.rotator.addEventListener("blur", onBlur);
355
+ this.rotator.addEventListener("focusout", onFocusOut);
168
356
  this.rotator.tabIndex = this.rotator.tabIndex >= 0 ? this.rotator.tabIndex : 0;
169
357
  this.cleanups.push(() => this.rotator.removeEventListener("click", onClick));
170
- this.cleanups.push(() => this.rotator.removeEventListener("blur", onBlur));
358
+ this.cleanups.push(() => this.rotator.removeEventListener("focusout", onFocusOut));
359
+
360
+ const interactiveOverlay = this.element.querySelector<HTMLElement>(`.${CLASS.overlayInteractive}`);
361
+ if (interactiveOverlay) {
362
+ const stopClick = (event: Event): void => event.stopPropagation();
363
+ interactiveOverlay.addEventListener("click", stopClick);
364
+ this.cleanups.push(() => interactiveOverlay.removeEventListener("click", stopClick));
365
+ }
171
366
 
172
367
  const onScroll = (): void => this.reposition();
173
368
  window.addEventListener("scroll", onScroll, { passive: true });
@@ -177,6 +372,14 @@ export class HoloCard {
177
372
  }
178
373
  }
179
374
 
375
+ private parallaxBackground(x: number, y: number): Vec2 {
376
+ return { x: round(50 + (x - 50) * this.parallax), y: round(50 + (y - 50) * this.parallax) };
377
+ }
378
+
379
+ private rangeGlare(x: number, y: number, o: number): Glare {
380
+ return { x: round(50 + (x - 50) * this.glareRange), y: round(50 + (y - 50) * this.glareRange), o };
381
+ }
382
+
180
383
  private interact(event: PointerEvent): void {
181
384
  this.endShowcase();
182
385
 
@@ -206,15 +409,17 @@ export class HoloCard {
206
409
  const center = { x: percent.x - 50, y: percent.y - 50 };
207
410
 
208
411
  this.pendingUpdate = {
209
- background: { x: adjust(percent.x, 0, 100, 37, 63), y: adjust(percent.y, 0, 100, 33, 67) },
210
- rotate: { x: round(-(center.x / 3.5)), y: round(center.y / 3.5) },
211
- glare: { x: round(percent.x), y: round(percent.y), o: 1 },
412
+ background: this.parallaxBackground(adjust(percent.x, 0, 100, 37, 63), adjust(percent.y, 0, 100, 33, 67)),
413
+ rotate: { x: round(-(center.x * this.tiltFactorX)), y: round(center.y * this.tiltFactorY) },
414
+ glare: this.rangeGlare(round(percent.x), round(percent.y), 1),
415
+ pointer: { x: round(percent.x), y: round(percent.y) },
212
416
  };
213
417
 
214
418
  if (this.interactRaf === null) {
215
419
  this.interactRaf = requestFrame(() => {
216
420
  if (this.pendingUpdate) {
217
- this.updateSprings(this.pendingUpdate.background, this.pendingUpdate.rotate, this.pendingUpdate.glare);
421
+ const update = this.pendingUpdate;
422
+ this.updateSprings(update.background, update.rotate, update.glare, update.pointer);
218
423
  this.pendingUpdate = null;
219
424
  }
220
425
  this.interactRaf = null;
@@ -222,7 +427,7 @@ export class HoloCard {
222
427
  }
223
428
  }
224
429
 
225
- private interactEnd(delay = 500): void {
430
+ private interactEnd(delay = this.returnDelay): void {
226
431
  if (this.interactRaf !== null) {
227
432
  cancelFrame(this.interactRaf);
228
433
  this.interactRaf = null;
@@ -234,18 +439,26 @@ export class HoloCard {
234
439
  }
235
440
  this.endTimer = setTimeout(() => {
236
441
  this.setInteracting(false);
237
- this.setSpringDynamics(SNAP_STIFFNESS, SNAP_DAMPING);
442
+ this.setGroupDynamics(this.snapDynamics);
238
443
  void this.springRotate.set({ x: 0, y: 0 }, { soft: 1 });
239
444
  void this.springGlare.set({ x: 50, y: 50, o: 0 }, { soft: 1 });
240
445
  void this.springBackground.set({ x: 50, y: 50 }, { soft: 1 });
446
+ void this.springPointer.set({ x: 50, y: 50 }, { soft: 1 });
241
447
  }, delay);
242
448
  }
243
449
 
244
- private setSpringDynamics(stiffness: number, damping: number): void {
245
- for (const spring of [this.springRotate, this.springGlare, this.springBackground]) {
246
- spring.stiffness = stiffness;
247
- spring.damping = damping;
248
- }
450
+ private setGroupDynamics(dyn: BaseDynamics): void {
451
+ assignDynamics(this.springRotate, dyn);
452
+ assignDynamics(this.springGlare, dyn);
453
+ assignDynamics(this.springBackground, dyn);
454
+ assignDynamics(this.springPointer, dyn);
455
+ }
456
+
457
+ private applyLiveDynamics(): void {
458
+ assignDynamics(this.springRotate, this.liveRotate);
459
+ assignDynamics(this.springGlare, this.liveGlare);
460
+ assignDynamics(this.springBackground, this.liveBackground);
461
+ assignDynamics(this.springPointer, this.livePointer);
249
462
  }
250
463
 
251
464
  private settle(opts: SpringSetOpts): void {
@@ -254,11 +467,12 @@ export class HoloCard {
254
467
  void this.springRotateDelta.set({ x: 0, y: 0 }, opts);
255
468
  }
256
469
 
257
- private updateSprings(background: Vec2, rotate: Vec2, glare: Glare): void {
258
- this.setSpringDynamics(SPRING_INTERACT.stiffness, SPRING_INTERACT.damping);
470
+ private updateSprings(background: Vec2, rotate: Vec2, glare: Glare, pointer: Vec2): void {
471
+ this.applyLiveDynamics();
259
472
  void this.springBackground.set(background);
260
473
  void this.springRotate.set(rotate);
261
474
  void this.springGlare.set(glare);
475
+ void this.springPointer.set(pointer);
262
476
  }
263
477
 
264
478
  private setInteracting(value: boolean): void {
@@ -286,6 +500,7 @@ export class HoloCard {
286
500
  const rotate = this.springRotate.current;
287
501
  const rotateDelta = this.springRotateDelta.current;
288
502
  const background = this.springBackground.current;
503
+ const pointer = this.springPointer.current;
289
504
  const translate = this.springTranslate.current;
290
505
  const scale = this.springScale.current;
291
506
 
@@ -297,9 +512,13 @@ export class HoloCard {
297
512
  style.setProperty("--pointer-from-center", String(fromCenter));
298
513
  style.setProperty("--pointer-from-top", String(glare.y / 100));
299
514
  style.setProperty("--pointer-from-left", String(glare.x / 100));
515
+ style.setProperty("--pointer-dx", String(round((pointer.x - 50) / 50)));
516
+ style.setProperty("--pointer-dy", String(round((pointer.y - 50) / 50)));
300
517
  style.setProperty("--card-opacity", String(glare.o));
301
518
  style.setProperty("--rotate-x", `${rotate.x + rotateDelta.x}deg`);
302
519
  style.setProperty("--rotate-y", `${rotate.y + rotateDelta.y}deg`);
520
+ style.setProperty("--tilt-x", String(round(rotate.x + rotateDelta.x)));
521
+ style.setProperty("--tilt-y", String(round(rotate.y + rotateDelta.y)));
303
522
  style.setProperty("--background-x", `${background.x}%`);
304
523
  style.setProperty("--background-y", `${background.y}%`);
305
524
  style.setProperty("--card-scale", String(scale));
@@ -311,12 +530,14 @@ export class HoloCard {
311
530
  if (getActiveCard() === this) {
312
531
  this.popover();
313
532
  this.element.classList.add(CLASS.active);
533
+ this.element.style.setProperty("--card-active", "1");
314
534
  if (this.options.gyroscope) {
315
535
  this.startGyroscope();
316
536
  }
317
537
  } else {
318
538
  this.retreat();
319
539
  this.element.classList.remove(CLASS.active);
540
+ this.element.style.setProperty("--card-active", "0");
320
541
  this.stopGyroscope();
321
542
  }
322
543
  }
@@ -391,11 +612,17 @@ export class HoloCard {
391
612
  x: clamp(orientation.relative.gamma, -limit.x, limit.x),
392
613
  y: clamp(orientation.relative.beta, -limit.y, limit.y),
393
614
  };
615
+ const gx = adjust(degrees.x, -limit.x, limit.x, 0, 100);
616
+ const gy = adjust(degrees.y, -limit.y, limit.y, 0, 100);
394
617
  this.setInteracting(true);
395
618
  this.updateSprings(
396
- { x: adjust(degrees.x, -limit.x, limit.x, 37, 63), y: adjust(degrees.y, -limit.y, limit.y, 33, 67) },
397
- { x: round(degrees.x * -1), y: round(degrees.y) },
398
- { x: adjust(degrees.x, -limit.x, limit.x, 0, 100), y: adjust(degrees.y, -limit.y, limit.y, 0, 100), o: 1 },
619
+ this.parallaxBackground(
620
+ adjust(degrees.x, -limit.x, limit.x, 37, 63),
621
+ adjust(degrees.y, -limit.y, limit.y, 33, 67),
622
+ ),
623
+ { x: round(degrees.x * -1 * this.tiltScaleX), y: round(degrees.y * this.tiltScaleY) },
624
+ this.rangeGlare(gx, gy, 1),
625
+ { x: gx, y: gy },
399
626
  );
400
627
  }
401
628
 
@@ -409,30 +636,44 @@ export class HoloCard {
409
636
  if (!this.isVisible) {
410
637
  return;
411
638
  }
412
- const s = 0.02;
413
- const d = 0.5;
639
+ const config = this.showcaseConfig;
640
+ const amp = config.intensity;
414
641
  let r = 0;
415
642
  this.showcaseStart = setTimeout(() => {
643
+ if (this.endTimer) {
644
+ clearTimeout(this.endTimer);
645
+ this.endTimer = null;
646
+ }
416
647
  this.setInteracting(true);
417
- this.setSpringDynamics(s, d);
648
+ this.setGroupDynamics(config.dynamics);
418
649
  if (!this.isVisible) {
419
650
  this.setInteracting(false);
420
651
  return;
421
652
  }
422
653
  this.showcaseInterval = setInterval(() => {
423
- r += 0.05;
424
- void this.springRotate.set({ x: Math.sin(r) * 25, y: Math.cos(r) * 25 });
425
- void this.springGlare.set({ x: 55 + Math.sin(r) * 55, y: 55 + Math.cos(r) * 55, o: 0.8 });
426
- void this.springBackground.set({ x: 20 + Math.sin(r) * 20, y: 20 + Math.cos(r) * 20 });
654
+ r += config.speed;
655
+ void this.springRotate.set({ x: Math.sin(r) * amp, y: Math.cos(r) * amp });
656
+ void this.springGlare.set({
657
+ x: 50 + Math.sin(r) * amp * 2.2,
658
+ y: 50 + Math.cos(r) * amp * 2.2,
659
+ o: 0.8,
660
+ });
661
+ void this.springBackground.set({
662
+ x: 50 + Math.sin(r) * amp * 0.8,
663
+ y: 50 + Math.cos(r) * amp * 0.8,
664
+ });
665
+ void this.springPointer.set({ x: 50 + Math.sin(r) * amp * 1.6, y: 50 + Math.cos(r) * amp * 1.6 });
427
666
  }, 20);
428
- this.showcaseEnd = setTimeout(() => {
429
- if (this.showcaseInterval) {
430
- clearInterval(this.showcaseInterval);
431
- this.showcaseInterval = null;
432
- }
433
- this.interactEnd(0);
434
- }, 4000);
435
- }, 2000);
667
+ if (!config.loop) {
668
+ this.showcaseEnd = setTimeout(() => {
669
+ if (this.showcaseInterval) {
670
+ clearInterval(this.showcaseInterval);
671
+ this.showcaseInterval = null;
672
+ }
673
+ this.interactEnd(0);
674
+ }, config.duration);
675
+ }
676
+ }, config.delay);
436
677
  }
437
678
 
438
679
  private endShowcase(): void {
@@ -483,6 +724,43 @@ export class HoloCard {
483
724
  this.element.dataset.effect = effect ?? "none";
484
725
  }
485
726
 
727
+ /** The `.holo-card__front` element, for appending custom content at runtime. */
728
+ get front(): HTMLElement | null {
729
+ return this.frontElement;
730
+ }
731
+
732
+ /** Apply CSS custom properties to the root element (for content linkage). */
733
+ setVars(vars: CssVars): void {
734
+ applyVars(this.element, vars);
735
+ }
736
+
737
+ /** Update fine-grained visual controls at runtime. */
738
+ setVisual(visual: VisualOptions): void {
739
+ this.applyVisual(visual);
740
+ }
741
+
742
+ /**
743
+ * Insert an extra layer between the artwork and the foil at runtime, returning
744
+ * the created element. Requires the card to have a `.holo-card__front`.
745
+ */
746
+ addLayer(layer: HoloLayerOptions): HTMLElement {
747
+ const front = this.frontElement;
748
+ if (!front) {
749
+ throw new Error("@kongyo2/cards-css: cannot add a layer — the card has no .holo-card__front element.");
750
+ }
751
+ const doc = front.ownerDocument;
752
+ const element = buildLayerElement(doc, layer);
753
+ if (!this.layersElement) {
754
+ const container = doc.createElement("div");
755
+ container.className = CLASS.layers;
756
+ const shine = front.querySelector(`.${CLASS.shine}`);
757
+ front.insertBefore(container, shine);
758
+ this.layersElement = container;
759
+ }
760
+ this.layersElement.appendChild(element);
761
+ return element;
762
+ }
763
+
486
764
  get active(): boolean {
487
765
  return getActiveCard() === this;
488
766
  }
@@ -514,6 +792,7 @@ export class HoloCard {
514
792
  this.springRotate,
515
793
  this.springGlare,
516
794
  this.springBackground,
795
+ this.springPointer,
517
796
  this.springRotateDelta,
518
797
  this.springTranslate,
519
798
  this.springScale,
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { buildHoloCardElement } from "./dom.js";
3
3
  import type { CreateHoloCardOptions, HoloCardOptions } from "./types.js";
4
4
 
5
5
  export { HoloCard } from "./holo-card.js";
6
- export { buildHoloCardElement, CLASS } from "./dom.js";
6
+ export { buildHoloCardElement, buildLayerElement, applyVars, normalizeMask, CLASS, type ResolvedMask } from "./dom.js";
7
7
  export {
8
8
  generateTextures,
9
9
  texturesToCssVariables,
@@ -22,9 +22,21 @@ export {
22
22
  type RelativeOrientation,
23
23
  } from "./orientation.js";
24
24
  export { getActiveCard, setActiveCard, subscribeActiveCard } from "./active-registry.js";
25
- export { Spring, type SpringValue, type SpringOpts, type SpringSetOpts } from "./spring.js";
25
+ export { Spring, type SpringValue, type SpringOpts, type SpringSetOpts, type SpringDynamics } from "./spring.js";
26
26
  export { round, clamp, adjust } from "./math.js";
27
- export type { HoloEffect, HoloCardOptions, CreateHoloCardOptions } from "./types.js";
27
+ export type {
28
+ HoloEffect,
29
+ HoloCardOptions,
30
+ CreateHoloCardOptions,
31
+ HoloContent,
32
+ CssVars,
33
+ SpringTuning,
34
+ PhysicsOptions,
35
+ ShowcaseOptions,
36
+ VisualOptions,
37
+ MaskOptions,
38
+ HoloLayerOptions,
39
+ } from "./types.js";
28
40
 
29
41
  export const createHoloCard = (options: CreateHoloCardOptions): HoloCard => {
30
42
  const element = buildHoloCardElement(options);