@profpowell/blink-wc 0.0.0-stage → 1.5.2

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.
@@ -0,0 +1,757 @@
1
+ /* ─────────────────────────────────────────────────────────────────────────
2
+ * <blink-wc> — Vanilla Breeze component styles
3
+ *
4
+ * Theming hooks (CSS custom properties):
5
+ * --blink-rate : duration of one blink cycle (set by JS from `rate`)
6
+ * --blink-min-opacity : opacity of the "off" phase (set by JS from min-opacity)
7
+ * --blink-count : iteration count (set by JS from `count`)
8
+ * --blink-stagger : per-unit delay step for letter modes (default 0.08s)
9
+ * --blink-dot : Morse dot unit in ms (read by JS; default 170)
10
+ * --blink-bg, --blink-fg : background/foreground for themed variants
11
+ * --blink-glow : glow radius for themed variants
12
+ *
13
+ * Vanilla Breeze integration:
14
+ * Themed variants read VB design tokens (--color-surface, --color-text,
15
+ * --color-error, --color-warning, --color-success, --color-accent,
16
+ * --font-mono, --font-sans, --radius-m, …) via layered var() fallbacks. A
17
+ * --blink-* override always wins; absent that, a VB token is used; absent
18
+ * that, a sensible built-in default applies. Pages running a VB theme get a
19
+ * matching blink automatically, with zero config.
20
+ *
21
+ * State hooks (data-* set by JS):
22
+ * data-behavior : blink | pulse | flicker
23
+ * data-state : running | paused
24
+ * data-split : present when text is split into per-unit spans
25
+ * data-lit : true | false (morse mode)
26
+ * data-visible : true | false (IntersectionObserver)
27
+ * data-tab-visible : true | false (Page Visibility)
28
+ * ──────────────────────────────────────────────────────────────────────── */
29
+
30
+ /* Join Vanilla Breeze's cascade contract. The order statement is VB's own
31
+ (copied verbatim) so it is harmless when VB loads first and still correct when
32
+ this file loads first. Being layered means VB themes/packs and any unlayered
33
+ author CSS can restyle <blink-wc> without specificity fights. */
34
+ @layer tokens, reset, native-elements, custom-elements, web-components, utils, bundle-theme, bundle-effects, bundle-components;
35
+
36
+ @layer web-components {
37
+ blink-wc {
38
+ display: inline-block;
39
+ --blink-rate: 1s;
40
+ --blink-stagger: 0.08s;
41
+ }
42
+
43
+ /* Unupgraded fallback: render content statically, no animation */
44
+ blink-wc:not(:defined),
45
+ blink-wc:not([data-ready]) {
46
+ display: inline-block;
47
+ }
48
+
49
+ blink-wc .blink-content {
50
+ display: inline-block;
51
+ }
52
+
53
+ /* Screen-reader copy of split text (the animated spans are aria-hidden). */
54
+ blink-wc .blink-sr {
55
+ position: absolute;
56
+ inline-size: 1px;
57
+ block-size: 1px;
58
+ overflow: hidden;
59
+ clip-path: inset(50%);
60
+ white-space: nowrap;
61
+ }
62
+
63
+ /* ─── Keyframes ──────────────────────────────────────────────────────── */
64
+
65
+ /* blink — the classic: hard on/off, no easing */
66
+ @keyframes blink-wc-blink {
67
+ 0% {
68
+ opacity: 1;
69
+ }
70
+ 50% {
71
+ opacity: var(--blink-min-opacity, 0);
72
+ }
73
+ 100% {
74
+ opacity: 1;
75
+ }
76
+ }
77
+
78
+ /* pulse — a soft breathing fade in and out */
79
+ @keyframes blink-wc-pulse {
80
+ 0%,
81
+ 100% {
82
+ opacity: 1;
83
+ }
84
+ 50% {
85
+ opacity: var(--blink-min-opacity, 0.2);
86
+ }
87
+ }
88
+
89
+ /* flicker — irregular, mostly-on flutter, like a tired fluorescent tube */
90
+ @keyframes blink-wc-flicker {
91
+ 0%,
92
+ 19%,
93
+ 23%,
94
+ 54%,
95
+ 57%,
96
+ 100% {
97
+ opacity: 1;
98
+ }
99
+ 20%,
100
+ 22%,
101
+ 55%,
102
+ 56% {
103
+ opacity: var(--blink-min-opacity, 0.25);
104
+ }
105
+ }
106
+
107
+ /* ─── Core behavior application (whole-text blink) ───────────────────── */
108
+
109
+ blink-wc[data-behavior='blink'] .blink-content {
110
+ animation: blink-wc-blink var(--blink-rate, 1s) step-end var(--blink-count, infinite);
111
+ }
112
+ blink-wc[data-behavior='pulse'] .blink-content {
113
+ animation: blink-wc-pulse var(--blink-rate, 1s) ease-in-out var(--blink-count, infinite);
114
+ }
115
+ blink-wc[data-behavior='flicker'] .blink-content {
116
+ animation: blink-wc-flicker var(--blink-rate, 1s) step-end var(--blink-count, infinite);
117
+ }
118
+
119
+ /* Per-unit, morse, and step modes drive their own animation/state — silence the
120
+ whole-text blink on the content. (Placed after the behavior rules so it wins
121
+ the tie.) */
122
+ blink-wc[data-split] .blink-content,
123
+ blink-wc[mode='morse'] .blink-content,
124
+ blink-wc[data-stepping] .blink-content {
125
+ animation: none;
126
+ }
127
+
128
+ /* ─── Pause states ───────────────────────────────────────────────────── */
129
+
130
+ /* !important: the per-behavior `animation` shorthand resets play-state to
131
+ running, so the pause overrides must beat it regardless of specificity. */
132
+ /* .blink-content::after is the terminal cursor / typewriter caret. It needs
133
+ its own selector: :is() cannot match pseudo-elements. */
134
+ blink-wc[data-state='paused'] :is(.blink-content, .blink-char),
135
+ blink-wc[data-state='paused'] .blink-content::after,
136
+ blink-wc[data-visible='false'] :is(.blink-content, .blink-char),
137
+ blink-wc[data-visible='false'] .blink-content::after,
138
+ blink-wc[data-tab-visible='false'] :is(.blink-content, .blink-char),
139
+ blink-wc[data-tab-visible='false'] .blink-content::after {
140
+ animation-play-state: paused !important;
141
+ }
142
+ blink-wc[pause-on-hover]:where(:hover, :focus-within) :is(.blink-content, .blink-char),
143
+ blink-wc[pause-on-hover]:where(:hover, :focus-within) .blink-content::after {
144
+ animation-play-state: paused !important;
145
+ }
146
+ blink-wc[pause-on-hover] {
147
+ cursor: default;
148
+ }
149
+
150
+ /* ─── Accessibility: prefers-reduced-motion ──────────────────────────── */
151
+
152
+ /* Blinking and flashing content is a genuine accessibility hazard, so by
153
+ default everything stops and the text is shown fully — opt out with
154
+ reduced-motion="ignore". */
155
+ @media (prefers-reduced-motion: reduce) {
156
+ blink-wc:not([reduced-motion='ignore']) :is(.blink-content, .blink-char) {
157
+ animation: none !important;
158
+ opacity: 1 !important;
159
+ }
160
+ /* The terminal cursor and typewriter caret are pseudo-elements: hold them on. */
161
+ blink-wc:not([reduced-motion='ignore']) .blink-content::after {
162
+ animation: none !important;
163
+ }
164
+ /* Step modes rest at step 0; force the resting transform flat as well. */
165
+ blink-wc:not([reduced-motion='ignore'])[data-stepping] :is(.blink-content, .blink-char) {
166
+ transform: none !important;
167
+ }
168
+ blink-wc:not([reduced-motion='ignore'])[mode='morse'] .blink-content {
169
+ opacity: 1 !important;
170
+ text-shadow: 0 0 0.4em currentColor;
171
+ }
172
+ }
173
+
174
+ /* Vanilla Breeze's theme picker can turn "Motion Effects" off, which sets
175
+ :root[data-motion-reduced]. Treat that exactly like the OS preference. */
176
+ :root[data-motion-reduced]
177
+ blink-wc:not([reduced-motion='ignore'])
178
+ :is(.blink-content, .blink-char) {
179
+ animation: none !important;
180
+ opacity: 1 !important;
181
+ }
182
+ /* The terminal cursor and typewriter caret are pseudo-elements: hold them on. */
183
+ :root[data-motion-reduced] blink-wc:not([reduced-motion='ignore']) .blink-content::after {
184
+ animation: none !important;
185
+ }
186
+ /* Step modes rest at step 0; force the resting transform flat as well. */
187
+ :root[data-motion-reduced]
188
+ blink-wc:not([reduced-motion='ignore'])[data-stepping]
189
+ :is(.blink-content, .blink-char) {
190
+ transform: none !important;
191
+ }
192
+ :root[data-motion-reduced] blink-wc:not([reduced-motion='ignore'])[mode='morse'] .blink-content {
193
+ opacity: 1 !important;
194
+ text-shadow: 0 0 0.4em currentColor;
195
+ }
196
+
197
+ /* ─────────────────────────────────────────────────────────────────────────
198
+ * Surface themes
199
+ * ──────────────────────────────────────────────────────────────────────── */
200
+
201
+ /* ─── neon — a buzzing neon sign that flickers on with a colored glow ──── */
202
+ blink-wc[mode='neon'] {
203
+ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
204
+ font-weight: 800;
205
+ letter-spacing: 0.02em;
206
+ color: var(--blink-fg, var(--color-accent, var(--color-primary, oklch(72% 0.2 330))));
207
+ }
208
+ blink-wc[mode='neon'] .blink-content {
209
+ animation: blink-wc-neon var(--blink-rate, 3s) step-end infinite;
210
+ }
211
+ @keyframes blink-wc-neon {
212
+ 0%,
213
+ 18%,
214
+ 22%,
215
+ 25%,
216
+ 53%,
217
+ 57%,
218
+ 100% {
219
+ opacity: 1;
220
+ text-shadow:
221
+ 0 0 0.3em currentColor,
222
+ 0 0 0.8em currentColor,
223
+ 0 0 1.5em currentColor;
224
+ }
225
+ 20%,
226
+ 24%,
227
+ 55% {
228
+ opacity: 0.4;
229
+ text-shadow: none;
230
+ }
231
+ }
232
+
233
+ /* ─── crt — green phosphor terminal glow over faint scanlines ──────────── */
234
+ blink-wc[mode='crt'] {
235
+ font-family: var(--font-mono, ui-monospace, monospace);
236
+ /* A CRT is a dark screen in every theme and mode; the phosphor takes its hue
237
+ from the theme's --color-success, lifted/dimmed so it always reads. */
238
+ --_phosphor: var(--color-success, oklch(70% 0.2 145));
239
+ color-scheme: dark;
240
+ color: var(--blink-fg, oklch(from var(--_phosphor) max(l, 0.82) c h));
241
+ background-color: var(--blink-bg, oklch(from var(--_phosphor) 0.17 calc(c * 0.15) h));
242
+ padding: 0.5rem 0.75rem;
243
+ border-radius: var(--radius-m, 0.4rem);
244
+ text-shadow: 0 0 0.3em currentColor;
245
+ background-image: repeating-linear-gradient(
246
+ 0deg,
247
+ color-mix(in oklch, currentColor 8%, transparent) 0 1px,
248
+ transparent 1px 3px
249
+ );
250
+ }
251
+
252
+ /* ─── terminal — steady text with a blinking block cursor ──────────────── */
253
+ blink-wc[mode='terminal'] {
254
+ font-family: var(--font-mono, ui-monospace, monospace);
255
+ color: var(--blink-fg, var(--color-text, oklch(90% 0.02 150)));
256
+ background-color: var(
257
+ --blink-bg,
258
+ var(--color-surface-sunken, var(--color-surface, oklch(16% 0.01 250)))
259
+ );
260
+ padding: 0.5rem 0.75rem;
261
+ border-radius: var(--radius-m, 0.4rem);
262
+ }
263
+ blink-wc[mode='terminal'] .blink-content {
264
+ animation: none;
265
+ }
266
+ blink-wc[mode='terminal'] .blink-content::after {
267
+ content: '▋' / ''; /* decorative: not announced */
268
+ margin-inline-start: 0.1em;
269
+ color: var(--color-accent, var(--color-success, oklch(80% 0.2 145)));
270
+ animation: blink-wc-blink var(--blink-rate, 1s) step-end infinite;
271
+ }
272
+
273
+ /* ─── warning — amber hazard panel that blinks for attention ───────────── */
274
+ blink-wc[mode='warning'] {
275
+ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
276
+ font-weight: 800;
277
+ text-transform: uppercase;
278
+ letter-spacing: 0.08em;
279
+ --_bg: var(--blink-bg, var(--color-warning, oklch(82% 0.17 85)));
280
+ /* Black or white, whichever contrasts with the (possibly themed) panel. */
281
+ color: var(--blink-fg, oklch(from var(--_bg) clamp(0, (0.62 - l) * 1000, 1) 0 0));
282
+ background: var(--_bg);
283
+ padding: 0.5rem 1rem;
284
+ border-radius: var(--radius-m, 0.4rem);
285
+ }
286
+
287
+ /* ─── alert — a flashing red error banner ──────────────────────────────── */
288
+ blink-wc[mode='alert'] {
289
+ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
290
+ font-weight: 800;
291
+ text-transform: uppercase;
292
+ letter-spacing: 0.06em;
293
+ --_bg: var(--blink-bg, var(--color-error, oklch(55% 0.22 25)));
294
+ color: var(--blink-fg, oklch(from var(--_bg) clamp(0, (0.62 - l) * 1000, 1) 0 0));
295
+ background: var(--_bg);
296
+ padding: 0.5rem 1rem;
297
+ border-radius: var(--radius-m, 0.4rem);
298
+ }
299
+
300
+ /* ─── police — alternating red/blue light bar ──────────────────────────── */
301
+ blink-wc[mode='police'] {
302
+ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
303
+ font-weight: 900;
304
+ text-transform: uppercase;
305
+ letter-spacing: 0.06em;
306
+ color: white;
307
+ }
308
+ blink-wc[mode='police'] .blink-content {
309
+ padding: 0.4rem 0.9rem;
310
+ border-radius: var(--radius-m, 0.4rem);
311
+ animation: blink-wc-police var(--blink-rate, 0.8s) step-end infinite;
312
+ }
313
+ @keyframes blink-wc-police {
314
+ 0% {
315
+ background: oklch(55% 0.25 25);
316
+ box-shadow: 0 0 1.5em oklch(55% 0.25 25);
317
+ }
318
+ 50% {
319
+ background: oklch(55% 0.2 265);
320
+ box-shadow: 0 0 1.5em oklch(55% 0.2 265);
321
+ }
322
+ }
323
+
324
+ /* ─── vegas — a glowing gold marquee sign ──────────────────────────────── */
325
+ blink-wc[mode='vegas'] {
326
+ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
327
+ font-weight: 900;
328
+ text-transform: uppercase;
329
+ letter-spacing: 0.04em;
330
+ color: var(
331
+ --blink-fg,
332
+ light-dark(var(--color-warning-text, oklch(52% 0.13 75)), oklch(92% 0.15 90))
333
+ );
334
+ }
335
+ blink-wc[mode='vegas'] .blink-content {
336
+ animation: blink-wc-vegas var(--blink-rate, 1.2s) ease-in-out infinite;
337
+ }
338
+ @keyframes blink-wc-vegas {
339
+ 0%,
340
+ 100% {
341
+ text-shadow:
342
+ 0 0 0.3em currentColor,
343
+ 0 0 0.7em currentColor;
344
+ }
345
+ 50% {
346
+ opacity: var(--blink-min-opacity, 0.55);
347
+ text-shadow: none;
348
+ }
349
+ }
350
+
351
+ /* ─── morse — blink the text out as Morse code, with a dot/dash caption ──── */
352
+ blink-wc[mode='morse'] {
353
+ font-family: var(--font-mono, ui-monospace, monospace);
354
+ color: var(--blink-fg, var(--color-success-text, var(--color-success, oklch(85% 0.2 145))));
355
+ }
356
+ blink-wc[mode='morse'] .blink-content {
357
+ opacity: var(--blink-min-opacity, 0.12);
358
+ transition: opacity 60ms linear;
359
+ }
360
+ blink-wc[mode='morse'][data-lit='true'] .blink-content {
361
+ opacity: 1;
362
+ text-shadow:
363
+ 0 0 0.35em currentColor,
364
+ 0 0 0.8em currentColor;
365
+ }
366
+ blink-wc[mode='morse']::after {
367
+ content: attr(data-morse) / ''; /* duplicates the text: not announced */
368
+ display: block;
369
+ margin-block-start: 0.4rem;
370
+ font-size: 0.75em;
371
+ letter-spacing: 0.12em;
372
+ opacity: 0.55;
373
+ }
374
+
375
+ /* ─────────────────────────────────────────────────────────────────────────
376
+ * Per-unit motion modes
377
+ *
378
+ * For the letter modes the component wraps every character (or word, with
379
+ * unit="word") in <span class="blink-char" style="--i:N">. The --i index
380
+ * staggers each unit so the effect ripples across the text. Whitespace
381
+ * becomes <span class="blink-char blink-space">.
382
+ * ──────────────────────────────────────────────────────────────────────── */
383
+
384
+ blink-wc[data-split] .blink-char {
385
+ display: inline-block;
386
+ white-space: pre;
387
+ }
388
+
389
+ /* wave — a hard blink that travels across the units, one after another */
390
+ blink-wc[mode='wave'] .blink-char {
391
+ animation: blink-wc-blink var(--blink-rate, 1.2s) step-end infinite;
392
+ animation-delay: calc((var(--i, 0) / var(--n, 1)) * var(--blink-rate, 1.2s));
393
+ }
394
+
395
+ /* twinkle — every unit blinks on its own random clock, like a field of stars */
396
+ blink-wc[mode='twinkle'] .blink-char {
397
+ animation: blink-wc-blink var(--rate, 1s) step-end infinite;
398
+ animation-delay: var(--delay, 0s);
399
+ }
400
+
401
+ /* sparkle — twinkle with a glinting scale + glow */
402
+ blink-wc[mode='sparkle'] .blink-char {
403
+ animation: blink-wc-sparkle var(--rate, 1.2s) ease-in-out infinite;
404
+ animation-delay: var(--delay, 0s);
405
+ }
406
+ @keyframes blink-wc-sparkle {
407
+ 0%,
408
+ 100% {
409
+ opacity: 0.3;
410
+ transform: scale(0.85);
411
+ }
412
+ 50% {
413
+ opacity: 1;
414
+ transform: scale(1.18);
415
+ text-shadow: 0 0 0.5em currentColor;
416
+ }
417
+ }
418
+
419
+ /* rainbow — each unit a hue of the spectrum, blinking as the colors cycle */
420
+ blink-wc[mode='rainbow'] .blink-char {
421
+ color: hsl(calc(var(--i, 0) * 30deg) 90% 60%);
422
+ animation:
423
+ blink-wc-blink var(--blink-rate, 1s) step-end infinite,
424
+ blink-wc-hue 2.4s linear infinite;
425
+ animation-delay: calc(var(--i, 0) * -0.06s);
426
+ }
427
+ @keyframes blink-wc-hue {
428
+ to {
429
+ filter: hue-rotate(360deg);
430
+ }
431
+ }
432
+
433
+ /* chase — a single lit unit sweeps along the row, theater-marquee style */
434
+ blink-wc[mode='chase'] .blink-char {
435
+ opacity: 0.25;
436
+ animation: blink-wc-chase 1.4s ease-in-out infinite;
437
+ animation-delay: calc((var(--i, 0) / var(--n, 1)) * 1.4s);
438
+ }
439
+ @keyframes blink-wc-chase {
440
+ 0%,
441
+ 100% {
442
+ opacity: 0.25;
443
+ }
444
+ 25% {
445
+ opacity: 1;
446
+ color: var(--color-accent, var(--color-primary, currentColor));
447
+ text-shadow: 0 0 0.55em currentColor;
448
+ }
449
+ }
450
+
451
+ /* glitch — units blink while jittering with a chromatic-aberration shadow */
452
+ blink-wc[mode='glitch'] .blink-char {
453
+ text-shadow:
454
+ 0.03em 0 oklch(60% 0.24 25 / 0.7),
455
+ -0.03em 0 oklch(70% 0.18 220 / 0.7);
456
+ animation: blink-wc-glitch 0.45s steps(2, jump-none) infinite;
457
+ animation-delay: calc(var(--i, 0) * -0.05s);
458
+ }
459
+ @keyframes blink-wc-glitch {
460
+ 0%,
461
+ 100% {
462
+ transform: translate(0, 0);
463
+ opacity: 1;
464
+ }
465
+ 30% {
466
+ transform: translate(0.04em, -0.04em);
467
+ opacity: 0.55;
468
+ }
469
+ 60% {
470
+ transform: translate(-0.04em, 0.03em);
471
+ opacity: 1;
472
+ }
473
+ }
474
+
475
+ /* cascade — a soft fade pulse rippling unit-to-unit */
476
+ blink-wc[mode='cascade'] .blink-char {
477
+ animation: blink-wc-pulse var(--blink-rate, 1.4s) ease-in-out infinite;
478
+ animation-delay: calc(var(--i, 0) * var(--blink-stagger, 0.08s));
479
+ }
480
+
481
+ /* typewriter — units appear one by one, with a blinking caret at the end */
482
+ blink-wc[mode='typewriter'] .blink-char {
483
+ opacity: 0;
484
+ animation: blink-wc-type 0.01s step-end forwards;
485
+ animation-delay: calc(var(--i, 0) * var(--blink-stagger, 0.12s));
486
+ }
487
+ @keyframes blink-wc-type {
488
+ to {
489
+ opacity: 1;
490
+ }
491
+ }
492
+ blink-wc[mode='typewriter'] .blink-content::after {
493
+ content: '▋' / ''; /* decorative: not announced */
494
+ margin-inline-start: 0.05em;
495
+ color: var(--color-accent, currentColor);
496
+ animation: blink-wc-blink var(--blink-rate, 1s) step-end infinite;
497
+ }
498
+
499
+ /* ─────────────────────────────────────────────────────────────────────────
500
+ * Multi-step engine
501
+ *
502
+ * The JS advances data-step="0..N-1" across one cycle, holding each step for an
503
+ * equal slice. Each step is just a CSS state, so a step can change colour,
504
+ * transform, outline, glow — anything. A plain on/off blink is the 2-step case.
505
+ *
506
+ * --blink-step-ease controls the transition duration *between* steps: 0s
507
+ * (default) is a hard cut (true blink); a non-zero value morphs smoothly.
508
+ * --blink-step-timing is the transition timing-function (default `ease`).
509
+ *
510
+ * Both are read from the *incoming* step's computed style, so a step can carry
511
+ * its own duration and easing. The hold time of each step is set by
512
+ * `step-durations`. With step-by="letter"/"word" each unit carries its own
513
+ * data-step (offset by --blink-stagger) so the states ripple across the text.
514
+ * Authors target steps with `blink-wc :is(.blink-content,.blink-char)[data-step='K']`.
515
+ * ──────────────────────────────────────────────────────────────────────── */
516
+
517
+ blink-wc :is(.blink-content, .blink-char)[data-step] {
518
+ display: inline-block;
519
+ transition:
520
+ opacity var(--blink-step-ease, 0s) var(--blink-step-timing, ease),
521
+ color var(--blink-step-ease, 0s) var(--blink-step-timing, ease),
522
+ transform var(--blink-step-ease, 0s) var(--blink-step-timing, ease),
523
+ text-shadow var(--blink-step-ease, 0s) var(--blink-step-timing, ease),
524
+ letter-spacing var(--blink-step-ease, 0s) var(--blink-step-timing, ease),
525
+ -webkit-text-stroke-color var(--blink-step-ease, 0s) var(--blink-step-timing, ease);
526
+ }
527
+
528
+ /* Default two-state: behavior="steps" with no preset is a plain blink. */
529
+ blink-wc[behavior='steps'] :is(.blink-content, .blink-char)[data-step='1'] {
530
+ opacity: var(--blink-min-opacity, 0);
531
+ }
532
+
533
+ /* ─── extrude — an isometric 3D block that punches toward you on-beat ───── */
534
+ blink-wc[mode='extrude'] {
535
+ --blink-step-ease: 0.18s;
536
+ --_ext: var(--blink-shadow, var(--color-primary, oklch(45% 0.06 250)));
537
+ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
538
+ font-weight: 900;
539
+ letter-spacing: 0.01em;
540
+ color: var(--blink-fg, var(--color-accent, oklch(72% 0.18 255)));
541
+ perspective: 600px;
542
+ }
543
+ blink-wc[mode='extrude'] :is(.blink-content, .blink-char) {
544
+ transform-origin: center;
545
+ }
546
+ blink-wc[mode='extrude'] :is(.blink-content, .blink-char)[data-step='1'] {
547
+ transform: scale(1.14) translateY(-0.04em);
548
+ text-shadow:
549
+ 0.025em 0.025em var(--_ext),
550
+ 0.05em 0.05em var(--_ext),
551
+ 0.075em 0.075em var(--_ext),
552
+ 0.1em 0.1em var(--_ext),
553
+ 0.125em 0.125em var(--_ext),
554
+ 0.15em 0.15em color-mix(in oklch, var(--_ext), black 35%);
555
+ }
556
+
557
+ /* ─── collapse — the text squashes flat, then pops back up ─────────────── */
558
+ blink-wc[mode='collapse'] {
559
+ --blink-step-ease: 0.12s;
560
+ font-weight: 800;
561
+ }
562
+ blink-wc[mode='collapse'] :is(.blink-content, .blink-char) {
563
+ transform-origin: center bottom;
564
+ }
565
+ blink-wc[mode='collapse'] :is(.blink-content, .blink-char)[data-step='1'] {
566
+ transform: scaleY(0.06) scaleX(1.12);
567
+ opacity: 0.6;
568
+ }
569
+
570
+ /* ─── outline — alternates between a solid fill and a hollow outline ───── */
571
+ blink-wc[mode='outline'] {
572
+ --blink-step-ease: 0.12s;
573
+ --_stroke: var(--blink-fg, var(--color-accent, oklch(60% 0.2 285)));
574
+ font-weight: 900;
575
+ color: var(--_stroke);
576
+ }
577
+ blink-wc[mode='outline'] :is(.blink-content, .blink-char)[data-step='1'] {
578
+ color: transparent;
579
+ -webkit-text-stroke: 0.035em var(--_stroke);
580
+ }
581
+
582
+ /* ─── morph — a 4-step showcase: each step its own colour + transform ──── */
583
+ blink-wc[mode='morph'] {
584
+ --blink-step-ease: 0.16s;
585
+ font-weight: 900;
586
+ }
587
+ blink-wc[mode='morph'] :is(.blink-content, .blink-char)[data-step='0'] {
588
+ color: var(--color-accent, oklch(65% 0.2 20));
589
+ transform: scale(1);
590
+ }
591
+ blink-wc[mode='morph'] :is(.blink-content, .blink-char)[data-step='1'] {
592
+ color: var(--color-success, oklch(70% 0.18 145));
593
+ transform: scale(1.1) rotate(-2deg);
594
+ }
595
+ blink-wc[mode='morph'] :is(.blink-content, .blink-char)[data-step='2'] {
596
+ color: var(--color-warning, oklch(78% 0.16 85));
597
+ transform: scale(0.92) rotate(2deg);
598
+ letter-spacing: 0.06em;
599
+ }
600
+ blink-wc[mode='morph'] :is(.blink-content, .blink-char)[data-step='3'] {
601
+ color: var(--color-primary, oklch(60% 0.2 285));
602
+ transform: scale(1.06);
603
+ text-shadow: 0 0 0.5em currentColor;
604
+ }
605
+
606
+ /* ─── depth — a true-3D punch toward the viewer (perspective + translateZ) ─ */
607
+ blink-wc[mode='depth'] {
608
+ --blink-step-ease: 0.2s;
609
+ perspective: 700px;
610
+ font-weight: 900;
611
+ color: var(--blink-fg, var(--color-accent, oklch(72% 0.18 255)));
612
+ }
613
+ blink-wc[mode='depth'] :is(.blink-content, .blink-char) {
614
+ transform-style: preserve-3d;
615
+ }
616
+ blink-wc[mode='depth'] :is(.blink-content, .blink-char)[data-step='1'] {
617
+ transform: translateZ(140px);
618
+ text-shadow: 0 0.3em 0.8em oklch(0% 0 0 / 0.4);
619
+ }
620
+
621
+ /* ─── revolve — a 4-step turn on the Y axis that flips to the back and around */
622
+ blink-wc[mode='revolve'] {
623
+ --blink-step-ease: 0.25s;
624
+ perspective: 800px;
625
+ font-weight: 900;
626
+ }
627
+ blink-wc[mode='revolve'] :is(.blink-content, .blink-char) {
628
+ transform-style: preserve-3d;
629
+ backface-visibility: visible;
630
+ }
631
+ blink-wc[mode='revolve'] :is(.blink-content, .blink-char)[data-step='0'] {
632
+ transform: rotateY(0deg);
633
+ }
634
+ blink-wc[mode='revolve'] :is(.blink-content, .blink-char)[data-step='1'] {
635
+ transform: rotateY(90deg);
636
+ opacity: 0.5;
637
+ }
638
+ blink-wc[mode='revolve'] :is(.blink-content, .blink-char)[data-step='2'] {
639
+ transform: rotateY(180deg);
640
+ }
641
+ blink-wc[mode='revolve'] :is(.blink-content, .blink-char)[data-step='3'] {
642
+ transform: rotateY(270deg);
643
+ opacity: 0.5;
644
+ }
645
+
646
+ /* ─────────────────────────────────────────────────────────────────────────
647
+ * More per-unit motion modes
648
+ * ──────────────────────────────────────────────────────────────────────── */
649
+
650
+ /* zoom — each letter swells toward you and fades, in a staggered ripple */
651
+ blink-wc[mode='zoom'] .blink-char {
652
+ animation: blink-wc-zoom var(--blink-rate, 1s) ease-in-out infinite;
653
+ animation-delay: calc(var(--i, 0) * var(--blink-stagger, 0.08s));
654
+ }
655
+ @keyframes blink-wc-zoom {
656
+ 0%,
657
+ 100% {
658
+ transform: scale(0.6);
659
+ opacity: var(--blink-min-opacity, 0.4);
660
+ }
661
+ 50% {
662
+ transform: scale(1.3);
663
+ opacity: 1;
664
+ }
665
+ }
666
+
667
+ /* flip3d — split-flap board: each letter tumbles a full turn on the X axis */
668
+ blink-wc[mode='flip3d'] {
669
+ perspective: 600px;
670
+ }
671
+ blink-wc[mode='flip3d'] .blink-char {
672
+ animation: blink-wc-flip3d var(--blink-rate, 1.4s) ease-in-out infinite;
673
+ animation-delay: calc(var(--i, 0) * var(--blink-stagger, 0.1s));
674
+ }
675
+ @keyframes blink-wc-flip3d {
676
+ 0%,
677
+ 100% {
678
+ transform: rotateX(0deg);
679
+ }
680
+ 50% {
681
+ transform: rotateX(1turn);
682
+ opacity: 0.4;
683
+ }
684
+ }
685
+
686
+ /* swing — letters dangle and swing like pendulums from their top edge */
687
+ blink-wc[mode='swing'] .blink-char {
688
+ transform-origin: top center;
689
+ animation: blink-wc-swing var(--blink-rate, 1.6s) ease-in-out infinite;
690
+ animation-delay: calc(var(--i, 0) * var(--blink-stagger, 0.08s));
691
+ }
692
+ @keyframes blink-wc-swing {
693
+ 0%,
694
+ 100% {
695
+ transform: rotate(-12deg);
696
+ }
697
+ 50% {
698
+ transform: rotate(12deg);
699
+ }
700
+ }
701
+
702
+ /* shake — nervous jitter with a blink, each letter slightly out of phase */
703
+ blink-wc[mode='shake'] .blink-char {
704
+ animation: blink-wc-shake 0.4s steps(4, jump-none) infinite;
705
+ animation-delay: calc(var(--i, 0) * -0.05s);
706
+ }
707
+ @keyframes blink-wc-shake {
708
+ 0%,
709
+ 100% {
710
+ transform: translate(0, 0);
711
+ }
712
+ 20% {
713
+ transform: translate(-0.05em, 0.04em);
714
+ opacity: 0.7;
715
+ }
716
+ 40% {
717
+ transform: translate(0.05em, -0.03em);
718
+ }
719
+ 60% {
720
+ transform: translate(-0.04em, -0.04em);
721
+ opacity: 0.7;
722
+ }
723
+ 80% {
724
+ transform: translate(0.04em, 0.03em);
725
+ }
726
+ }
727
+
728
+ /* heartbeat — a double-thump scale pulse rippling across the letters */
729
+ blink-wc[mode='heartbeat'] .blink-char {
730
+ animation: blink-wc-heartbeat var(--blink-rate, 1.2s) ease-in-out infinite;
731
+ animation-delay: calc(var(--i, 0) * var(--blink-stagger, 0.05s));
732
+ }
733
+ @keyframes blink-wc-heartbeat {
734
+ 0%,
735
+ 40%,
736
+ 100% {
737
+ transform: scale(1);
738
+ }
739
+ 10% {
740
+ transform: scale(1.3);
741
+ }
742
+ 20% {
743
+ transform: scale(1);
744
+ }
745
+ 30% {
746
+ transform: scale(1.2);
747
+ }
748
+ }
749
+
750
+ /* decode — glyphs scramble (set in JS) then lock onto the real text. Monospace
751
+ keeps the width stable while glyphs change. Matrix-green glow by default. */
752
+ blink-wc[mode='decode'] {
753
+ font-family: var(--font-mono, ui-monospace, monospace);
754
+ color: var(--blink-fg, var(--color-success-text, var(--color-success, oklch(85% 0.2 145))));
755
+ text-shadow: 0 0 0.25em currentColor;
756
+ }
757
+ }