@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,189 @@
1
+ import { createScope } from '../../core/scope';
2
+ import { inert, type Instance } from '../../core/types';
3
+ import { createDialog } from '../dialog/engine';
4
+
5
+ export interface TrailerOptions {
6
+ triggers?: boolean | string;
7
+ autoplay?: boolean;
8
+ closeOnBackdrop?: boolean;
9
+ onOpen?: () => void;
10
+ onClose?: () => void;
11
+ }
12
+ export interface TrailerInstance extends Instance {
13
+ open(origin?: HTMLElement | null): void;
14
+ close(): void;
15
+ toggle(origin?: HTMLElement | null): void;
16
+ play(): Promise<boolean>;
17
+ pause(): void;
18
+ seek(seconds: number): void;
19
+ setVolume(volume: number): void;
20
+ readonly isOpen: boolean;
21
+ }
22
+ const time = (value: number) => {
23
+ const seconds = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
24
+ return `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
25
+ };
26
+
27
+ /** Native modal + consumer-owned video, with optional accessible custom transport controls. */
28
+ export function createTrailer(root: HTMLElement | null, options: TrailerOptions = {}): TrailerInstance {
29
+ const video = root?.querySelector<HTMLVideoElement>('video');
30
+ if (!root || !video || root.tagName !== 'DIALOG') return inert({ open() {}, close() {}, toggle() {}, play: async () => false, pause() {}, seek() {}, setVolume() {}, isOpen: false });
31
+ const scope = createScope();
32
+ const get = <T extends HTMLElement>(name: string) => root.querySelector<T>(`[data-mk-trailer-${name}]`);
33
+ const playButton = get<HTMLButtonElement>('play');
34
+ const muteButton = get<HTMLButtonElement>('mute');
35
+ const fullButton = get<HTMLButtonElement>('fullscreen');
36
+ // The Fullscreen API rejects HTMLDialogElement. A consumer can wrap media and
37
+ // transport in a stage; otherwise fullscreen just the native video element.
38
+ const fullTarget = get<HTMLElement>('stage') ?? video;
39
+ const captionsButton = get<HTMLButtonElement>('captions');
40
+ const seekInput = get<HTMLInputElement>('seek');
41
+ const volumeInput = get<HTMLInputElement>('volume');
42
+ const output = get<HTMLElement>('time');
43
+ const status = get<HTMLElement>('status');
44
+ const originalText = [output, status].filter((el): el is HTMLElement => !!el).map(el => [el, el.textContent] as const);
45
+ scope.add(() => originalText.forEach(([el, text]) => { el.textContent = text; }));
46
+ scope.attr(root, 'data-mk-trailer-ready', '');
47
+ scope.attr(root, 'data-mk-trailer-state', 'paused');
48
+ const controls = video.controls;
49
+ // Keep the browser's controls unless the consumer supplied a complete transport.
50
+ if (playButton && muteButton && seekInput && (!video.querySelector('track') || captionsButton)) video.controls = false;
51
+ scope.add(() => { video.controls = controls; });
52
+ for (const input of [seekInput, volumeInput]) {
53
+ if (!input) continue;
54
+ const initialValue = input.value;
55
+ scope.add(() => { input.value = initialValue; });
56
+ scope.attr(input, 'min', '0');
57
+ scope.attr(input, 'max', '1');
58
+ scope.attr(input, 'step', input === volumeInput ? '.01' : '.1');
59
+ scope.attr(input, 'aria-label', input.getAttribute('aria-label') ?? (input === volumeInput ? 'Volumen' : 'Posición del video'));
60
+ }
61
+ if (status) scope.attr(status, 'role', 'status');
62
+ const tell = (message: string) => { if (!scope.disposed && status) status.textContent = message; };
63
+ const sync = () => {
64
+ if (scope.disposed) return;
65
+ const duration = Number.isFinite(video.duration) ? Math.max(0, video.duration) : 0;
66
+ scope.attr(root, 'data-mk-trailer-state', video.ended ? 'ended' : video.paused ? 'paused' : 'playing');
67
+ if (playButton) {
68
+ scope.attr(playButton, 'aria-label', video.paused || video.ended ? 'Reproducir' : 'Pausar');
69
+ scope.attr(playButton, 'aria-pressed', String(!video.paused && !video.ended));
70
+ }
71
+ if (muteButton) {
72
+ scope.attr(muteButton, 'aria-label', video.muted ? 'Activar sonido' : 'Silenciar');
73
+ scope.attr(muteButton, 'aria-pressed', String(video.muted));
74
+ }
75
+ if (seekInput) {
76
+ scope.attr(seekInput, 'max', String(duration || 1));
77
+ scope.attr(seekInput, 'disabled', duration ? null : '');
78
+ scope.attr(seekInput, 'aria-valuetext', `${time(video.currentTime)} de ${time(duration)}`);
79
+ seekInput.value = String(Number.isFinite(video.currentTime) ? video.currentTime : 0);
80
+ }
81
+ if (volumeInput) volumeInput.value = String(video.muted ? 0 : video.volume);
82
+ if (captionsButton) {
83
+ scope.attr(captionsButton, 'disabled', video.textTracks.length ? null : '');
84
+ scope.attr(captionsButton, 'aria-pressed', String(video.textTracks[0]?.mode === 'showing'));
85
+ }
86
+ if (output) output.textContent = `${time(video.currentTime)} / ${time(duration)}`;
87
+ };
88
+ let playRequest = 0;
89
+ let wantsPlayback = false;
90
+ const pause = () => { if (!scope.disposed) { wantsPlayback = false; ++playRequest; video.pause(); sync(); } };
91
+ const play = async () => {
92
+ if (scope.disposed) return false;
93
+ wantsPlayback = true;
94
+ const request = ++playRequest;
95
+ try {
96
+ await video.play();
97
+ if (scope.disposed || request !== playRequest) {
98
+ // A stale request must not pause a newer play after a quick close/reopen.
99
+ if (scope.disposed || !wantsPlayback) video.pause();
100
+ return false;
101
+ }
102
+ tell('');
103
+ sync();
104
+ return true;
105
+ } catch {
106
+ if (scope.disposed || request !== playRequest) return false;
107
+ wantsPlayback = false;
108
+ tell('No se pudo reproducir el video. Revisa la fuente y vuelve a intentarlo.');
109
+ sync();
110
+ return false;
111
+ }
112
+ };
113
+ const seek = (seconds: number) => {
114
+ if (scope.disposed || !Number.isFinite(seconds) || !Number.isFinite(video.duration) || video.duration <= 0) return;
115
+ video.currentTime = Math.max(0, Math.min(video.duration, seconds));
116
+ sync();
117
+ };
118
+ const setVolume = (volume: number) => {
119
+ if (scope.disposed || !Number.isFinite(volume)) return;
120
+ video.volume = Math.max(0, Math.min(1, volume));
121
+ video.muted = volume <= 0;
122
+ sync();
123
+ };
124
+ const exitFullscreen = () => {
125
+ if (document.fullscreenElement !== fullTarget) return;
126
+ void document.exitFullscreen?.().catch(() => {});
127
+ };
128
+ const dialog = createDialog(root, {
129
+ triggers: options.triggers,
130
+ closeOnBackdrop: options.closeOnBackdrop ?? true,
131
+ drag: false,
132
+ autoResize: false,
133
+ animateContent: false,
134
+ onOpen: () => { sync(); if (options.autoplay) void play(); options.onOpen?.(); },
135
+ onClose: () => { ++playRequest; pause(); exitFullscreen(); options.onClose?.(); },
136
+ });
137
+ if (playButton) scope.on(playButton, 'click', () => video.paused || video.ended ? void play() : pause());
138
+ if (muteButton) scope.on(muteButton, 'click', () => { video.muted = !video.muted; sync(); });
139
+ if (seekInput) scope.on(seekInput, 'input', () => seek(Number(seekInput.value)));
140
+ if (volumeInput) scope.on(volumeInput, 'input', () => setVolume(Number(volumeInput.value)));
141
+ if (captionsButton) scope.on(captionsButton, 'click', () => {
142
+ const track = video.textTracks[0];
143
+ if (track) track.mode = track.mode === 'showing' ? 'hidden' : 'showing';
144
+ sync();
145
+ });
146
+ const fullscreen = async () => {
147
+ if (scope.disposed) return;
148
+ try {
149
+ if (document.fullscreenElement === fullTarget) await document.exitFullscreen?.();
150
+ else if (fullTarget.requestFullscreen) {
151
+ await fullTarget.requestFullscreen();
152
+ if (scope.disposed || !dialog.isOpen) exitFullscreen();
153
+ }
154
+ else tell('La pantalla completa no está disponible en este navegador.');
155
+ } catch { tell('No se pudo abrir la pantalla completa.'); }
156
+ };
157
+ if (fullButton) {
158
+ scope.attr(fullButton, 'aria-label', 'Pantalla completa');
159
+ scope.on(fullButton, 'click', () => { void fullscreen(); });
160
+ scope.on(document, 'fullscreenchange', () => {
161
+ scope.attr(fullButton, 'aria-label', document.fullscreenElement === fullTarget ? 'Salir de pantalla completa' : 'Pantalla completa');
162
+ });
163
+ }
164
+ for (const event of ['loadedmetadata', 'durationchange', 'timeupdate', 'play', 'pause', 'ended', 'volumechange']) scope.on(video, event, sync);
165
+ scope.on(video, 'error', () => { tell('El video no está disponible.'); scope.attr(root, 'data-mk-trailer-state', 'error'); });
166
+ scope.on(root, 'keydown', (event) => {
167
+ if (!dialog.isOpen || (event.target instanceof Element && event.target.closest('button, input, select, textarea, a, [contenteditable]'))) return;
168
+ if (event.key === ' ' || event.key.toLowerCase() === 'k') { event.preventDefault(); video.paused ? void play() : pause(); }
169
+ else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { event.preventDefault(); seek(video.currentTime + (event.key === 'ArrowRight' ? 5 : -5)); }
170
+ else if (event.key.toLowerCase() === 'm') { video.muted = !video.muted; sync(); }
171
+ });
172
+ sync();
173
+ return {
174
+ open: (origin) => { if (!scope.disposed) dialog.open(origin); },
175
+ close: () => { if (!scope.disposed) dialog.close(); },
176
+ toggle: (origin) => { if (!scope.disposed) dialog.toggle(origin); },
177
+ play, pause, seek, setVolume,
178
+ get isOpen() { return dialog.isOpen; },
179
+ destroy() {
180
+ if (scope.disposed) return;
181
+ wantsPlayback = false;
182
+ ++playRequest;
183
+ video.pause();
184
+ exitFullscreen();
185
+ dialog.destroy();
186
+ scope.dispose();
187
+ },
188
+ };
189
+ }
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import { useRef, type DialogHTMLAttributes } from 'react';
3
+ import { useRecipe } from '../../react/use-recipe';
4
+ import { createTrailer, type TrailerOptions } from './engine';
5
+
6
+ export interface TrailerProps extends Omit<DialogHTMLAttributes<HTMLDialogElement>, 'open' | 'onClose'> {
7
+ options?: TrailerOptions;
8
+ }
9
+ /** Consumer supplies <video>, optional controls and tracks; no remote media is injected. */
10
+ export function Trailer({ options, className, children, ...props }: TrailerProps) {
11
+ const ref = useRef<HTMLDialogElement>(null);
12
+ useRecipe(ref, createTrailer, options);
13
+ return <dialog {...props} ref={ref} className={['mk-trailer', className].filter(Boolean).join(' ')}>{children}</dialog>;
14
+ }
15
+
16
+ export { useTrailer } from '../../react/use-trailer';
@@ -0,0 +1,36 @@
1
+ .mk-trailer {
2
+ --mk-trailer-ink: #f6f3e8;
3
+ --mk-trailer-paper: #131816;
4
+ width: min(1100px, calc(100vw - 32px));
5
+ max-width: none;
6
+ max-height: calc(100dvh - 32px);
7
+ padding: 0;
8
+ border: 0;
9
+ border-radius: 0;
10
+ color: var(--mk-trailer-ink);
11
+ background: var(--mk-trailer-paper);
12
+ overflow: auto;
13
+ margin: auto;
14
+ }
15
+ .mk-trailer::backdrop { background: rgb(4 10 7 / .8); }
16
+ .mk-trailer__header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 20px; }
17
+ .mk-trailer__title { font: 500 .75rem/1.5 var(--mk-font-mono, monospace); text-transform: uppercase; letter-spacing: .16em; margin: 0; }
18
+ .mk-trailer video { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #060b08; }
19
+ .mk-trailer [data-mk-trailer-stage]:fullscreen { display: flex; flex-direction: column; justify-content: center; width: 100%; height: 100%; background: var(--mk-trailer-paper); color: var(--mk-trailer-ink); }
20
+ .mk-trailer [data-mk-trailer-stage]:fullscreen video { max-height: calc(100dvh - 90px); }
21
+ .mk-trailer__controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 20px 16px; font: .7rem/1.5 var(--mk-font-mono, monospace); }
22
+ .mk-trailer button { display: grid; place-items: center; min-width: 44px; min-height: 44px; border: 1px solid currentColor; color: inherit; background: transparent; cursor: pointer; font: inherit; }
23
+ .mk-trailer button:hover { background: rgb(255 255 255 / .12); }
24
+ .mk-trailer button:focus-visible, .mk-trailer input:focus-visible { outline: 2px solid #e5ff96; outline-offset: 4px; }
25
+ .mk-trailer button svg { width: 18px; height: 18px; fill: currentColor; }
26
+ .mk-trailer [data-mk-trailer-seek] { flex: 1; min-width: 70px; accent-color: #d6f2ac; }
27
+ .mk-trailer [data-mk-trailer-volume] { width: 72px; accent-color: #d6f2ac; }
28
+ .mk-trailer [data-mk-trailer-time] { white-space: nowrap; }
29
+ .mk-trailer [data-mk-trailer-status] { padding: 0 20px; font: .8rem/1.5 var(--mk-font, sans-serif); }
30
+ .mk-trailer [data-mk-trailer-status]:empty { display: none; }
31
+ .mk-trailer__pause-icon, .mk-trailer__sound-off { display: none; }
32
+ .mk-trailer [data-mk-trailer-play][aria-pressed="true"] .mk-trailer__play-icon,
33
+ .mk-trailer [data-mk-trailer-mute][aria-pressed="true"] .mk-trailer__sound-on { display: none; }
34
+ .mk-trailer [data-mk-trailer-play][aria-pressed="true"] .mk-trailer__pause-icon,
35
+ .mk-trailer [data-mk-trailer-mute][aria-pressed="true"] .mk-trailer__sound-off { display: block; }
36
+ @media (max-width: 540px) { .mk-trailer [data-mk-trailer-volume] { display: none; } .mk-trailer__controls { gap: 8px; padding-inline: 12px; } .mk-trailer [data-mk-trailer-seek] { flex-basis: 100%; order: -1; } }
@@ -0,0 +1,98 @@
1
+ /*
2
+ * Calendar date helpers on local dates (no time zone maths): the calendar and
3
+ * slot-picker components share them. Days are compared by their YYYY-MM-DD key.
4
+ */
5
+
6
+ export type DateInput = Date | string | number;
7
+
8
+ /** Local midnight of a date; "2026-10-23" is read as a local day, not UTC. */
9
+ export function toDate(input: DateInput | null | undefined, fallback = new Date()): Date {
10
+ if (input instanceof Date) return Number.isNaN(input.getTime()) ? startOfDay(fallback) : startOfDay(input);
11
+ if (typeof input === 'string') {
12
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input.trim());
13
+ if (day) return new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
14
+ }
15
+ if (typeof input === 'string' || typeof input === 'number') {
16
+ const parsed = new Date(input);
17
+ if (!Number.isNaN(parsed.getTime())) return startOfDay(parsed);
18
+ }
19
+ return startOfDay(fallback);
20
+ }
21
+
22
+ /** A full date-time (events keep their hour). */
23
+ export function toDateTime(input: DateInput): Date | null {
24
+ if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input;
25
+ if (typeof input === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(input.trim())) return toDate(input);
26
+ const parsed = new Date(input);
27
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
28
+ }
29
+
30
+ export const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
31
+
32
+ export const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
33
+
34
+ /** Same day number in another month, clamped to that month's length (Jan 31 + 1 → Feb 28/29). */
35
+ export function addMonths(date: Date, months: number): Date {
36
+ const target = new Date(date.getFullYear(), date.getMonth() + months, 1);
37
+ const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
38
+ return new Date(target.getFullYear(), target.getMonth(), Math.min(date.getDate(), last));
39
+ }
40
+
41
+ export const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
42
+
43
+ export function startOfWeek(date: Date, firstDay = 0): Date {
44
+ const shift = (date.getDay() - firstDay + 7) % 7;
45
+ return addDays(startOfDay(date), -shift);
46
+ }
47
+
48
+ export const sameDay = (a: Date | null | undefined, b: Date | null | undefined) =>
49
+ !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
50
+
51
+ export const sameMonth = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
52
+
53
+ const pad = (n: number) => String(n).padStart(2, '0');
54
+
55
+ /** YYYY-MM-DD of the local day. */
56
+ export const dayKey = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
57
+
58
+ /** The weeks shown for a month: from the week of the 1st to the week of the last day (4–6 rows). */
59
+ export function monthWeeks(date: Date, firstDay = 0): Date[][] {
60
+ const first = startOfMonth(date);
61
+ const last = new Date(date.getFullYear(), date.getMonth() + 1, 0);
62
+ const weeks: Date[][] = [];
63
+ for (let start = startOfWeek(first, firstDay); start <= last; start = addDays(start, 7)) {
64
+ weeks.push(Array.from({ length: 7 }, (_, i) => addDays(start, i)));
65
+ }
66
+ return weeks;
67
+ }
68
+
69
+ export const weekDays = (date: Date, firstDay = 0) => Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(date, firstDay), i));
70
+
71
+ /** Weekday names in display order, e.g. ["dom", "lun", …] for es with firstDay 0. */
72
+ export function weekdayNames(locale: string, firstDay = 0, width: 'long' | 'short' | 'narrow' = 'short'): string[] {
73
+ const format = safeFormat(locale, { weekday: width });
74
+ // 2024-09-01 was a Sunday.
75
+ return Array.from({ length: 7 }, (_, i) => format.format(new Date(2024, 8, 1 + ((firstDay + i) % 7))));
76
+ }
77
+
78
+ export function safeFormat(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
79
+ try {
80
+ return new Intl.DateTimeFormat(locale, options);
81
+ } catch {
82
+ return new Intl.DateTimeFormat(undefined, options);
83
+ }
84
+ }
85
+
86
+ export const capitalize = (text: string) => text.charAt(0).toLocaleUpperCase() + text.slice(1);
87
+
88
+ /** Options given as JSON text (data-mk-* attributes through autoInit) become values. */
89
+ export function fromJson<T>(value: T | string | undefined): T | undefined {
90
+ if (typeof value !== 'string') return value;
91
+ try {
92
+ return JSON.parse(value) as T;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ export const clampFirstDay =(n: unknown) => (Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 6 ? (n as number) : 0);
@@ -104,6 +104,20 @@ Tres líneas que se pliegan en una X cuando `aria-expanded="true"`: las de fuera
104
104
 
105
105
  Al pasar el ratón, el texto sube (−150 %) y entra su copia (`data-text`), la flecha sale por la derecha y entra su copia (`data-icon`), y el botón se rellena con el color del texto (0.8 s, `--mk-ease-quint`, el texto 0.1 s después). En pantallas táctiles no cambia. Con movimiento reducido solo cambia el color.
106
106
 
107
+ ## Estado
108
+
109
+ ```html
110
+ <a class="mk-status" href="#contacto">
111
+ <span class="mk-status__dot"></span>
112
+ <span>Disponible para proyectos</span>
113
+ <span class="mk-status__sep" aria-hidden="true"></span>
114
+ <span class="mk-status__muted">Agenda una llamada</span>
115
+ <span class="mk-icon" aria-hidden="true">arrow_forward</span>
116
+ </a>
117
+ ```
118
+
119
+ La píldora de DOVV Studio: superficie, borde suave y sombra. Si es enlace o botón, al pasar el ratón sube 1 px, crece su sombra y la flecha avanza 2 px (0.3 s). El punto verde tiene un halo que late (opacidad .6 → 0 y escala 1 → 2.6, 1.6 s `ease-out`, sin fin). Variantes `.mk-status__dot--busy` (ámbar) y `--off` (sin pulso); color propio con `--mk-status-color`. El punto funciona solo, fuera de la píldora. La píldora no parte líneas; en pantallas de 480 px o menos oculta `__sep` y `__muted`, así que pon lo esencial fuera de `__muted`.
120
+
107
121
  ## Squash y vibración
108
122
 
109
123
  - `.mk-button` se aplasta un instante al recibir el foco por puntero (`scaleX(.95) scaleY(.9)`, 0.5 s `cubic-bezier(.37,1.42,.37,1)`). El foco de teclado no produce rebote.
@@ -0,0 +1,63 @@
1
+ /* status — "Available for new projects" pill and a live dot whose halo pulses (DOVV Studio: 1.6 s ease-out, scale 1 → 2.6). */
2
+ .mk-status {
3
+ display: inline-flex;
4
+ align-items: center;
5
+ gap: 10px;
6
+ height: 40px;
7
+ padding: 0 16px 0 14px;
8
+ border-radius: 100px;
9
+ background: var(--mk-surface);
10
+ color: var(--mk-text);
11
+ font: inherit;
12
+ font-size: 14px;
13
+ font-weight: 500;
14
+ letter-spacing: -0.01em;
15
+ white-space: nowrap;
16
+ text-decoration: none;
17
+ box-shadow: 0 0 0 1px var(--mk-outline-soft), 0 6px 20px -8px rgb(0 0 0 / 0.18);
18
+ transition: box-shadow 0.3s, transform 0.3s;
19
+ }
20
+ /* One line always: on phones the secondary half goes. */
21
+ @media (max-width: 480px) {
22
+ .mk-status > :is(.mk-status__sep, .mk-status__muted) { display: none; }
23
+ }
24
+ a.mk-status:hover,
25
+ button.mk-status:hover {
26
+ transform: translateY(-1px);
27
+ box-shadow: 0 0 0 1px var(--mk-outline), 0 10px 24px -8px rgb(0 0 0 / 0.22);
28
+ }
29
+ .mk-status__sep { width: 1px; height: 14px; background: var(--mk-outline-soft); }
30
+ .mk-status__muted { color: var(--mk-muted); }
31
+ .mk-status > svg,
32
+ .mk-status > .mk-icon { color: var(--mk-muted); transition: transform 0.3s; }
33
+ .mk-status:hover > svg,
34
+ .mk-status:hover > .mk-icon { transform: translateX(2px); }
35
+ .mk-status__dot {
36
+ --mk-status-color: #16c847;
37
+ position: relative;
38
+ display: inline-block;
39
+ flex: none;
40
+ width: 8px;
41
+ height: 8px;
42
+ border-radius: 50%;
43
+ background: var(--mk-status-color);
44
+ }
45
+ .mk-status__dot::after {
46
+ content: '';
47
+ position: absolute;
48
+ inset: 0;
49
+ border-radius: inherit;
50
+ background: inherit;
51
+ animation: mk-status-pulse 1.6s ease-out infinite;
52
+ }
53
+ .mk-status__dot--busy { --mk-status-color: #f59e0b; }
54
+ .mk-status__dot--off { --mk-status-color: var(--mk-muted); }
55
+ .mk-status__dot--off::after { animation: none; }
56
+ @keyframes mk-status-pulse {
57
+ 0% { opacity: 0.6; transform: scale(1); }
58
+ 100% { opacity: 0; transform: scale(2.6); }
59
+ }
60
+ @media (prefers-reduced-motion: reduce) {
61
+ .mk-status { transition: none; }
62
+ .mk-status__dot::after { animation: none; }
63
+ }
@@ -1,3 +1,5 @@
1
+ import { MkStudioDockElement } from '../components/studio-dock/element';
2
+ import { MkTrailerElement } from '../components/trailer/element';
1
3
  import { defineElement } from './base';
2
4
  import { MkThemePickerElement } from '../components/theme-picker/element';
3
5
  import { MkDialogElement } from '../components/dialog/element';
@@ -9,6 +11,12 @@ import { MkNavElement } from '../components/nav/element';
9
11
  import { MkStepsElement } from '../components/steps/element';
10
12
  import { MkCursorElement } from '../components/cursor/element';
11
13
  import { MkIslandElement } from '../components/island/element';
14
+ import { MkQuantityElement } from '../components/quantity/element';
15
+ import { MkPaginationElement } from '../components/pagination/element';
16
+ import { MkHoverCardElement } from '../components/hover-card/element';
17
+ import { MkInputOtpElement } from '../components/input-otp/element';
18
+ import { MkCalendarElement } from '../components/calendar/element';
19
+ import { MkSlotPickerElement } from '../components/slot-picker/element';
12
20
  // new-component:imports (scripts/new-component.mjs inserts above this line)
13
21
 
14
22
  export { MkElement, SafeHTMLElement, defineElement, type OptionType } from './base';
@@ -23,6 +31,14 @@ export { MkStepsElement } from '../components/steps/element';
23
31
  export { MkCursorElement } from '../components/cursor/element';
24
32
  export { toast } from '../components/toast/store';
25
33
  export { MkIslandElement } from '../components/island/element';
34
+ export { MkQuantityElement } from '../components/quantity/element';
35
+ export { MkPaginationElement } from '../components/pagination/element';
36
+ export { MkHoverCardElement } from '../components/hover-card/element';
37
+ export { MkInputOtpElement } from '../components/input-otp/element';
38
+ export { MkStudioDockElement, defineStudioDock } from '../components/studio-dock/element';
39
+ export { MkTrailerElement, defineTrailer } from '../components/trailer/element';
40
+ export { MkCalendarElement } from '../components/calendar/element';
41
+ export { MkSlotPickerElement } from '../components/slot-picker/element';
26
42
  // new-component:exports
27
43
 
28
44
  /** Tag name → element class, for every component. */
@@ -37,6 +53,14 @@ export const ELEMENTS = {
37
53
  'mk-steps': MkStepsElement,
38
54
  'mk-cursor': MkCursorElement,
39
55
  'mk-island': MkIslandElement,
56
+ 'mk-quantity': MkQuantityElement,
57
+ 'mk-pagination': MkPaginationElement,
58
+ 'mk-hover-card': MkHoverCardElement,
59
+ 'mk-input-otp': MkInputOtpElement,
60
+ 'mk-studio-dock': MkStudioDockElement,
61
+ 'mk-trailer': MkTrailerElement,
62
+ 'mk-calendar': MkCalendarElement,
63
+ 'mk-slot-picker': MkSlotPickerElement,
40
64
  // new-component:entries
41
65
  } as const;
42
66
 
package/src/index.ts CHANGED
@@ -175,3 +175,28 @@ export {
175
175
  type ToastAction,
176
176
  } from './components/toast/store';
177
177
  export { createIsland, type IslandOptions, type IslandInstance } from './components/island/engine';
178
+ export { createQuantity, type QuantityOptions, type QuantityInstance } from './components/quantity/engine';
179
+ export { createPagination, type PaginationOptions, type PaginationInstance } from './components/pagination/engine';
180
+ export { createHoverCard, type HoverCardOptions, type HoverCardInstance } from './components/hover-card/engine';
181
+ export { createInputOtp, type InputOtpOptions, type InputOtpInstance } from './components/input-otp/engine';
182
+ export { createJoinedCta, type JoinedCtaOptions, type JoinedCtaInstance } from './recipes/joined-cta/create-joined-cta';
183
+ export { createImageZoom, type ImageZoomOptions, type ImageZoomInstance } from './recipes/image-zoom/create-image-zoom';
184
+ export { createFontCycle, type FontCycleOptions, type FontCycleInstance } from './recipes/font-cycle/create-font-cycle';
185
+ export { createThumbnailNav, type ThumbnailNavOptions, type ThumbnailNavInstance } from './recipes/thumbnail-nav/create-thumbnail-nav';
186
+ export { createActionButton, type ActionButtonOptions, type ActionButtonInstance } from './recipes/action-button/create-action-button';
187
+ export { createFilmHero, type FilmHeroOptions, type FilmHeroInstance } from './recipes/film-hero/create-film-hero';
188
+ export { createEditorialReveal, type EditorialRevealOptions, type EditorialRevealInstance } from './recipes/editorial-reveal/create-editorial-reveal';
189
+ export { createStudioDock, type StudioDockOptions, type StudioDockInstance } from './components/studio-dock/engine';
190
+ export { createTrailer, type TrailerOptions, type TrailerInstance } from './components/trailer/engine';
191
+ export type { FontCycleFace } from './recipes/font-cycle/create-font-cycle';
192
+ export type { ActionButtonVariant } from './recipes/action-button/create-action-button';
193
+ export type { StudioDockState, StudioDockView } from './components/studio-dock/engine';
194
+ export { createCalendar, type CalendarOptions, type CalendarInstance } from './components/calendar/engine';
195
+ export { createSlotPicker, type SlotPickerOptions, type SlotPickerInstance } from './components/slot-picker/engine';
196
+ export type { CalendarEvent, CalendarEventStatus, CalendarRange, CalendarView, CalendarLabels } from './components/calendar/engine';
197
+ export type { SlotSource, SlotValue, SlotPickerLabels } from './components/slot-picker/engine';
198
+ export { dayKey, toDate, type DateInput } from './core/dates';
199
+ export { createWeightHover, type WeightHoverOptions, type WeightHoverInstance } from './recipes/weight-hover/create-weight-hover';
200
+ export { createSticker, type StickerOptions, type StickerInstance, type StickerState } from './recipes/sticker/create-sticker';
201
+ export { createScrollDock, type ScrollDockOptions, type ScrollDockInstance } from './recipes/scroll-dock/create-scroll-dock';
202
+ export { createLocalClock, type LocalClockOptions, type LocalClockInstance } from './recipes/local-clock/create-local-clock';
@@ -78,3 +78,24 @@ export { confetti, type ConfettiOptions } from '../motion/confetti';
78
78
  export { MK_HEAD_SCRIPT } from '../core/reduced';
79
79
  export { MK_THEME_HEAD_SCRIPT, themeHeadScript } from '../theme/theme';
80
80
  export { Island, IslandDots, type IslandProps, type IslandPanel } from '../components/island/react';
81
+ export { Quantity, type QuantityProps } from '../components/quantity/react';
82
+ export { Pagination, type PaginationProps } from '../components/pagination/react';
83
+ export { HoverCard, type HoverCardProps } from '../components/hover-card/react';
84
+ export { InputOtp, type InputOtpProps } from '../components/input-otp/react';
85
+ export { useJoinedCta } from './use-joined-cta';
86
+ export { useImageZoom } from './use-image-zoom';
87
+ export { useFontCycle } from './use-font-cycle';
88
+ export { useThumbnailNav } from './use-thumbnail-nav';
89
+ export { useActionButton } from './use-action-button';
90
+ export { useFilmHero } from './use-film-hero';
91
+ export { useEditorialReveal } from './use-editorial-reveal';
92
+ export { useStudioDock } from './use-studio-dock';
93
+ export { useTrailer } from './use-trailer';
94
+ export { StudioDock, type StudioDockProps } from '../components/studio-dock/react';
95
+ export { Trailer, type TrailerProps } from '../components/trailer/react';
96
+ export { Calendar, type CalendarProps } from '../components/calendar/react';
97
+ export { SlotPicker, type SlotPickerProps } from '../components/slot-picker/react';
98
+ export { useWeightHover } from './use-weight-hover';
99
+ export { useSticker } from './use-sticker';
100
+ export { useScrollDock } from './use-scroll-dock';
101
+ export { useLocalClock } from './use-local-clock';
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+ import type { DependencyList, RefObject } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createActionButton, type ActionButtonOptions } from '../recipes/action-button/create-action-button';
5
+ export const useActionButton = (ref: RefObject<HTMLElement | null>, options?: ActionButtonOptions, deps?: DependencyList) =>
6
+ useRecipe(ref, createActionButton, options, deps);
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+ import type { DependencyList, RefObject } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createEditorialReveal, type EditorialRevealOptions } from '../recipes/editorial-reveal/create-editorial-reveal';
5
+ export function useEditorialReveal(ref: RefObject<HTMLElement | null>, options: EditorialRevealOptions = {}, deps?: DependencyList) {
6
+ return useRecipe(ref, createEditorialReveal, options, deps);
7
+ }
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+ import type { DependencyList, RefObject } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createFilmHero, type FilmHeroOptions } from '../recipes/film-hero/create-film-hero';
5
+ export const useFilmHero = (ref: RefObject<HTMLElement | null>, options?: FilmHeroOptions, deps?: DependencyList) =>
6
+ useRecipe(ref, createFilmHero, options, deps);
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+ import type { DependencyList, RefObject } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createFontCycle, type FontCycleOptions } from '../recipes/font-cycle/create-font-cycle';
5
+
6
+ export const useFontCycle = (ref: RefObject<HTMLElement | null>, options?: FontCycleOptions, deps?: DependencyList) =>
7
+ useRecipe(ref, createFontCycle, options, deps);
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import type { RefObject, DependencyList } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createImageZoom, type ImageZoomOptions } from '../recipes/image-zoom/create-image-zoom';
5
+ export const useImageZoom = (ref: RefObject<HTMLElement | null>, options?: ImageZoomOptions, deps?: DependencyList) => useRecipe(ref, createImageZoom, options, deps);
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import type { RefObject, DependencyList } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createJoinedCta, type JoinedCtaOptions } from '../recipes/joined-cta/create-joined-cta';
5
+ export const useJoinedCta = (ref: RefObject<HTMLElement | null>, options?: JoinedCtaOptions, deps?: DependencyList) => useRecipe(ref, createJoinedCta, options, deps);
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import type { RefObject, DependencyList } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createLocalClock, type LocalClockOptions } from '../recipes/local-clock/create-local-clock';
5
+ export const useLocalClock = (ref: RefObject<HTMLElement | null>, options?: LocalClockOptions, deps?: DependencyList) => useRecipe(ref, createLocalClock, options, deps);
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import type { RefObject, DependencyList } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createScrollDock, type ScrollDockOptions } from '../recipes/scroll-dock/create-scroll-dock';
5
+ export const useScrollDock = (ref: RefObject<HTMLElement | null>, options?: ScrollDockOptions, deps?: DependencyList) => useRecipe(ref, createScrollDock, options, deps);
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import type { RefObject, DependencyList } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createSticker, type StickerOptions } from '../recipes/sticker/create-sticker';
5
+ export const useSticker = (ref: RefObject<HTMLElement | null>, options?: StickerOptions, deps?: DependencyList) => useRecipe(ref, createSticker, options, deps);
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+ import type { DependencyList, RefObject } from 'react';
3
+ import { useLatest, useRecipe } from './use-recipe';
4
+ import { createStudioDock, type StudioDockOptions } from '../components/studio-dock/engine';
5
+ export function useStudioDock(ref: RefObject<HTMLElement | null>, options: StudioDockOptions = {}, deps?: DependencyList) {
6
+ const change = useLatest(options.onChange);
7
+ return useRecipe(ref, createStudioDock, { ...options, onChange: state => change.current?.(state) }, deps);
8
+ }
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+ import type { DependencyList, RefObject } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createThumbnailNav, type ThumbnailNavOptions } from '../recipes/thumbnail-nav/create-thumbnail-nav';
5
+
6
+ export const useThumbnailNav = (ref: RefObject<HTMLElement | null>, options?: ThumbnailNavOptions, deps?: DependencyList) =>
7
+ useRecipe(ref, createThumbnailNav, options, deps);
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+ import type { DependencyList, RefObject } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createTrailer, type TrailerOptions } from '../components/trailer/engine';
5
+ export const useTrailer = (ref: RefObject<HTMLElement | null>, options?: TrailerOptions, deps?: DependencyList) =>
6
+ useRecipe(ref, createTrailer, options, deps);
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+ import type { RefObject, DependencyList } from 'react';
3
+ import { useRecipe } from './use-recipe';
4
+ import { createWeightHover, type WeightHoverOptions } from '../recipes/weight-hover/create-weight-hover';
5
+ export const useWeightHover = (ref: RefObject<HTMLElement | null>, options?: WeightHoverOptions, deps?: DependencyList) => useRecipe(ref, createWeightHover, options, deps);