@mimi-ng/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +19 -1
  2. package/dist/collection.json +1 -1
  3. package/dist/init/css.js +17 -0
  4. package/dist/init/index.js +7 -5
  5. package/dist/init/items.js +9 -0
  6. package/dist/ng-add/index.js +17 -1
  7. package/dist/ng-add/schema.json +27 -0
  8. package/dist/registry.json +222 -1
  9. package/dist/templates/components/avatar/avatar.ts +1 -1
  10. package/dist/templates/components/avatar/avatar.variants.ts +4 -4
  11. package/dist/templates/components/badge/badge.ts +41 -6
  12. package/dist/templates/components/badge/badge.variants.ts +105 -11
  13. package/dist/templates/components/button/button.ts +74 -7
  14. package/dist/templates/components/button/button.variants.ts +145 -21
  15. package/dist/templates/components/card/card.ts +8 -5
  16. package/dist/templates/components/checkbox/checkbox.ts +3 -3
  17. package/dist/templates/components/confirm/confirm-texts.ts +46 -0
  18. package/dist/templates/components/confirm/confirm.ts +190 -0
  19. package/dist/templates/components/confirm/index.ts +2 -0
  20. package/dist/templates/components/context-menu/context-menu.ts +378 -0
  21. package/dist/templates/components/context-menu/index.ts +14 -0
  22. package/dist/templates/components/dialog/dialog-texts.ts +41 -0
  23. package/dist/templates/components/dialog/dialog.ts +701 -0
  24. package/dist/templates/components/dialog/index.ts +18 -0
  25. package/dist/templates/components/dropdown-menu/dropdown-menu.ts +252 -0
  26. package/dist/templates/components/dropdown-menu/index.ts +14 -0
  27. package/dist/templates/components/form-field/form-error.ts +1 -1
  28. package/dist/templates/components/form-field/form-field.ts +3 -2
  29. package/dist/templates/components/input/input.variants.ts +6 -4
  30. package/dist/templates/components/menu/index.ts +15 -0
  31. package/dist/templates/components/menu/menu.ts +656 -0
  32. package/dist/templates/components/pagination/index.ts +2 -0
  33. package/dist/templates/components/pagination/pagination-page-size.ts +74 -0
  34. package/dist/templates/components/pagination/pagination-texts.ts +69 -0
  35. package/dist/templates/components/pagination/pagination.ts +443 -0
  36. package/dist/templates/components/popover/index.ts +12 -0
  37. package/dist/templates/components/popover/popover.ts +399 -0
  38. package/dist/templates/components/select/index.ts +12 -0
  39. package/dist/templates/components/select/select-texts.ts +62 -0
  40. package/dist/templates/components/select/select.ts +1085 -0
  41. package/dist/templates/components/separator/separator.variants.ts +10 -7
  42. package/dist/templates/components/skeleton/skeleton.ts +4 -1
  43. package/dist/templates/components/switch/switch.ts +6 -6
  44. package/dist/templates/components/tabs/index.ts +2 -0
  45. package/dist/templates/components/tabs/tabs.ts +426 -0
  46. package/dist/templates/components/textarea/textarea.variants.ts +14 -11
  47. package/dist/templates/components/toast/index.ts +3 -0
  48. package/dist/templates/components/toast/toast-config.ts +45 -0
  49. package/dist/templates/components/toast/toast-texts.ts +46 -0
  50. package/dist/templates/components/toast/toast.ts +1000 -0
  51. package/dist/templates/components/toolbar/index.ts +1 -0
  52. package/dist/templates/components/toolbar/toolbar.ts +377 -0
  53. package/dist/templates/components/tooltip/index.ts +7 -0
  54. package/dist/templates/components/tooltip/tooltip.ts +407 -0
  55. package/dist/templates/theme/provider.ts +62 -6
  56. package/dist/templates/theme/theme-base.css +307 -9
  57. package/dist/templates/theme/types.ts +278 -4
  58. package/dist/templates/utils/cn.ts +15 -1
  59. package/dist/templates/utils/control-styles.ts +2 -2
  60. package/dist/templates/utils/overlay.ts +140 -0
  61. package/dist/ui/index.js +80 -8
  62. package/dist/ui/schema.json +44 -0
  63. package/dist/utils/write-files.js +156 -59
  64. package/package.json +6 -5
@@ -0,0 +1,407 @@
1
+ import {
2
+ type OverlayRef,
3
+ createFlexibleConnectedPositionStrategy,
4
+ createOverlayRef,
5
+ createRepositionScrollStrategy,
6
+ } from '@angular/cdk/overlay';
7
+ import { ComponentPortal } from '@angular/cdk/portal';
8
+ import {
9
+ ChangeDetectionStrategy,
10
+ Component,
11
+ type ComponentRef,
12
+ DestroyRef,
13
+ Directive,
14
+ ElementRef,
15
+ HostAttributeToken,
16
+ Injector,
17
+ afterNextRender,
18
+ booleanAttribute,
19
+ computed,
20
+ effect,
21
+ inject,
22
+ input,
23
+ numberAttribute,
24
+ output,
25
+ signal,
26
+ untracked,
27
+ } from '@angular/core';
28
+ import { cn } from '@/components/ui/utils/cn';
29
+ import {
30
+ MIMI_OVERLAY_ANIMATION,
31
+ MIMI_VIEWPORT_MARGIN,
32
+ type MimiOverlayAlign,
33
+ type MimiOverlaySide,
34
+ mimiArrowOffset,
35
+ mimiConnectedPositions,
36
+ mimiPlacedSide,
37
+ mimiWaitForExit,
38
+ } from '@/components/ui/utils/overlay';
39
+
40
+ /*
41
+ * Tooltip (docs/design/Mimi F2 Overlays.dc.html, «4 · Tooltip»): padding 6px 10px, radio 8px,
42
+ * texto de 12.5px peso 500, sombra neutral, a 10px del trigger, flecha de 8px. Tokens de la spec
43
+ * 13 (MimiTooltipTokens), con la cascada de la 6.2.
44
+ *
45
+ * Por dentro usa la API programática de @angular/cdk/overlay (createOverlayRef y un
46
+ * ComponentPortal): una directiva no tiene plantilla. Popover nativo en la ubicación 'global': el
47
+ * tooltip no recibe foco, así que no hace falta junto al trigger, y no cambia sus hermanos en el
48
+ * DOM. Nada del CDK sale en la API pública.
49
+ *
50
+ * Accesibilidad (WCAG 1.4.13): abre con el puntero y con el foco de teclado, después del retraso;
51
+ * se puede pasar el puntero al tooltip sin que se cierre (un puente invisible cubre la
52
+ * separación); Escape lo cierra sin mover el foco. Solo texto: nada interactivo dentro.
53
+ */
54
+
55
+ export type MimiTooltipSide = MimiOverlaySide;
56
+ export type MimiTooltipAlign = MimiOverlayAlign;
57
+
58
+ /** Separación con el trigger (diseño: 10px). */
59
+ const OFFSET = 10;
60
+ /** Retraso por defecto si no hay --mimi-tooltip-delay ni mimiTooltipDelay (spec 13). */
61
+ const DEFAULT_DELAY = 300;
62
+ /** «Salto sin retraso»: si otro tooltip se cerró hace menos que esto, el nuevo abre enseguida. */
63
+ export const MIMI_TOOLTIP_SKIP_DELAY = 300;
64
+
65
+ /** Estado compartido mínimo entre todos los tooltips: cuándo se cerró el último. */
66
+ const shared = { lastHiddenAt: Number.NEGATIVE_INFINITY };
67
+
68
+ let nextId = 0;
69
+
70
+ /** `300ms`, `0.3s` o un número de ms; null si no se entiende. */
71
+ function parseDelay(value: string): number | null {
72
+ const match = /^\s*(-?\d*\.?\d+)\s*(ms|s)?\s*$/.exec(value);
73
+ if (!match) return null;
74
+ const amount = Number(match[1]) * (match[2] === 's' ? 1000 : 1);
75
+ return Math.max(0, amount);
76
+ }
77
+
78
+ /** Para comparar nombres: minúsculas y un solo espacio entre palabras. */
79
+ function normalizeName(value: string): string {
80
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
81
+ }
82
+
83
+ /**
84
+ * Panel del tooltip (uso interno: lo crea `mimiTooltip`).
85
+ * @internal No está en el índice; no forma parte de la API de Mimi.
86
+ */
87
+ @Component({
88
+ selector: 'mimi-tooltip-panel',
89
+ template: `
90
+ <div
91
+ data-mimi-overlay-panel
92
+ role="tooltip"
93
+ [id]="panelId()"
94
+ [class]="classes()"
95
+ [attr.data-state]="state()"
96
+ [attr.data-side]="side()"
97
+ [style.--mimi-tooltip-arrow-offset]="arrowOffset()"
98
+ (pointerleave)="pointerLeave.emit($event.relatedTarget)"
99
+ >
100
+ {{ text() }}
101
+ @if (shortcut()) {
102
+ <span class="font-mono text-[11px] opacity-75">{{ shortcut() }}</span>
103
+ }
104
+ @if (arrow()) {
105
+ <span aria-hidden="true" [attr.data-side]="side()" [class]="arrowClasses"></span>
106
+ }
107
+ </div>
108
+ `,
109
+ styles: MIMI_OVERLAY_ANIMATION,
110
+ host: { style: 'display: contents' },
111
+ changeDetection: ChangeDetectionStrategy.OnPush,
112
+ })
113
+ export class MimiTooltipPanel {
114
+ readonly panelId = input('');
115
+ readonly text = input('');
116
+ readonly shortcut = input('');
117
+ readonly arrow = input(true);
118
+ readonly side = input<MimiTooltipSide>('top');
119
+ readonly state = input<'open' | 'closed'>('closed');
120
+ readonly arrowOffset = input<string | null>(null);
121
+ readonly userClass = input('');
122
+ /** El puntero sale del tooltip, hacia este elemento (si vuelve al trigger, sigue abierto). */
123
+ readonly pointerLeave = output<EventTarget | null>();
124
+
125
+ protected readonly classes = computed(() =>
126
+ cn(
127
+ 'relative flex w-max max-w-[calc(100vw_-_16px)] items-center gap-2 px-2.5 py-1.5',
128
+ 'rounded-[var(--mimi-tooltip-radius,8px)] bg-[color:var(--mimi-tooltip-bg,var(--mimi-tooltip))] text-[color:var(--mimi-tooltip-fg,var(--mimi-tooltip-foreground))]',
129
+ 'text-[length:var(--mimi-tooltip-font-size,12.5px)] font-medium shadow-[shadow:var(--mimi-shadow-neutral)]',
130
+ // Puente invisible sobre la separación: el puntero pasa del trigger al tooltip sin salir.
131
+ "before:absolute before:content-['']",
132
+ 'data-[side=top]:before:inset-x-0 data-[side=top]:before:top-full data-[side=top]:before:h-[10px]',
133
+ 'data-[side=bottom]:before:inset-x-0 data-[side=bottom]:before:bottom-full data-[side=bottom]:before:h-[10px]',
134
+ 'data-[side=left]:before:inset-y-0 data-[side=left]:before:left-full data-[side=left]:before:w-[10px]',
135
+ 'data-[side=right]:before:inset-y-0 data-[side=right]:before:right-full data-[side=right]:before:w-[10px]',
136
+ this.userClass(),
137
+ ),
138
+ );
139
+
140
+ protected readonly arrowClasses = cn(
141
+ 'pointer-events-none absolute size-[var(--mimi-tooltip-arrow,8px)] rotate-45 rounded-[1px] bg-[color:var(--mimi-tooltip-bg,var(--mimi-tooltip))]',
142
+ 'data-[side=top]:bottom-[calc(var(--mimi-tooltip-arrow,8px)/-2)] data-[side=top]:left-[var(--mimi-tooltip-arrow-offset,50%)] data-[side=top]:-translate-x-1/2',
143
+ 'data-[side=bottom]:top-[calc(var(--mimi-tooltip-arrow,8px)/-2)] data-[side=bottom]:left-[var(--mimi-tooltip-arrow-offset,50%)] data-[side=bottom]:-translate-x-1/2',
144
+ 'data-[side=left]:right-[calc(var(--mimi-tooltip-arrow,8px)/-2)] data-[side=left]:top-[var(--mimi-tooltip-arrow-offset,50%)] data-[side=left]:-translate-y-1/2',
145
+ 'data-[side=right]:left-[calc(var(--mimi-tooltip-arrow,8px)/-2)] data-[side=right]:top-[var(--mimi-tooltip-arrow-offset,50%)] data-[side=right]:-translate-y-1/2',
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Tooltip en cualquier elemento: `<button mimiTooltip="Copiar">`. Solo texto. Si necesitas un
151
+ * enlace o un botón dentro, usa Popover. En pantallas táctiles no se muestra.
152
+ */
153
+ @Directive({
154
+ selector: '[mimiTooltip]',
155
+ host: {
156
+ '[attr.aria-describedby]': 'describedBy()',
157
+ '[attr.data-tooltip-state]': 'visible() ? "open" : "closed"',
158
+ '(pointerenter)': 'onPointerEnter($event)',
159
+ '(pointerleave)': 'onPointerLeave($event)',
160
+ '(pointerdown)': 'hide()',
161
+ '(focusin)': 'onFocusIn($event)',
162
+ '(focusout)': 'onFocusOut($event)',
163
+ },
164
+ })
165
+ export class MimiTooltip {
166
+ private readonly injector = inject(Injector);
167
+ private readonly element = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
168
+ /** aria-describedby que ya tenía el elemento: se conserva. */
169
+ private readonly ownDescribedBy = inject(new HostAttributeToken('aria-describedby'), {
170
+ optional: true,
171
+ });
172
+
173
+ /** Texto del tooltip. Vacío, no se muestra. */
174
+ readonly text = input('', { alias: 'mimiTooltip' });
175
+ /** Lado preferido. Si no cabe, pasa al opuesto. */
176
+ readonly side = input<MimiTooltipSide>('top', { alias: 'mimiTooltipSide' });
177
+ /** Alineación con el trigger. */
178
+ readonly align = input<MimiTooltipAlign>('center', { alias: 'mimiTooltipAlign' });
179
+ /** Retraso al abrir, en ms. Sin él, se usa --mimi-tooltip-delay (300ms). */
180
+ readonly delay = input<number | undefined, unknown>(undefined, {
181
+ alias: 'mimiTooltipDelay',
182
+ transform: (value: unknown) =>
183
+ value === undefined || value === null || value === '' ? undefined : numberAttribute(value),
184
+ });
185
+ /** No mostrarlo. */
186
+ readonly disabled = input(false, { alias: 'mimiTooltipDisabled', transform: booleanAttribute });
187
+ /** Atajo de teclado, en monoespaciada (p. ej. ⌘C). */
188
+ readonly shortcut = input('', { alias: 'mimiTooltipShortcut' });
189
+ /** Flecha hacia el trigger (decorativa). */
190
+ readonly arrow = input(true, { alias: 'mimiTooltipArrow', transform: booleanAttribute });
191
+ /** Clases del tooltip, mezcladas con `cn()`. */
192
+ readonly tooltipClass = input('', { alias: 'mimiTooltipClass' });
193
+
194
+ /** id del tooltip (role="tooltip"). */
195
+ readonly tooltipId = `mimi-tooltip-${nextId++}`;
196
+ /** Visible (también deja de serlo al empezar la animación de salida). */
197
+ readonly visible = signal(false);
198
+
199
+ private readonly placedSide = signal<MimiTooltipSide>('top');
200
+ private readonly arrowOffset = signal<string | null>(null);
201
+ private overlayRef: OverlayRef | null = null;
202
+ private panelRef: ComponentRef<MimiTooltipPanel> | null = null;
203
+ private openTimer: ReturnType<typeof setTimeout> | undefined;
204
+ private hiding = 0;
205
+
206
+ protected readonly describedBy = computed(() => {
207
+ const ids = this.ownDescribedBy ? [this.ownDescribedBy] : [];
208
+ // Si el texto ya es el nombre del elemento (botón de ícono), no se repite.
209
+ if (this.visible() && !this.textIsLabel()) ids.push(this.tooltipId);
210
+ return ids.length > 0 ? ids.join(' ') : null;
211
+ });
212
+
213
+ private readonly syncPanel = effect(() => {
214
+ const inputs = {
215
+ panelId: this.tooltipId,
216
+ text: this.text(),
217
+ shortcut: this.shortcut(),
218
+ arrow: this.arrow(),
219
+ side: this.placedSide(),
220
+ arrowOffset: this.arrowOffset(),
221
+ userClass: this.tooltipClass(),
222
+ };
223
+ untracked(() => {
224
+ if (!this.panelRef) return;
225
+ for (const [name, value] of Object.entries(inputs)) this.panelRef.setInput(name, value);
226
+ });
227
+ });
228
+
229
+ private readonly hideWhenOff = effect(() => {
230
+ if (this.disabled() || !this.text().trim()) untracked(() => this.hide());
231
+ });
232
+
233
+ constructor() {
234
+ inject(DestroyRef).onDestroy(() => {
235
+ clearTimeout(this.openTimer);
236
+ if (this.visible()) shared.lastHiddenAt = Date.now();
237
+ this.overlayRef?.dispose();
238
+ });
239
+ }
240
+
241
+ /** Muestra el tooltip después del retraso (o enseguida, si otro se cerró hace poco). */
242
+ open(): void {
243
+ clearTimeout(this.openTimer);
244
+ if (this.disabled() || !this.text().trim() || this.visible()) return;
245
+ const recent = Date.now() - shared.lastHiddenAt < MIMI_TOOLTIP_SKIP_DELAY;
246
+ const delay = recent ? 0 : this.openDelay();
247
+ if (delay === 0) this.show();
248
+ else this.openTimer = setTimeout(() => this.show(), delay);
249
+ }
250
+
251
+ /** Lo oculta (sin mover el foco). */
252
+ hide(): void {
253
+ clearTimeout(this.openTimer);
254
+ if (!this.visible()) return;
255
+ this.visible.set(false);
256
+ shared.lastHiddenAt = Date.now();
257
+ const panelRef = this.panelRef;
258
+ if (!panelRef) return;
259
+ panelRef.setInput('state', 'closed');
260
+ const hiding = ++this.hiding;
261
+ afterNextRender(
262
+ {
263
+ read: () => {
264
+ const panel = panelRef.location.nativeElement.firstElementChild as HTMLElement | null;
265
+ void mimiWaitForExit(panel ?? undefined).then(() => {
266
+ if (hiding === this.hiding && !this.visible()) this.overlayRef?.detach();
267
+ });
268
+ },
269
+ },
270
+ { injector: this.injector },
271
+ );
272
+ }
273
+
274
+ protected onPointerEnter(event: PointerEvent): void {
275
+ // En pantallas táctiles no hay hover: no se muestra (spec 4, Tooltip).
276
+ if (event.pointerType === 'touch') return;
277
+ this.open();
278
+ }
279
+
280
+ protected onPointerLeave(event: PointerEvent): void {
281
+ if (this.isInPanel(event.relatedTarget)) return;
282
+ this.hide();
283
+ }
284
+
285
+ protected onFocusIn(event: FocusEvent): void {
286
+ // Solo con el foco de teclado: un clic no debe mostrarlo.
287
+ if (!this.isFocusVisible(event.target)) return;
288
+ this.open();
289
+ }
290
+
291
+ protected onFocusOut(event: FocusEvent): void {
292
+ const next = event.relatedTarget as Node | null;
293
+ if (next && this.element.contains(next)) return;
294
+ this.hide();
295
+ }
296
+
297
+ private show(): void {
298
+ if (this.disabled() || !this.text().trim()) return;
299
+ ++this.hiding;
300
+ const overlayRef = this.overlay();
301
+ if (!overlayRef.hasAttached()) {
302
+ this.panelRef = overlayRef.attach(new ComponentPortal(MimiTooltipPanel, null, this.injector));
303
+ this.panelRef.instance.pointerLeave.subscribe((to) => {
304
+ if (!(to instanceof Node && this.element.contains(to))) this.hide();
305
+ });
306
+ }
307
+ this.placedSide.set(this.side());
308
+ for (const [name, value] of Object.entries({
309
+ panelId: this.tooltipId,
310
+ text: this.text(),
311
+ shortcut: this.shortcut(),
312
+ arrow: this.arrow(),
313
+ side: this.side(),
314
+ arrowOffset: this.arrowOffset(),
315
+ userClass: this.tooltipClass(),
316
+ state: 'open',
317
+ })) {
318
+ this.panelRef!.setInput(name, value);
319
+ }
320
+ this.strategy?.withPositions(mimiConnectedPositions(this.side(), this.align(), OFFSET));
321
+ overlayRef.updatePosition();
322
+ this.visible.set(true);
323
+ }
324
+
325
+ private strategy: ReturnType<typeof createFlexibleConnectedPositionStrategy> | null = null;
326
+
327
+ private overlay(): OverlayRef {
328
+ if (this.overlayRef) return this.overlayRef;
329
+ const strategy = createFlexibleConnectedPositionStrategy(this.injector, this.element)
330
+ .withPositions(mimiConnectedPositions(this.side(), this.align(), OFFSET))
331
+ .withViewportMargin(MIMI_VIEWPORT_MARGIN)
332
+ .withFlexibleDimensions(false)
333
+ .withPush(false)
334
+ .withTransformOriginOn('[data-mimi-overlay-panel]');
335
+ strategy.positionChanges.subscribe(({ connectionPair }) => {
336
+ const side = mimiPlacedSide(connectionPair);
337
+ this.placedSide.set(side);
338
+ const panel = this.panelRef?.location.nativeElement.firstElementChild as HTMLElement | null;
339
+ this.arrowOffset.set(
340
+ panel
341
+ ? mimiArrowOffset(
342
+ this.element.getBoundingClientRect(),
343
+ panel.getBoundingClientRect(),
344
+ side,
345
+ )
346
+ : null,
347
+ );
348
+ });
349
+ this.strategy = strategy;
350
+ this.overlayRef = createOverlayRef(this.injector, {
351
+ positionStrategy: strategy,
352
+ scrollStrategy: createRepositionScrollStrategy(this.injector),
353
+ });
354
+ this.overlayRef.keydownEvents().subscribe((event) => {
355
+ // Escape lo cierra sin mover el foco (WCAG 1.4.13). El CDK solo se lo entrega al overlay
356
+ // de más arriba, así que un Dialog debajo no se cierra con el mismo Escape.
357
+ if (event.key === 'Escape' && this.visible()) {
358
+ event.preventDefault();
359
+ this.hide();
360
+ }
361
+ });
362
+ return this.overlayRef;
363
+ }
364
+
365
+ /** Retraso: la entrada, o --mimi-tooltip-delay del tema, o 300ms. */
366
+ private openDelay(): number {
367
+ const own = this.delay();
368
+ if (own !== undefined && !Number.isNaN(own)) return Math.max(0, own);
369
+ const token = getComputedStyle(this.element).getPropertyValue('--mimi-tooltip-delay');
370
+ return parseDelay(token) ?? DEFAULT_DELAY;
371
+ }
372
+
373
+ /**
374
+ * El texto ya está en el nombre del elemento (`aria-labelledby` o `aria-label`), sin distinguir
375
+ * mayúsculas ni espacios: «Comentarios» dentro de «Comentarios, 3 sin leer» no se repite.
376
+ */
377
+ private textIsLabel(): boolean {
378
+ const text = normalizeName(this.text());
379
+ return !!text && normalizeName(this.accessibleName()).includes(text);
380
+ }
381
+
382
+ private accessibleName(): string {
383
+ const labelledBy = this.element.getAttribute('aria-labelledby')?.trim();
384
+ if (labelledBy) {
385
+ const root = this.element.getRootNode() as Document | ShadowRoot;
386
+ return labelledBy
387
+ .split(/\s+/)
388
+ .map((id) => root.getElementById?.(id)?.textContent ?? '')
389
+ .join(' ');
390
+ }
391
+ return this.element.getAttribute('aria-label') ?? '';
392
+ }
393
+
394
+ private isInPanel(target: EventTarget | null): boolean {
395
+ const host = this.panelRef?.location.nativeElement as HTMLElement | undefined;
396
+ return !!host && target instanceof Node && host.contains(target);
397
+ }
398
+
399
+ private isFocusVisible(target: EventTarget | null): boolean {
400
+ if (!(target instanceof Element)) return false;
401
+ try {
402
+ return target.matches(':focus-visible');
403
+ } catch {
404
+ return true;
405
+ }
406
+ }
407
+ }
@@ -39,6 +39,15 @@ const RADII: Record<string, string> = {
39
39
  badge: 'badge-radius',
40
40
  };
41
41
 
42
+ /** Las sombras van a --mimi-shadow-*, salvo el glow: glowSuccess → --mimi-glow-success. */
43
+ const shadowName = (key: string): string =>
44
+ key.startsWith('glow') ? kebab(key) : `shadow-${kebab(key)}`;
45
+
46
+ /** Alturas → --mimi-control-*; los demás compartidos, sin prefijo: focusRing → --mimi-focus-ring. */
47
+ const CONTROL_SIZES = new Set(['height', 'heightSm', 'heightLg']);
48
+ const controlName = (key: string): string =>
49
+ CONTROL_SIZES.has(key) ? `control-${kebab(key)}` : kebab(key);
50
+
42
51
  /** Grupos del preset: en qué modo van y cómo se nombra cada variable (sin el prefijo --mimi-). */
43
52
  const GROUPS: readonly {
44
53
  key: keyof MimiThemePreset;
@@ -47,12 +56,13 @@ const GROUPS: readonly {
47
56
  }[] = [
48
57
  { key: 'colors', scope: 'light', name: kebab },
49
58
  { key: 'darkColors', scope: 'dark', name: kebab },
50
- { key: 'shadows', scope: 'light', name: (k) => `shadow-${kebab(k)}` },
51
- { key: 'darkShadows', scope: 'dark', name: (k) => `shadow-${kebab(k)}` },
59
+ { key: 'shadows', scope: 'light', name: shadowName },
60
+ { key: 'darkShadows', scope: 'dark', name: shadowName },
52
61
  { key: 'radii', scope: 'any', name: (k) => RADII[k] ?? `radius-${kebab(k)}` },
53
62
  { key: 'fonts', scope: 'any', name: (k) => `font-${kebab(k)}` },
54
63
  { key: 'motion', scope: 'any', name: kebab },
55
- { key: 'controls', scope: 'any', name: (k) => `control-${kebab(k)}` },
64
+ { key: 'effects', scope: 'any', name: kebab },
65
+ { key: 'controls', scope: 'any', name: controlName },
56
66
  ];
57
67
 
58
68
  /** Prefijo de las variables de cada componente: button.fontWeight → --mimi-btn-font-weight. */
@@ -60,8 +70,50 @@ const COMPONENT_PREFIX: Record<keyof MimiComponentTokens, string> = {
60
70
  button: 'btn',
61
71
  input: 'input',
62
72
  card: 'card',
73
+ avatar: 'avatar',
74
+ switch: 'switch',
75
+ checkbox: 'checkbox',
76
+ formField: 'field',
77
+ separator: 'separator',
78
+ skeleton: 'skeleton',
79
+ badge: 'badge',
80
+ textarea: 'textarea',
81
+ popover: 'popover',
82
+ tooltip: 'tooltip',
83
+ menu: 'menu',
84
+ select: 'select',
85
+ dialog: 'dialog',
86
+ confirm: 'confirm',
87
+ toast: 'toast',
88
+ tabs: 'tabs',
89
+ toolbar: 'toolbar',
90
+ pagination: 'pagination',
63
91
  };
64
92
 
93
+ /** Nombres de la 0.1.0 que siguen funcionando hasta la 1.0 (spec 12): ruta → qué usar. */
94
+ const DEPRECATED: readonly [path: readonly string[], use: string][] = [
95
+ [['components', 'button', 'paddingX'], 'components.button.px'],
96
+ [['components', 'input', 'paddingX'], 'components.input.px'],
97
+ [['components', 'input', 'placeholderColor'], 'la variable --mimi-input-placeholder en tu CSS'],
98
+ [['radii', 'badge'], 'components.badge.radius'],
99
+ ];
100
+ const warnedDeprecated = new Set<string>();
101
+
102
+ /** En modo desarrollo, avisa una sola vez por cada nombre viejo que use el preset. */
103
+ function warnDeprecated(preset: MimiThemePreset): void {
104
+ if (!isDevMode()) return;
105
+ for (const [path, use] of DEPRECATED) {
106
+ const name = path.join('.');
107
+ let value: unknown = preset;
108
+ for (const key of path) value = (value as Record<string, unknown> | undefined)?.[key];
109
+ if (value === undefined || warnedDeprecated.has(name)) continue;
110
+ warnedDeprecated.add(name);
111
+ console.warn(
112
+ `[Mimi] Theme: ${name} es un nombre de la 0.1.0: usa ${use}. Sigue funcionando hasta la 1.0.`,
113
+ );
114
+ }
115
+ }
116
+
65
117
  /** Caracteres que romperían el CSS o cerrarían la etiqueta <style>. */
66
118
  const UNSAFE_VALUE = /[;{}<]/;
67
119
 
@@ -75,7 +127,7 @@ export function mimiThemeToCss(preset: MimiThemePreset): string {
75
127
  if (text === '') return;
76
128
  if (UNSAFE_VALUE.test(text)) {
77
129
  if (isDevMode()) {
78
- console.warn(`[mimi] Se ignora --mimi-${name}: el valor "${text}" no es seguro.`);
130
+ console.warn(`[Mimi] Theme: se ignora --mimi-${name}: el valor "${text}" no es seguro.`);
79
131
  }
80
132
  return;
81
133
  }
@@ -105,11 +157,14 @@ export function mimiThemeToCss(preset: MimiThemePreset): string {
105
157
 
106
158
  // El :root del preset le ganaría al prefers-reduced-motion de theme-base.css.
107
159
  const hasMotion = declarations.any.some((d) =>
108
- /--mimi-(press-scale|press-scale-sm|lift|transition):/.test(d),
160
+ /--mimi-(press-scale|press-scale-sm|lift|transition|btn-press-scale):/.test(d),
109
161
  );
110
162
  if (hasMotion) {
163
+ const btnScale = declarations.any.some((d) => d.startsWith(' --mimi-btn-press-scale:'))
164
+ ? '\n --mimi-btn-press-scale: 1;'
165
+ : '';
111
166
  blocks.push(
112
- '@media (prefers-reduced-motion: reduce) {\n :root {\n --mimi-press-scale: 1;\n --mimi-press-scale-sm: 1;\n --mimi-lift: 0;\n }\n}',
167
+ `@media (prefers-reduced-motion: reduce) {\n :root {\n --mimi-press-scale: 1;\n --mimi-press-scale-sm: 1;\n --mimi-lift: 0;${btnScale}\n }\n}`,
113
168
  );
114
169
  }
115
170
 
@@ -143,6 +198,7 @@ export function applyMimiTheme(
143
198
 
144
199
  /** Registra un preset del tema. Opcional: sin él, se usa theme-base.css tal cual. */
145
200
  export function provideMimiTheme(preset: MimiThemePreset): EnvironmentProviders {
201
+ warnDeprecated(preset);
146
202
  return makeEnvironmentProviders([
147
203
  { provide: MIMI_THEME, useValue: preset },
148
204
  provideAppInitializer(() => applyMimiTheme(inject(DOCUMENT), inject(MIMI_THEME))),