@yewdiel21/motion-kit 0.16.0 → 0.18.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 (158) hide show
  1. package/README.md +44 -0
  2. package/dist/auto/index.d.ts +23 -3
  3. package/dist/auto/index.js +43 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
  6. package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
  7. package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
  8. package/dist/chunk-Q7IW4FV3.js.map +1 -0
  9. package/dist/css/components/badge-button.css +16 -0
  10. package/dist/css/components/bento-grid.css +13 -0
  11. package/dist/css/components/drilldown-menu.css +134 -0
  12. package/dist/css/components/equalizer-slider.css +26 -0
  13. package/dist/css/components/handwriting-text.css +15 -0
  14. package/dist/css/components/magnetize-button.css +18 -0
  15. package/dist/css/components/morphing-page-dots.css +13 -0
  16. package/dist/css/components/not-found.css +22 -0
  17. package/dist/css/components/notification-stack.css +42 -0
  18. package/dist/css/components/particle-button.css +14 -0
  19. package/dist/css/components/radio-group.css +29 -0
  20. package/dist/css/components/rating-scale.css +23 -0
  21. package/dist/css/components/scroll-preview-dock.css +18 -0
  22. package/dist/css/components/select.css +16 -0
  23. package/dist/css/components/signature-pad.css +131 -0
  24. package/dist/css/components/stepper.css +36 -0
  25. package/dist/css/components/sticky-header.css +10 -0
  26. package/dist/css/components/temperature-slider.css +29 -0
  27. package/dist/css/components/voice-message-bubble.css +67 -0
  28. package/dist/css/components/waveform-player.css +51 -0
  29. package/dist/css/components/wheel-picker.css +18 -0
  30. package/dist/css/tokens.css +4 -0
  31. package/dist/elements/index.d.ts +292 -4
  32. package/dist/elements/index.js +856 -4
  33. package/dist/elements/index.js.map +1 -1
  34. package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
  35. package/dist/index.css +713 -0
  36. package/dist/index.d.ts +23 -23
  37. package/dist/index.js +43 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/react/index.d.ts +151 -7
  40. package/dist/react/index.js +893 -3
  41. package/dist/react/index.js.map +1 -1
  42. package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
  43. package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
  44. package/package.json +110 -110
  45. package/registry.json +244 -0
  46. package/src/auto/auto-init.ts +339 -293
  47. package/src/components/badge-button/README.md +28 -0
  48. package/src/components/badge-button/badge-button.css +15 -0
  49. package/src/components/badge-button/element.ts +16 -0
  50. package/src/components/badge-button/engine.ts +85 -0
  51. package/src/components/badge-button/react.tsx +31 -0
  52. package/src/components/bento-grid/README.md +26 -0
  53. package/src/components/bento-grid/bento-grid.css +12 -0
  54. package/src/components/bento-grid/element.ts +22 -0
  55. package/src/components/bento-grid/engine.ts +153 -0
  56. package/src/components/bento-grid/react.tsx +25 -0
  57. package/src/components/drilldown-menu/README.md +163 -0
  58. package/src/components/drilldown-menu/drilldown-menu.css +133 -0
  59. package/src/components/drilldown-menu/element.ts +59 -0
  60. package/src/components/drilldown-menu/engine.ts +378 -0
  61. package/src/components/drilldown-menu/react.tsx +76 -0
  62. package/src/components/equalizer-slider/README.md +43 -0
  63. package/src/components/equalizer-slider/element.ts +41 -0
  64. package/src/components/equalizer-slider/engine.ts +32 -0
  65. package/src/components/equalizer-slider/equalizer-slider.css +26 -0
  66. package/src/components/equalizer-slider/react.tsx +60 -0
  67. package/src/components/equalizer-slider/shared.ts +161 -0
  68. package/src/components/handwriting-text/README.md +29 -0
  69. package/src/components/handwriting-text/element.ts +14 -0
  70. package/src/components/handwriting-text/engine.ts +108 -0
  71. package/src/components/handwriting-text/glyphs.ts +71 -0
  72. package/src/components/handwriting-text/handwriting-text.css +14 -0
  73. package/src/components/handwriting-text/react.tsx +23 -0
  74. package/src/components/magnetize-button/README.md +26 -0
  75. package/src/components/magnetize-button/element.ts +13 -0
  76. package/src/components/magnetize-button/engine.ts +84 -0
  77. package/src/components/magnetize-button/magnetize-button.css +17 -0
  78. package/src/components/magnetize-button/react.tsx +22 -0
  79. package/src/components/morphing-page-dots/README.md +28 -0
  80. package/src/components/morphing-page-dots/element.ts +16 -0
  81. package/src/components/morphing-page-dots/engine.ts +89 -0
  82. package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
  83. package/src/components/morphing-page-dots/react.tsx +32 -0
  84. package/src/components/not-found/README.md +26 -0
  85. package/src/components/not-found/element.ts +15 -0
  86. package/src/components/not-found/engine.ts +57 -0
  87. package/src/components/not-found/not-found.css +21 -0
  88. package/src/components/not-found/react.tsx +23 -0
  89. package/src/components/notification-stack/README.md +24 -0
  90. package/src/components/notification-stack/element.ts +26 -0
  91. package/src/components/notification-stack/engine.ts +329 -0
  92. package/src/components/notification-stack/notification-stack.css +41 -0
  93. package/src/components/notification-stack/react.tsx +28 -0
  94. package/src/components/particle-button/README.md +26 -0
  95. package/src/components/particle-button/element.ts +13 -0
  96. package/src/components/particle-button/engine.ts +52 -0
  97. package/src/components/particle-button/particle-button.css +13 -0
  98. package/src/components/particle-button/react.tsx +22 -0
  99. package/src/components/radio-group/README.md +43 -0
  100. package/src/components/radio-group/element.ts +41 -0
  101. package/src/components/radio-group/engine.ts +11 -0
  102. package/src/components/radio-group/radio-group.css +28 -0
  103. package/src/components/radio-group/react.tsx +60 -0
  104. package/src/components/radio-group/shared.ts +118 -0
  105. package/src/components/rating-scale/README.md +43 -0
  106. package/src/components/rating-scale/element.ts +41 -0
  107. package/src/components/rating-scale/engine.ts +63 -0
  108. package/src/components/rating-scale/rating-scale.css +22 -0
  109. package/src/components/rating-scale/react.tsx +60 -0
  110. package/src/components/scroll-preview-dock/README.md +24 -0
  111. package/src/components/scroll-preview-dock/element.ts +20 -0
  112. package/src/components/scroll-preview-dock/engine.ts +228 -0
  113. package/src/components/scroll-preview-dock/react.tsx +26 -0
  114. package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
  115. package/src/components/select/README.md +23 -9
  116. package/src/components/select/select.css +16 -0
  117. package/src/components/signature-pad/README.md +97 -0
  118. package/src/components/signature-pad/element.ts +57 -0
  119. package/src/components/signature-pad/engine.ts +550 -0
  120. package/src/components/signature-pad/react.tsx +56 -0
  121. package/src/components/signature-pad/signature-pad.css +130 -0
  122. package/src/components/stepper/README.md +46 -0
  123. package/src/components/stepper/element.ts +42 -0
  124. package/src/components/stepper/engine.ts +103 -0
  125. package/src/components/stepper/react.tsx +60 -0
  126. package/src/components/stepper/stepper.css +35 -0
  127. package/src/components/sticky-header/README.md +30 -0
  128. package/src/components/sticky-header/element.ts +19 -0
  129. package/src/components/sticky-header/engine.ts +95 -0
  130. package/src/components/sticky-header/react.tsx +25 -0
  131. package/src/components/sticky-header/sticky-header.css +9 -0
  132. package/src/components/temperature-slider/README.md +42 -0
  133. package/src/components/temperature-slider/element.ts +53 -0
  134. package/src/components/temperature-slider/engine.ts +44 -0
  135. package/src/components/temperature-slider/react.tsx +66 -0
  136. package/src/components/temperature-slider/temperature-slider.css +29 -0
  137. package/src/components/voice-message-bubble/README.md +31 -0
  138. package/src/components/voice-message-bubble/element.ts +20 -0
  139. package/src/components/voice-message-bubble/engine.ts +79 -0
  140. package/src/components/voice-message-bubble/react.tsx +27 -0
  141. package/src/components/voice-message-bubble/voice-message-bubble.css +14 -0
  142. package/src/components/waveform-player/README.md +30 -0
  143. package/src/components/waveform-player/element.ts +21 -0
  144. package/src/components/waveform-player/engine.ts +344 -0
  145. package/src/components/waveform-player/react.tsx +27 -0
  146. package/src/components/waveform-player/waveform-player.css +50 -0
  147. package/src/components/wheel-picker/README.md +35 -0
  148. package/src/components/wheel-picker/element.ts +25 -0
  149. package/src/components/wheel-picker/engine.ts +239 -0
  150. package/src/components/wheel-picker/react.tsx +32 -0
  151. package/src/components/wheel-picker/wheel-picker.css +17 -0
  152. package/src/css/tokens.css +4 -0
  153. package/src/elements/index.ts +318 -258
  154. package/src/index.ts +21 -0
  155. package/src/react/index.ts +192 -171
  156. package/dist/chunk-SDQ776LG.js.map +0 -1
  157. /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
  158. /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
@@ -0,0 +1,43 @@
1
+ # EqualizerSlider
2
+
3
+ Un control de nivel con barras de onda y un cursor que acompaña inmediatamente al rango nativo. Las barras que cambian de estado responden con swatch; el número rueda con las teclas o la API y el cursor responde al presionarlo.
4
+
5
+ ```ts
6
+ import { createEqualizerSlider } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/equalizer-slider';
8
+
9
+ const control = createEqualizerSlider(document.querySelector('.mk-equalizer-slider'), {
10
+ value: 60, name: 'volume', label: 'Volumen',
11
+ onInput: (value) => { audio.volume = value / 100; },
12
+ });
13
+ control.setValue(35);
14
+ ```
15
+
16
+ ## Opciones y contrato
17
+
18
+ `min` (0), `max` (100), `step` (1), `value` / `defaultValue` (60), `waveform: number[]` (alturas 0–1), `bars` (40, 12–72), `label`, `name`, `disabled`, `error`, `formatValue`, `onInput(value)`, `onChange(value)`.
19
+
20
+ Es un control numérico; no reproduce, captura ni analiza audio. waveform es una forma decorativa estática. onInput se emite durante el ajuste; onChange al confirmar, una sola vez por valor. En React se puede actualizar con onInput o confirmar con onChange. El arrastre siempre cambia la geometría sin esperar a una animación.
21
+
22
+ La instancia expone `value: number`, `setValue(value, notify = false)`, `reset(notify = false)` y `destroy()`. Las escrituras imperativas actualizan el control incluso estando disabled. `setValue` no cambia el valor inicial al que vuelve reset. Las notificaciones usan callbacks y eventos `mk:input / mk:change` con `detail: { value }`.
23
+
24
+ ## React y HTML
25
+
26
+ ```tsx
27
+ import { EqualizerSlider } from '@yewdiel21/motion-kit/react';
28
+
29
+ <EqualizerSlider value={level} onInput={setLevel} label="Nivel" name="level" />
30
+ ```
31
+
32
+ `value` controla la instancia; `defaultValue` inicia una instancia que conserva sus propios cambios. Las opciones pueden cambiar sin perder la selección no controlada. `instanceRef` recibe la instancia y admite cambiar de objeto ref. Los children y dangerouslySetInnerHTML no se pasan al host porque el motor posee ese DOM.
33
+
34
+ ```html
35
+ <mk-equalizer-slider class="mk-equalizer-slider" value="60" label="Nivel" name="level" bars="44"></mk-equalizer-slider>
36
+ ```
37
+
38
+ Registra con `defineEqualizerSlider()` desde `@yewdiel21/motion-kit/elements` o utiliza el auto registro. Los atributos de opciones se pueden cambiar en vivo; el valor imperativo se conserva al cambiar otra opción. `disabled="false"` se lee como false. El elemento expone `value`, `setValue`, `reset`.
39
+
40
+ ## Accesibilidad y lifecycle
41
+
42
+ Se conserva el input nativo, su etiqueta, teclado, tacto, validación y participación en formularios. El reset de formulario es cancelable; el control espera al reset nativo y verifica defaultPrevented. Movimiento reducido conserva toda la funcionalidad. destroy es idempotente y libera animaciones, eventos y timers, elimina solo las partes creadas y restaura los atributos del host. Los temas utilizan DM Sans y tokens --mk-*; el CSS de este componente es independiente de los otros estilos.
43
+
@@ -0,0 +1,41 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createEqualizerSlider, type EqualizerSliderInstance } from './engine';
3
+
4
+ /** Light DOM; attribute changes keep the current imperative value and reset baseline. */
5
+ export class MkEqualizerSliderElement extends MkElement<EqualizerSliderInstance> {
6
+ static options: Record<string, OptionType> = {"min":"number","max":"number","step":"number","value":"number","label":"string","name":"string","disabled":"boolean","error":"string","waveform":"json","bars":"number"};
7
+ private baseline: number | undefined;
8
+ private saved: number | undefined;
9
+ protected create(options: Record<string, unknown>) {
10
+ const created = createEqualizerSlider(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as number | undefined, defaultValue: this.baseline });
11
+ if (this.baseline === undefined) this.baseline = created.value;
12
+ this.saved = undefined;
13
+ return created;
14
+ }
15
+ disconnectedCallback() {
16
+ this.saved = this.instance?.value;
17
+ super.disconnectedCallback();
18
+ }
19
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
20
+ if (previous === next) return;
21
+ if (name === 'value' && (!this.instance || !this.isConnected)) {
22
+ this.saved = next === null ? this.baseline ?? 60 : Number.isFinite(Number(next)) ? Number(next) : this.baseline ?? 60;
23
+ return;
24
+ }
25
+ if (!this.instance || !this.isConnected) return;
26
+ if (name === 'value') {
27
+ const parsed = next === null ? this.baseline ?? 60 : Number(next);
28
+ this.instance.setValue(parsed, false);
29
+ return;
30
+ }
31
+ this.saved = this.instance.value;
32
+ super.attributeChangedCallback(name, previous, next);
33
+ }
34
+ get value(): number { return this.instance?.value ?? this.saved ?? 60; }
35
+ set value(next: number) { this.setValue(next); }
36
+ setValue(value: number, notify = false) { if (this.instance) this.instance.setValue(value, notify); else this.saved = value; }
37
+ reset(notify = false) { this.instance?.reset(notify); }
38
+
39
+ }
40
+ export const defineEqualizerSlider = () => defineElement('mk-equalizer-slider', MkEqualizerSliderElement);
41
+
@@ -0,0 +1,32 @@
1
+ import { gsap, EASE } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createNumericRange, type NumericOptions, type NumericInstance } from './shared';
4
+
5
+ export interface EqualizerSliderOptions extends NumericOptions {
6
+ /** Static waveform heights 0…1. This control does not analyze or play audio. */
7
+ waveform?: number[];
8
+ /** Number of decorative bars, 12…72; default 40. */
9
+ bars?: number;
10
+ }
11
+ export interface EqualizerSliderInstance extends NumericInstance {}
12
+ export function createEqualizerSlider(root: HTMLElement | null, options: EqualizerSliderOptions = {}): EqualizerSliderInstance {
13
+ return createNumericRange(root, options, {
14
+ prefix: 'equalizer', label: 'Volumen', min: 0, max: 100, value: 60, format: (n) => `${n} %`,
15
+ decorate({ track, keep }) {
16
+ const wave = document.createElement('span'); wave.dataset.mkEqualizerWave = ''; wave.setAttribute('aria-hidden', 'true'); track.prepend(wave);
17
+ const count = Math.max(12, Math.min(72, Math.round(Number.isFinite(options.bars) ? options.bars! : 40)));
18
+ const bars = Array.from({ length: count }, (_, i) => {
19
+ const bar = document.createElement('span');
20
+ const supplied = options.waveform?.[i % options.waveform.length];
21
+ const height = Number.isFinite(supplied) ? Math.max(0.1, Math.min(1, supplied!)) : 0.25 + 0.75 * Math.abs(Math.sin(i * 1.73) * Math.cos(i * 0.41));
22
+ bar.style.height = `${height * 100}%`; wave.append(bar); return bar;
23
+ });
24
+ return (ratio, _value, animate) => bars.forEach((bar, i) => {
25
+ const on = i / Math.max(1, count - 1) <= ratio;
26
+ const changed = bar.hasAttribute('data-mk-on') !== on;
27
+ bar.toggleAttribute('data-mk-on', on);
28
+ if (animate && changed && !reduced()) keep(gsap.fromTo(bar, { scaleY: 0.72 }, { scaleY: 1, duration: 0.35, ease: EASE.swatch, overwrite: 'auto', clearProps: 'transform' }));
29
+ });
30
+ },
31
+ });
32
+ }
@@ -0,0 +1,26 @@
1
+ :is(.mk-equalizer-slider, mk-equalizer-slider) { display: block; width: 100%; min-width: 0; color: var(--mk-text); font: 500 .875rem/1.4 var(--mk-font); }
2
+ [data-mk-equalizer-body] { display: grid; gap: .875rem; }
3
+ [data-mk-equalizer-header] { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
4
+ [data-mk-equalizer-output] { font-variant-numeric: tabular-nums; white-space: nowrap; }
5
+ [data-mk-equalizer-track] { position: relative; height: 76px; min-width: 0; border-radius: 24px; background: var(--mk-surface-2); }
6
+ [data-mk-equalizer-input] { position: absolute; z-index: 3; inset: 0; box-sizing: border-box; width: 100%; height: 100%; margin: 0; appearance: none; -webkit-appearance: none; background: transparent; cursor: ew-resize; opacity: 0; }
7
+ [data-mk-equalizer-input]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 32px; height: 76px; background: transparent; }
8
+ [data-mk-equalizer-input]::-moz-range-thumb { width: 32px; height: 76px; border: 0; background: transparent; }
9
+ [data-mk-equalizer-thumb] { position: absolute; z-index: 2; top: 50%; left: calc(16px + (100% - 32px) * var(--mk-equalizer-ratio, .5)); translate: -50% -50%; pointer-events: none; }
10
+ [data-mk-equalizer-track]:has(:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
11
+ [data-mk-equalizer-ends] { display: flex; justify-content: space-between; margin-top: -.375rem; color: var(--mk-muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
12
+ [data-mk-equalizer-message] { color: var(--mk-error); font-size: .8125rem; font-weight: 400; }
13
+ [data-mk-equalizer-message]:empty { display: none; }
14
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] { opacity: .4; }
15
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] [data-mk-equalizer-input] { cursor: not-allowed; }
16
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-error] [data-mk-equalizer-track] { outline: 1px solid var(--mk-error); }
17
+ [data-mk-equalizer-output] { padding: .375rem .75rem; border-radius: 64px; background: var(--mk-surface-2); font-size: .8125rem; }
18
+ [data-mk-equalizer-wave] { position: absolute; inset: 14px 16px; display: flex; align-items: center; gap: 3px; pointer-events: none; }
19
+ [data-mk-equalizer-wave] > span { flex: 1; min-width: 1px; border-radius: 64px; background: var(--mk-outline-soft); transform-origin: center; transition: background .16s; }
20
+ [data-mk-equalizer-wave] > span[data-mk-on] { background: var(--mk-text); }
21
+ [data-mk-equalizer-thumb] { width: 4px; height: 54px; border: 3px solid var(--mk-surface-2); border-radius: 64px; background: var(--mk-text); box-sizing: content-box; transition: height .35s var(--mk-ease-swatch), border-width .35s var(--mk-ease-swatch); }
22
+ :is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-held] [data-mk-equalizer-thumb] { height: 66px; border-width: 5px; }
23
+ @media (hover: hover) { [data-mk-equalizer-track]:has(:not(:disabled):hover) [data-mk-equalizer-thumb] { height: 62px; } }
24
+ @media (prefers-reduced-motion: reduce) { [data-mk-equalizer-thumb], [data-mk-equalizer-wave] > span { transition: none; } }
25
+ @media (forced-colors: active) { [data-mk-equalizer-wave] > span { background: GrayText; } [data-mk-equalizer-wave] > span[data-mk-on], [data-mk-equalizer-thumb] { background: Highlight; } }
26
+
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createEqualizerSlider, type EqualizerSliderOptions, type EqualizerSliderInstance } from './engine';
5
+
6
+ export interface EqualizerSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<EqualizerSliderOptions, 'value' | 'defaultValue'> {
7
+ value?: number;
8
+ defaultValue?: number;
9
+ instanceRef?: MutableRefObject<EqualizerSliderInstance | null>;
10
+ /** The engine owns this component's DOM. */
11
+ children?: never;
12
+ dangerouslySetInnerHTML?: never;
13
+ }
14
+ export function EqualizerSlider({ value, defaultValue, min, max, step, label, name, disabled, error, formatValue, waveform, bars, onChange, onInput, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: EqualizerSliderProps) {
15
+ const root = useRef<HTMLDivElement>(null);
16
+ const engine = useRef<EqualizerSliderInstance | null>(null);
17
+ const latestValue = useLatest(value);
18
+ const change = useLatest(onChange);
19
+ const input = useLatest(onInput);
20
+ const baseline = useRef(value !== undefined ? value : defaultValue);
21
+ const current = useRef(baseline.current);
22
+ const reconcile = () => queueMicrotask(() => {
23
+ if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
24
+ });
25
+ useIsomorphicLayoutEffect(() => {
26
+ const created = createEqualizerSlider(root.current, {
27
+ min, max, step, label, name, disabled, error, formatValue, waveform, bars, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
28
+ onChange: (next) => { change.current?.(next); reconcile(); },
29
+ onInput: (next) => { input.current?.(next); if (input.current || !change.current) reconcile(); },
30
+ });
31
+ engine.current = created;
32
+ const form = root.current?.closest('form');
33
+ const pending = new Set<ReturnType<typeof setTimeout>>();
34
+ const reset = (event: Event) => {
35
+ const timer = setTimeout(() => {
36
+ pending.delete(timer);
37
+ if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
38
+ }, 0);
39
+ pending.add(timer);
40
+ };
41
+ form?.addEventListener('reset', reset);
42
+ return () => {
43
+ form?.removeEventListener('reset', reset);
44
+ pending.forEach((timer) => clearTimeout(timer));
45
+ current.current = created.value; created.destroy(); engine.current = null;
46
+ };
47
+ // Callbacks and controlled value are read through refs.
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, [min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
50
+ useIsomorphicLayoutEffect(() => {
51
+ if (value !== undefined) engine.current?.setValue(value, false);
52
+ });
53
+ useIsomorphicLayoutEffect(() => {
54
+ if (!instanceRef) return;
55
+ instanceRef.current = engine.current;
56
+ return () => { instanceRef.current = null; };
57
+ }, [instanceRef, min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
58
+ return <div {...props} ref={root} className={['mk-equalizer-slider', className].filter(Boolean).join(' ')} />;
59
+ }
60
+
@@ -0,0 +1,161 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope, type Scope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { press, rollDigits } from '../../motion/micro';
6
+
7
+ export interface NumericOptions {
8
+ min?: number; max?: number; step?: number; value?: number; defaultValue?: number; label?: string;
9
+ name?: string; disabled?: boolean; error?: string; formatValue?: (value: number) => string;
10
+ onInput?: (value: number) => void; onChange?: (value: number) => void;
11
+ }
12
+ export interface NumericInstance extends Instance { readonly value: number; setValue(value: number, notify?: boolean): void; reset(notify?: boolean): void }
13
+ /** Private: changes the range or format in place, so the change can glide instead of remounting. */
14
+ export interface NumericRange extends NumericInstance { configure(next: { min?: number; max?: number; step?: number; format?: (value: number) => string; value?: number }, notify?: boolean): void }
15
+ export interface NumericParts { root: HTMLElement; track: HTMLElement; thumb: HTMLElement; scope: Scope; keep(effect: gsap.core.Animation | null): void }
16
+ export interface NumericDesign {
17
+ prefix: string; label: string; min: number; max: number; value: number;
18
+ format(value: number): string;
19
+ decorate?(parts: NumericParts): ((ratio: number, value: number, animate: boolean) => void) | void;
20
+ }
21
+ const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
22
+ let sequence = 0;
23
+
24
+ /** Private renderer: transparent native range over an immediate visual track. */
25
+ export function createNumericRange(root: HTMLElement | null, options: NumericOptions, design: NumericDesign): NumericRange {
26
+ if (!root) return inert({ value: design.value, setValue() {}, reset() {}, configure() {} });
27
+ registerMotion();
28
+ const scope = createScope();
29
+ const effects = new Set<gsap.core.Animation>();
30
+ const keep = (effect: gsap.core.Animation | null) => {
31
+ for (const prior of effects) if (prior.totalProgress() === 1) effects.delete(prior);
32
+ if (effect) effects.add(effect);
33
+ };
34
+ scope.add(() => { effects.forEach((effect) => effect.kill()); effects.clear(); });
35
+ let min = finite(options.min) ? options.min : design.min;
36
+ let max = finite(options.max) && options.max > min ? options.max : Math.max(min + 1, design.max);
37
+ let step = finite(options.step) && options.step > 0 ? options.step : 1;
38
+ let lastStep = Math.max(0, Math.floor((max - min) / step + 1e-8));
39
+ let format = options.formatValue ?? design.format;
40
+ const snap = (n: number) => Number((min + Math.max(0, Math.min(lastStep, Math.round((n - min) / step))) * step).toFixed(8));
41
+ const initial = snap(finite(options.defaultValue) ? options.defaultValue : finite(options.value) ? options.value : design.value);
42
+ let value = snap(finite(options.value) ? options.value : initial); let committed = value; let held = false;
43
+ const prefix = design.prefix;
44
+ const part = <K extends keyof HTMLElementTagNameMap>(tag: K, parent: Element, name: string) => {
45
+ const el = document.createElement(tag); el.setAttribute(`data-mk-${prefix}-${name}`, ''); parent.append(el); return el;
46
+ };
47
+ const shell = part('div', root, 'body');
48
+ scope.add(() => shell.remove());
49
+ const header = part('div', shell, 'header');
50
+ const label = part('label', header, 'label');
51
+ label.textContent = options.label ?? design.label;
52
+ const output = part('output', header, 'output');
53
+ output.setAttribute('aria-hidden', 'true');
54
+ const track = part('div', shell, 'track');
55
+ const thumb = part('span', track, 'thumb');
56
+ thumb.setAttribute('aria-hidden', 'true');
57
+ const input = part('input', track, 'input');
58
+ input.type = 'range'; input.min = String(min); input.max = String(max); input.step = String(step);
59
+ input.defaultValue = String(initial); input.value = String(value);
60
+ input.id = `mk-${prefix}-${++sequence}`; label.htmlFor = input.id;
61
+ input.disabled = Boolean(options.disabled);
62
+ if (options.name) input.name = options.name;
63
+ const message = part('span', shell, 'message');
64
+ message.textContent = options.error ?? '';
65
+ if (options.error) {
66
+ message.id = `${input.id}-error`; input.setAttribute('aria-invalid', 'true'); input.setAttribute('aria-describedby', message.id);
67
+ }
68
+ const ends = part('div', shell, 'ends');
69
+ ends.setAttribute('aria-hidden', 'true');
70
+ const minEnd = part('span', ends, 'min'); minEnd.textContent = format(min);
71
+ const maxEnd = part('span', ends, 'max'); maxEnd.textContent = format(max);
72
+ scope.attr(root, 'data-mk-ready', '');
73
+ scope.attr(root, 'data-mk-disabled', options.disabled ? '' : null);
74
+ scope.attr(root, 'data-mk-error', options.error ? '' : null);
75
+ scope.attr(root, 'data-mk-held', null);
76
+ scope.attr(root, 'style', root.getAttribute('style'));
77
+ const decorate = design.decorate?.({ root, track, thumb, scope, keep });
78
+ let shown: number | null = null;
79
+ let glide: gsap.core.Tween | null = null;
80
+ scope.add(() => glide?.kill());
81
+ const show = (ratio: number, animate: boolean) => {
82
+ shown = ratio;
83
+ root.style.setProperty(`--mk-${prefix}-ratio`, String(ratio));
84
+ decorate?.(ratio, value, animate);
85
+ };
86
+ const paint = (animate: boolean, before: number) => {
87
+ const ratio = (value - min) / (max - min);
88
+ const text = format(value);
89
+ input.value = String(value); input.setAttribute('aria-valuetext', text);
90
+ if (animate && !held && !reduced()) keep(rollDigits(output, text, value >= before ? 1 : -1, 0.3));
91
+ else { const roll = rollDigits(output, text, value >= before ? 1 : -1, 0); roll?.progress(1).kill(); output.textContent = text; }
92
+ glide?.kill(); glide = null;
93
+ // Presets, keys and range changes glide on the kit's swatch spring; a held
94
+ // thumb follows the pointer exactly. The value itself is already final.
95
+ if (animate && !held && !reduced() && shown !== null && Math.abs(ratio - shown) > 1e-4) {
96
+ const proxy = { ratio: shown };
97
+ glide = gsap.to(proxy, { ratio, duration: 0.5, ease: EASE.swatch, onUpdate: () => show(Math.max(0, Math.min(1, proxy.ratio)), true) });
98
+ } else show(ratio, animate && !held);
99
+ };
100
+ const emit = (type: 'input' | 'change') => {
101
+ if (scope.disposed) return;
102
+ const emitted = value;
103
+ (type === 'input' ? options.onInput : options.onChange)?.(emitted);
104
+ if (!scope.disposed) root.dispatchEvent(new CustomEvent(`mk:${type}`, { bubbles: true, detail: { value: emitted } }));
105
+ };
106
+ const set = (next: number, notify = false, animate = true) => {
107
+ if (scope.disposed || !finite(next)) return;
108
+ const before = value; value = snap(next); paint(animate, before);
109
+ if (notify && value !== before) { emit('input'); committed = value; emit('change'); }
110
+ // A controlled onInput render commonly writes this same value back before
111
+ // the native change event. Keep its pending commit until release.
112
+ else if (!notify && value !== before) committed = value;
113
+ };
114
+ scope.on(input, 'input', () => {
115
+ if (input.disabled || input.matches(':disabled')) { input.value = String(value); return; }
116
+ const before = value; value = snap(Number(input.value)); paint(true, before);
117
+ if (value !== before) emit('input');
118
+ });
119
+ scope.on(input, 'change', () => {
120
+ if (input.disabled || input.matches(':disabled')) return;
121
+ const before = value; value = snap(Number(input.value));
122
+ if (value !== before) paint(true, before);
123
+ if (value !== committed) { committed = value; emit('change'); }
124
+ });
125
+ scope.on(input, 'pointerdown', () => {
126
+ if (input.disabled || input.matches(':disabled')) return;
127
+ held = true; root.setAttribute('data-mk-held', ''); keep(press(thumb));
128
+ });
129
+ const release = () => {
130
+ if (!held) return;
131
+ held = false; root.removeAttribute('data-mk-held');
132
+ if (!reduced()) keep(gsap.fromTo(thumb, { scale: 1.12 }, { scale: 1, duration: 0.4, ease: EASE.swatch, overwrite: 'auto', clearProps: 'transform' }));
133
+ };
134
+ scope.on(window, 'pointerup', release); scope.on(window, 'pointercancel', release); scope.on(input, 'blur', release);
135
+ if (input.form) scope.on(input.form, 'reset', (event) => scope.timeout(() => {
136
+ if (!scope.disposed && !event.defaultPrevented) set(initial);
137
+ }, 0));
138
+ const configure: NumericRange['configure'] = (next, notify = false) => {
139
+ if (scope.disposed) return;
140
+ const before = value;
141
+ if (finite(next.min)) min = next.min;
142
+ if (finite(next.max)) max = next.max > min ? next.max : min + 1;
143
+ if (finite(next.step) && next.step > 0) step = next.step;
144
+ if (next.format) format = next.format;
145
+ lastStep = Math.max(0, Math.floor((max - min) / step + 1e-8));
146
+ input.min = String(min); input.max = String(max); input.step = String(step);
147
+ const ends = [[minEnd, format(min)], [maxEnd, format(max)]] as const;
148
+ for (const [end, text] of ends) {
149
+ if (reduced()) end.textContent = text;
150
+ else keep(rollDigits(end, text, 1, 0.3));
151
+ }
152
+ value = snap(finite(next.value) ? next.value : value);
153
+ paint(true, before);
154
+ if (value !== before) {
155
+ committed = value;
156
+ if (notify) { emit('input'); emit('change'); }
157
+ }
158
+ };
159
+ paint(false, value);
160
+ return { get value() { return value; }, setValue: set, reset: (notify = false) => set(initial, notify), configure, destroy: () => scope.dispose() };
161
+ }
@@ -0,0 +1,29 @@
1
+ # Handwriting text
2
+
3
+ Una línea de tinta se escribe con trazos SVG originales. Cada trazo se dibuja mediante `stroke-dashoffset`; no es una animación de texto tecleado ni depende de una fuente externa. Incluye glifos latinos A–Z/a–z, cifras, puntuación básica y acentos agudo, diéresis y tilde. Los símbolos no incluidos se muestran como texto SVG normal, sin fingir que se escriben a mano. El texto completo conserva su nombre accesible.
4
+
5
+ ```ts
6
+ import { createHandwritingText } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/handwriting-text';
8
+
9
+ const drawing = createHandwritingText(document.querySelector('#signature'), {
10
+ text: 'Make it move.', duration: 2.4,
11
+ });
12
+ drawing.setText('Una nueva idea');
13
+ drawing.replay();
14
+ // Al desmontar: drawing.destroy();
15
+ ```
16
+
17
+ `HandwritingTextOptions`: `text` (por defecto el texto del host), `duration` (segundos, 2.4), `autoplay` (true), `onComplete`. `HandwritingTextInstance`: `setText(text, animate = true)`, `replay()`, `text`, `destroy()`. Emite `mk:complete` cuando termina una reproducción animada. Una cadena vacía produce un estado vacío; admite saltos de línea. Movimiento reducido muestra los trazos completos inmediatamente.
18
+
19
+ ```tsx
20
+ <HandwritingText text={message} instanceRef={drawingRef} />
21
+ ```
22
+
23
+ El adaptador React controla `text`; recibe `instanceRef` y los atributos de un span. El motor posee los hijos, por lo que `children` y `dangerouslySetInnerHTML` no se admiten y se filtran también en ejecución.
24
+
25
+ ```html
26
+ <mk-handwriting-text text="Make it move." duration="2.4"></mk-handwriting-text>
27
+ ```
28
+
29
+ `defineHandwritingText()` registra `MkHandwritingTextElement`. Sus atributos son `text`, `duration` y `autoplay`; `element.instance` expone el motor. Conserva el texto imperativo al cambiar presentación o reconectar; un cambio explícito del atributo `text` lo reemplaza. CSS: `--mk-handwriting-width` (28rem); hereda `color` y todos los temas. `destroy()` detiene la reproducción y devuelve los nodos y atributos originales.
@@ -0,0 +1,14 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createHandwritingText, type HandwritingTextInstance } from './engine';
3
+
4
+ export class MkHandwritingTextElement extends MkElement<HandwritingTextInstance> {
5
+ static options: Record<string, OptionType> = { text: 'string', duration: 'number', autoplay: 'boolean' };
6
+ private retained: string | undefined;
7
+ protected create(options: Record<string, unknown>) { return createHandwritingText(this, { ...options, ...(this.retained === undefined ? {} : { text: this.retained }) }); }
8
+ disconnectedCallback() { this.retained = this.instance?.text ?? this.retained; super.disconnectedCallback(); }
9
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
10
+ if (previous !== next) this.retained = name === 'text' ? undefined : this.instance?.text ?? this.retained;
11
+ super.attributeChangedCallback(name, previous, next);
12
+ }
13
+ }
14
+ export const defineHandwritingText = () => defineElement('mk-handwriting-text', MkHandwritingTextElement);
@@ -0,0 +1,108 @@
1
+ import { gsap, EASE, 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 { GLYPHS } from './glyphs';
6
+
7
+ export interface HandwritingTextOptions {
8
+ text?: string;
9
+ /** Duration of the complete pen drawing in seconds, default 2.4. */
10
+ duration?: number;
11
+ autoplay?: boolean;
12
+ onComplete?: () => void;
13
+ }
14
+ export interface HandwritingTextInstance extends Instance {
15
+ setText(text: string, animate?: boolean): void;
16
+ replay(): void;
17
+ readonly text: string;
18
+ }
19
+
20
+ const SVG = 'http://www.w3.org/2000/svg';
21
+ export function createHandwritingText(root: HTMLElement | null, options: HandwritingTextOptions = {}): HandwritingTextInstance {
22
+ if (!root) return inert({ setText() {}, replay() {}, text: '' });
23
+ registerMotion();
24
+ const scope = createScope();
25
+ let text = options.text ?? root.textContent ?? '';
26
+ const authored = [...root.childNodes];
27
+ const svg = document.createElementNS(SVG, 'svg');
28
+ svg.setAttribute('aria-hidden', 'true');
29
+ svg.dataset.mkHandwritingDrawing = '';
30
+ root.replaceChildren(svg);
31
+ scope.add(() => { svg.remove(); root.append(...authored); });
32
+ scope.attr(root, 'data-mk-handwriting', '');
33
+ scope.attr(root, 'data-mk-ready', '');
34
+ scope.attr(root, 'role', 'img');
35
+ let strokes: SVGPathElement[] = [];
36
+ let timeline: gsap.core.Timeline | null = null;
37
+ const duration = Number.isFinite(options.duration) ? Math.max(0.1, Math.min(30, options.duration!)) : 2.4;
38
+ const complete = () => {
39
+ if (scope.disposed) return;
40
+ options.onComplete?.();
41
+ root.dispatchEvent(new CustomEvent('mk:complete', { bubbles: true }));
42
+ };
43
+ const replay = () => {
44
+ if (scope.disposed) return;
45
+ timeline?.kill();
46
+ timeline = null;
47
+ if (reduced() || !strokes.length) { gsap.set(strokes, { strokeDashoffset: 0 }); return; }
48
+ gsap.set(strokes, { strokeDashoffset: 1 });
49
+ timeline = gsap.timeline({ onComplete: complete });
50
+ // Each pen lift is explicit: the paths draw sequentially, never by revealing typed characters.
51
+ const step = duration / strokes.length;
52
+ for (const stroke of strokes) timeline.to(stroke, { strokeDashoffset: 0, duration: step, ease: EASE.wipe });
53
+ };
54
+ const draw = () => {
55
+ timeline?.kill();
56
+ timeline = null;
57
+ strokes = [];
58
+ svg.replaceChildren();
59
+ scope.attr(root, 'aria-label', text);
60
+ scope.attr(root, 'data-mk-empty', text ? null : '');
61
+ let x = 5;
62
+ let y = 6;
63
+ let width = 10;
64
+ for (const char of text) {
65
+ if (char === '\n') { width = Math.max(width, x + 5); x = 5; y += 64; continue; }
66
+ if (/\s/u.test(char)) { x += 14; continue; }
67
+ const normalized = char.normalize('NFD');
68
+ const glyph = GLYPHS[normalized[0]!];
69
+ if (glyph) {
70
+ const paths = [...glyph.strokes];
71
+ if (normalized.includes('\u0301')) paths.push('M16 14L23 7');
72
+ if (normalized.includes('\u0303')) paths.push('M11 14Q16 8 20 13Q24 17 28 10');
73
+ if (normalized.includes('\u0308')) paths.push('M12 12L12.3 12.3M23 12L23.3 12.3');
74
+ for (const d of paths) {
75
+ const path = document.createElementNS(SVG, 'path');
76
+ path.setAttribute('d', d);
77
+ path.setAttribute('transform', `translate(${x} ${y})`);
78
+ path.setAttribute('pathLength', '1');
79
+ path.setAttribute('stroke-dasharray', '1');
80
+ path.setAttribute('stroke-dashoffset', '0');
81
+ svg.append(path);
82
+ strokes.push(path);
83
+ }
84
+ x += glyph.width;
85
+ } else {
86
+ // Unsupported symbols remain honest, readable SVG text; they are never claimed as pen strokes.
87
+ const fallback = document.createElementNS(SVG, 'text');
88
+ fallback.textContent = char;
89
+ fallback.setAttribute('x', String(x));
90
+ fallback.setAttribute('y', String(y + 42));
91
+ fallback.setAttribute('data-mk-handwriting-fallback', '');
92
+ svg.append(fallback);
93
+ x += 34;
94
+ }
95
+ }
96
+ svg.setAttribute('viewBox', `0 0 ${Math.max(width, x + 8)} ${y + 64}`);
97
+ svg.setAttribute('preserveAspectRatio', 'xMinYMid meet');
98
+ };
99
+ draw();
100
+ if (options.autoplay !== false) replay();
101
+ scope.add(() => { timeline?.kill(); gsap.killTweensOf(strokes); });
102
+ return {
103
+ replay,
104
+ setText(next, animate = true) { if (scope.disposed || next === text) return; text = String(next); draw(); if (animate) replay(); },
105
+ get text() { return text; },
106
+ destroy: () => scope.dispose(),
107
+ };
108
+ }
@@ -0,0 +1,71 @@
1
+ /** Original monoline pen drawings. Coordinates use a 44-unit baseline; no font outlines. */
2
+ export const GLYPHS: Record<string, { width: number; strokes: string[] }> = {
3
+ A: { width: 35, strokes: ['M2 43L26 5L27 42', 'M10 30L31 28'] },
4
+ B: { width: 34, strokes: ['M4 43L14 7', 'M10 8C40 -2 35 25 11 25C40 14 37 45 4 43'] },
5
+ C: { width: 34, strokes: ['M33 12C18 -8 -3 20 5 36Q12 52 31 35'] },
6
+ D: { width: 35, strokes: ['M5 43L15 7', 'M10 7C46 -3 40 48 4 43'] },
7
+ E: { width: 30, strokes: ['M31 7L13 9L4 42L29 40', 'M10 25L27 23'] },
8
+ F: { width: 29, strokes: ['M4 43L14 8L32 7', 'M10 25L27 24'] },
9
+ G: { width: 36, strokes: ['M33 12C15 -8 -4 27 7 39Q21 51 31 26L19 28'] },
10
+ H: { width: 36, strokes: ['M4 43L14 7', 'M28 43L37 7', 'M8 27L32 24'] },
11
+ I: { width: 20, strokes: ['M17 8L8 42', 'M9 9L25 7', 'M1 43L17 41'] },
12
+ J: { width: 28, strokes: ['M19 8L32 7L24 33Q17 54 3 38'] },
13
+ K: { width: 34, strokes: ['M4 43L14 7', 'M35 7L10 29L29 43'] },
14
+ L: { width: 28, strokes: ['M14 7L4 42Q15 39 29 41'] },
15
+ M: { width: 47, strokes: ['M3 43L15 7L22 35L44 7L35 43'] },
16
+ N: { width: 37, strokes: ['M4 43L14 7L28 42L38 7'] },
17
+ O: { width: 36, strokes: ['M25 7C5 1 -5 43 15 43C35 46 46 3 25 7Z'] },
18
+ P: { width: 32, strokes: ['M4 43L14 8C40 -2 39 30 8 27'] },
19
+ Q: { width: 37, strokes: ['M25 7C5 1 -5 43 15 43C35 46 46 3 25 7Z', 'M19 33L33 48'] },
20
+ R: { width: 34, strokes: ['M4 43L14 8C40 -2 39 30 8 27L29 44'] },
21
+ S: { width: 31, strokes: ['M31 12C19 -5 -4 16 16 25C39 35 12 55 2 36'] },
22
+ T: { width: 31, strokes: ['M3 10L34 7', 'M20 9L11 43'] },
23
+ U: { width: 36, strokes: ['M13 7L5 31C-1 52 27 48 31 29L37 7'] },
24
+ V: { width: 35, strokes: ['M7 7L10 43L37 7'] },
25
+ W: { width: 48, strokes: ['M7 7L9 43L27 15L28 43L50 7'] },
26
+ X: { width: 34, strokes: ['M10 7L28 43', 'M36 7L2 43'] },
27
+ Y: { width: 34, strokes: ['M8 7L19 27L37 7', 'M19 27L14 43'] },
28
+ Z: { width: 32, strokes: ['M7 9L34 7L2 43L29 41'] },
29
+ a: { width: 27, strokes: ['M23 23C9 12 2 40 13 42C21 44 26 21 23 22L20 39Q20 47 29 37'] },
30
+ b: { width: 27, strokes: ['M4 42Q21 -6 13 3Q5 7 7 36C19 7 34 30 19 40Q11 46 7 38L29 36'] },
31
+ c: { width: 25, strokes: ['M23 24C17 13 3 26 6 37Q10 49 27 35'] },
32
+ d: { width: 28, strokes: ['M22 24C8 12 0 39 11 42Q20 46 29 4Q31 -1 27 3L21 38Q21 46 31 35'] },
33
+ e: { width: 25, strokes: ['M5 33Q25 29 21 21Q17 13 8 27C-1 43 15 49 27 35'] },
34
+ f: { width: 23, strokes: ['M2 57Q1 63 7 52L22 7Q24 -3 16 5Q7 17 11 27L24 24', 'M3 27L20 26'] },
35
+ g: { width: 28, strokes: ['M24 24C10 12 1 39 12 42Q20 45 24 23L18 54Q12 68 6 57Q3 48 31 36'] },
36
+ h: { width: 28, strokes: ['M4 42Q24 -6 14 4Q8 10 5 41C17 9 26 18 21 34Q17 49 31 35'] },
37
+ i: { width: 15, strokes: ['M9 24L5 36Q2 47 18 35', 'M12 13L12.2 13.2'] },
38
+ j: { width: 18, strokes: ['M13 24L6 53Q1 68 -4 56Q-6 48 21 35', 'M16 13L16.2 13.2'] },
39
+ k: { width: 27, strokes: ['M4 42Q24 -7 14 4Q8 10 5 41', 'M25 22Q8 38 10 32Q17 27 19 37Q21 45 30 35'] },
40
+ l: { width: 18, strokes: ['M3 32Q28 -4 15 3Q7 8 6 31Q3 49 21 35'] },
41
+ m: { width: 40, strokes: ['M3 41L9 23L5 39Q18 11 20 25L17 39Q31 11 32 25L29 36Q28 46 43 35'] },
42
+ n: { width: 28, strokes: ['M3 41L9 23L5 39Q21 10 23 25L20 36Q19 46 31 35'] },
43
+ o: { width: 27, strokes: ['M22 22C7 14 0 39 12 42Q22 46 26 28Q26 18 19 24Q15 33 30 32'] },
44
+ p: { width: 28, strokes: ['M-1 58L11 22L7 35Q19 12 25 25Q29 40 14 42L8 39L31 35'] },
45
+ q: { width: 28, strokes: ['M23 24C10 12 1 39 12 42Q22 45 24 23L17 58Q15 62 19 53L31 36'] },
46
+ r: { width: 23, strokes: ['M2 41L9 22Q8 31 16 24Q20 18 21 23Q21 27 17 28L26 35'] },
47
+ s: { width: 24, strokes: ['M22 21Q18 15 10 24Q6 28 17 33Q25 39 14 43Q3 46 6 38L27 35'] },
48
+ t: { width: 21, strokes: ['M16 10L7 35Q3 48 24 35', 'M2 25L23 23'] },
49
+ u: { width: 29, strokes: ['M9 23L4 35Q1 52 24 24L20 38Q21 45 32 35'] },
50
+ v: { width: 26, strokes: ['M6 23Q-1 58 22 27Q25 20 21 23Q19 31 29 31'] },
51
+ w: { width: 39, strokes: ['M7 23Q-1 56 19 27Q10 56 34 27Q37 20 33 23Q31 31 42 31'] },
52
+ x: { width: 25, strokes: ['M7 24Q14 28 17 39Q20 45 28 35', 'M24 23L3 42'] },
53
+ y: { width: 28, strokes: ['M8 23L4 35Q2 50 24 24L16 54Q11 67 5 58Q0 50 31 36'] },
54
+ z: { width: 25, strokes: ['M6 25Q16 19 24 24L5 41Q14 36 20 43Q25 51 10 59Q1 63 5 54L28 36'] },
55
+ '0': { width: 29, strokes: ['M23 8C4 0 -2 42 13 43C28 44 34 7 23 8Z'] },
56
+ '1': { width: 20, strokes: ['M5 17L17 8L9 42', 'M2 43L18 43'] },
57
+ '2': { width: 28, strokes: ['M4 17C10 -2 35 7 23 22L3 42Q14 38 28 42'] },
58
+ '3': { width: 27, strokes: ['M6 11Q32 0 23 20L13 25Q34 17 24 36Q17 48 3 39'] },
59
+ '4': { width: 29, strokes: ['M20 7L2 31L30 29', 'M25 9L17 43'] },
60
+ '5': { width: 28, strokes: ['M29 8L10 9L5 25Q27 14 25 32Q20 49 3 39'] },
61
+ '6': { width: 28, strokes: ['M25 8C7 -1 -6 43 13 43C30 43 29 16 13 25L5 33'] },
62
+ '7': { width: 27, strokes: ['M4 10L29 9L9 43', 'M9 26L24 25'] },
63
+ '8': { width: 28, strokes: ['M18 24C-6 12 23 -2 27 12Q28 21 10 28C-5 37 13 52 24 38Q33 27 18 24Z'] },
64
+ '9': { width: 28, strokes: ['M24 20Q5 38 4 21C4 3 31 1 25 21L14 44'] },
65
+ '.': { width: 12, strokes: ['M6 41L6.4 41.4'] },
66
+ ',': { width: 12, strokes: ['M8 40L4 49'] },
67
+ '!': { width: 13, strokes: ['M12 5L6 31', 'M5 41L5.4 41.4'] },
68
+ '?': { width: 25, strokes: ['M3 14Q10 0 23 10Q29 18 16 24L11 31', 'M9 42L9.4 42.4'] },
69
+ '-': { width: 23, strokes: ['M4 28L22 27'] },
70
+ "'": { width: 11, strokes: ['M9 7L6 15'] },
71
+ };
@@ -0,0 +1,14 @@
1
+ .mk-handwriting-text,
2
+ mk-handwriting-text {
3
+ --mk-handwriting-width: 28rem;
4
+ display: inline-block;
5
+ width: min(100%, var(--mk-handwriting-width));
6
+ color: var(--mk-text);
7
+ font-family: var(--mk-font);
8
+ line-height: 1;
9
+ }
10
+ [data-mk-handwriting] [data-mk-handwriting-drawing] { display: block; width: 100%; height: auto; overflow: visible; }
11
+ [data-mk-handwriting] path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
12
+ [data-mk-handwriting-fallback] { fill: currentColor; stroke: none; font: 30px var(--mk-font); }
13
+ [data-mk-handwriting][data-mk-empty] { min-height: 1rem; }
14
+ @media (prefers-reduced-motion: reduce) { [data-mk-handwriting] path { stroke-dashoffset: 0 !important; } }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type Ref } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createHandwritingText, type HandwritingTextInstance, type HandwritingTextOptions } from './engine';
5
+
6
+ export interface HandwritingTextProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'dangerouslySetInnerHTML' | 'onComplete'>, HandwritingTextOptions { instanceRef?: Ref<HandwritingTextInstance>; }
7
+ export function HandwritingText({ text = 'make it move.', duration, autoplay, onComplete, instanceRef, className, ...props }: HandwritingTextProps) {
8
+ const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as HTMLAttributes<HTMLSpanElement>;
9
+ const root = useRef<HTMLSpanElement>(null);
10
+ const instance = useRef<HandwritingTextInstance | null>(null);
11
+ const latest = useLatest({ text, onComplete });
12
+ useIsomorphicLayoutEffect(() => {
13
+ const created = createHandwritingText(root.current, { text: latest.current.text, duration, autoplay, onComplete: () => latest.current.onComplete?.() });
14
+ instance.current = created;
15
+ return () => { created.destroy(); instance.current = null; };
16
+ }, [duration, autoplay]);
17
+ useIsomorphicLayoutEffect(() => { instance.current?.setText(text, autoplay !== false); }, [text, autoplay]);
18
+ useIsomorphicLayoutEffect(() => {
19
+ if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
20
+ return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
21
+ }, [instanceRef, duration, autoplay]);
22
+ return <span {...safe} ref={root} role="img" aria-label={text} className={['mk-handwriting-text', className].filter(Boolean).join(' ')} />;
23
+ }