@yewdiel21/motion-kit 0.9.1 → 0.10.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.
Files changed (129) hide show
  1. package/README.md +93 -83
  2. package/dist/auto/index.d.ts +22 -3
  3. package/dist/auto/index.js +41 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-BFC27F2G.js → chunk-IKQ5FODN.js} +2 -2
  6. package/dist/{chunk-JQMJQSXD.js → chunk-TFNRIZHU.js} +1614 -11
  7. package/dist/chunk-TFNRIZHU.js.map +1 -0
  8. package/dist/{chunk-XDWBVFHQ.js → chunk-XAKC2QY7.js} +1202 -3
  9. package/dist/chunk-XAKC2QY7.js.map +1 -0
  10. package/dist/css/components/calendar.css +159 -0
  11. package/dist/css/components/hover-card.css +14 -0
  12. package/dist/css/components/input-otp.css +10 -0
  13. package/dist/css/components/pagination.css +11 -0
  14. package/dist/css/components/quantity.css +16 -0
  15. package/dist/css/components/slot-picker.css +126 -0
  16. package/dist/css/components/studio-dock.css +35 -0
  17. package/dist/css/components/trailer.css +37 -0
  18. package/dist/css/controls.css +65 -0
  19. package/dist/elements/index.d.ts +72 -4
  20. package/dist/elements/index.js +170 -4
  21. package/dist/elements/index.js.map +1 -1
  22. package/dist/index.css +1017 -0
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +45 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/index.d.ts +105 -6
  27. package/dist/react/index.js +350 -3
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/{reduced-B98f8LPs.d.ts → reduced-BmBzKH94.d.ts} +199 -2
  30. package/dist/{reveal-C0hCDcB_.d.ts → reveal-jsX-uwUz.d.ts} +211 -1
  31. package/dist/{store-pHiUvxP8.d.ts → store-DYb3tTsZ.d.ts} +1 -1
  32. package/package.json +1 -1
  33. package/registry.json +178 -104
  34. package/skills/motion-kit/SKILL.md +209 -189
  35. package/src/auto/auto-init.ts +38 -0
  36. package/src/components/calendar/README.md +82 -0
  37. package/src/components/calendar/calendar.css +158 -0
  38. package/src/components/calendar/element.ts +27 -0
  39. package/src/components/calendar/engine.ts +412 -0
  40. package/src/components/calendar/react.tsx +69 -0
  41. package/src/components/hover-card/README.md +16 -0
  42. package/src/components/hover-card/element.ts +8 -0
  43. package/src/components/hover-card/engine.ts +69 -0
  44. package/src/components/hover-card/hover-card.css +13 -0
  45. package/src/components/hover-card/react.tsx +19 -0
  46. package/src/components/input-otp/README.md +18 -0
  47. package/src/components/input-otp/element.ts +14 -0
  48. package/src/components/input-otp/engine.ts +106 -0
  49. package/src/components/input-otp/input-otp.css +9 -0
  50. package/src/components/input-otp/react.tsx +21 -0
  51. package/src/components/pagination/README.md +17 -0
  52. package/src/components/pagination/element.ts +14 -0
  53. package/src/components/pagination/engine.ts +102 -0
  54. package/src/components/pagination/pagination.css +10 -0
  55. package/src/components/pagination/react.tsx +22 -0
  56. package/src/components/quantity/README.md +20 -0
  57. package/src/components/quantity/element.ts +14 -0
  58. package/src/components/quantity/engine.ts +85 -0
  59. package/src/components/quantity/quantity.css +15 -0
  60. package/src/components/quantity/react.tsx +21 -0
  61. package/src/components/slot-picker/README.md +68 -0
  62. package/src/components/slot-picker/element.ts +27 -0
  63. package/src/components/slot-picker/engine.ts +412 -0
  64. package/src/components/slot-picker/react.tsx +76 -0
  65. package/src/components/slot-picker/slot-picker.css +125 -0
  66. package/src/components/studio-dock/README.md +44 -0
  67. package/src/components/studio-dock/element.ts +11 -0
  68. package/src/components/studio-dock/engine.ts +212 -0
  69. package/src/components/studio-dock/react.tsx +23 -0
  70. package/src/components/studio-dock/studio-dock.css +34 -0
  71. package/src/components/trailer/README.md +28 -0
  72. package/src/components/trailer/element.ts +13 -0
  73. package/src/components/trailer/engine.ts +189 -0
  74. package/src/components/trailer/react.tsx +16 -0
  75. package/src/components/trailer/trailer.css +36 -0
  76. package/src/core/dates.ts +98 -0
  77. package/src/css/controls/README.md +14 -0
  78. package/src/css/controls/status.css +63 -0
  79. package/src/elements/index.ts +24 -0
  80. package/src/index.ts +25 -0
  81. package/src/react/index.ts +21 -0
  82. package/src/react/use-action-button.ts +6 -0
  83. package/src/react/use-editorial-reveal.ts +7 -0
  84. package/src/react/use-film-hero.ts +6 -0
  85. package/src/react/use-font-cycle.ts +7 -0
  86. package/src/react/use-image-zoom.ts +5 -0
  87. package/src/react/use-joined-cta.ts +5 -0
  88. package/src/react/use-local-clock.ts +5 -0
  89. package/src/react/use-scroll-dock.ts +5 -0
  90. package/src/react/use-sticker.ts +5 -0
  91. package/src/react/use-studio-dock.ts +8 -0
  92. package/src/react/use-thumbnail-nav.ts +7 -0
  93. package/src/react/use-trailer.ts +6 -0
  94. package/src/react/use-weight-hover.ts +5 -0
  95. package/src/recipes/action-button/README.md +31 -0
  96. package/src/recipes/action-button/action-button.css +113 -0
  97. package/src/recipes/action-button/create-action-button.ts +56 -0
  98. package/src/recipes/editorial-reveal/README.md +35 -0
  99. package/src/recipes/editorial-reveal/create-editorial-reveal.ts +91 -0
  100. package/src/recipes/editorial-reveal/editorial-reveal.css +17 -0
  101. package/src/recipes/film-hero/README.md +23 -0
  102. package/src/recipes/film-hero/create-film-hero.ts +65 -0
  103. package/src/recipes/film-hero/film-hero.css +29 -0
  104. package/src/recipes/font-cycle/README.md +11 -0
  105. package/src/recipes/font-cycle/create-font-cycle.ts +126 -0
  106. package/src/recipes/font-cycle/font-cycle.css +36 -0
  107. package/src/recipes/image-zoom/README.md +16 -0
  108. package/src/recipes/image-zoom/create-image-zoom.ts +27 -0
  109. package/src/recipes/image-zoom/image-zoom.css +7 -0
  110. package/src/recipes/joined-cta/README.md +13 -0
  111. package/src/recipes/joined-cta/create-joined-cta.ts +22 -0
  112. package/src/recipes/joined-cta/joined-cta.css +17 -0
  113. package/src/recipes/local-clock/README.md +31 -0
  114. package/src/recipes/local-clock/create-local-clock.ts +61 -0
  115. package/src/recipes/scroll-dock/README.md +46 -0
  116. package/src/recipes/scroll-dock/create-scroll-dock.ts +72 -0
  117. package/src/recipes/scroll-dock/scroll-dock.css +70 -0
  118. package/src/recipes/sticker/README.md +59 -0
  119. package/src/recipes/sticker/create-sticker.ts +490 -0
  120. package/src/recipes/sticker/sticker.css +112 -0
  121. package/src/recipes/thumbnail-nav/README.md +16 -0
  122. package/src/recipes/thumbnail-nav/create-thumbnail-nav.ts +165 -0
  123. package/src/recipes/thumbnail-nav/thumbnail-nav.css +108 -0
  124. package/src/recipes/weight-hover/README.md +37 -0
  125. package/src/recipes/weight-hover/create-weight-hover.ts +136 -0
  126. package/src/recipes/weight-hover/weight-hover.css +7 -0
  127. package/dist/chunk-JQMJQSXD.js.map +0 -1
  128. package/dist/chunk-XDWBVFHQ.js.map +0 -1
  129. /package/dist/{chunk-BFC27F2G.js.map → chunk-IKQ5FODN.js.map} +0 -0
@@ -0,0 +1,31 @@
1
+ # action-button
2
+
3
+ Botones con sombra firme inspirados en Don't Look Up y etiquetas en máscara inspiradas en la ficha A24.
4
+
5
+ ## HTML / Astro
6
+
7
+ ```html
8
+ <button class="mk-action-button" data-mk="action-button" data-mk-variant="primary">Empezar</button>
9
+ <button class="mk-action-button" data-mk="action-button" data-mk-variant="film">
10
+ <span data-mk-action-label>Ver trailer ↗</span>
11
+ </button>
12
+ ```
13
+
14
+ ```ts
15
+ const button = createActionButton(element, {
16
+ variant: 'icon', toggle: true,
17
+ onPressedChange: value => console.log(value),
18
+ });
19
+ button.setPressed(true);
20
+ button.destroy();
21
+ ```
22
+
23
+ React: `useActionButton(ref, options, deps?)`. Conserva botones/enlaces semánticos escritos por el consumidor; la receta no ejecuta navegación ni reproduce sonidos. Variantes: `primary`, `secondary`, `icon`, `stepper`, `film`. Las variantes también pueden usarse sólo con CSS mediante `data-mk-action-variant`; `film` necesita el motor para duplicar su etiqueta marcada.
24
+
25
+ ## Movimiento / accesibilidad
26
+
27
+ Press 130ms ease-out, color150ms ease. Hover baja3px y reduce sombra; active5×6px. Film usa clip.4s/translate.35s/scale.3s y copia retrasada75ms. Es una adaptación reusable; no reproduce todos los overrides particulares del juego original ni su lógica económica.
28
+
29
+ `toggle` es opt-in y usa `aria-pressed`; controles disabled y `aria-disabled` no cambian el estado. Un icono necesita `aria-label`. La copia rolling tiene `aria-hidden`, el nodo original se conserva. Focus visible explícito. Reduced-motion elimina las transiciones y la duplicación visual; los gestos de botón siguen indicando estado. `destroy()` restaura nodos/atributos y retira listeners.
30
+
31
+ Tokens: `--mk-action-ink`, `--mk-action-paper`, `--mk-action-lift`, `--mk-action-tilt`, `--mk-action-focus`, `--mk-film-font`.
@@ -0,0 +1,113 @@
1
+ .mk-action-button {
2
+ --mk-action-ink: var(--mk-text, #171713);
3
+ --mk-action-paper: var(--mk-surface, #f7f5ee);
4
+ --mk-action-lift: 5px;
5
+ --mk-action-tilt: -1.2deg;
6
+ appearance: none;
7
+ position: relative;
8
+ display: inline-flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ gap: .6em;
12
+ min-height: 44px;
13
+ padding: .65em 1.5em;
14
+ border: 3px solid var(--mk-action-paper);
15
+ border-radius: 24px 30px 22px 28px / 30px 22px 28px 24px;
16
+ background: var(--mk-action-ink);
17
+ color: var(--mk-action-paper);
18
+ box-shadow: var(--mk-action-lift) calc(var(--mk-action-lift) + 1px) 0 var(--mk-action-ink);
19
+ transform: rotate(var(--mk-action-tilt));
20
+ font: 700 .85rem/1.15 var(--mk-font, sans-serif);
21
+ letter-spacing: .045em;
22
+ text-transform: uppercase;
23
+ text-decoration: none;
24
+ cursor: pointer;
25
+ touch-action: manipulation;
26
+ transition: transform 130ms ease-out, box-shadow 130ms ease-out, background-color 150ms ease, color 150ms ease, border-color 150ms ease;
27
+ }
28
+ .mk-action-button[data-mk-action-variant="secondary"],
29
+ .mk-action-button[data-mk-action-variant="icon"] {
30
+ --mk-action-tilt: 1.1deg;
31
+ background: var(--mk-action-paper);
32
+ border-color: var(--mk-action-ink);
33
+ color: var(--mk-action-ink);
34
+ }
35
+ .mk-action-button[data-mk-action-variant="icon"] {
36
+ width: 44px;
37
+ padding: 8px;
38
+ border-radius: 16px 20px 15px 19px / 20px 15px 19px 16px;
39
+ }
40
+ .mk-action-button svg { width: 1.25em; height: 1.25em; flex: none; }
41
+ .mk-action-button[data-mk-action-variant="stepper"] {
42
+ --mk-action-tilt: 0deg;
43
+ min-width: 44px;
44
+ padding: .5em;
45
+ border: 2px solid var(--mk-action-ink);
46
+ border-radius: 11px;
47
+ background: var(--mk-action-paper);
48
+ color: var(--mk-action-ink);
49
+ box-shadow: none;
50
+ }
51
+ .mk-action-button[data-mk-action-variant="film"] {
52
+ --mk-action-tilt: 0deg;
53
+ border: 0;
54
+ border-radius: 0;
55
+ background: transparent;
56
+ color: inherit;
57
+ box-shadow: none;
58
+ padding: .5em 0;
59
+ font-family: var(--mk-film-font, Georgia, serif);
60
+ font-size: 1.25rem;
61
+ }
62
+ .mk-action-button[data-mk-action-variant="film"]::before,
63
+ .mk-action-button[data-mk-action-variant="film"]::after {
64
+ content: "";
65
+ position: absolute;
66
+ inset: 0 0 auto;
67
+ height: 1px;
68
+ background: currentColor;
69
+ transition: transform .4s cubic-bezier(.32,.72,0,1);
70
+ }
71
+ .mk-action-button[data-mk-action-variant="film"]::after { inset: auto 0 0; }
72
+ [data-mk-action-label] { display: inline-grid; overflow: clip; }
73
+ .mk-action-button__line, .mk-action-button__copy {
74
+ grid-area: 1 / 1;
75
+ transition: clip-path .4s cubic-bezier(.32,.72,0,1), translate .35s cubic-bezier(.32,.72,0,1), scale .3s cubic-bezier(.34,2.27,.64,1);
76
+ }
77
+ .mk-action-button__line { clip-path: inset(0); transform-origin: bottom; }
78
+ .mk-action-button__copy { clip-path: inset(0 0 100%); translate: 0 -.25em; scale: 1.05; transform-origin: top; }
79
+ @media (hover: hover) {
80
+ .mk-action-button:not(:disabled):not([aria-disabled="true"]):hover {
81
+ transform: rotate(var(--mk-action-tilt)) translate(3px, 3px);
82
+ box-shadow: 2px 3px 0 var(--mk-action-ink);
83
+ }
84
+ .mk-action-button:is([data-mk-action-variant="secondary"], [data-mk-action-variant="icon"], [data-mk-action-variant="stepper"]):not(:disabled):not([aria-disabled="true"]):hover {
85
+ background: var(--mk-action-ink);
86
+ border-color: var(--mk-action-paper);
87
+ color: var(--mk-action-paper);
88
+ }
89
+ .mk-action-button[data-mk-action-variant="stepper"]:not(:disabled):not([aria-disabled="true"]):hover { transform: none; box-shadow: none; }
90
+ }
91
+ .mk-action-button:not(:disabled):not([aria-disabled="true"]):active,
92
+ .mk-action-button[aria-pressed="true"] {
93
+ transform: rotate(var(--mk-action-tilt)) translate(var(--mk-action-lift), calc(var(--mk-action-lift) + 1px));
94
+ box-shadow: 0 0 0 var(--mk-action-ink);
95
+ }
96
+ .mk-action-button[aria-pressed="true"] { background: var(--mk-action-ink); color: var(--mk-action-paper); }
97
+ .mk-action-button[aria-pressed="true"]:not(:disabled):not([aria-disabled="true"]):hover { transform: rotate(var(--mk-action-tilt)) translate(var(--mk-action-lift), calc(var(--mk-action-lift) + 1px)); box-shadow: none; }
98
+ .mk-action-button[data-mk-action-variant="stepper"]:not(:disabled):not([aria-disabled="true"]):active { transform: translateY(1px); box-shadow: none; }
99
+ .mk-action-button[data-mk-action-variant="film"]:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) {
100
+ transform: scale(1.03) rotate(-.75deg);
101
+ box-shadow: none;
102
+ }
103
+ .mk-action-button[data-mk-action-variant="film"]:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) .mk-action-button__line { clip-path: inset(100% 0 0); translate: 0 .25em; scale: .95; }
104
+ .mk-action-button[data-mk-action-variant="film"]:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) .mk-action-button__copy { clip-path: inset(0); translate: 0; scale: 1; transition-delay: 75ms; }
105
+ .mk-action-button[data-mk-action-variant="film"]:active:not(:disabled) { transform: scale(.97); }
106
+ .mk-action-button:focus-visible { outline: 3px solid var(--mk-action-focus, #356bdf); outline-offset: 7px; }
107
+ .mk-action-button:disabled, .mk-action-button[aria-disabled="true"] { opacity: .35; cursor: default; }
108
+ @media (prefers-reduced-motion: reduce) {
109
+ .mk-action-button, .mk-action-button *, .mk-action-button::before, .mk-action-button::after { transition: none; }
110
+ .mk-action-button[data-mk-action-variant="film"]:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) { transform: none; }
111
+ .mk-action-button[data-mk-action-variant="film"]:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) .mk-action-button__line { clip-path: none; translate: none; scale: 1; }
112
+ .mk-action-button__copy { display: none; }
113
+ }
@@ -0,0 +1,56 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+
4
+ export type ActionButtonVariant = 'primary' | 'secondary' | 'icon' | 'stepper' | 'film';
5
+ export interface ActionButtonOptions {
6
+ variant?: ActionButtonVariant;
7
+ /** Toggle aria-pressed on activation. Ordinary action buttons leave it unset. */
8
+ toggle?: boolean;
9
+ pressed?: boolean;
10
+ onPressedChange?: (pressed: boolean) => void;
11
+ }
12
+ export interface ActionButtonInstance extends Instance {
13
+ setPressed(pressed: boolean): void;
14
+ readonly pressed: boolean;
15
+ }
16
+
17
+ /** Drawn press buttons and a masked rolling-label film variant. Uses native activation. */
18
+ export function createActionButton(root: HTMLElement | null, options: ActionButtonOptions = {}): ActionButtonInstance {
19
+ if (!root) return inert({ setPressed() {}, pressed: false });
20
+ const scope = createScope();
21
+ const variant = options.variant ?? (root.getAttribute('data-mk-action-variant') as ActionButtonVariant | null) ?? 'primary';
22
+ scope.attr(root, 'data-mk-action-variant', variant);
23
+ scope.attr(root, 'data-mk-action-ready', '');
24
+ const label = root.querySelector<HTMLElement>('[data-mk-action-label]');
25
+ if (label && variant === 'film') {
26
+ const nodes = [...label.childNodes];
27
+ const line = document.createElement('span');
28
+ line.className = 'mk-action-button__line';
29
+ line.append(...nodes);
30
+ const copy = document.createElement('span');
31
+ copy.className = 'mk-action-button__copy';
32
+ copy.setAttribute('aria-hidden', 'true');
33
+ // Plain text prevents duplicate interactive controls or IDs inside the label.
34
+ copy.textContent = line.textContent;
35
+ label.append(line, copy);
36
+ scope.add(() => { label.replaceChildren(...nodes); });
37
+ }
38
+ let pressed = options.pressed ?? root.getAttribute('aria-pressed') === 'true';
39
+ if (options.toggle || options.pressed !== undefined) scope.attr(root, 'aria-pressed', String(pressed));
40
+ const setPressed = (next: boolean) => {
41
+ if (scope.disposed || next === pressed) return;
42
+ pressed = next;
43
+ scope.attr(root, 'aria-pressed', String(pressed));
44
+ options.onPressedChange?.(pressed);
45
+ root.dispatchEvent(new CustomEvent('mk:action-change', { bubbles: true, detail: { pressed } }));
46
+ };
47
+ scope.on(root, 'click', (event) => {
48
+ if (event.defaultPrevented) return;
49
+ if (root.hasAttribute('disabled') || root.getAttribute('aria-disabled') === 'true') {
50
+ event.preventDefault();
51
+ return;
52
+ }
53
+ if (options.toggle) setPressed(!pressed);
54
+ });
55
+ return { setPressed, get pressed() { return pressed; }, destroy: () => scope.dispose() };
56
+ }
@@ -0,0 +1,35 @@
1
+ # editorial-reveal
2
+
3
+ Composición de títulos enmascarados, tarjetas, CTA con flecha duplicada, cursor decorativo y grupos de contacto escalonados. Referencia conductual: CoMinVi. Reutiliza `mask-text`, `cascade`, `parallax` y `contrast` sin copiar assets ni código de la referencia.
4
+
5
+ ```html
6
+ <section class="mk-editorial-reveal" data-mk="editorial-reveal">
7
+ <header data-mk-editorial-header>Estudio</header>
8
+ <section data-mk-tone="light">
9
+ <h1 data-mk-editorial-title>Un nuevo punto de vista.</h1>
10
+ <a href="/story" data-mk-editorial-card>
11
+ <img data-mk-editorial-card-image src="/your-image.webp" alt="Paisaje">
12
+ <h2>Explorar la historia</h2>
13
+ </a>
14
+ <a href="/story" data-mk-editorial-button><span data-mk-editorial-button-content>Leer</span></a>
15
+ </section>
16
+ <div data-mk-editorial-group>
17
+ <h2 data-mk-cascade="rise">Conversemos</h2>
18
+ <label data-mk-cascade="fade">Correo <input type="email"></label>
19
+ </div>
20
+ <span data-mk-editorial-cursor hidden>Leer ↗</span>
21
+ </section>
22
+ ```
23
+
24
+ ```ts
25
+ import { createEditorialReveal } from '@yewdiel21/motion-kit';
26
+ import { useEditorialReveal } from '@yewdiel21/motion-kit/react';
27
+ const motion = createEditorialReveal(root, { trigger: 'manual' });
28
+ motion.play(); motion.reset(); motion.destroy();
29
+ ```
30
+
31
+ Opciones: `trigger` (`scroll` por defecto, `load`, `manual`), `duration` 1.2 s, `stagger` .03 s, `y` 110 %, `ease` curva `(0.6,0,0,1)`, `hoverDuration` .5 s, `buttonShift` 2.25 em, `imageScale` 1.04 y `parallax` true. Los grupos mantienen el easing `expo.out` de `cascade`; la curva de referencia se aplica a títulos, CTA e imágenes. La entrada se reproduce una vez. `reset()` permite repetirla manualmente.
32
+
33
+ Marca un marco opcional con `[data-mk-editorial-image]` y su hijo con `[data-mk-parallax-target]` para un parallax de intensidad .25. El header observa únicamente las secciones de su propia instancia. El estado de tarjeta se activa tanto con puntero como con foco; el cursor es decorativo, se oculta en touch y con movimiento reducido, y nunca sustituye al cursor del sistema. Sin JavaScript el contenido sigue visible.
34
+
35
+ Con movimiento reducido el título queda entero, las entradas no ocultan campos y las interacciones conservan sus estados sin desplazamiento. `destroy()` es idempotente y restaura atributos/estilos propios y los helpers. El formulario, navegación y assets son responsabilidad de quien compone el contenido.
@@ -0,0 +1,91 @@
1
+ import { gsap, CustomEase, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { createMaskText } from '../mask-text/create-mask-text';
6
+ import { createCascade } from '../cascade/create-cascade';
7
+ import { createParallax } from '../parallax/create-parallax';
8
+ import { createContrast } from '../contrast/create-contrast';
9
+
10
+ export interface EditorialRevealOptions {
11
+ trigger?: 'scroll' | 'load' | 'manual';
12
+ /** Title and staged groups, seconds. Reference default 1.2. */
13
+ duration?: number;
14
+ stagger?: number;
15
+ /** Title pieces' start offset, percent. Reference default 110. */
16
+ y?: number;
17
+ ease?: string;
18
+ hoverDuration?: number;
19
+ /** Duplicate label/arrow content moves by this many em. */
20
+ buttonShift?: number;
21
+ imageScale?: number;
22
+ parallax?: boolean;
23
+ }
24
+ export interface EditorialRevealInstance extends Instance { play(): void; reset(): void; }
25
+ let nextScope = 0;
26
+
27
+ /** Editorial composition: masked headings, staged contact groups, restrained image/card motion and keyboard-equivalent buttons. */
28
+ export function createEditorialReveal(root: HTMLElement | null, options: EditorialRevealOptions = {}): EditorialRevealInstance {
29
+ if (!root) return inert({ play() {}, reset() {} });
30
+ registerMotion();
31
+ const scope = createScope();
32
+ const context = gsap.context(() => {}, root);
33
+ const trigger = options.trigger ?? 'scroll';
34
+ const duration = options.duration ?? 1.2;
35
+ const stagger = options.stagger ?? .03;
36
+ const hoverDuration = options.hoverDuration ?? .5;
37
+ const ease = options.ease ?? 'mk-editorial-reference';
38
+ CustomEase.create('mk-editorial-reference', 'M0,0 C0.6,0 0,1 1,1');
39
+ // Helpers restore their own nodes; these snapshots also preserve caller inline styles/readiness.
40
+ const preserve = (el: HTMLElement) => ['style', 'data-mk-ready', 'aria-label'].forEach(name => scope.attr(el, name, el.getAttribute(name)));
41
+ preserve(root);
42
+ root.querySelectorAll<HTMLElement>('[data-mk-editorial-title],[data-mk-editorial-group],[data-mk-cascade],[data-mk-editorial-image],[data-mk-parallax-target],[data-mk-editorial-header],[data-mk-editorial-card-image],[data-mk-editorial-button-content]').forEach(preserve);
43
+ const titles = [...root.querySelectorAll<HTMLElement>('[data-mk-editorial-title]')].map(el => createMaskText(el, { by: 'lines', trigger, once: true, y: options.y ?? 110, duration, stagger, ease, delay: 0 }));
44
+ const groups = [...root.querySelectorAll<HTMLElement>('[data-mk-editorial-group]')].map(el => createCascade(el, { trigger, duration, step: stagger, once: true }));
45
+ const images = options.parallax === false ? [] : [...root.querySelectorAll<HTMLElement>('[data-mk-editorial-image]')].map(el => createParallax(el, { amount: .25 }));
46
+ const header = root.querySelector<HTMLElement>('[data-mk-editorial-header]');
47
+ const sections = [...root.querySelectorAll<HTMLElement>('[data-mk-tone]')];
48
+ const scopeId = String(++nextScope);
49
+ sections.forEach(el => scope.attr(el, 'data-mk-editorial-scope', scopeId));
50
+ const contrast = header ? createContrast(header, { sections: `[data-mk-editorial-scope="${scopeId}"]` }) : null;
51
+ scope.add(() => { titles.forEach(instance => instance.destroy()); groups.forEach(instance => instance.destroy()); images.forEach(instance => instance.destroy()); contrast?.destroy(); context.revert(); });
52
+ root.querySelectorAll<HTMLElement>('[data-mk-editorial-button]').forEach(button => {
53
+ const content = button.querySelector<HTMLElement>('[data-mk-editorial-button-content]');
54
+ if (!content) return;
55
+ let pointerInside = false;
56
+ const render = () => {
57
+ const active = pointerInside || button.contains(root.ownerDocument.activeElement);
58
+ context.add(() => gsap.to(content, { x: active && !reduced() ? `${options.buttonShift ?? 2.25}em` : '0em', duration: reduced() ? 0 : hoverDuration, ease, overwrite: true }));
59
+ };
60
+ scope.on(button, 'pointerenter', () => { pointerInside = true; render(); });
61
+ scope.on(button, 'pointerleave', () => { pointerInside = false; render(); });
62
+ scope.on(button, 'focusin', render);
63
+ scope.on(button, 'focusout', () => { scope.timeout(render, 0); });
64
+ });
65
+ const cursor = root.querySelector<HTMLElement>('[data-mk-editorial-cursor]');
66
+ if (cursor) { preserve(cursor); scope.attr(cursor, 'aria-hidden', 'true'); scope.attr(cursor, 'hidden', ''); }
67
+ root.querySelectorAll<HTMLElement>('[data-mk-editorial-card]').forEach(card => {
68
+ const image = card.querySelector<HTMLElement>('[data-mk-editorial-card-image]');
69
+ let pointerInside = false;
70
+ const render = () => {
71
+ const active = pointerInside || card.contains(root.ownerDocument.activeElement);
72
+ scope.attr(card, 'data-mk-editorial-active', active ? '' : null);
73
+ if (image) context.add(() => gsap.to(image, { scale: active && !reduced() ? options.imageScale ?? 1.04 : 1, duration: reduced() ? 0 : hoverDuration, ease, overwrite: true }));
74
+ };
75
+ scope.on(card, 'pointerenter', event => { pointerInside = true; render(); if (cursor && !reduced() && event.pointerType !== 'touch') scope.attr(cursor, 'hidden', null); });
76
+ scope.on(card, 'pointerleave', () => { pointerInside = false; render(); if (cursor) scope.attr(cursor, 'hidden', ''); });
77
+ scope.on(card, 'pointermove', event => {
78
+ if (!cursor || reduced() || event.pointerType === 'touch') return;
79
+ const rect = root.getBoundingClientRect();
80
+ cursor.style.transform = `translate(${event.clientX - rect.left + 16}px, ${event.clientY - rect.top + 16}px)`;
81
+ });
82
+ scope.on(card, 'focusin', render);
83
+ scope.on(card, 'focusout', () => { scope.timeout(render, 0); });
84
+ });
85
+ scope.attr(root, 'data-mk-ready', '');
86
+ return {
87
+ play() { if (!scope.disposed) { titles.forEach(instance => instance.play()); groups.forEach(instance => instance.play()); } },
88
+ reset() { if (!scope.disposed) { titles.forEach(instance => instance.hide()); groups.forEach(instance => instance.reset()); } },
89
+ destroy() { scope.dispose(); },
90
+ };
91
+ }
@@ -0,0 +1,17 @@
1
+ .mk-editorial-reveal { position: relative; isolation: isolate; color: var(--mk-text); }
2
+ .mk-editorial-reveal [data-mk-editorial-image], .mk-editorial-reveal [data-mk-editorial-card] { overflow: clip; border-radius: 12px; }
3
+ .mk-editorial-reveal [data-mk-editorial-image] > :is(img,video,picture) { display: block; width: 100%; height: 100%; object-fit: cover; }
4
+ .mk-editorial-reveal [data-mk-editorial-card] { display: grid; gap: 20px; padding: 24px; background: var(--mk-surface-2); color: inherit; text-decoration: none; transition: background-color .5s cubic-bezier(.6, 0, 0, 1), color .5s cubic-bezier(.6, 0, 0, 1); }
5
+ .mk-editorial-reveal [data-mk-editorial-card][data-mk-editorial-active] { background: var(--mk-accent); color: var(--mk-on-accent); }
6
+ .mk-editorial-reveal [data-mk-editorial-card-image] { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; }
7
+ .mk-editorial-reveal [data-mk-editorial-button] { display: inline-flex; overflow: clip; align-items: center; padding: 12px 18px; border: 1px solid var(--mk-outline-soft); border-radius: 8px; background: var(--mk-text); color: var(--mk-surface); text-decoration: none; font: inherit; cursor: pointer; }
8
+ .mk-editorial-reveal [data-mk-editorial-button-content] { position: relative; display: flex; align-items: center; gap: 1em; padding-inline-end: 2.25em; }
9
+ .mk-editorial-reveal [data-mk-editorial-button-content]::before, .mk-editorial-reveal [data-mk-editorial-button-content]::after { content: '↗'; position: absolute; inset-block-start: 0; }
10
+ .mk-editorial-reveal [data-mk-editorial-button-content]::before { inset-inline-start: -2.25em; }
11
+ .mk-editorial-reveal [data-mk-editorial-button-content]::after { inset-inline-end: 0; }
12
+ .mk-editorial-reveal [data-mk-editorial-cursor] { pointer-events: none; position: absolute; z-index: 3; inset: 0 auto auto 0; width: max-content; height: max-content; padding: 10px 14px; border-radius: 30px; background: var(--mk-text); color: var(--mk-surface); font-size: .75rem; }
13
+ .mk-editorial-reveal [hidden] { display: none !important; }
14
+ .mk-editorial-reveal [data-mk-editorial-header][data-mk-on='dark'] { color: var(--mk-surface); }
15
+ .mk-editorial-reveal :is(a,button,input,textarea):focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
16
+ .mk-editorial-reveal [data-mk-editorial-group] [data-mk-cascade='rise'] { position: relative; }
17
+ @media (prefers-reduced-motion: reduce) { .mk-editorial-reveal *, .mk-editorial-reveal *::after, .mk-editorial-reveal *::before { animation: none !important; transition: none !important; } }
@@ -0,0 +1,23 @@
1
+ # film-hero
2
+
3
+ Hero editorial de cine: líneas ascendentes, reglas que se trazan, imagen/video con escala y parallax dentro del contenedor de scroll. Componer con `action-button`, `details` y `trailer` para una ficha completa. La demo incluye créditos, premios, un selector de siguiente producción y medios locales originales.
4
+
5
+ ```html
6
+ <section class="mk-film-hero" data-mk="film-hero">
7
+ <h1 class="mk-film-hero__title">
8
+ <span class="mk-film-hero__mask"><span data-mk-film-line>Una historia</span></span>
9
+ </h1>
10
+ <span data-mk-film-rule></span>
11
+ <div class="mk-film-hero__visual">
12
+ <img class="mk-film-hero__media" src="/mi-imagen.jpg" alt="…" />
13
+ </div>
14
+ </section>
15
+ ```
16
+
17
+ `createFilmHero(root, { duration:.7, stagger:.08, parallax:.12, scale:1.08, edge:'organic', scroller })` → `replay()`, `refresh()`, `destroy()`. React: `useFilmHero(ref, options, deps?)`. Para demos/paneles con scroll, marcar el contenedor `data-mk-scroll-root` o pasar `scroller`; no requiere scroll global ni Lenis. `edge:'straight'` conserva el recorte rectangular.
18
+
19
+ Las líneas se marcan explícitamente; no cambia el HTML del título ni duplica su nombre accesible. Reduced-motion deja visibles texto/reglas y desactiva parallax. Sin JavaScript el contenido sigue visible. El observer, rAF pendiente, listeners, GSAP y estilos se revierten con `destroy()`.
20
+
21
+ El borde `organic` es un polígono CSS estático **aproximado**. El A24 original usa un algoritmo procedural con ruido, respuesta al cursor y disolución al scroll, además de un disco Three para la siguiente producción. Esta receta no incluye ese algoritmo ni afirma equivalencia. El disco CSS de la demo es otra aproximación decorativa. Las duraciones .7/.9s y stagger proceden de las fuentes inspeccionadas; `power3.out` reemplaza la curva custom del original. No incluye assets ni tipografías de A24.
22
+
23
+ Tokens: `--mk-film-ink`, `--mk-film-paper`, `--mk-film-font`; el motor escribe y restaura `--mk-film-shift`/`--mk-film-scale`.
@@ -0,0 +1,65 @@
1
+ import { gsap, registerMotion } from '../../core/gsap';
2
+ import { entrances, reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+
6
+ export interface FilmHeroOptions {
7
+ duration?: number;
8
+ stagger?: number;
9
+ /** Media travel as a fraction of the hero height. 0 disables parallax. */
10
+ parallax?: number;
11
+ scale?: number;
12
+ edge?: 'organic' | 'straight';
13
+ /** Scrollable demo panels and nested page shells can supply their own scroll root. */
14
+ scroller?: HTMLElement | Window;
15
+ }
16
+ export interface FilmHeroInstance extends Instance { replay(): void; refresh(): void; }
17
+
18
+ /** Editorial line reveals with scoped, scroll-driven media. Organic edge is an approximation. */
19
+ export function createFilmHero(root: HTMLElement | null, options: FilmHeroOptions = {}): FilmHeroInstance {
20
+ if (!root) return inert({ replay() {}, refresh() {} });
21
+ const scope = createScope();
22
+ scope.attr(root, 'data-mk-film-ready', '');
23
+ scope.attr(root, 'data-mk-film-edge', options.edge ?? 'organic');
24
+ scope.attr(root, 'style', root.getAttribute('style'));
25
+ const still = reduced();
26
+ const lines = [...root.querySelectorAll<HTMLElement>('[data-mk-film-line]')];
27
+ const rules = [...root.querySelectorAll<HTMLElement>('[data-mk-film-rule]')];
28
+ [...lines, ...rules].forEach(element => scope.attr(element, 'style', element.getAttribute('style')));
29
+ let timeline: gsap.core.Timeline | null = null;
30
+ const context = gsap.context(() => {}, root);
31
+ const replay = () => {
32
+ if (scope.disposed || !entrances()) return;
33
+ registerMotion();
34
+ context.add(() => {
35
+ timeline?.kill();
36
+ timeline = gsap.timeline();
37
+ if (lines.length) timeline.fromTo(lines, { yPercent: 120 }, { yPercent: 0, duration: options.duration ?? .7, stagger: options.stagger ?? .08, ease: 'power3.out' }, 0);
38
+ if (rules.length) timeline.fromTo(rules, { scaleX: 0, transformOrigin: 'left center' }, { scaleX: 1, duration: .9, stagger: .06, ease: 'power3.out' }, .08);
39
+ });
40
+ };
41
+ replay();
42
+ const scroller = options.scroller ?? root.closest<HTMLElement>('[data-mk-scroll-root]') ?? window;
43
+ let frame = 0;
44
+ const refresh = () => {
45
+ if (scope.disposed || still) return;
46
+ const rect = root.getBoundingClientRect();
47
+ const view = scroller instanceof HTMLElement ? scroller.getBoundingClientRect() : { top: 0, height: window.innerHeight };
48
+ const progress = Math.max(0, Math.min(1, (view.top - rect.top) / Math.max(1, rect.height)));
49
+ root.style.setProperty('--mk-film-shift', `${(progress * rect.height * (options.parallax ?? .12)).toFixed(2)}px`);
50
+ root.style.setProperty('--mk-film-scale', String((options.scale ?? 1.08) - progress * Math.max(0, (options.scale ?? 1.08) - 1)));
51
+ };
52
+ if (!still) {
53
+ const schedule = () => {
54
+ if (frame) return;
55
+ frame = requestAnimationFrame(() => { frame = 0; refresh(); });
56
+ };
57
+ scope.on(scroller, 'scroll', schedule, { passive: true });
58
+ scope.on(window, 'resize', schedule, { passive: true });
59
+ if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(schedule)).observe(root);
60
+ scope.add(() => cancelAnimationFrame(frame));
61
+ refresh();
62
+ }
63
+ scope.add(() => { timeline?.kill(); context.revert(); });
64
+ return { replay, refresh, destroy: () => scope.dispose() };
65
+ }
@@ -0,0 +1,29 @@
1
+ .mk-film-hero {
2
+ --mk-film-ink: var(--mk-text, #191b18);
3
+ --mk-film-paper: var(--mk-surface, #f4f1e8);
4
+ --mk-film-font: Georgia, 'Times New Roman', serif;
5
+ position: relative;
6
+ isolation: isolate;
7
+ color: var(--mk-film-ink);
8
+ background: var(--mk-film-paper);
9
+ overflow: clip;
10
+ }
11
+ .mk-film-hero__credits { padding: clamp(20px, 5cqw, 64px); text-align: center; }
12
+ .mk-film-hero__eyebrow { font: 500 .65rem/1.5 var(--mk-font-mono, monospace); text-transform: uppercase; letter-spacing: .18em; }
13
+ .mk-film-hero__title { margin: .2em 0 .35em; font: 400 clamp(3.5rem, 10cqw, 8rem)/.82 var(--mk-film-font); letter-spacing: -.065em; text-transform: uppercase; }
14
+ .mk-film-hero__mask { display: block; overflow: clip; padding-bottom: .08em; margin-bottom: -.08em; }
15
+ [data-mk-film-line] { display: block; }
16
+ [data-mk-film-rule] { display: block; height: 1px; border: 0; background: currentColor; margin: 16px 0; }
17
+ .mk-film-hero__metadata { display: grid; gap: 0; margin: 24px 0 0; font: .8rem/1.5 var(--mk-font, sans-serif); }
18
+ .mk-film-hero__metadata > div { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; padding: 8px 0; border-top: 1px solid currentColor; }
19
+ .mk-film-hero__metadata dt { font: .6rem/1.5 var(--mk-font-mono, monospace); text-transform: uppercase; letter-spacing: .14em; }
20
+ .mk-film-hero__metadata dd { margin: 0; text-align: right; }
21
+ .mk-film-hero__visual { position: relative; min-height: clamp(260px, 45cqw, 540px); display: grid; place-items: center; overflow: clip; color: #fffdf1; background: #243c36; }
22
+ .mk-film-hero__media { position: absolute; inset: -8% 0; width: 100%; height: 116%; object-fit: cover; transform: translateY(var(--mk-film-shift, 0px)) scale(var(--mk-film-scale, 1)); transform-origin: center; }
23
+ .mk-film-hero__visual::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(transparent, #00120c66); }
24
+ .mk-film-hero__caption { position: relative; z-index: 1; padding: 48px 24px; text-align: center; }
25
+ .mk-film-hero__caption blockquote { font: 400 clamp(2.25rem, 6cqw, 5.5rem)/.95 var(--mk-film-font); letter-spacing: -.04em; margin: 0 0 24px; text-transform: uppercase; }
26
+ .mk-film-hero[data-mk-film-edge="organic"] .mk-film-hero__visual {
27
+ clip-path: polygon(0 7%, 9% 8%, 20% 5%, 32% 2%, 43% 0, 54% 2%, 68% 6%, 82% 8%, 93% 6%, 100% 4%, 100% 100%, 0 100%);
28
+ }
29
+ @media (prefers-reduced-motion: reduce) { .mk-film-hero__media { transform: none; } }
@@ -0,0 +1,11 @@
1
+ # font-cycle
2
+
3
+ Caras tipográficas alternadas por carácter, con copia enmascarada que entra al hover y al foco. Adaptación de la tipografía de Mat Voyce; el original también superpone GIFs. No incluye fuentes ni ilustraciones del sitio.
4
+
5
+ ```html
6
+ <button class="mk-font-cycle" data-mk="font-cycle" data-mk-font-alternate="Georgia, serif">Explore</button>
7
+ ```
8
+
9
+ `createFontCycle(root, { faces, text, duration: .6, stagger: .035, overlaySrc })` devuelve `play()`, `reset()`, `active`, `destroy()`. Duraciones en segundos, ease `power3.out`. `faces` es una tupla de `{ family?, axes?: { wght: 500, wdth: 75 }, weight?, style? }`; cada carácter alterna las dos caras. Los ejes que no admita la fuente no tienen efecto. `overlaySrc` (o `data-mk-font-overlay`) es una imagen decorativa propia, opcional.
10
+
11
+ React: `useFontCycle(ref, options, deps)`. La receta administra los hijos del elemento mientras está montada; no cambies sus hijos durante ese intervalo. Una copia accesible conserva el texto sin duplicar letras. El foco activa el mismo efecto que hover; con movimiento reducido el cambio es inmediato y no se muestra la imagen animada. `destroy()` restaura los nodos y atributos originales.
@@ -0,0 +1,126 @@
1
+ import { gsap, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { splitGraphemes } from '../../core/split';
5
+ import { inert, type Instance } from '../../core/types';
6
+
7
+ export interface FontCycleFace {
8
+ /** A font supplied by the application. No font files are downloaded by this recipe. */
9
+ family?: string;
10
+ axes?: Readonly<Record<string, number>>;
11
+ weight?: number;
12
+ style?: 'normal' | 'italic' | 'oblique';
13
+ }
14
+
15
+ export interface FontCycleOptions {
16
+ text?: string;
17
+ /** Alternated per character; the second face rolls in on hover or focus. */
18
+ faces?: readonly [FontCycleFace, FontCycleFace];
19
+ /** Seconds per character. */
20
+ duration?: number;
21
+ /** Seconds between characters. */
22
+ stagger?: number;
23
+ /** Optional decorative artwork owned by the consumer. Hidden with reduced motion. */
24
+ overlaySrc?: string;
25
+ }
26
+
27
+ export interface FontCycleInstance extends Instance {
28
+ play(): void;
29
+ reset(): void;
30
+ readonly active: boolean;
31
+ }
32
+
33
+ function applyFace(element: HTMLElement, face: FontCycleFace) {
34
+ if (face.family) element.style.fontFamily = face.family;
35
+ if (face.weight !== undefined) element.style.fontWeight = String(face.weight);
36
+ if (face.style) element.style.fontStyle = face.style;
37
+ const axes = Object.entries(face.axes ?? {}).filter(([tag, value]) => /^[\w ]{4}$/.test(tag) && Number.isFinite(value));
38
+ if (axes.length) element.style.fontVariationSettings = axes.map(([tag, value]) => `"${tag}" ${value}`).join(', ');
39
+ }
40
+
41
+ /** Alternating variable-font faces in a masked, reversible character roll. */
42
+ export function createFontCycle(root: HTMLElement | null, options: FontCycleOptions = {}): FontCycleInstance {
43
+ if (!root || typeof document === 'undefined') return inert({ play() {}, reset() {}, active: false });
44
+ registerMotion();
45
+ const scope = createScope();
46
+ const original = [...root.childNodes];
47
+ const text = options.text ?? root.textContent ?? '';
48
+ const faces = options.faces ?? [
49
+ { weight: 500, axes: { wght: 500, wdth: 75 } },
50
+ { family: root.dataset.mkFontAlternate, weight: 900, axes: { wght: 900, wdth: 125 } },
51
+ ];
52
+ const duration = Math.max(0, options.duration ?? 0.6);
53
+ const stagger = Math.max(0, options.stagger ?? 0.035);
54
+ const tracks: HTMLElement[] = [];
55
+ let animation: gsap.core.Tween | undefined;
56
+ let active = false;
57
+ let hovered = false;
58
+ let focused = false;
59
+
60
+ const copy = document.createElement('span');
61
+ copy.className = 'mk-font-cycle__accessible';
62
+ copy.textContent = text;
63
+ const visual = document.createElement('span');
64
+ visual.className = 'mk-font-cycle__visual';
65
+ visual.setAttribute('aria-hidden', 'true');
66
+ for (const [index, letter] of splitGraphemes(text, Number.MAX_SAFE_INTEGER).entries()) {
67
+ const character = document.createElement('span');
68
+ character.className = 'mk-font-cycle__char';
69
+ const track = document.createElement('span');
70
+ track.className = 'mk-font-cycle__track';
71
+ for (let side = 0; side < 2; side++) {
72
+ const face = document.createElement('span');
73
+ face.className = 'mk-font-cycle__face';
74
+ face.textContent = letter === ' ' ? '\u00a0' : letter;
75
+ applyFace(face, faces[(index + side) % 2]!);
76
+ const sizer = face.cloneNode(true) as HTMLElement;
77
+ sizer.className = 'mk-font-cycle__sizer';
78
+ character.append(sizer);
79
+ track.append(face);
80
+ }
81
+ character.append(track);
82
+ visual.append(character);
83
+ tracks.push(track);
84
+ }
85
+ root.replaceChildren(copy, visual);
86
+ const overlaySrc = options.overlaySrc ?? root.dataset.mkFontOverlay;
87
+ if (overlaySrc) {
88
+ const overlay = document.createElement('img');
89
+ overlay.className = 'mk-font-cycle__overlay';
90
+ overlay.alt = '';
91
+ overlay.setAttribute('aria-hidden', 'true');
92
+ scope.attr(root, 'data-mk-font-overlay-ready', null);
93
+ const loaded = () => {
94
+ if (!scope.disposed) scope.attr(root, 'data-mk-font-overlay-ready', overlay.naturalWidth > 0 ? '' : null);
95
+ };
96
+ scope.on(overlay, 'load', loaded);
97
+ scope.on(overlay, 'error', () => {
98
+ if (!scope.disposed) scope.attr(root, 'data-mk-font-overlay-ready', null);
99
+ });
100
+ overlay.src = overlaySrc;
101
+ root.append(overlay);
102
+ if (overlay.complete) loaded();
103
+ }
104
+ scope.add(() => root.replaceChildren(...original));
105
+ scope.attr(root, 'class', `${root.className} mk-font-cycle`.trim());
106
+ scope.attr(root, 'data-mk-ready', '');
107
+
108
+ const setActive = (next: boolean) => {
109
+ if (scope.disposed || next === active) return;
110
+ active = next;
111
+ scope.attr(root, 'data-mk-active', next ? '' : null);
112
+ animation?.kill();
113
+ if (reduced() || duration === 0) gsap.set(tracks, { yPercent: next ? -50 : 0 });
114
+ else animation = gsap.to(tracks, { yPercent: next ? -50 : 0, duration, stagger, ease: 'power3.out', overwrite: true });
115
+ };
116
+ scope.on(root, 'mouseenter', () => { hovered = true; setActive(true); });
117
+ scope.on(root, 'mouseleave', () => { hovered = false; setActive(focused); });
118
+ scope.on(root, 'focusin', () => { focused = true; setActive(true); });
119
+ scope.on(root, 'focusout', (event) => {
120
+ if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return;
121
+ focused = false;
122
+ setActive(hovered);
123
+ });
124
+ scope.add(() => animation?.kill());
125
+ return { play: () => setActive(true), reset: () => setActive(false), get active() { return active; }, destroy: () => scope.dispose() };
126
+ }
@@ -0,0 +1,36 @@
1
+ .mk-font-cycle {
2
+ position: relative;
3
+ display: inline-block;
4
+ color: inherit;
5
+ font-family: var(--mk-font, sans-serif);
6
+ isolation: isolate;
7
+ }
8
+ .mk-font-cycle__accessible {
9
+ position: absolute;
10
+ width: 1px;
11
+ height: 1px;
12
+ padding: 0;
13
+ overflow: hidden;
14
+ clip-path: inset(50%);
15
+ white-space: nowrap;
16
+ }
17
+ .mk-font-cycle__visual { display: inline-flex; white-space: pre; }
18
+ .mk-font-cycle__char {
19
+ position: relative;
20
+ display: inline-grid;
21
+ overflow: hidden;
22
+ line-height: 1.12;
23
+ vertical-align: bottom;
24
+ }
25
+ .mk-font-cycle__sizer { grid-area: 1 / 1; visibility: hidden; padding-inline: .035em; }
26
+ .mk-font-cycle__track { position: absolute; inset: 0 0 auto; display: flex; flex-direction: column; will-change: transform; }
27
+ .mk-font-cycle__face { display: block; flex: none; text-align: center; padding-inline: .035em; }
28
+ .mk-font-cycle__overlay { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; pointer-events: none; opacity: 0; }
29
+ .mk-font-cycle[data-mk-active][data-mk-font-overlay-ready] .mk-font-cycle__overlay { opacity: 1; }
30
+ .mk-font-cycle[data-mk-active][data-mk-font-overlay-ready] .mk-font-cycle__visual { opacity: 0; }
31
+ .mk-font-cycle:focus-visible { outline: 2px solid var(--mk-accent, currentColor); outline-offset: .15em; }
32
+ @media (prefers-reduced-motion: reduce) {
33
+ .mk-font-cycle__track { will-change: auto; }
34
+ .mk-font-cycle[data-mk-active][data-mk-font-overlay-ready] .mk-font-cycle__visual { opacity: 1; }
35
+ .mk-font-cycle[data-mk-active][data-mk-font-overlay-ready] .mk-font-cycle__overlay { display: none; }
36
+ }