Refactor air architecture: clean dependency boundaries, enrich Behavior/Dom, fix AudioContext

- getAir() is now the single entry point — returns behavior, dom, translator, logger
- behavior.play() encapsulates soundEnabled check + preset resolution
- dom.resolve() resolves ResponsiveProp using current viewport/breakpoints
- internal/ has no knowledge of config/ (correct dependency direction)
- internal/ types stripped of Air prefix; public barrel re-exports with prefix
- context.ts defines TranslatorService locally (structurally compatible with TerraTranslator)
- button/text components updated to use only getAir(), no direct terra/config imports
- AudioContext fix: withRunningContext() resumes on first user gesture before playing
- AIR_IMPLEMENTATION_GUIDE updated: 12 semantics, correct APIs, current patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
glm-5
dev 6 months ago
parent 9a9c4d4f14
commit b30092dd06

@ -2,7 +2,14 @@
import '$uix/air/tokens/index.css';
import '$uix/air/themes/index.css';
import { setContext } from 'svelte';
import { AIR_DEFAULT_THEME, type AirThemeId } from '$uix/air';
import {
AIR_DEFAULT_THEME,
type AirTheme,
BASE_LIGHT_THEME,
BASE_DARK_THEME,
EARTH_LIGHT_THEME,
EARTH_DARK_THEME,
} from '$uix/air/themes';
import { useAir } from '$uix/air/config';
import {
createTerraCurrencyFormatter,
@ -21,15 +28,19 @@
} from './demo-locale';
let { children } = $props();
let theme = $state<AirThemeId>(AIR_DEFAULT_THEME);
const THEMES: AirTheme[] = [BASE_LIGHT_THEME, BASE_DARK_THEME, EARTH_LIGHT_THEME, EARTH_DARK_THEME];
let currentTheme = $state<AirTheme>(BASE_LIGHT_THEME);
let localeId = $state<AirDemoLocaleId>('en-US');
let soundEnabled = $state(true);
let motionEnabled = $state(true);
const themes: AirThemeId[] = ['base-light', 'base-dark'];
const localeOptions = AIR_DEMO_LOCALES;
const currentLocale = $derived(getAirDemoLocale(localeId));
const themeLabel = (t: AirTheme) => t.id.replace('-', ' ');
const translator = createTerraTranslator({
locale: () => currentLocale.id,
translate: () => (value) => translateAirDemoValue(localeId, value),
@ -88,12 +99,14 @@
get dir() { return currentLocale.dir; },
get soundEnabled() { return soundEnabled; },
get motionEnabled() { return motionEnabled; },
get soundPresets() { return currentTheme.soundPresets; },
get motionPresets() { return currentTheme.motionPresets; },
});
$effect(() => {
document.documentElement.lang = currentLocale.id;
document.documentElement.dir = currentLocale.dir;
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-theme', currentTheme.id);
});
</script>
@ -101,7 +114,7 @@
<title>Air Tests</title>
</svelte:head>
<div class="air-demo-shell" data-theme={theme} lang={currentLocale.id} dir={currentLocale.dir}>
<div class="air-demo-shell" data-theme={currentTheme.id} lang={currentLocale.id} dir={currentLocale.dir}>
<div class="air-demo-atmosphere" aria-hidden="true"></div>
<header class="air-demo-toolbar">
@ -130,13 +143,13 @@
</div>
<div class="air-demo-ctrl-sep" aria-hidden="true"></div>
<div class="air-demo-ctrl-group" aria-label="Theme">
{#each themes as item}
{#each THEMES as t}
<button
type="button"
class="air-demo-chip"
class:air-demo-chip--active={theme === item}
onclick={() => (theme = item)}
>{item.replace('base-', '')}</button>
class:air-demo-chip--active={currentTheme.id === t.id}
onclick={() => (currentTheme = t)}
>{themeLabel(t)}</button>
{/each}
</div>
<div class="air-demo-ctrl-sep" aria-hidden="true"></div>

@ -4,8 +4,11 @@
isReducedSound,
playSemantic,
playSound,
SOUND_PRESETS
BASE_SOUND_PRESETS as SOUND_PRESETS
} from '$uix/air/internal/sound';
import { getAir } from '$uix/air/config';
const airConfig = getAir();
import { SOUND_TOKENS } from '$uix/air/internal/sound';
import type {
SoundSemantic,
@ -57,7 +60,10 @@
if (phase === 'enter') stopPersistence();
activeSemantic = semantic;
activePhase = phase;
const result = playSemantic(semantic, phase, volumeMultiplier);
const result = playSemantic(semantic, phase, {
volumeMultiplier: volumeMultiplier * airConfig.soundVolume.current,
presets: airConfig.soundPresets.current,
});
if (semantic === 'persistence' && phase === 'enter' && typeof result === 'function') {
persistenceStop = result;
}

@ -1,17 +1,17 @@
# air — Guía de implementación de componentes
Referencia práctica para implementar un componente nuevo en `air`.
Para arquitectura general ver `air-arquitectura.md`.
Para arquitectura general ver `AIR_ARCHITECTURE.md`.
---
## Checklist por componente
```
[ ] 1. Tokens CSS del componente
[ ] 2. CSS de recipe (data-* + variantes)
[ ] 3. Wrapper Svelte
[ ] 4. Semántica asignada (motion + sound)
[ ] 1. Semántica asignada (motion + sound)
[ ] 2. Tokens CSS del componente
[ ] 3. CSS de recipe (data-* + variantes)
[ ] 4. Wrapper Svelte
[ ] 5. Props públicas tipadas
[ ] 6. Barrel export
[ ] 7. Demo en /test/air/[componente]
@ -21,19 +21,24 @@ Para arquitectura general ver `air-arquitectura.md`.
## Paso 1 — Identificar la semántica
Antes de escribir una línea de código, asignar la semántica. Hay exactamente **7 semánticas**:
| Semántica | Componentes típicos | Trigger |
|---|---|---|
| `feedback` | Button, Toggle, Checkbox, Switch, Radio | `pointerdown` |
| `revelation` | Dropdown, Popover, Tooltip, Menú contextual | `in:` transition |
| `context` | Dialog, Drawer, Sheet | `in:` transition |
| `attention` | Input con error, Alert de error | prop `invalid`/`error` → true |
| `emphasis` | Badge de novedad, Indicador de onboarding | montaje del elemento |
| `destruction` | Item de lista eliminado, Toast de error | `out:` transition |
| `persistence` | Spinner, Button en loading, Progress indeterminate | prop `loading`/`active` → true |
Si el componente no encaja en ninguna, probablemente no necesita semántica (es puramente visual o estructural).
Antes de escribir código, asignar la semántica. Hay exactamente **12 semánticas**:
| Semántica | Componentes típicos | Trigger |
|----------------|--------------------------------------------------|--------------------------------|
| `feedback` | Button, Toggle, Checkbox, Switch, Radio | `pointerdown` |
| `selection` | Checkbox, Switch, Radio, Toggle | cambio de estado checked/on |
| `revelation` | Dropdown, Popover, Tooltip, Menú contextual | `in:` / `out:` transition |
| `context` | Dialog, Drawer, Sheet | `in:` / `out:` transition |
| `expansion` | Accordion, Collapsible | `in:` / `out:` transition |
| `navigation` | Tabs, Breadcrumb, cambio de ruta | cambio de tab/ruta |
| `notification` | Toast, Snackbar, Banner | montaje del elemento |
| `attention` | Input con error, Alert de error | prop `invalid`/`error` → true |
| `emphasis` | Badge de novedad, indicadores de onboarding | montaje del elemento |
| `completion` | estado de éxito, checkmark final, confirmación | prop `success` → true |
| `destruction` | ítem de lista eliminado, Toast de error | `out:` transition |
| `persistence` | Spinner, Button en loading, Progress indeterminate | prop `loading`/`active` → true |
Si el componente no encaja en ninguna es puramente visual o estructural (Field, Label, Separator, Avatar…) — sin semántica.
---
@ -42,7 +47,7 @@ Si el componente no encaja en ninguna, probablemente no necesita semántica (es
Crear `tokens/components/[componente].css`. Define los tokens `--air-[componente]-*` que el CSS de recipe usará.
```css
/* tokens/component/select.css */
/* tokens/components/select.css */
:root {
/* Trigger */
--air-select-height-xs: 26px;
@ -59,25 +64,25 @@ Crear `tokens/components/[componente].css`. Define los tokens `--air-[componente
--air-select-content-shadow: var(--air-shadow-2);
/* Item */
--air-select-item-height: 32px;
--air-select-item-px: 10px;
--air-select-item-radius: var(--air-radius-md);
--air-select-item-height: 32px;
--air-select-item-px: 10px;
--air-select-item-radius: var(--air-radius-md);
}
```
**Reglas de naming:**
- Prefijo siempre `--air-[componente]-`
- Tokens privados de implementación: `--_air-[componente]-` (no son API pública)
- No hardcodear valores de color — siempre `var(--air-color-*)`
- Tokens privados de implementación: `--_air-[componente]-` (no son API pública, pueden cambiar)
- Nunca hardcodear colores — siempre `var(--air-color-*)`
---
## Paso 3 — CSS de recipe
Crear `components/[componente]/[componente].css`. Este archivo responde a los `data-*` de terra y aplica los tokens del paso 2.
Crear `components/[componente]/[componente].css`. Responde a los `data-*` de terra y aplica los tokens del paso 2.
```css
/* component/select/select.css */
/* components/select/select.css */
/* ── Base ── */
.air-select-trigger {
@ -93,8 +98,8 @@ Crear `components/[componente]/[componente].css`. Este archivo responde a los `d
[data-size='md'] .air-select-trigger { --_air-select-h: var(--air-select-height-md); }
[data-size='lg'] .air-select-trigger { --_air-select-h: var(--air-select-height-lg); }
/* ── Estados — transversales, no específicos por variante ── */
[data-disabled] .air-select-trigger { opacity: 0.5; pointer-events: none; }
/* ── Estados — transversales, sin combinatorias con variantes ── */
[data-disabled] .air-select-trigger { opacity: 0.5; pointer-events: none; }
[data-state='open'] .air-select-trigger { border-color: var(--air-color-accent); }
```
@ -102,7 +107,8 @@ Crear `components/[componente]/[componente].css`. Este archivo responde a los `d
- `size` solo toca dimensiones (`height`, `padding`, `font-size`)
- `variant` solo toca apariencia (`background`, `border`, `color`)
- estados (`[data-disabled]`, `[data-state]`) son transversales — funcionan con cualquier variante sin combinatorias
- Este archivo se importa globalmente en el `.svelte`: `import './select.css'`
- Este archivo se importa en el `.svelte`: `import './select.css'`
- Tokens y selectores `data-*` deben estar en archivos `.css` globales, **no** en `<style>` scoped
---
@ -110,166 +116,177 @@ Crear `components/[componente]/[componente].css`. Este archivo responde a los `d
Crear `components/[componente]/[componente].svelte`.
### Caso A: el root es un elemento DOM nativo
El punto de entrada es siempre `getAir()` — expone todo lo necesario sin importaciones adicionales.
```ts
const air = getAir();
// air.behavior.play(semantic, phase, opts?) — reproduce sonido si está habilitado
// air.behavior.motionEnabled.current — boolean
// air.dom.resolve(responsiveProp) — resuelve ResponsiveProp al valor actual
// air.translator.current?.translate(value) — traduce un valor
// air.translator.current?.translatePath(path, ...args) — traduce una clave de ruta
// air.logger.current?.warn(msg, ...meta) — log de advertencia
```
Se puede usar `use:` directamente con los behaviors:
### Caso A — root es un elemento DOM nativo
`use:` funciona directamente con los behaviors:
```svelte
<!-- components/badge/badge.svelte -->
<script lang="ts">
import './badge.css';
import type { BadgeProps } from './types';
import { airEmphasis } from '$uix/air/internal/behaviors.svelte';
import { emphasis } from '$uix/air/internal/behavior';
let { variant = 'soft', color = 'primary', isNew = false, children, ...restProps }: BadgeProps = $props();
</script>
<span
class="air-badge"
data-variant={variant}
data-color={color}
{...restProps}
>
<span class="air-badge" data-variant={variant} data-color={color} {...restProps}>
{#if isNew}
<span class="air-badge-dot" use:airEmphasis></span>
<span class="air-badge-dot" use:emphasis></span>
{/if}
{@render children?.()}
</span>
```
### Caso B: el root es un componente terra
### Caso B — root es un componente terra
`use:` no funciona en componentes Svelte. Usar `bind:ref` + `$effect`:
`use:` no funciona en componentes Svelte. Usar `bind:ref` + `$effect` con `addEventListener`:
```svelte
<!-- components/button/button.svelte -->
<script lang="ts">
import type { ButtonRootProps } from './types';
import { Button as TerraButton } from '$uix/terra';
import { getAirConfig } from '$uix/air/config/air-config.svelte';
import { playSemantic } from '$uix/air/internal/sound/presets';
import { getAir } from '$uix/air/config';
import type { ButtonRootProps } from './types';
let { ...restProps }: ButtonRootProps = $props();
let { intent, children, ...restProps }: ButtonRootProps = $props();
const airConfig = getAirConfig();
let ref = $state<HTMLElement | null>(null);
const air = getAir();
let ref = $state<HTMLElement | null>(null);
let pressed = $state(false);
$effect(() => {
if (!ref) return;
const down = () => {
if (ref!.hasAttribute('disabled') || ref!.hasAttribute('data-disabled')) return;
pressed = true;
if (airConfig.soundEnabled.current) {
playSemantic('feedback', 'enter', {
intent: resolvedIntent, // modula pitch según danger/success/etc.
presets: airConfig.soundPresets.current,
});
}
air.behavior.play('feedback', 'enter', { intent });
};
const up = () => { pressed = false; };
ref.addEventListener('pointerdown', down);
ref.addEventListener('pointerup', up);
ref.addEventListener('pointerup', up);
ref.addEventListener('pointerleave', up);
ref.addEventListener('pointercancel', up);
return () => {
ref!.removeEventListener('pointerdown', down);
ref!.removeEventListener('pointerup', up);
ref!.removeEventListener('pointerup', up);
ref!.removeEventListener('pointerleave', up);
ref!.removeEventListener('pointercancel', up);
};
});
const pressClass = $derived(
pressed && airConfig.motionEnabled.current ? 'air-motion-press' : ''
pressed && air.behavior.motionEnabled.current ? 'air-motion-press' : ''
);
</script>
<TerraButton.Root
class={`air-button ${pressClass}`.trim()}
bind:ref
{...restProps}
>
<TerraButton.Root class={`air-button ${pressClass}`.trim()} bind:ref {...restProps}>
{@render children?.()}
</TerraButton.Root>
```
### Caso C: overlay con transición
### Caso C — overlay con transición (revelation / context / expansion)
Para dropdowns, dialogs y similares usar las transiciones de `motion/transitions.ts`:
Usar las transiciones de `$uix/air/internal/motion`:
```svelte
<!-- components/popover/popover-content.svelte -->
<script lang="ts">
import { airReveal, airDismiss } from '$uix/air/motion/transitions';
import { reveal, dismiss } from '$uix/air/internal/motion';
// Para dialog/drawer: contextIn, contextOut
// Para accordion: expand, expandOut
</script>
{#if open}
<div
class="air-popover-content"
transition:airReveal={{ origin: placement }}
out:airDismiss
>
<div class="air-popover-content" in:reveal out:dismiss>
{@render children?.()}
</div>
{/if}
```
Las transiciones ya incluyen el sonido internamente — no hay que añadir nada más.
Las transiciones gestionan el sonido internamente — no hay que añadir nada más.
---
## Paso 5 — Semántica `attention` en componentes con estado de error
Para inputs, fields u otros componentes con estado de error:
## Paso 5 — Semántica `attention` (inputs con error)
```svelte
<script>
import { airAttention } from '$uix/air/internal/behaviors.svelte';
<script lang="ts">
import { attention } from '$uix/air/internal/behavior';
let { invalid = false, ...restProps } = $props();
</script>
<!-- use: funciona si el elemento es DOM nativo -->
<div class="air-field" use:airAttention={() => invalid}>
<div class="air-field" use:attention={() => invalid}>
...
</div>
```
`airAttention` solo dispara en la transición `false → true`. No dispara en montaje inicial (si el campo ya empieza inválido) ni en cada re-render.
`attention` solo dispara en la transición `false → true`. No dispara en montaje inicial.
---
## Paso 6 — Semántica `selection` (checkbox, switch, radio)
```svelte
<script lang="ts">
import { selection } from '$uix/air/internal/behavior';
let { checked = $bindable(false), ...restProps } = $props();
</script>
<span class="air-checkbox" use:selection={() => checked}>
...
</span>
```
`selection` dispara sonido + animación one-shot en cada cambio. `enter` al activar, `exit` al desactivar.
---
## Paso 6 — Semántica `persistence` en componentes loading
## Paso 7 — Semántica `persistence` (loading / spinner)
```svelte
<script>
import { airPersistence } from '$uix/air/internal/behaviors.svelte';
<script lang="ts">
import { persistence } from '$uix/air/internal/behavior';
let { loading = false } = $props();
</script>
<!-- El span del spinner es el nodo que recibe air-motion-spin -->
<span use:airPersistence={() => loading} class="spinner-icon">
<!-- El nodo que recibe el behavior es el elemento visual del spinner -->
<span use:persistence={() => loading} class="spinner-icon">
<Icon.LoaderCircle size="100%" />
</span>
```
`airPersistence` aplica `.air-motion-spin` mientras `loading=true` y gestiona el loop de sonido (con fade-out al detenerse).
`persistence` aplica la clase de motion mientras `loading=true` y gestiona el loop de sonido con fade-out al detenerse.
---
## Paso 7 — Props y tipos
## Paso 8 — Props y tipos
```typescript
// component/select/types.ts
import type { AirIntent } from '$uix/air'; // si aplica intent
// components/select/types.ts
import type { AirSemantic } from '$uix/air';
import type { ResponsiveProp } from '$uix/air/internal/dom';
export type SelectSize = 'xs' | 'sm' | 'md' | 'lg';
export type SelectVariant = 'outline' | 'ghost';
export type SelectRootProps = {
size?: SelectSize;
size?: ResponsiveProp<SelectSize>; // soporta { base: 'sm', md: 'md' }
variant?: SelectVariant;
intent?: AirIntent; // si el componente soporta intent semántico
intent?: string; // si el componente soporta intent semántico (feedback)
disabled?: boolean;
// ...
};
@ -277,103 +294,120 @@ export type SelectRootProps = {
---
## Paso 8 — Barrel export
## Paso 9 — Barrel export
```typescript
// component/select/index.ts
// components/select/index.ts
export { default as Select } from './select.svelte';
export type { SelectRootProps, SelectSize, SelectVariant } from './types';
```
Y añadir al barrel principal `src/uix/air/index.ts`:
Añadir al barrel principal `src/uix/air/index.ts`:
```typescript
export { Select } from './component/select';
export type { SelectRootProps } from './component/select';
export { Select } from './components/select';
export type { SelectRootProps } from './components/select';
```
---
## Paso 9 — Demo en /test/air/[componente]
## Paso 10 — Demo en /test/air/[componente]
La demo debe cubrir **todos los estados y semánticas**. Estructura mínima:
La demo debe cubrir todos los estados y semánticas. Estructura mínima:
```
/test/air/[componente]/+page.svelte
Secciones:
Hero — descripción + señales del componente (semánticas incluidas)
Hero — descripción del componente y semánticas asignadas
Playground — controles interactivos
Semánticas — tabla de semánticas asignadas + demo en vivo del trigger
Semánticas — tabla: semántica | trigger | motion | sound + demo en vivo
Variants — todas las variantes visuales
Sizes — escala de tamaños
States — idle, disabled, loading, error, etc.
[casos específicos del componente]
```
### Sección Semánticas (ejemplo)
```svelte
<section>
<h2>Semánticas</h2>
<div class="semantics-table">
<!-- Una fila por semántica asignada -->
<!-- feedback | pointerdown | scale press | tono seco -->
<!-- persistence | loading=true | air-motion-spin | loop filtrado -->
</div>
<!-- Demo en vivo de cada semántica -->
<CodeExample title="Feedback — press en vivo">
<Button intent="danger">Borrar cuenta</Button>
<!-- El usuario puede escuchar/ver que es diferente a primary -->
</CodeExample>
</section>
```
---
## Paso 10 — Verificación final
Antes de considerar el componente terminado:
## Paso 11 — Verificación final
```
[ ] Sonido se escucha (con sound toggle activo en layout)
[ ] Motion se ve (con motion toggle activo en layout)
[ ] Ambos se desactivan con los toggles del layout
[ ] Sonido se escucha (con sound toggle activo en /test/air layout)
[ ] Motion se ve (con motion toggle activo en /test/air layout)
[ ] Ambos se desactivan con los toggles del layout de test
[ ] intent='danger' suena diferente a intent='primary' (si aplica feedback)
[ ] El error state dispara attention correctamente (si aplica)
[ ] prefers-reduced-motion: los keyframes no se ejecutan
[ ] prefers-reduced-motion: los keyframes CSS no se ejecutan
[ ] No hay sonido sin gesto de usuario previo (AudioContext policy)
[ ] Funciona en dark theme
[ ] No hay colores hardcodeados en el CSS del componente
[ ] Los selectores data-* están en .css global, no en <style scoped>
[ ] Los selectores data-* están en .css global, no en <style> scoped
[ ] No hay imports directos de $terra/config — solo de $uix/terra (primitivos)
[ ] Responsive props se resuelven con air.dom.resolve(prop)
```
---
## Referencia rápida de APIs
### Behaviors (Svelte actions — solo en elementos DOM nativos)
```typescript
// Behaviors (use: en DOM nativo)
airFeedback(node, getIntent?) // press feedback
airAttention(node, getActive) // error attention
airEmphasis(node) // discovery pulse
airPersistence(node, getActive) // loading loop
// Transitions (Svelte in:/out:/transition:)
airReveal(node, { origin?, stagger?, distance? })
airDismiss(node, { semantic? })
airContext(node, { side? })
// Sound directo (cuando no hay behavior disponible)
playSemantic(semantic, phase, { intent?, sound?, presets?, volumeMultiplier? })
// Config
const config = getAirConfig();
config.soundEnabled.current // boolean
config.motionEnabled.current // boolean
config.soundVolume.current // number
config.soundPresets.current // AirSoundPresets | undefined
import {
feedback, // press + sonido en pointerdown. Arg: getIntent?
selection, // sonido + animación one-shot en cambio checked. Arg: getChecked
attention, // vibración + sonido en invalid false→true. Arg: getActive
emphasis, // pulso cíclico + sonido al montar. Sin args
persistence, // clase spin + loop de sonido mientras active. Arg: getActive
completion, // animación + sonido al activarse. Arg: getActive
} from '$uix/air/internal/behavior';
```
### Transiciones (Svelte in:/out:/transition:)
```typescript
import {
reveal, // Popover, Tooltip, Dropdown — semántica revelation
dismiss, // out: para revelation
contextIn, // Dialog, Drawer — semántica context (in:)
contextOut, // Dialog, Drawer — semántica context (out:)
expand, // Accordion, Collapsible — semántica expansion (in:)
expandOut, // Accordion, Collapsible — semántica expansion (out:)
} from '$uix/air/internal/motion';
```
Las transiciones ya gestionan el sonido internamente.
### Motion como Svelte action (state semántic)
```typescript
import { motion } from '$uix/air/internal/motion';
// use:motion={{ type: 'feedback', active: pressed }}
// Aplica/quita la clase CSS de estado según el valor de active
```
### Config — punto de entrada único
```typescript
import { getAir } from '$uix/air/config';
const air = getAir();
air.behavior.play(semantic, phase, opts?) // reproduce sonido si está habilitado
air.behavior.soundEnabled.current // boolean
air.behavior.motionEnabled.current // boolean
air.behavior.soundVolume.current // number 0–1
air.behavior.soundPresets.current // SoundPresets | undefined
air.behavior.motionPresets.current // MotionPresets | undefined
air.dom.resolve(responsiveProp) // resuelve ResponsiveProp al valor actual
air.dom.viewport.width // ancho del viewport (reactivo)
air.dom.breakpoints.current // Breakpoints resueltos
air.translator.current?.translate(value) // traduce un valor opaco
air.translator.current?.translatePath(path, ...args) // traduce una clave de ruta
air.logger.current?.warn(msg, ...meta) // advertencia de desarrollo
air.logger.current?.error(msg, ...meta) // error
```
---
@ -381,22 +415,35 @@ config.soundPresets.current // AirSoundPresets | undefined
## Preguntas frecuentes
**¿Por qué `use:` no funciona en mi componente?**
`use:` solo funciona en elementos DOM nativos, no en componentes Svelte. Si el root de tu componente es un wrapper de terra, usa `bind:ref` + `$effect` con `addEventListener` directamente.
`use:` solo funciona en elementos DOM nativos, no en componentes Svelte. Si el root es un wrapper de terra, usa `bind:ref` + `$effect` con `addEventListener` directamente (ver Caso B).
**¿Cómo evito que el sonido se reproduzca en el montaje inicial?**
`airAttention` y `airPersistence` ya lo controlan internamente. Para sound manual, asegúrate de disparar `playSemantic` solo desde handlers de eventos de usuario, no desde `$effect` reactivos a datos.
`attention`, `selection`, `persistence` y `completion` ya lo controlan internamente: solo disparan en transiciones de estado, no en el primer render. Para sonido manual desde un `$effect`, asegúrate de disparar `air.behavior.play()` solo desde handlers de eventos de usuario.
**¿Puedo usar mi propio sonido WAV para un componente específico?**
Sí. Los defaults sintéticos son solo eso — defaults. Hay tres niveles de override:
**¿El AudioContext necesita un gesto previo?**
Sí. El motor de sonido crea el `AudioContext` en el primer intento de reproducción y lo resume automáticamente en el primer `pointerdown`/`keydown`. Antes del primer gesto del usuario los sonidos se descartan silenciosamente — no hay warning en consola.
1. Por llamada concreta: `playSemantic('feedback', 'enter', { sound: myDef })`
2. Por app/subárbol: `<Air soundPresets={{ feedback: { enter: myDef } }}>`
3. Parcial: solo declarar las semánticas que se quieren cambiar; el resto hereda
**¿Puedo usar mi propio sonido WAV?**
Sí. Hay tres niveles de override:
`myDef` puede ser osciladores sintéticos, un archivo WAV (`{ layers: [{ src: '/sounds/click.wav', volumeDb: -10 }] }`), o capas mezcladas. El sistema no impone nada sobre sonidos personalizados — la modulación de intent por semitono solo se aplica a los presets sintéticos del sistema.
1. Por llamada: `air.behavior.play('feedback', 'enter', { sound: myDef })`
2. Por subárbol: `<Air soundPresets={{ feedback: { enter: myDef } }}>`
3. Por tema: definir `soundPresets` en el objeto de tema
`myDef` puede ser osciladores sintéticos, un archivo WAV (`{ layers: [{ src: '/sounds/click.wav', volumeDb: -10 }] }`), o capas mezcladas.
**¿Qué es `intent` vs `color`?**
`color` elige una paleta visual sin semántica. `intent` comunica significado (danger, success, warning) y modifica el pitch del sonido de feedback además de la paleta visual. Si ambos se proporcionan, `intent` prevalece.
`color` elige una paleta visual sin semántica de significado. `intent` comunica significado (danger, success, warning) y modifica el pitch del sonido de feedback además de la paleta. Si ambos se proporcionan, `intent` prevalece.
**¿Cómo implemento un componente con ResponsiveProp?**
Usar `air.dom.resolve(prop)` — resuelve el valor correcto para el viewport actual sin importar nada de responsive:
```svelte
const resolvedSize = $derived(air.dom.resolve(size) ?? 'md');
```
**¿Dónde van los tokens de componente?**
En `tokens/components/[componente].css`, importado desde el entry point de tokens (`tokens/index.css` o similar). **No** inline en el `.svelte`, **no** en `<style>` scoped.
**¿Motion y sound siempre van juntos?**
Depende de la semántica. `feedback` tiene ambos. `attention` tiene ambos. `persistence` tiene ambos. Pero los usuarios pueden desactivar cada canal independientemente desde `<Air motionEnabled={false}>` o `<Air soundEnabled={false}>`.
**¿Puedo importar desde `$terra/config`?**
No. Los componentes de air no deben importar de `$terra/config` directamente. Todo lo que necesitan (translator, logger, etc.) viene de `getAir()`. Los únicos imports válidos de terra son los primitivos de componente (`$uix/terra`, `$uix/terra/[componente]/types`).

@ -1,416 +0,0 @@
# Air Motion
Especificacion del sistema de motion de `air`.
Este documento define como se mueve la interfaz, que presets existen, que tokens necesita el tema base y que reglas deben respetar los componentes. La motion de `air` no es un adorno: es parte del lenguaje visual del sistema.
---
## 1. Proposito
La motion en `air` existe para resolver cuatro cosas:
- reforzar jerarquia
- comunicar causa y efecto
- hacer mas legibles transiciones de estado
- aportar tactilidad sin teatralidad
La motion no existe para:
- llamar la atencion por si misma
- compensar una jerarquia visual debil
- introducir rebotes o desplazamientos gratuitos
- convertir cada componente en una demo de animacion
---
## 2. Principios
### Sobria
Toda animacion debe sentirse contenida. Si un usuario la nota mas que el cambio de estado que acompana, probablemente es excesiva.
### Estructural
La motion debe reforzar estructura:
- un overlay entra como overlay
- un dropdown se despliega desde su origen
- un button responde al gesto sin saltar por la pantalla
### Rapida
La interfaz de `air` no debe sentirse pesada. La salida suele ser igual o mas rapida que la entrada.
### Consistente
Los mismos tipos de transicion deben compartir:
- duracion
- easing
- distancia
- escala
### Accesible
Todo preset debe poder degradarse con `prefers-reduced-motion`.
---
## 3. Familias de motion
### Estado
Para cambios cortos de UI:
- hover
- focus-visible
- pressed
- loading
- invalid
- selected
### Overlay
Para elementos flotantes o superficies elevadas:
- tooltip
- popover
- select content
- dialog
- drawer
### Estructural
Para cambios de layout o revelado de contenido:
- accordion
- tabs indicator
- collapsible regions
- helper/error text
### Continua
Solo para feedback donde tenga sentido:
- spinner
- progress indeterminate
Regla:
- la motion continua debe ser excepcional
- si algo gira o pulsa en loop, debe comunicar trabajo en curso o actividad real
---
## 4. Tokens
El contrato de `air` ya define duraciones y easings base. El sistema de motion necesita ademas una pequena capa de distancia y escala.
### Duraciones
- `--air-duration-instant`
- `--air-duration-fast`
- `--air-duration-normal`
- `--air-duration-moderate`
- `--air-duration-slow`
### Easings
- `--air-ease-default`
- `--air-ease-out`
- `--air-ease-in`
- `--air-ease-spring`
### Distancias
- `--air-motion-distance-xs`
- `--air-motion-distance-sm`
- `--air-motion-distance-md`
### Escalas
- `--air-motion-scale-enter`
- `--air-motion-scale-press`
Regla:
- la motion no debe inventar distancias o escalas locales por componente si ya existe un token adecuado
---
## 5. Valores base
Direccion recomendada para `base-light` y `base-dark`:
```css
--air-duration-instant: 0ms;
--air-duration-fast: 120ms;
--air-duration-normal: 180ms;
--air-duration-moderate: 240ms;
--air-duration-slow: 320ms;
--air-ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--air-ease-out: cubic-bezier(0, 0, 0.2, 1);
--air-ease-in: cubic-bezier(0.4, 0, 1, 1);
--air-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--air-motion-distance-xs: 2px;
--air-motion-distance-sm: 4px;
--air-motion-distance-md: 8px;
--air-motion-scale-enter: 0.985;
--air-motion-scale-press: 0.985;
```
Notas:
- `fast` es la duracion de referencia para microinteraccion
- `normal` sirve para error/helper/loading transitions y feedback visibles pero discretos
- `moderate` y `slow` son para overlays, no para botones
---
## 6. Presets
## 6.1 Hover
Uso:
- color
- border-color
- shadow
- background
Regla:
- no mover botones en `hover`
- no desplazar componentes por pasar el cursor
Preset:
```css
transition:
background var(--air-duration-fast) var(--air-ease-default),
border-color var(--air-duration-fast) var(--air-ease-default),
color var(--air-duration-fast) var(--air-ease-default),
box-shadow var(--air-duration-fast) var(--air-ease-default);
```
## 6.2 Press
Uso:
- buttons
- icon buttons
- checkables tactiles
Regla:
- la respuesta debe sentirse tactil, no elástica
- el gesto debe ser corto y controlado
Preset:
```css
transform: scale(var(--air-motion-scale-press));
transition-duration: var(--air-duration-fast);
```
## 6.3 Focus
Uso:
- focus ring
- border de foco
Regla:
- el focus no debe aparecer como destello brusco
- tampoco debe ser tan lento que se sienta retrasado
Preset:
- `fast`
- `ease-default`
## 6.4 Spinner
Uso:
- loading de button
- progress indeterminate
Regla:
- movimiento continuo, uniforme y legible
- evitar easings complejos
Preset:
```css
animation: air-spin 0.8s linear infinite;
```
## 6.5 Fade
Uso:
- helper text
- error text
- estados suaves
Preset:
```css
opacity: 0 -> 1;
transition: opacity var(--air-duration-normal) var(--air-ease-default);
```
## 6.6 Slide-fade
Uso:
- dropdowns
- popovers
- small overlays
Preset:
```css
opacity: 0;
transform: translateY(var(--air-motion-distance-sm));
opacity: 1;
transform: translateY(0);
```
Duracion recomendada:
- entrada: `moderate` + `ease-out`
- salida: `normal` + `ease-in`
## 6.7 Scale-fade
Uso:
- menus compactos
- dialogs pequenos
- surfaces elevadas que deban sentirse contenidas
Preset:
```css
opacity: 0;
transform: scale(var(--air-motion-scale-enter));
opacity: 1;
transform: scale(1);
```
Regla:
- la escala debe ser casi imperceptible
- `air` no debe usar zooms teatrales
---
## 7. Mapeo por componente
### Button
Permitido:
- transicion de color, borde y sombra en hover
- press scale corto
- spinner continuo en loading
No permitido:
- lift en hover
- bounce
- desplazamiento vertical
### Field / Input
Permitido:
- transicion de border-color
- transicion de focus ring
- fade o slide-fade corto para helper/error
### Tabs
n
Permitido:
- indicador activo
- color del trigger
- reveal corto del panel si aporta claridad
### Popover / Select / Dropdown
Permitido:
- slide-fade o scale-fade
- origen ligado a placement
### Dialog / Drawer
Permitido:
- backdrop fade
- panel slide o scale-fade contenido
Regla:
- backdrop y panel no deben competir entre si
---
## 8. Accesibilidad
Todo preset debe degradarse con:
```css
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
Regla:
- reducir motion no debe romper feedback esencial
- el estado final debe seguir siendo claro sin depender del movimiento
---
## 9. Anti-patrones
- mover botones en `hover`
- usar rebotes como comportamiento base
- animar `width` o `height` cuando un fade/transform lo resuelve mejor
- meter motion distinta por componente para el mismo tipo de transicion
- usar duraciones lentas en microinteracciones
- usar motion como compensacion de una UI visualmente floja
---
## 10. Regla final
La motion de `air` debe sentirse como una extension natural del sistema visual:
- precisa
- contenida
- consistente
- tactil
- nunca dramatica
Si la animacion se vuelve protagonista, deja de ser `air`.

@ -0,0 +1,342 @@
# Air Semantics
## Qué es una semántica
En Air, cada componente no solo tiene un aspecto visual — también tiene un **significado interactivo**: indica al sistema qué tipo de cosa está ocurriendo en la UI. Ese significado es lo que llamamos semántica.
Las semánticas son el eje vertebrador de todos los subsistemas expresivos: motion, sonido, y cualquier canal futuro (haptics, color dinámico…). Cuando defines la semántica de una acción, los tres subsistemas responden de forma coherente sin que el componente sepa nada de animaciones ni de audio.
Un botón de borrado que dispara la semántica `destruction` recibe automáticamente:
- el sonido apropiado (tono descendente, tenso)
- la animación de salida del elemento eliminado
- la clase CSS de motion asociada al tema activo
Si el usuario cambia de tema, todo eso cambia en bloque sin tocar el componente.
---
## El set de semánticas
Este es el set **inicial y estable** — cubre el espectro completo de interacciones habituales en una UI. No es un sistema cerrado: si aparece un caso que no encaja en ninguna semántica existente, se puede extender. Pero antes de añadir, comprueba la sección [Cómo elegir semántica](#cómo-elegir-semántica) — la mayoría de casos encajan bien con las existentes.
El estado de implementación actual es:
```
── Micro-interacción ─────────────────────────────────────────────────
feedback Click, press, key — respuesta táctil inmediata ✓
selection Check, toggle, selección de ítem — cambio persistente ✓
── Aparición de contenido ────────────────────────────────────────────
revelation Dropdown, popover, tooltip — overlay emergente ✓
context Dialog, drawer — superficie modal (bloquea el foco) ✓
expansion Accordion, collapsible — contenido inline que crece ✓
── Movimiento espacial ───────────────────────────────────────────────
navigation Transición de vista o ruta ○
── Mensajes y estados ────────────────────────────────────────────────
notification Toast, snackbar — mensaje efímero no bloqueante ✓
attention Error crítico, warning bloqueante — alerta urgente ✓
emphasis Highlight, pulse, nudge — guía visual suave ✓
── Cierre y transformación ───────────────────────────────────────────
completion Éxito, confirmación — cierre positivo ✓
destruction Delete, discard — eliminación irreversible ✓
persistence Spinner, skeleton — espera activa indeterminada ✓
```
`✓` implementada · `○` presets definidos, action/transición pendiente
---
## Dos mecanismos, no uno
Las 12 semánticas se activan de dos formas distintas según su naturaleza.
### Semánticas de transición
`revelation · context · expansion · navigation`
Se producen cuando un elemento **entra o sale del DOM**. El mecanismo es una función de transición Svelte que se pasa a `transition:`, `in:` u `out:`.
```svelte
<script>
import { airReveal, airDismiss } from '$uix/air/internal/motion';
</script>
{#if open}
<div in:airReveal={{ origin: 'top' }} out:airDismiss>
<!-- contenido del dropdown -->
</div>
{/if}
```
La función de transición ya se encarga de todo: resuelve el preset del tema activo, reproduce el sonido, respeta `prefers-reduced-motion` y `motionEnabled`.
Funciones disponibles:
| Semántica | Entrada | Salida |
|---|---|---|
| `revelation` | `airReveal(options?)` | `airDismiss()` |
| `context` | `airContext(options?)` | `airContextOut()` |
| `expansion` | `airExpand()` | `airExpandOut()` |
| `navigation` | *(pendiente)* | *(pendiente)* |
### Semánticas de estado
`feedback · attention · emphasis · persistence · selection · completion · destruction`
Se producen cuando **el estado del elemento cambia** sin que el elemento entre o salga del DOM. El mecanismo es una Svelte action que observa el nodo y reacciona.
```svelte
<script>
import { airFeedback, airAttention, airPersistence } from '$uix/air/internal/behavior';
</script>
<!-- Botón que hace clic -->
<button use:airFeedback={() => intent}>
Guardar
</button>
<!-- Campo que valida -->
<div use:airAttention={() => hasError}>
<input ... />
</div>
<!-- Elemento en espera -->
<span use:airPersistence={() => loading}>
<Spinner />
</span>
```
El action escucha eventos DOM o un getter reactivo, y dispara motion+sound en el momento correcto.
Actions disponibles:
| Semántica | Action | Parámetro |
|---|---|---|
| `feedback` | `airFeedback` | `() => intent?` — getter del intent actual |
| `selection` | `airSelection` | `() => boolean` — getter del estado checked |
| `attention` | `airAttention` | `() => boolean` — getter del estado de error |
| `emphasis` | `airEmphasis` | *(ninguno)* — activo mientras el nodo existe |
| `completion` | `airCompletion` | `() => boolean` — getter del estado de éxito |
| `persistence` | `airPersistence` | `() => boolean` — getter del estado loading |
### Semánticas programáticas (evento puntual)
`notification · completion · destruction`
Se disparan desde lógica de negocio como **eventos únicos**, no desde interacción directa con un elemento ni como estado persistente. La distinción clave:
- Si el efecto es **puntual** (suena y desaparece): programático con `playSemantic`
- Si el efecto es **un estado visible** (mientras el elemento existe): action con `use:airX`
Ejemplo: `completion` como evento cuando se envía un formulario es programático. Pero un elemento que muestra visualmente el estado "completado" mientras está en el DOM usaría `use:airCompletion` (cuando esté implementado).
```ts
// Evento puntual → programático
playSemantic('completion', 'enter', { presets: config.soundPresets.current });
// Estado visible → action (cuando esté implementado)
use:airCompletion(() => isSuccess)
```
Se llaman con `playSemantic` desde el lugar donde ocurre el evento:
```ts
import { playSemantic } from '$uix/air/internal/sound';
import { getAir } from '$uix/air/config';
// En el handler de un formulario exitoso:
const config = getAir();
playSemantic('completion', 'enter', {
presets: config.soundPresets.current,
});
```
---
## Intent — modulación dentro de una semántica
`intent` es un parámetro adicional que afina el comportamiento **dentro** de la semántica, sin cambiarla. Actualmente modula el pitch del sonido `feedback` según el significado del botón:
| Intent | Efecto en pitch |
|---|---|
| `danger` | −4 semitonos (más grave, peso, gravedad) |
| `warning` | −2 semitonos (ligeramente tenso) |
| `primary` / `neutral` | ×1.0 (tono base) |
| `info` | +1 semitono |
| `success` | +3 semitonos (más agudo, luminoso) |
El componente pasa el intent como getter al action. El intent refleja la variante visual del componente:
```svelte
<!-- Botón destructivo — sonido más grave -->
<button use:airFeedback={() => 'danger'} data-variant="danger">
Eliminar cuenta
</button>
<!-- Botón de éxito — sonido más agudo -->
<button use:airFeedback={() => 'success'} data-variant="success">
Completar pedido
</button>
```
El resto de semánticas ignoran el intent — `attention` siempre suena urgente, `completion` siempre suena positivo, independientemente del contexto.
**Nota sobre `stateClass`:** el nombre de la clase CSS es siempre el mismo independientemente del tema (`air-motion-press`, `air-motion-attention`…). Los temas nunca sobreescriben `stateClass` — personalizan lo que esa clase hace via CSS bajo `[data-theme]`. Cambiar el nombre de la clase por tema rompería cualquier CSS de terceros que dependa de esas clases.
Si en el futuro aparece necesidad real de modular otras semánticas (p.ej. una `destruction` suave vs una agresiva, o una `attention` de warning vs error crítico), el sistema puede extenderse siguiendo el mismo patrón de `intentFreqMultiplier`. Por ahora no se añade especulación antes de tener el caso.
---
## Cómo elegir semántica
Cuando el caso no es obvio, estas heurísticas resuelven la mayoría de dudas:
**¿El elemento entra o sale del DOM?**
→ semántica de transición: `revelation`, `context`, `expansion`, `navigation`.
**¿El elemento cambia de estado sin entrar/salir del DOM?**
→ semántica de estado: `feedback`, `attention`, `emphasis`, `persistence`, `selection`.
**¿El evento lo genera el sistema, no el usuario directamente?**
→ semántica programática: `notification`, `completion`, `destruction`.
---
### Casos de conflicto frecuentes
**`notification` vs `attention`**
La diferencia es urgencia y bloqueo. `notification` es informativa y no interrumpe el flujo — el usuario puede ignorarla. `attention` es urgente y exige respuesta — un error de validación que impide avanzar. Si el mensaje puede ignorarse: `notification`. Si bloquea o exige acción: `attention`.
**`emphasis` vs `feedback`**
`feedback` responde a una acción del usuario (click, press). `emphasis` aparece por iniciativa del sistema para guiar la atención — un badge de novedad, un indicador de onboarding, un elemento que pulsa para ser descubierto. Si el usuario no hizo nada para provocarlo: `emphasis`.
**`completion` vs `selection`**
`selection` es un cambio de estado persistente que el usuario controla y puede deshacer (checkbox marcado, tab activo, ítem seleccionado). `completion` es un cierre positivo irreversible — el formulario se envió, la tarea terminó. Si el usuario puede volver atrás: `selection`. Si es el fin de un proceso: `completion`.
**`destruction` vs `attention`**
`destruction` acompaña la eliminación de contenido — el objeto desaparece. `attention` acompaña un error o alerta — el objeto sigue ahí pero exige corrección. Si el elemento se elimina: `destruction`. Si el elemento persiste con un problema: `attention`.
---
## Cadena de precedencia
Cuando el sistema decide qué motion o sonido reproducir, sigue este orden:
```
1. Override de instancia sound/motion prop en el action → máxima prioridad
2. Override de tema soundPresets / motionPresets en <Air> o useAir
3. Defaults del sistema DEFAULT_SOUND_PRESETS / DEFAULT_MOTION_PRESETS
```
El `intent` opera dentro del nivel que se resuelva — modula el resultado, no lo reemplaza.
---
## Desactivar motion y sound
### Global o por subtree
```ts
// Toda la app
useAir({ soundEnabled: false, motionEnabled: false });
// Solo un subtree
<Air soundEnabled={false}>
<SilentSection />
</Air>
```
### Por instancia
Cuando el autor del componente necesita silenciar una instancia específica, pasa el override directamente al action:
```ts
// En el componente o su consumidor
use:airFeedback={{ noSound: true }}
```
> **Nota:** La granularidad por instancia está en roadmap. Actualmente el control mínimo es el subtree via `<Air>`.
El sistema respeta automáticamente `prefers-reduced-motion` del sistema operativo — no hace falta ningún control adicional para esto.
---
## Ejemplo completo: componente nuevo
El siguiente ejemplo muestra cómo un componente de tipo "botón de acción peligrosa" declara su semántica completa — feedback en el click, destruction al confirmar.
```svelte
<!-- DestructiveButton.svelte -->
<script lang="ts">
import { airFeedback } from '$uix/air/internal/behavior';
import { airDismiss } from '$uix/air/internal/motion';
import { playSemantic } from '$uix/air/internal/sound';
import { getAir } from '$uix/air/config';
let { onclick, children }: { onclick: () => void; children: Snippet } = $props();
const config = getAir();
let confirming = $state(false);
function handleClick() {
if (!confirming) {
// Primera pulsación: feedback urgente
confirming = true;
return;
}
// Confirmación: destruction
playSemantic('destruction', 'enter', {
presets: config.soundPresets.current,
});
onclick();
}
</script>
<!--
airFeedback con intent='danger' → sonido más grave en cada click
airDismiss con semantic='destruction' → animación de salida destructiva
-->
<button
use:airFeedback={() => 'danger'}
onclick={handleClick}
data-variant="danger"
data-confirming={confirming || undefined}
>
{@render children()}
</button>
```
Lo que ocurre en cada interacción:
1. **Click 1**: `airFeedback` dispara `feedback·enter` con pitch bajo (intent `danger`)
2. **Click 2**: `playSemantic('destruction', 'enter')` reproduce el sonido de destrucción
3. El elemento que se elimina usa `out:airDismiss={{ semantic: 'destruction' }}` para su animación de salida
Cada pieza delega al sistema — el componente no sabe nada de frecuencias, duraciones ni keyframes.
---
## Guía de asignación para componentes nuevos
Al crear un componente, mapea su comportamiento a la semántica más cercana:
| Componente | Semántica | Mecanismo |
|---|---|---|
| Button, IconButton | `feedback` | `use:airFeedback` |
| Checkbox, Toggle, Radio | `selection` | `use:airSelection` |
| Dropdown, Popover, Tooltip | `revelation` | `in:airReveal` / `out:airDismiss` |
| Dialog | `context` | `in:airContext` / `out:airContextOut` |
| Drawer | `context` | `in:airContext({ side })` / `out:airContextOut` |
| Accordion, Collapsible | `expansion` | `in:airExpand` / `out:airExpandOut` |
| Link de navegación | `navigation` | *(pendiente)* |
| Toast, Snackbar | `notification` | `playSemantic('notification', ...)` |
| Input con error | `attention` | `use:airAttention` |
| Badge de novedad | `emphasis` | `use:airEmphasis` |
| Estado de éxito (visual) | `completion` | `use:airCompletion` |
| Éxito como evento | `completion` | `playSemantic('completion', ...)` |
| Botón de borrado | `destruction` | `playSemantic('destruction', ...)` + `out:airDismiss({ semantic: 'destruction' })` |
| Spinner, Skeleton | `persistence` | `use:airPersistence` |

@ -1,293 +0,0 @@
# CORREGIR_DUPLICIDAD_TOKENS.md
> Estado: propuesta pendiente de implementacion
> Fecha: 2026-04-06
> Afecta: `themes/base/`, tema por defecto, mecanismo de activacion
---
## 1. Problema
Los archivos `themes/base/light.css` y `themes/base/dark.css` duplican ~110 lineas de tokens identicos: spacing, radius, tipografia, iconos, shadows y motion. Solo los tokens de color difieren entre ambos.
La duplicacion existe por una limitacion arquitectural, no por decision de diseno:
- ambos archivos usan `:root` como selector para funcionar como fallback cuando no hay tema explicito
- `dark.css` no puede tener `:root` porque su orden de carga posterior pisaria los valores de `light.css`
- sin `:root` en dark, este depende de que light cargue primero para heredar los tokens comunes
- con `:root` en ambos, el ultimo en orden de fuente gana siempre, rompiendo light
### Estado actual
```
light.css → [data-theme='base-light'], :root { ... colores + comunes ... }
dark.css → [data-theme='base-dark'] { ... colores + comunes ... } ← depende de light
```
Dark no es autosuficiente. Si se importara solo `dark.css`, faltarian spacing, tipografia, radius, motion, iconos y shadows.
---
## 2. Por que los demas no lo resuelven mejor
El problema no es de CSS. Es de arquitectura de entrega. El navegador no puede saber la preferencia del usuario antes de pintar sin que alguien se lo diga.
| Sistema | Estrategia | Limitacion |
| --------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Radix Themes | Un solo CSS compilado con ambos temas. `:root` es light, `.dark` override. | Funciona pero acopla compilacion |
| shadcn/ui | Delega a Next.js + next-themes. SSR inyecta la clase antes del hydration. | Resuelve el delivery, no el CSS |
| Mantine | Script inline obligatorio en `<head>`. Documentado como requisito. | Funciona pero es intrusivo |
| Primer (GitHub) | Tokens compilados para cada tema. Ninguno usa `:root`. El servidor decide. | El enfoque mas maduro pero requiere build tool dedicado |
| Chakra UI v3 | Panda CSS genera `_light` como base incondicional, `_dark` como `.dark` override. Tokens estaticos una sola vez. | El mas elegante pero requiere runtime de build |
Ninguno ha inventado algo mejor porque no pueden: es una limitacion del modelo de carga del navegador.
---
## 3. Solucion adoptada
Separar tokens en dos capas fisicas y activar el tema por clase en `<html>`.
### 3.1 Estructura de archivos propuesta
```
themes/base/
_static.css ← tokens no-color, una sola vez, en :root
light.css ← solo colores light, sin :root
dark.css ← solo colores dark, sin :root
index.css ← barrel: importa _static, luego light, luego dark
```
### 3.2 Contenido de cada archivo
#### `_static.css` — tokens no-color (singleton)
```css
:root {
/* Spacing scale */
--air-space-0: 0px;
--air-space-1: 4px;
/* ... */
/* Radius scale */
--air-radius-none: 0px;
--air-radius-sm: 4px;
/* ... */
/* Typography: familias, escala de 12 pasos, pesos, leading, tracking */
--air-font-sans: 'Instrument Sans', system-ui, sans-serif;
/* ... */
/* Icons */
--air-icon-size-sm: 16px;
/* ... */
/* Shadows */
--air-shadow-0: none;
/* ... */
/* Motion: duraciones, easings, distancias, escalas */
--air-duration-fast: 120ms;
/* ... */
/* Compatibility aliases (deprecated) */
--air-color-bg: var(--air-color-surface-default);
/* ... */
}
```
#### `light.css` — solo colores light
```css
[data-theme='base-light'] {
/* Primitives: neutral */
--air-primitive-neutral-1: #fcfbf9;
/* ... */
/* Primitives: primary, info, success, warning, danger */
/* ... */
/* Semantic surfaces, content, borders */
/* ... */
/* Semantic palettes: primary, neutral, success, warning, danger, info */
/* ... */
}
```
#### `dark.css` — solo colores dark
```css
[data-theme='base-dark'] {
/* Primitives: neutral */
--air-primitive-neutral-1: #111110;
/* ... */
/* Primitives: primary, info, success, warning, danger */
/* ... */
/* Semantic surfaces, content, borders */
/* ... */
/* Semantic palettes: primary, neutral, success, warning, danger, info */
/* ... */
}
```
### 3.3 Activacion del tema
El tema se activa mediante `data-theme` en `<html>`:
```html
<html data-theme="base-light"></html>
```
```html
<html data-theme="base-dark"></html>
```
Ningun archivo de tema usa `:root`. Los tokens estaticos siempre estan disponibles. El tema solo aporta colores.
### 3.4 Tema por defecto
El tema por defecto se decide fuera del CSS. Dos mecanismos posibles:
#### Opcion A: SSR / cookie
```javascript
// hooks.server.js
event.locals.theme = event.cookies.get('theme') ?? 'base-light';
```
```html
<!-- app.html -->
<html data-theme="%sveltekit.theme%"></html>
```
#### Opcion B: Script inline en `<head>`
```html
<!-- app.html -->
<script>
document.documentElement.setAttribute(
'data-theme',
localStorage.getItem('theme') || 'base-light'
);
</script>
```
El script se ejecuta antes del primer paint porque es sincrono y esta antes que los estilos. Evita flash de tema incorrecto.
### 3.5 Cambio de tema en runtime
```javascript
document.documentElement.setAttribute('data-theme', 'base-dark');
localStorage.setItem('theme', 'base-dark');
```
---
## 4. Por que funciona
| Problema anterior | Como se resuelve |
| ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Duplicacion de ~110 lineas comunes | Tokens estaticos en `_static.css`, una sola vez |
| Dark depende de light para tokens comunes | Cada tema es autosuficiente en colores; los estaticos siempre estan en `:root` |
| `:root` en ambos temas causa conflicto de especificidad | Ningun tema usa `:root` |
| Ultimo import gana cuando no hay atributo | No hay fallback CSS; el atributo siempre esta presente |
| Flash de tema incorrecto | La clase viene del SSR o de un script inline sincrono |
| No se puede cambiar el default sin reordenar imports | El default lo decide el servidor o el script, no el CSS |
---
## 5. Resultado esperado
### Antes (actual)
| Archivo | Lineas | Contiene |
| --------- | ------ | ----------------------------------------------- |
| light.css | ~300 | colores light + todos los estaticos |
| dark.css | ~300 | colores dark + todos los estaticos (duplicados) |
### Despues (propuesto)
| Archivo | Lineas | Contiene |
| ------------ | ------ | ------------------------------------------------------------- |
| \_static.css | ~110 | spacing, radius, tipografia, iconos, shadows, motion, aliases |
| light.css | ~170 | solo primitivos y semanticos de color light |
| dark.css | ~170 | solo primitivos y semanticos de color dark |
Ahorro neto: ~110 lineas de duplicacion eliminadas. Cada tema es mas legible porque solo contiene lo que cambia.
---
## 6. Cambios necesarios
### 6.1 CSS
1. Crear `themes/base/_static.css` con todos los tokens no-color en `:root`
2. Eliminar de `light.css` todos los tokens no-color, dejar solo bloques de color
3. Eliminar de `dark.css` todos los tokens no-color, dejar solo bloques de color
4. Quitar `:root` del selector de ambos archivos de tema
5. Actualizar `themes/base/index.css` para importar `_static.css` primero
### 6.2 HTML / SSR
1. Asegurar que `<html>` siempre tenga `data-theme` antes del primer paint
2. Eliminar el `$effect` que setea el tema en `+layout.svelte` como unico mecanismo
3. Reemplazar por script inline en `app.html` o por inyeccion SSR via hooks
### 6.3 JS (si aplica)
1. Actualizar la logica de `setAirTheme` para escribir en `<html>` en vez de un `<div>` intermedio
2. Verificar que el cambio de tema en runtime sigue funcionando
---
## 7. Riesgos y consideraciones
### Riesgo: flash si el atributo no esta antes del CSS
Si `<html>` llega sin `data-theme`, los tokens de color no estaran definidos y la UI se vera sin color hasta que JS corrija. La solucion es que el atributo siempre este presente antes del paint.
### Riesgo: temas de terceros
Un tema de terceros solo necesita implementar colores. Los tokens estaticos ya estan garantizados por `_static.css`. Eso simplifica el contrato para autores de tema.
### Consideracion: `prefers-color-scheme`
Se puede anadir un fallback automatico:
```css
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
/* tokens de dark como fallback del sistema operativo */
}
}
```
Esto cubre el caso extremo donde el script SSR falla y el usuario prefiere dark. No es necesario en la primera iteracion.
---
## 8. Orden de implementacion
1. Crear `_static.css` con los tokens no-color extraidos de `light.css`
2. Limpiar `light.css`: quitar tokens no-color, quitar `:root` del selector
3. Limpiar `dark.css`: quitar tokens no-color ya presentes via `_static.css`
4. Actualizar `index.css` con el nuevo orden de imports
5. Añadir script inline en `app.html` para setear `data-theme` antes del paint
6. Actualizar la logica de tema en JS para operar sobre `<html>`
7. Verificar build y tests
8. Verificar visualmente light y dark en la demo
---
## 9. Criterio de exito
- [ ] No hay tokens duplicados entre `light.css` y `dark.css`
- [ ] Ambos temas son visualmente correctos
- [ ] Cambio de tema en runtime funciona sin flash
- [ ] Un tema de terceros solo necesita implementar colores
- [ ] Build pasa sin errores nuevos
- [ ] La demo `/test/air` valida ambos temas correctamente

@ -2,17 +2,13 @@
import { dev } from '$app/environment';
import '$uix/air/components/button/button.css';
import * as Icon from '$uix/air/icons';
import { resolveResponsiveProp } from '$uix/air/internal/responsive';
import { Button as TerraButton } from '$uix/terra';
import { getTerra } from '$uix/terra/config/terra-config';
import { getTerraLogger } from '$uix/terra/config/logger';
import { resolveTerraTranslation } from '$uix/terra/config/translator.svelte';
import { getAir } from '$uix/air/config/air.svelte.js';
import { playSemantic } from '$uix/air/internal/sound/presets';
import { getAir } from '$uix/air/config';
import type { ButtonRootProps } from './types';
const terraConfig = getTerra();
const airConfig = getAir();
const air = getAir();
let {
variant = 'solid',
@ -43,10 +39,7 @@ import { getAir } from '$uix/air/config/air.svelte.js';
const down = () => {
if (ref!.hasAttribute('disabled') || ref!.hasAttribute('data-disabled')) return;
pressed = true;
if (airConfig.soundEnabled.current) playSemantic('feedback', 'enter', {
intent: resolvedColor,
presets: airConfig.soundPresets.current,
});
air.behavior.play('feedback', 'enter', { intent: resolvedColor });
};
const up = () => { pressed = false; };
ref.addEventListener('pointerdown', down);
@ -65,12 +58,12 @@ import { getAir } from '$uix/air/config/air.svelte.js';
let didWarnIntentColorConflict = $state(false);
const resolvedSize = $derived(resolveResponsiveProp(size, airConfig.viewport.width, airConfig.breakpoints.current) ?? 'md');
const resolvedRounded = $derived(resolveResponsiveProp(rounded, airConfig.viewport.width, airConfig.breakpoints.current));
const resolvedSize = $derived(air.dom.resolve(size) ?? 'md');
const resolvedRounded = $derived(air.dom.resolve(rounded));
const resolvedColor = $derived(intent ?? color ?? 'primary');
const pressClass = $derived(
pressed && airConfig.motionEnabled.current && !loading ? 'air-motion-press' : ''
pressed && air.behavior.motionEnabled.current && !loading ? 'air-motion-press' : ''
);
const colorClass = $derived(`air-button--color-${resolvedColor}`);
@ -96,19 +89,18 @@ import { getAir } from '$uix/air/config/air.svelte.js';
const translatedLoadingText = $derived(
loadingText === undefined
? undefined
: resolveTerraTranslation(loadingText, terraConfig.translator.current, '')
: (air.translator.current?.translate(loadingText) ?? (typeof loadingText === 'string' ? loadingText : ''))
);
const translatedAriaLabel = $derived(
ariaLabel === undefined
? undefined
: resolveTerraTranslation(ariaLabel, terraConfig.translator.current, '')
: (air.translator.current?.translate(ariaLabel) ?? (typeof ariaLabel === 'string' ? ariaLabel : ''))
);
$effect(() => {
if (!dev || didWarnIntentColorConflict) return;
if (intent === undefined || color === undefined) return;
const logger = terraConfig.logger.current ?? getTerraLogger();
logger.warn(
air.logger.current?.warn(
'[air/button] Both "intent" and "color" were provided. "intent" takes precedence and "color" is ignored.',
{ intent, color, resolvedColor, variant }
);

@ -1,5 +1,5 @@
import type { RootProps as TerraButtonRootProps } from '$uix/terra/button';
import type { ResponsiveProp } from '$uix/air/internal/responsive';
import type { ResponsiveProp } from '$uix/air/internal/dom';
export type AirButtonVariant = 'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain';
export type AirButtonColor = 'primary' | 'neutral' | 'success' | 'warning' | 'danger' | 'info';

@ -1,7 +1,7 @@
<script lang="ts">
import './input.css';
import type { InputProps } from './types';
import { airAttention } from '$uix/air/internal/behavior/behaviors.svelte.js';
import { attention as airAttention } from '$uix/air/internal/behavior';
let {
size = 'md',

@ -1,7 +1,7 @@
<script lang="ts">
import './input.css';
import type { TextareaProps } from './types';
import { airAttention } from '$uix/air/internal/behavior/behaviors.svelte.js';
import { attention as airAttention } from '$uix/air/internal/behavior';
let {
size = 'md',

@ -3,8 +3,7 @@
import { browser } from '$app/environment';
import type { TextProps } from './types';
import { useContainerWidth, useTextLayout } from '../../internal/canvas/use-canvas.svelte.js';
import { getTerra } from '$uix/terra/config/terra-config';
import { resolveTerraTranslationPath } from '$uix/terra/config/translator.svelte';
import { getAir } from '$uix/air/config';
let {
size = 3,
@ -22,7 +21,7 @@
...restProps
}: TextProps = $props();
const config = getTerra();
const air = getAir();
// ── Tokens de escala ─────────────────────────────────────────────────────
const sizeTokens = $derived(
@ -77,13 +76,11 @@
// ── Hint de líneas ocultas — traducido via Terra ───────────────────
const clampHint = $derived.by(() => {
if (!isClipped) return '';
return resolveTerraTranslationPath(
return air.translator.current?.translatePath(
'air.text.clampHint',
config.translator.current,
`+{0} {1}`, // fallback inline si no hay translator
hiddenLines,
hiddenLines === 1 ? 'line' : 'lines',
);
) ?? `+${hiddenLines} ${hiddenLines === 1 ? 'line' : 'lines'}`;
});
// ── Styles combinados ────────────────────────────────────────────────────

@ -1,111 +1,33 @@
import { createTerraContext } from '$terra/utils/context';
import { readableActive, type Active } from '$terra/utils';
import { readableActive } from '$terra/utils';
import { getTerra } from '$terra/config';
import { createTerra } from '$terra/config/terra-config';
import {
initViewportTracking,
AIR_BREAKPOINTS_DEFAULT,
type AirBreakpoints,
viewport
} from '$uix/air/internal/responsive/responsive.svelte.js';
import { initViewportTracking } from '$uix/air/internal/dom/responsive.svelte.js';
import { createBehavior, type BehaviorProps } from '$uix/air/internal/behavior/behavior.svelte.js';
import { createDom, type DomProps } from '$uix/air/internal/dom/dom.svelte.js';
import { ctx } from '$uix/air/internal/context.js';
import type { AirOwnProps, AirPropsWithoutChildren } from './types';
import type { AirSoundPresets } from '$uix/air/internal/sound/types';
type AirStateProps = {
soundEnabled: Active<boolean | undefined>;
motionEnabled: Active<boolean | undefined>;
soundVolume: Active<number | undefined>;
soundPresets: Active<AirSoundPresets | undefined>;
breakpoints: Active<Partial<AirBreakpoints> | undefined>;
};
export const AirContext = createTerraContext<AirState>('Air');
export function getAir() {
const fallback = new AirState(null, {
soundEnabled: readableActive(() => undefined),
motionEnabled: readableActive(() => undefined),
soundVolume: readableActive(() => undefined),
soundPresets: readableActive(() => undefined),
breakpoints: readableActive(() => undefined),
});
return AirContext.getOr(fallback).opts;
}
type AirStateProps = BehaviorProps & DomProps;
export function createAir(props: AirOwnProps) {
const opts: AirStateProps = {
const parent = ctx.getOr(null);
const terra = getTerra();
const stateProps: AirStateProps = {
soundEnabled: readableActive(() => props.soundEnabled),
motionEnabled: readableActive(() => props.motionEnabled),
soundVolume: readableActive(() => props.soundVolume),
soundPresets: readableActive(() => props.soundPresets),
motionPresets: readableActive(() => props.motionPresets),
breakpoints: readableActive(() => props.breakpoints),
};
return AirContext.set(new AirState(AirContext.getOr(null), opts));
}
export class AirState {
readonly opts: {
soundEnabled: Active<boolean>;
motionEnabled: Active<boolean>;
soundVolume: Active<number>;
/** Presets merged: parent ← child (el hijo solo declara lo que sobreescribe). */
soundPresets: Active<AirSoundPresets | undefined>;
/**
* Breakpoints resueltos: defaults ← parent ← own (merge por clave).
* Siempre devuelve un objeto completo con los 6 breakpoints.
*/
breakpoints: Active<AirBreakpoints>;
/**
* Ancho actual del viewport. Fuente reactiva única — los componentes
* leen airConfig.viewport.width sin importar nada de responsive.
*/
viewport: typeof viewport;
};
constructor(parent: AirState | null, props: AirStateProps) {
this.opts = {
soundEnabled: readableActive(
() => props.soundEnabled?.current ?? parent?.opts.soundEnabled.current ?? true
),
motionEnabled: readableActive(
() => props.motionEnabled?.current ?? parent?.opts.motionEnabled.current ?? true
),
soundVolume: readableActive(
() => props.soundVolume?.current ?? parent?.opts.soundVolume.current ?? 1
),
soundPresets: readableActive(() => {
const own = props.soundPresets?.current;
const inherited = parent?.opts.soundPresets.current;
if (!own && !inherited) return undefined;
return { ...inherited, ...own };
}),
breakpoints: readableActive(() => ({
...AIR_BREAKPOINTS_DEFAULT,
...parent?.opts.breakpoints.current,
...props.breakpoints?.current,
})),
viewport,
};
}
return ctx.set({
behavior: createBehavior(stateProps, parent?.behavior),
dom: createDom(stateProps, parent?.dom),
translator: terra.translator,
logger: terra.logger,
});
}
/**
* Configura air (y terra) directamente desde el `<script>` de un componente.
* No requiere ningún componente wrapper en el template.
*
* El objeto `props` debe usar getters para las propiedades reactivas:
*
* ```ts
* let soundEnabled = $state(true);
* useAir({
* get soundEnabled() { return soundEnabled; },
* translator, // estático — reactividad interna propia
* dateTimeFormatter,
* });
* ```
*
* Usa `<Air>` en el template solo cuando necesites override de subárbol
* (envolver una sección con configuración distinta al padre).
*/
export function useAir(props: AirPropsWithoutChildren) {
createTerra({
portalTo: readableActive(() => props.portalTo),
@ -122,6 +44,7 @@ export function useAir(props: AirPropsWithoutChildren) {
get motionEnabled() { return props.motionEnabled; },
get soundVolume() { return props.soundVolume; },
get soundPresets() { return props.soundPresets; },
get motionPresets() { return props.motionPresets; },
get breakpoints() { return props.breakpoints; },
});
$effect(() => initViewportTracking());

@ -2,7 +2,7 @@
import type { AirProps } from '../types';
import Terra from '$terra/config/component/terra.svelte';
import { useAir } from '../air.svelte.js';
import { initViewportTracking } from '$uix/air/internal/responsive/responsive.svelte.js';
import { initViewportTracking } from '$uix/air/internal/dom/responsive.svelte.js';
let {
children,
@ -10,6 +10,7 @@
motionEnabled,
soundVolume,
soundPresets,
motionPresets,
breakpoints,
...terraProps
}: AirProps = $props();
@ -19,6 +20,7 @@
get motionEnabled() { return motionEnabled; },
get soundVolume() { return soundVolume; },
get soundPresets() { return soundPresets; },
get motionPresets() { return motionPresets; },
get breakpoints() { return breakpoints; },
});

@ -1,3 +1,4 @@
export { default as Air } from './component/air.svelte';
export { getAir, createAir, useAir, AirState } from './air.svelte.ts';
export { createAir, useAir } from './air.svelte.ts';
export { getContext as getAir } from '$uix/air/internal/context.js';
export type { AirProps, AirPropsWithoutChildren, AirOwnProps } from './types';

@ -1,7 +1,11 @@
import type { TerraPropsWithoutChildren } from '$terra/config/types';
import type { WithChildren } from '$terra/utils';
import type { AirSoundPresets } from '$uix/air/internal/sound/types';
import type { AirBreakpoints } from '$uix/air/internal/responsive/responsive.svelte.js';
import type { SoundPresets } from '$uix/air/internal/sound/types';
import type { MotionPresets } from '$uix/air/internal/motion/types';
// Re-exportados con prefijo Air para la API pública de air.
export type { SoundPresets as AirSoundPresets, MotionPresets as AirMotionPresets };
import type { Breakpoints } from '$uix/air/internal/dom/responsive.svelte.js';
export type AirOwnProps = {
/** Habilita todos los sonidos de la interfaz. Por defecto true. */
@ -19,7 +23,24 @@ export type AirOwnProps = {
* @example
* <Air soundPresets={{ feedback: { enter: { layers: [{ src: '/sounds/click.wav', volumeDb: -10 }] } } }}>
*/
soundPresets?: AirSoundPresets;
soundPresets?: SoundPresets;
/**
* Override parcial de las transiciones JS por semántica.
* Permite al diseñador reemplazar cualquier animación con datos declarativos
* (JSON puro) o con una función imperativa (librería externa como escape hatch).
* Solo se declaran las semánticas que se quieren sobreescribir;
* el resto usa los defaults del tema base.
*
* @example — datos declarativos
* ```svelte
* <Air motionPresets={{ reveal: { duration: 300, easing: 'backOut', distance: 8 } }}>
* ```
* @example — función imperativa (GSAP, Motion One…)
* ```svelte
* <Air motionPresets={{ reveal: (node, opts) => ({ duration: 400, css: (t) => `opacity:${t}` }) }}>
* ```
*/
motionPresets?: MotionPresets;
/**
* Override parcial de breakpoints del sistema.
* Solo se declaran los que se quieren cambiar; el resto hereda los defaults.
@ -28,7 +49,7 @@ export type AirOwnProps = {
* @example
* useAir({ breakpoints: { lg: 1100, '2xl': 1600 } })
*/
breakpoints?: Partial<AirBreakpoints>;
breakpoints?: Partial<Breakpoints>;
};
export type AirPropsWithoutChildren = TerraPropsWithoutChildren & AirOwnProps;

@ -13,7 +13,16 @@ export { default as Mark } from './components/mark';
export { default as Highlight } from './components/highlight';
export { default as TextVirtualList } from './components/text-virtual-list/text-virtual-list.svelte';
export { AIR_DEFAULT_THEME, AIR_THEME_ATTR, getAirTheme, setAirTheme } from './themes';
export {
AIR_DEFAULT_THEME,
AIR_THEME_ATTR,
getAirTheme,
setAirTheme,
BASE_LIGHT_THEME,
BASE_DARK_THEME,
EARTH_LIGHT_THEME,
EARTH_DARK_THEME,
} from './themes';
export type { AirIconProps, AirIconSize, AirIconStrokeWidth } from './icons';
export type {
AirTextAlign,
@ -51,4 +60,5 @@ export type { CodeBlockProps } from './components/code-block';
export type { AirKbdSize, KbdProps } from './components/kbd';
export type { AirMarkColor, MarkProps } from './components/mark';
export type { HighlightProps } from './components/highlight';
export type { AirThemeId } from './themes';
export type { AirThemeId, AirTheme } from './themes';
export type { Semantic as AirSemantic, SemanticPhase as AirSemanticPhase } from './internal/semantic';

@ -0,0 +1,59 @@
import { readableActive, type Active } from '$terra/utils';
import type { SoundPresets, SoundSemantic, SoundPhase } from '../sound/types';
import type { MotionPresets } from '../motion/types';
import { playSemantic, type PlaySemanticOptions } from '../sound/presets';
export type BehaviorProps = {
soundEnabled: Active<boolean | undefined>;
motionEnabled: Active<boolean | undefined>;
soundVolume: Active<number | undefined>;
soundPresets: Active<SoundPresets | undefined>;
motionPresets: Active<MotionPresets | undefined>;
};
export type Behavior = {
soundEnabled: Active<boolean>;
motionEnabled: Active<boolean>;
soundVolume: Active<number>;
/** Merged: parent ← child (el hijo solo declara lo que sobreescribe). */
soundPresets: Active<SoundPresets | undefined>;
/** Merged: parent ← child (el hijo solo declara lo que sobreescribe). */
motionPresets: Active<MotionPresets | undefined>;
/**
* Reproduce el sonido de una semántica si el sonido está habilitado.
* Aplica automáticamente los presets configurados en el árbol de Air.
*/
play(semantic: SoundSemantic, phase: SoundPhase, opts?: Omit<PlaySemanticOptions, 'presets'>): void;
};
export function createBehavior(props: BehaviorProps, parent?: Behavior): Behavior {
const soundEnabled = readableActive(
() => props.soundEnabled?.current ?? parent?.soundEnabled.current ?? true
);
const soundPresets = readableActive(() => {
const own = props.soundPresets?.current;
const inherited = parent?.soundPresets.current;
if (!own && !inherited) return undefined;
return { ...inherited, ...own };
});
return {
soundEnabled,
motionEnabled: readableActive(
() => props.motionEnabled?.current ?? parent?.motionEnabled.current ?? true
),
soundVolume: readableActive(
() => props.soundVolume?.current ?? parent?.soundVolume.current ?? 1
),
soundPresets,
motionPresets: readableActive(() => {
const own = props.motionPresets?.current;
const inherited = parent?.motionPresets.current;
if (!own && !inherited) return undefined;
return { ...inherited, ...own };
}),
play(semantic, phase, opts = {}) {
if (!soundEnabled.current) return;
playSemantic(semantic, phase, { ...opts, presets: soundPresets.current });
},
};
}

@ -8,15 +8,40 @@
* Solo importa de aquí.
*/
import { getAir } from '../../config/air.svelte.js';
import { getContext } from '../context.js';
import { playSemantic } from '../sound/presets';
import { DEFAULT_MOTION_PRESETS } from '../motion/defaults';
import type { Semantic } from '../semantic/index';
// ── Utilidad interna ────────────────────────────────────────────────────────
// ── Utilidades internas ─────────────────────────────────────────────────────
function isDisabled(node: Element): boolean {
return node.hasAttribute('data-disabled') || (node as HTMLElement).hasAttribute('disabled');
}
/**
* Resuelve la clase CSS de estado para una semántica:
* 1. stateClass del tema activo (<Air motionPresets>)
* 2. stateClass de DEFAULT_MOTION_PRESETS (fallback)
*/
function resolveStateClass(semantic: Semantic): string | undefined {
const config = getContext();
return config.behavior.motionPresets.current?.[semantic]?.stateClass
?? DEFAULT_MOTION_PRESETS[semantic]?.stateClass;
}
/**
* Aplica una clase CSS de animación one-shot: elimina, espera un frame para
* reiniciar el estado de animación, añade, y la quita al terminar.
*/
function triggerAnimationClass(node: HTMLElement, cls: string) {
node.classList.remove(cls);
requestAnimationFrame(() => {
node.classList.add(cls);
node.addEventListener('animationend', () => node.classList.remove(cls), { once: true });
});
}
// ── feedback ────────────────────────────────────────────────────────────────
//
// Para: Button, IconButton, Toggle, Checkbox, Switch, Radio.
@ -26,19 +51,16 @@ function isDisabled(node: Element): boolean {
/**
* @param getIntent — getter opcional que devuelve el intent actual del componente.
* Permite modular el pitch del sonido feedback según el significado de la acción.
* Ejemplo: `use:airFeedback={() => intent}` en un botón con intent="danger".
* Ejemplo: `use:feedback={() => intent}` en un botón con intent="danger".
*/
export function airFeedback(node: HTMLElement, getIntent?: () => string | undefined) {
const config = getAir();
export function feedback(node: HTMLElement, getIntent?: () => string | undefined) {
const config = getContext();
let pressed = $state(false);
// Motion — clase CSS, reacciona al estado pressed
$effect(() => {
if (config.motionEnabled.current) {
node.classList.toggle('air-motion-press', pressed);
} else {
node.classList.remove('air-motion-press');
}
const cls = resolveStateClass('feedback');
if (cls) node.classList.toggle(cls, config.behavior.motionEnabled.current && pressed);
});
// Eventos — registrados una vez, sin re-registro reactivo
@ -46,9 +68,9 @@ export function airFeedback(node: HTMLElement, getIntent?: () => string | undefi
const down = () => {
if (isDisabled(node)) return;
pressed = true;
if (config.soundEnabled.current) playSemantic('feedback', 'enter', {
if (config.behavior.soundEnabled.current) playSemantic('feedback', 'enter', {
intent: getIntent?.(),
presets: config.soundPresets.current,
presets: config.behavior.soundPresets.current,
});
};
const up = () => { pressed = false; };
@ -75,29 +97,20 @@ export function airFeedback(node: HTMLElement, getIntent?: () => string | undefi
//
// La función recibe un getter reactivo para poder leer el estado del padre.
export function airAttention(node: HTMLElement, getActive: () => boolean) {
const config = getAir();
export function attention(node: HTMLElement, getActive: () => boolean) {
const config = getContext();
let prevActive = false;
$effect(() => {
const active = getActive();
if (active && !prevActive) {
if (config.motionEnabled.current) {
// Quitar y re-añadir para forzar reinicio de la animación
node.classList.remove('air-motion-attention');
// rAF garantiza que el browser procese el remove antes del add
requestAnimationFrame(() => {
node.classList.add('air-motion-attention');
node.addEventListener(
'animationend',
() => node.classList.remove('air-motion-attention'),
{ once: true }
);
});
if (config.behavior.motionEnabled.current) {
const cls = resolveStateClass('attention');
if (cls) triggerAnimationClass(node, cls);
}
if (config.soundEnabled.current) {
playSemantic('attention', 'enter', { presets: config.soundPresets.current });
if (config.behavior.soundEnabled.current) {
playSemantic('attention', 'enter', { presets: config.behavior.soundPresets.current });
}
}
@ -111,20 +124,16 @@ export function airAttention(node: HTMLElement, getActive: () => boolean) {
// Motion: pulso cíclico suave (el CSS pausa en hover/focus automáticamente).
// Sound: pulso cálido, máximo 2 repeticiones (gestionado por el preset de sound).
export function airEmphasis(node: HTMLElement) {
const config = getAir();
export function emphasis(node: HTMLElement) {
const config = getContext();
$effect(() => {
if (config.motionEnabled.current) {
node.classList.add('air-motion-emphasis');
const cls = resolveStateClass('emphasis');
if (config.behavior.motionEnabled.current && cls) node.classList.add(cls);
if (config.behavior.soundEnabled.current) {
playSemantic('emphasis', 'enter', { presets: config.behavior.soundPresets.current });
}
if (config.soundEnabled.current) {
playSemantic('emphasis', 'enter', { presets: config.soundPresets.current });
}
return () => {
node.classList.remove('air-motion-emphasis');
};
return () => { if (cls) node.classList.remove(cls); };
});
}
@ -135,23 +144,22 @@ export function airEmphasis(node: HTMLElement) {
// no el root del componente si son distintos).
// Sound: loop neutro mientras active=true, se detiene al desactivar.
export function airPersistence(node: HTMLElement, getActive: () => boolean) {
const config = getAir();
export function persistence(node: HTMLElement, getActive: () => boolean) {
const config = getContext();
let stopSound: (() => void) | null = null;
$effect(() => {
const active = getActive();
const cls = resolveStateClass('persistence');
if (active) {
if (config.motionEnabled.current) {
node.classList.add('air-motion-spin');
}
if (config.soundEnabled.current && !stopSound) {
const result = playSemantic('persistence', 'enter', { presets: config.soundPresets.current });
if (config.behavior.motionEnabled.current && cls) node.classList.add(cls);
if (config.behavior.soundEnabled.current && !stopSound) {
const result = playSemantic('persistence', 'enter', { presets: config.behavior.soundPresets.current });
if (typeof result === 'function') stopSound = result;
}
} else {
node.classList.remove('air-motion-spin');
if (cls) node.classList.remove(cls);
stopSound?.();
stopSound = null;
}
@ -160,7 +168,68 @@ export function airPersistence(node: HTMLElement, getActive: () => boolean) {
return {
destroy() {
stopSound?.();
node.classList.remove('air-motion-spin');
const cls = resolveStateClass('persistence');
if (cls) node.classList.remove(cls);
}
};
}
// ── selection ────────────────────────────────────────────────────────────────
//
// Para: Checkbox, Toggle, Switch, Radio.
// Motion: clase CSS one-shot en cada cambio de estado.
// Sound: enter al activar, exit al desactivar.
//
// getChecked debe ser un getter reactivo que devuelve el estado actual.
export function selection(node: HTMLElement, getChecked: () => boolean) {
const config = getContext();
let prev: boolean | undefined = undefined;
$effect(() => {
const checked = getChecked();
if (prev !== undefined && checked !== prev) {
const phase = checked ? 'enter' : 'exit';
if (config.behavior.soundEnabled.current) {
playSemantic('selection', phase, { presets: config.behavior.soundPresets.current });
}
if (config.behavior.motionEnabled.current) {
const cls = resolveStateClass('selection');
if (cls) triggerAnimationClass(node, cls);
}
}
prev = checked;
});
}
// ── completion ───────────────────────────────────────────────────────────────
//
// Para: estado de éxito visual persistente (checkmark, banner de confirmación).
// Motion: clase CSS aplicada mientras active=true.
// Sound: sonido positivo una sola vez al activarse.
//
// Para completion como evento puntual (sin estado visual), usar playSemantic directamente.
export function completion(node: HTMLElement, getActive: () => boolean) {
const config = getContext();
let prev = false;
$effect(() => {
const active = getActive();
if (active && !prev) {
if (config.behavior.soundEnabled.current) {
playSemantic('completion', 'enter', { presets: config.behavior.soundPresets.current });
}
if (config.behavior.motionEnabled.current) {
const cls = resolveStateClass('completion');
if (cls) triggerAnimationClass(node, cls);
}
}
prev = active;
});
}

@ -1,2 +1 @@
export * from './behaviors.svelte.ts';
export { feedback, attention, emphasis, persistence, selection, completion } from './behaviors.svelte.ts';

@ -0,0 +1,43 @@
import { createTerraContext } from '$terra/utils/context';
import { readableActive, type Active } from '$terra/utils';
import { createBehavior, type Behavior } from './behavior/behavior.svelte.js';
import { createDom, type Dom } from './dom/dom.svelte.js';
// Tipos mínimos — estructuralmente compatibles con terra sin importarlo
export interface Logger {
warn(msg: string, ...args: unknown[]): void;
error(msg: string, ...args: unknown[]): void;
info?(msg: string, ...args: unknown[]): void;
}
export interface TranslatorService {
translate(value: unknown): string;
translatePath(path: string, ...args: unknown[]): string;
getLocale(): string;
}
export type ContextValue = {
behavior: Behavior;
dom: Dom;
translator: Active<TranslatorService | undefined>;
logger: Active<Logger | undefined>;
};
export const ctx = createTerraContext<ContextValue>('Air');
const FALLBACK: ContextValue = {
behavior: createBehavior({
soundEnabled: readableActive(() => undefined),
motionEnabled: readableActive(() => undefined),
soundVolume: readableActive(() => undefined),
soundPresets: readableActive(() => undefined),
motionPresets: readableActive(() => undefined),
}),
dom: createDom({ breakpoints: readableActive(() => undefined) }),
translator: readableActive(() => undefined),
logger: readableActive(() => undefined),
};
export function getContext(): ContextValue {
return ctx.getOr(FALLBACK);
}

@ -0,0 +1,39 @@
import { readableActive, type Active } from '$terra/utils';
import { BREAKPOINTS_DEFAULT, viewport, resolveResponsiveProp, type Breakpoints, type ResponsiveProp } from './responsive.svelte.js';
export type DomProps = {
breakpoints: Active<Partial<Breakpoints> | undefined>;
};
export type Dom = {
/**
* Breakpoints resueltos: defaults ← parent ← own.
* Siempre devuelve un objeto completo con los 6 breakpoints.
*/
breakpoints: Active<Breakpoints>;
/**
* Ancho actual del viewport. Fuente reactiva única — los componentes
* leen airConfig.dom.viewport.width sin importar nada de responsive.
*/
viewport: typeof viewport;
/**
* Resuelve un ResponsiveProp usando el viewport y breakpoints actuales.
* Los componentes llaman `airConfig.dom.resolve(prop)` sin importar nada de responsive.
*/
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
};
export function createDom(props: DomProps, parent?: Dom): Dom {
const breakpoints = readableActive(() => ({
...BREAKPOINTS_DEFAULT,
...parent?.breakpoints.current,
...props.breakpoints?.current,
}));
return {
breakpoints,
viewport,
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, viewport.width, breakpoints.current);
},
};
}

@ -1 +1,2 @@
export * from './responsive.svelte.ts';
export * from './dom.svelte.js'

@ -1,8 +1,8 @@
import { browser } from '$app/environment';
export type AirBreakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
export type Breakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
export type ResponsiveProp<T> = T | Partial<Record<AirBreakpoint, T>>;
export type ResponsiveProp<T> = T | Partial<Record<Breakpoint, T>>;
/**
* Breakpoints por defecto del sistema.
@ -10,7 +10,7 @@ export type ResponsiveProp<T> = T | Partial<Record<AirBreakpoint, T>>;
* o `useAir({ breakpoints: { lg: 1100 } })`.
* Solo se declaran los que se quieren cambiar — el resto hereda los defaults.
*/
export const AIR_BREAKPOINTS_DEFAULT: Record<AirBreakpoint, number> = {
export const BREAKPOINTS_DEFAULT: Record<Breakpoint, number> = {
base: 0,
sm : 480,
md : 768,
@ -19,7 +19,7 @@ export const AIR_BREAKPOINTS_DEFAULT: Record<AirBreakpoint, number> = {
xxl : 1536,
};
export type AirBreakpoints = Record<AirBreakpoint, number>;
export type Breakpoints = Record<Breakpoint, number>;
// ── Fuente reactiva única de viewport width ──────────────────────────────────
//
@ -56,7 +56,7 @@ export function initViewportTracking(): (() => void) | undefined {
export function isResponsivePropObject<T>(
value: ResponsiveProp<T> | undefined
): value is Partial<Record<AirBreakpoint, T>> {
): value is Partial<Record<Breakpoint, T>> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@ -65,12 +65,12 @@ export function isResponsivePropObject<T>(
*
* @param value — prop que puede ser escalar u objeto de breakpoints
* @param width — ancho actual del viewport (default: viewportWidth del módulo)
* @param breakpoints — tabla de breakpoints (default: AIR_BREAKPOINTS_DEFAULT)
* @param breakpoints — tabla de breakpoints (default: BREAKPOINTS_DEFAULT)
*/
export function resolveResponsiveProp<T>(
value: ResponsiveProp<T> | undefined,
width = viewport.width,
breakpoints: AirBreakpoints = AIR_BREAKPOINTS_DEFAULT
breakpoints: Breakpoints = BREAKPOINTS_DEFAULT
): T | undefined {
if (value === undefined) return undefined;
if (!isResponsivePropObject(value)) return value;

@ -1,29 +0,0 @@
import { tick } from 'svelte';
type MotionPreset = 'feedback' | 'revelation' | 'context' | 'none';
interface AirMotionOptions {
type?: MotionPreset;
index?: number; // Para el staggering (escalonamiento)
origin?: 'top' | 'bottom' | 'left' | 'right' | 'center';
}
export function airMotion(node: HTMLElement, options: AirMotionOptions = {}) {
const { type = 'feedback', index = 0, origin = 'top' } = options;
// 1. Aplicar variables de delay para Staggering
if (index > 0) {
node.style.setProperty('--air-delay', `${index * 20}ms`);
node.style.transitionDelay = 'var(--air-delay)';
}
// 2. Determinar la clase de entrada según el tipo
node.classList.add(`air-motion-${type}`);
node.classList.add(`air-origin-${origin}`);
return {
destroy() {
node.classList.remove(`air-motion-${type}`);
}
};
}

@ -0,0 +1,65 @@
import type { MotionPresets } from './types';
/**
* Presets de motion por defecto del sistema.
* Fallback garantizado cuando ningún tema activo declara un preset.
*
* Viven en internal/ — los temas importan desde aquí, nunca al revés.
* themes/base/motion.ts re-exporta esto como BASE_MOTION_PRESETS.
*/
export const DEFAULT_MOTION_PRESETS: MotionPresets = {
// ── Micro-interacción ─────────────────────────────────────────────────────
feedback: {
stateClass: 'air-motion-press'
},
selection: {
stateClass: 'air-motion-selection'
},
// ── Aparición de contenido ────────────────────────────────────────────────
revelation: {
enter: { duration: 180, easing: 'cubicOut', opacity: true, distance: 4, stagger: 20 },
exit: { duration: 120, easing: 'cubicIn', opacity: true, scale: 0.92 }
},
context: {
enter: { duration: 240, easing: 'cubicOut', opacity: true, scale: 0.985, distance: 16 },
exit: { duration: 160, easing: 'cubicIn', opacity: true, scale: 0.96 }
},
expansion: {
enter: { duration: 200, easing: 'cubicOut', opacity: true },
exit: { duration: 150, easing: 'cubicIn', opacity: true }
},
// ── Movimiento espacial ───────────────────────────────────────────────────
navigation: {
stateClass: 'air-motion-navigation'
},
// ── Mensajes y estados ────────────────────────────────────────────────────
notification: {
stateClass: 'air-motion-notification'
},
attention: {
stateClass: 'air-motion-attention'
},
emphasis: {
stateClass: 'air-motion-emphasis'
},
// ── Cierre y transformación ───────────────────────────────────────────────
completion: {
stateClass: 'air-motion-completion'
},
destruction: {
exit: { duration: 120, easing: 'cubicIn', opacity: true, scale: 0.88 },
stateClass: 'air-motion-destruction'
},
persistence: {
stateClass: 'air-motion-spin'
}
};

@ -1,5 +1,7 @@
export { airReveal, airDismiss, airContext } from './transitions';
export { reveal, dismiss, contextIn, contextOut, expand, expandOut } from './transitions';
export type { MotionOrigin, RevealOptions, ContextOptions } from './transitions';
export { airMotion } from './motion.svelte.js';
export type { MotionType, AirMotionOptions } from './motion.svelte.js';
export { motion } from './motion.svelte.js';
export type { MotionType, MotionOptions } from './motion.svelte.js';
export type { EasingName, MotionPresetData, MotionTransitionFn, MotionPreset, MotionPresets, Semantic } from './types';

@ -1,46 +1,59 @@
import { getAir } from '../../config/air.svelte.js';
import { getContext } from '../context.js';
import { DEFAULT_MOTION_PRESETS } from './defaults';
import type { Semantic } from '$uix/air/internal/semantic';
export type MotionType = 'press' | 'attention' | 'emphasis' | 'spin';
export type MotionType = Semantic;
export interface AirMotionOptions {
export interface MotionOptions {
type: MotionType;
/** Activar o desactivar la clase de motion. Por defecto true. */
active?: boolean;
}
const CLASS_MAP: Record<MotionType, string> = {
press: 'air-motion-press',
attention: 'air-motion-attention',
emphasis: 'air-motion-emphasis',
spin: 'air-motion-spin'
};
/**
* Resuelve la clase CSS de estado para una semántica:
* 1. motionPresets del tema activo
* 2. DEFAULT_MOTION_PRESETS (tema base — fallback garantizado)
*/
function resolveStateClass(semantic: Semantic): string | undefined {
const config = getContext();
return config.behavior.motionPresets.current?.[semantic]?.stateClass
?? DEFAULT_MOTION_PRESETS[semantic]?.stateClass;
}
/**
* Action para motion basado en estado (no transición DOM).
* Aplica la clase correspondiente al tipo semántico cuando `active` es true.
* Aplica la clase CSS correspondiente a la semántica cuando `active` es true.
* La clase se resuelve desde los presets del tema activo — el tema base
* es el fallback garantizado.
*
* Uso: `use:airMotion={{ type: 'press', active: pressed }}`
* Uso: `use:motion={{ type: 'feedback', active: pressed }}`
*/
export function airMotion(node: HTMLElement, initOptions: AirMotionOptions) {
// $state hace el objeto reactivo; update() lo muta y $effect re-corre
export function motion(node: HTMLElement, initOptions: MotionOptions) {
let options = $state(initOptions);
let appliedClass: string | undefined;
$effect(() => {
const motionEnabled = getAir().motionEnabled.current;
const motionEnabled = getContext().behavior.motionEnabled.current;
const { type, active = true } = options;
// Limpiar todas las clases gestionadas por esta action
for (const cls of Object.values(CLASS_MAP)) {
node.classList.remove(cls);
// Limpiar la clase aplicada anteriormente
if (appliedClass) {
node.classList.remove(appliedClass);
appliedClass = undefined;
}
if (active && motionEnabled) {
node.classList.add(CLASS_MAP[type]);
const cls = resolveStateClass(type);
if (cls) {
node.classList.add(cls);
appliedClass = cls;
}
}
});
return {
update(newOptions: AirMotionOptions) {
update(newOptions: MotionOptions) {
options = newOptions;
}
};

@ -1,16 +1,24 @@
import type { TransitionConfig } from 'svelte/transition';
import { cubicOut, cubicIn } from 'svelte/easing';
import { getAir } from '../../config/air.svelte.js';
import {
linear,
cubicIn, cubicOut, cubicInOut,
sineIn, sineOut, sineInOut,
backOut, elasticOut
} from 'svelte/easing';
import { getContext } from '../context.js';
import { playSemantic } from '../sound/presets';
import { DEFAULT_MOTION_PRESETS } from './defaults';
import type { Semantic } from '$uix/air/internal/semantic';
import type { MotionPresetData, MotionPreset, EasingName } from './types';
export type MotionOrigin = 'top' | 'bottom' | 'left' | 'right' | 'center';
export interface RevealOptions {
/** De dónde emerge el elemento — debe coincidir con el placement del overlay. */
origin?: MotionOrigin;
/** Índice para escalonamiento (stagger). Cada unidad suma 20ms de delay. */
/** Índice para escalonamiento. Cada unidad suma `stagger` ms de delay. */
stagger?: number;
/** Distancia de desplazamiento en px. Por defecto 4 (distance-sm). */
/** Distancia en px. Sobreescribe el valor del preset. */
distance?: number;
}
@ -19,92 +27,264 @@ export interface ContextOptions {
side?: 'top' | 'bottom' | 'left' | 'right' | 'center';
}
// ── Easing map ───────────────────────────────────────────────────────────────
const EASING_MAP: Record<EasingName, (t: number) => number> = {
linear,
cubicIn, cubicOut, cubicInOut,
sineIn, sineOut, sineInOut,
backOut, elasticOut
};
function resolveEasing(name?: EasingName): (t: number) => number {
return name ? (EASING_MAP[name] ?? cubicOut) : cubicOut;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function reducedMotion(): boolean {
return typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// ── revelation ──────────────────────────────────────────────────────────────
function isMotionFn(preset: MotionPreset): preset is (node: Element, options?: unknown) => TransitionConfig {
return typeof preset === 'function';
}
/**
* Resuelve el preset para una semántica y fase:
* 1. motionPresets del tema activo (<Air motionPresets={...}>)
* 2. DEFAULT_MOTION_PRESETS (tema base — fallback garantizado)
*/
function resolvePreset(
semantic: Semantic,
phase: 'enter' | 'exit'
): MotionPreset | undefined {
const config = getContext();
return config.behavior.motionPresets.current?.[semantic]?.[phase]
?? DEFAULT_MOTION_PRESETS[semantic as keyof typeof DEFAULT_MOTION_PRESETS]?.[phase];
}
// ── revelation enter ─────────────────────────────────────────────────────────
//
// Entrada: dropdown, popover, tooltip, menú contextual.
// El sonido se dispara en el mismo momento que la animación — misma semántica.
// Entrada de overlays: dropdown, popover, tooltip, menú contextual.
export function airReveal(node: Element, options: RevealOptions = {}): TransitionConfig {
const config = getAir();
const { origin = 'top', stagger = 0, distance = 4 } = options;
export function reveal(node: Element, options: RevealOptions = {}): TransitionConfig {
const config = getContext();
const { origin = 'top', stagger = 0, distance: distanceOpt } = options;
if (config.soundEnabled.current) playSemantic('revelation', 'enter', { presets: config.soundPresets.current });
if (config.behavior.soundEnabled.current) {
playSemantic('revelation', 'enter', { presets: config.behavior.soundPresets.current });
}
if (!config.motionEnabled.current || reducedMotion()) {
if (!config.behavior.motionEnabled.current || reducedMotion()) {
return { duration: 0, css: () => '' };
}
const dy = origin === 'top' ? distance : origin === 'bottom' ? -distance : 0;
const dx = origin === 'left' ? distance : origin === 'right' ? -distance : 0;
const preset = resolvePreset('revelation', 'enter');
if (!preset) return { duration: 0, css: () => '' };
if (isMotionFn(preset)) return preset(node, options);
const { duration = 180, easing: easingName, opacity = true, distance = 4, stagger: staggerMs = 20 } = preset as MotionPresetData;
const d = distanceOpt ?? distance;
const dy = origin === 'top' ? d : origin === 'bottom' ? -d : 0;
const dx = origin === 'left' ? d : origin === 'right' ? -d : 0;
return {
delay: stagger * 20,
duration: 180,
easing: cubicOut,
css: (t, u) => `opacity:${t};transform:translate(${dx * u}px,${dy * u}px)`
delay: stagger * staggerMs,
duration,
easing: resolveEasing(easingName),
css: (t, u) => {
const parts: string[] = [];
if (opacity) parts.push(`opacity:${t}`);
if (dx || dy) parts.push(`transform:translate(${dx * u}px,${dy * u}px)`);
return parts.join(';');
}
};
}
// ── revelation exit / destruction ───────────────────────────────────────────
// ── revelation/destruction exit ──────────────────────────────────────────────
//
// Salida de overlays (revelation exit) y eliminación de elementos (destruction).
// Por defecto semántica 'revelation'. Pasar semantic='destruction' para borrados.
// Salida de overlays (semantic='revelation') y eliminación de elementos (semantic='destruction').
export function airDismiss(
export function dismiss(
node: Element,
options: { semantic?: 'revelation' | 'destruction' } = {}
options: { semantic?: Semantic } = {}
): TransitionConfig {
const config = getAir();
const config = getContext();
const { semantic = 'revelation' } = options;
if (config.soundEnabled.current) playSemantic(semantic, 'exit', { presets: config.soundPresets.current });
if (config.behavior.soundEnabled.current) {
playSemantic(semantic, 'exit', { presets: config.behavior.soundPresets.current });
}
if (!config.motionEnabled.current || reducedMotion()) {
if (!config.behavior.motionEnabled.current || reducedMotion()) {
return { duration: 0, css: () => '' };
}
const preset = resolvePreset(semantic, 'exit');
if (!preset) return { duration: 0, css: () => '' };
if (isMotionFn(preset)) return preset(node, options);
const { duration = 120, easing: easingName, opacity = true, scale = 0.92 } = preset as MotionPresetData;
return {
duration: 120,
easing: cubicIn,
css: (t) => `opacity:${t};transform:scale(${0.92 + 0.08 * t})`
duration,
easing: resolveEasing(easingName),
css: (t) => {
const parts: string[] = [];
if (opacity) parts.push(`opacity:${t}`);
if (scale !== undefined) parts.push(`transform:scale(${scale + (1 - scale) * t})`);
return parts.join(';');
}
};
}
// ── context ──────────────────────────────────────────────────────────────────
// ── context enter ────────────────────────────────────────────────────────────
//
// Dialog (scale-fade desde center) y Drawer (slide desde borde).
// Backdrop y panel usan esta misma función con distintas opciones.
// Entrada de superficies modales: dialog (center) y drawer (desde borde).
export function airContext(node: Element, options: ContextOptions = {}): TransitionConfig {
const config = getAir();
export function contextIn(node: Element, options: ContextOptions = {}): TransitionConfig {
const config = getContext();
const { side = 'center' } = options;
if (config.soundEnabled.current) playSemantic('context', 'enter', { presets: config.soundPresets.current });
if (config.behavior.soundEnabled.current) {
playSemantic('context', 'enter', { presets: config.behavior.soundPresets.current });
}
if (!config.motionEnabled.current || reducedMotion()) {
if (!config.behavior.motionEnabled.current || reducedMotion()) {
return { duration: 0, css: () => '' };
}
const preset = resolvePreset('context', 'enter');
if (!preset) return { duration: 0, css: () => '' };
if (isMotionFn(preset)) return preset(node, options);
const { duration = 240, easing: easingName, opacity = true, scale = 0.985, distance = 16 } = preset as MotionPresetData;
const easing = resolveEasing(easingName);
if (side === 'center') {
return {
duration: 240,
easing: cubicOut,
css: (t) => `opacity:${t};transform:scale(${0.985 + 0.015 * t})`
duration,
easing,
css: (t) => {
const parts: string[] = [];
if (opacity) parts.push(`opacity:${t}`);
if (scale !== undefined) parts.push(`transform:scale(${scale + (1 - scale) * t})`);
return parts.join(';');
}
};
}
const distance = 16; // --air-motion-distance-lg
const dy = side === 'top' ? -distance : side === 'bottom' ? distance : 0;
const dx = side === 'left' ? -distance : side === 'right' ? distance : 0;
return {
duration: 240,
easing: cubicOut,
css: (t, u) => `opacity:${t};transform:translate(${dx * u}px,${dy * u}px)`
duration,
easing,
css: (t, u) => {
const parts: string[] = [];
if (opacity) parts.push(`opacity:${t}`);
if (dx || dy) parts.push(`transform:translate(${dx * u}px,${dy * u}px)`);
return parts.join(';');
}
};
}
// ── expansion enter ──────────────────────────────────────────────────────────
//
// Entrada de contenido inline: accordion, collapsible, reveal-on-click.
// Combina clip-path vertical + opacity desde el preset.
// Para animar altura real usa `slide` de svelte/transition — expand añade
// el sonido semántico y respeta el preset del tema.
export function expand(node: Element, _options: Record<string, never> = {}): TransitionConfig {
const config = getContext();
if (config.behavior.soundEnabled.current) {
playSemantic('expansion', 'enter', { presets: config.behavior.soundPresets.current });
}
if (!config.behavior.motionEnabled.current || reducedMotion()) {
return { duration: 0, css: () => '' };
}
const preset = resolvePreset('expansion', 'enter');
if (!preset) return { duration: 0, css: () => '' };
if (isMotionFn(preset)) return preset(node, _options);
const { duration = 200, easing: easingName, opacity = true } = preset as MotionPresetData;
return {
duration,
easing: resolveEasing(easingName),
css: (t) => {
const parts: string[] = [];
if (opacity) parts.push(`opacity:${t}`);
parts.push(`clip-path:inset(0 0 ${(1 - t) * 100}% 0)`);
return parts.join(';');
}
};
}
// ── expansion exit ───────────────────────────────────────────────────────────
export function expandOut(node: Element, _options: Record<string, never> = {}): TransitionConfig {
const config = getContext();
if (config.behavior.soundEnabled.current) {
playSemantic('expansion', 'exit', { presets: config.behavior.soundPresets.current });
}
if (!config.behavior.motionEnabled.current || reducedMotion()) {
return { duration: 0, css: () => '' };
}
const preset = resolvePreset('expansion', 'exit');
if (!preset) return { duration: 0, css: () => '' };
if (isMotionFn(preset)) return preset(node, _options);
const { duration = 150, easing: easingName, opacity = true } = preset as MotionPresetData;
return {
duration,
easing: resolveEasing(easingName),
css: (t) => {
const parts: string[] = [];
if (opacity) parts.push(`opacity:${t}`);
parts.push(`clip-path:inset(0 0 ${(1 - t) * 100}% 0)`);
return parts.join(';');
}
};
}
// ── context exit ─────────────────────────────────────────────────────────────
export function contextOut(node: Element, options: ContextOptions = {}): TransitionConfig {
const config = getContext();
if (config.behavior.soundEnabled.current) {
playSemantic('context', 'exit', { presets: config.behavior.soundPresets.current });
}
if (!config.behavior.motionEnabled.current || reducedMotion()) {
return { duration: 0, css: () => '' };
}
const preset = resolvePreset('context', 'exit');
if (!preset) return { duration: 0, css: () => '' };
if (isMotionFn(preset)) return preset(node, options);
const { duration = 160, easing: easingName, opacity = true, scale = 0.96 } = preset as MotionPresetData;
return {
duration,
easing: resolveEasing(easingName),
css: (t) => {
const parts: string[] = [];
if (opacity) parts.push(`opacity:${t}`);
if (scale !== undefined) parts.push(`transform:scale(${scale + (1 - scale) * t})`);
return parts.join(';');
}
};
}

@ -0,0 +1,100 @@
import type { TransitionConfig } from 'svelte/transition';
import type { Semantic } from '$uix/air/internal/semantic';
export type { Semantic };
/**
* Nombres de easing disponibles para presets declarativos.
* Corresponden directamente a las funciones exportadas por 'svelte/easing'.
*/
export type EasingName =
| 'linear'
| 'cubicIn'
| 'cubicOut'
| 'cubicInOut'
| 'sineIn'
| 'sineOut'
| 'sineInOut'
| 'backOut'
| 'elasticOut';
/**
* Definición declarativa de una transición JS — JSON puro, sin código.
* El engine de transitions.ts la interpreta igual que playSound
* interpreta las SyntheticLayer del sistema de sonido.
*/
export interface MotionPresetData {
/** Duración en ms. */
duration?: number;
/** Función de easing por nombre. */
easing?: EasingName;
/** Incluir fade de opacidad. Por defecto true. */
opacity?: boolean;
/** Escala de partida (enter) o de llegada (exit). */
scale?: number;
/** Distancia de desplazamiento en px. Usada en revelation y context con side. */
distance?: number;
/** Delay por unidad de índice stagger en ms. Por defecto 20. */
stagger?: number;
}
/**
* Función imperativa — el equivalente a FileLayer en sonido.
* Úsala para animaciones avanzadas (spring, librería externa como GSAP…).
*/
export type MotionTransitionFn = (node: Element, options?: unknown) => TransitionConfig;
/**
* Un preset de transición JS es datos declarativos O una función imperativa.
* Análogo a SyntheticLayer | FileLayer en el sistema de sonido.
*/
export type MotionPreset = MotionPresetData | MotionTransitionFn;
/**
* Entrada de motion para una semántica — cubre las dos capas del sistema:
*
* - `enter` / `exit`: transiciones JS para elementos que entran/salen del DOM
* (Svelte `transition:` / `in:` / `out:`). Se usan en overlays, modales,
* elementos destruidos, etc.
*
* - `stateClass`: clase CSS aplicada por la action `airMotion` para animaciones
* de estado (el elemento permanece en el DOM pero cambia visualmente).
* El tema define la clase; el CSS del tema implementa la animación.
* Esto permite al diseñador sobreescribir completamente la animación de
* feedback, emphasis, attention, etc. sin tocar código JS.
*/
export interface MotionEntry {
/** Transición JS al aparecer en el DOM. */
enter?: MotionPreset;
/** Transición JS al salir del DOM. */
exit?: MotionPreset;
/**
* Clase CSS para animaciones de estado (action `airMotion`).
* Si no se define, `airMotion` usa los defaults del tema base.
*
* @example
* ```ts
* // En el tema earth:
* feedback: { stateClass: 'earth-motion-press' }
* // En earth/motion.css:
* .earth-motion-press { animation: earth-press 120ms ease-out; }
* ```
*/
stateClass?: string;
}
/**
* Override parcial de motion por semántica.
* Cubre las 12 semánticas del framework — cada una con su entrada de motion.
* Estructura simétrica a SoundPresets: mismo eje semántico, mismo patrón.
*
* @example — datos declarativos (JSON puro)
* ```svelte
* <Air motionPresets={{
* revelation: { enter: { duration: 300, easing: 'backOut', distance: 8 } },
* context: { enter: { duration: 350, easing: 'sineOut' }, exit: { duration: 200 } },
* feedback: { stateClass: 'my-theme-press' },
* }}>
* ```
*/
export type MotionPresets = Partial<Record<Semantic, MotionEntry>>;

@ -0,0 +1,47 @@
/**
* Semánticas del framework Air — eje vertebrador de todos los subsistemas.
*
* Cualquier sistema expresivo (sonido, motion, haptics, color dinámico…)
* referencia este tipo como clave. Un tema define su personalidad una vez,
* por semántica, y todos los subsistemas la leen de forma coherente.
*
* Las 12 semánticas cubren el espectro completo de interacciones UI:
*
* ── Micro-interacción ──────────────────────────────────────────────────────
* feedback Respuesta táctil inmediata: click, press, key
* selection Cambio de estado persistente: check, toggle, select item
*
* ── Aparición de contenido ─────────────────────────────────────────────────
* revelation Overlay emergente: dropdown, popover, tooltip
* context Superficie modal: dialog, drawer (bloquea el foco)
* expansion Contenido inline que crece: accordion, collapsible
*
* ── Movimiento espacial ────────────────────────────────────────────────────
* navigation Transición de vista o ruta
*
* ── Mensajes y estados ─────────────────────────────────────────────────────
* notification Mensaje efímero no bloqueante: toast, snackbar
* attention Alerta urgente: error crítico, warning bloqueante
* emphasis Guía visual suave: highlight, pulse, nudge
*
* ── Cierre y transformación ────────────────────────────────────────────────
* completion Cierre positivo: éxito, tarea terminada, confirmación
* destruction Eliminación: delete, discard, borrado irreversible
* persistence Espera activa indeterminada: spinner, skeleton
*/
export type Semantic =
| 'feedback'
| 'selection'
| 'revelation'
| 'context'
| 'expansion'
| 'navigation'
| 'notification'
| 'attention'
| 'emphasis'
| 'completion'
| 'destruction'
| 'persistence';
/** Fase de una interacción con semántica (entrada o salida del estado/elemento). */
export type SemanticPhase = 'enter' | 'exit';

@ -0,0 +1,155 @@
import type { SoundPresetMap } from './types';
/**
* Presets de sonido por defecto del sistema — cubre las 12 semánticas.
* Fallback garantizado cuando ningún tema activo declara un preset.
*
* Viven en internal/ — los temas importan desde aquí, nunca al revés.
* themes/base/sound.ts re-exporta esto como BASE_SOUND_PRESETS.
*/
export const DEFAULT_SOUND_PRESETS: SoundPresetMap = {
// ── Micro-interacción ──────────────────────────────────────────────────
feedback: {
enter: {
layers: [{ frequency: 2600, durationMs: 65, oscillator: 'sine', volumeDb: -12, attack: 0.001, release: 0.03 }]
},
exit: {
layers: [{ frequency: 2210, durationMs: 40, oscillator: 'sine', volumeDb: -16, attack: 0.001, release: 0.02 }]
}
},
selection: {
enter: {
// Clic confirmatorio — más suave que feedback, indica estado persistente
layers: [{ frequency: 2400, durationMs: 50, oscillator: 'sine', volumeDb: -14, attack: 0.001, release: 0.02 }]
},
exit: {
// Deselección — tono descendente leve
layers: [{ frequency: 2000, durationMs: 35, oscillator: 'sine', volumeDb: -17, attack: 0.001, release: 0.015 }]
}
},
// ── Aparición de contenido ─────────────────────────────────────────────
revelation: {
enter: {
layers: [{ frequency: 800, endFrequency: 1200, durationMs: 160, oscillator: 'sine', volumeDb: -15, attack: 0.01, release: 0.04 }]
},
exit: {
layers: [{ frequency: 1000, durationMs: 55, oscillator: 'sine', volumeDb: -18, attack: 0.001, release: 0.03 }]
}
},
context: {
enter: {
layers: [{ frequency: 520, durationMs: 240, oscillator: 'sawtooth', volumeDb: -18, attack: 0.02, release: 0.12 }]
},
exit: {
layers: [{ frequency: 520, endFrequency: 320, durationMs: 150, oscillator: 'sawtooth', volumeDb: -21, attack: 0.001, release: 0.08 }]
}
},
expansion: {
enter: {
// Apertura suave — frecuencia ascendente, más corta que revelation
layers: [{ frequency: 720, endFrequency: 1100, durationMs: 140, oscillator: 'sine', volumeDb: -17, attack: 0.01, release: 0.07 }]
},
exit: {
// Cierre — frecuencia descendente
layers: [{ frequency: 1000, endFrequency: 680, durationMs: 100, oscillator: 'sine', volumeDb: -19, attack: 0.001, release: 0.05 }]
}
},
// ── Movimiento espacial ────────────────────────────────────────────────
navigation: {
enter: {
// Llegada a nueva vista — sweep ascendente, espacial
layers: [{ frequency: 700, endFrequency: 1000, durationMs: 240, oscillator: 'sine', volumeDb: -18, attack: 0.02, release: 0.10 }]
},
exit: {
// Salida de vista — sweep descendente
layers: [{ frequency: 900, endFrequency: 650, durationMs: 140, oscillator: 'sine', volumeDb: -20, attack: 0.001, release: 0.07 }]
}
},
// ── Mensajes y estados ─────────────────────────────────────────────────
notification: {
enter: {
// Dos tonos cálidos escalonados — no urgente, amigable
layers: [
{ frequency: 1400, durationMs: 120, oscillator: 'sine', volumeDb: -16, attack: 0.01, release: 0.06 },
{ frequency: 1760, durationMs: 80, oscillator: 'sine', volumeDb: -19, attack: 0.02, release: 0.04 }
],
layerDelayMs: 50
},
exit: {
// Las notificaciones desaparecen en silencio
layers: []
}
},
attention: {
enter: {
layers: [{ frequency: 1850, endFrequency: 1480, durationMs: 110, oscillator: 'sine', volumeDb: -10, attack: 0.001, release: 0.04 }]
},
exit: {
layers: [{ frequency: 1388, durationMs: 55, oscillator: 'square', volumeDb: -13, attack: 0.001, release: 0.02 }]
}
},
emphasis: {
enter: {
layers: [
{ frequency: 1600, durationMs: 120, oscillator: 'sine', volumeDb: -16, attack: 0.01, release: 0.08 },
{ frequency: 1760, durationMs: 100, oscillator: 'sine', volumeDb: -18, attack: 0.01, release: 0.08 }
],
layerDelayMs: 40
},
exit: {
layers: [{ frequency: 1600, durationMs: 88, oscillator: 'sine', volumeDb: -20, attack: 0.001, release: 0.03 }]
}
},
// ── Cierre y transformación ────────────────────────────────────────────
completion: {
enter: {
// Dos tonos ascendentes — positivo, luminoso, celebratorio pero discreto
layers: [
{ frequency: 1600, durationMs: 120, oscillator: 'sine', volumeDb: -13, attack: 0.005, release: 0.06 },
{ frequency: 2000, durationMs: 100, oscillator: 'sine', volumeDb: -15, attack: 0.01, release: 0.05 }
],
layerDelayMs: 60
},
exit: {
layers: []
}
},
destruction: {
enter: {
layers: [
{ frequency: 1250, durationMs: 95, oscillator: 'triangle', volumeDb: -14, attack: 0.001, release: 0.08 },
{ frequency: 913, durationMs: 57, oscillator: 'triangle', volumeDb: -17, attack: 0.001, release: 0.10 }
],
layerDelayMs: 25
},
exit: {
layers: [{ frequency: 750, endFrequency: 375, durationMs: 50, oscillator: 'triangle', volumeDb: -20, attack: 0.001, release: 0.05 }]
}
},
persistence: {
enter: {
layers: [{ frequency: 900, durationMs: 400, oscillator: 'sine', volumeDb: -20, filterFreq: 1200 }],
loop: true,
loopFilterFreq: 1200
},
exit: {
layers: []
}
}
};

@ -1,8 +1,9 @@
export { airSound, isReducedSound, initAirSound } from './sound.svelte.js';
export { playSemantic, SOUND_PRESETS } from './presets';
export { sound, isReducedSound, initAirSound } from './sound.svelte.js';
export { playSemantic } from './presets';
export { BASE_SOUND_PRESETS } from '$uix/air/themes/base/sound';
export { playSound, getAudioContext } from './sound.engine';
export type {
AirSoundOptions,
SoundOptions,
SoundSemantic,
SoundPhase,
SoundToken,
@ -10,6 +11,8 @@ export type {
SyntheticLayer,
FileLayer,
SoundDefinition,
SoundPresetMap
SoundPresetMap,
SoundPresets
} from './types';
export { SOUND_TOKENS } from './types';
export type { Semantic, SemanticPhase } from '$uix/air/internal/semantic';

@ -1,5 +1,6 @@
import type { SoundSemantic, SoundPhase, SoundPresetMap, AirSoundPresets, SoundDefinition } from './types';
import type { SoundSemantic, SoundPhase, SoundPresets, SoundDefinition } from './types';
import { playSound } from './sound.engine';
import { DEFAULT_SOUND_PRESETS } from './defaults';
// ── Intent → pitch shift (semitones) ────────────────────────────────────────
//
@ -29,198 +30,6 @@ function intentFreqMultiplier(semantic: SoundSemantic, intent?: string): number
return Math.pow(2, st / 12);
}
export const SOUND_PRESETS: SoundPresetMap = {
feedback: {
enter: {
layers: [
{
frequency: 2600,
durationMs: 65,
oscillator: 'sine',
volumeDb: -12,
attack: 0.001,
release: 0.03
}
]
},
exit: {
layers: [
{
frequency: 2210,
durationMs: 40,
oscillator: 'sine',
volumeDb: -16,
attack: 0.001,
release: 0.02
}
]
}
},
revelation: {
enter: {
layers: [
{
frequency: 800,
endFrequency: 1200,
durationMs: 160,
oscillator: 'sine',
volumeDb: -15,
attack: 0.01,
release: 0.04
}
]
},
exit: {
layers: [
{
frequency: 1000,
durationMs: 55,
oscillator: 'sine',
volumeDb: -18,
attack: 0.001,
release: 0.03
}
]
}
},
context: {
enter: {
layers: [
{
frequency: 520,
durationMs: 240,
oscillator: 'sawtooth',
volumeDb: -18,
attack: 0.02,
release: 0.12
}
]
},
exit: {
layers: [
{
frequency: 520,
endFrequency: 320,
durationMs: 150,
oscillator: 'sawtooth',
volumeDb: -21,
attack: 0.001,
release: 0.08
}
]
}
},
attention: {
enter: {
layers: [
{
frequency: 1850,
endFrequency: 1480,
durationMs: 110,
oscillator: 'sine',
volumeDb: -10,
attack: 0.001,
release: 0.04
}
]
},
exit: {
layers: [
{
frequency: 1388,
durationMs: 55,
oscillator: 'square',
volumeDb: -13,
attack: 0.001,
release: 0.02
}
]
}
},
emphasis: {
enter: {
layers: [
{
frequency: 1600,
durationMs: 120,
oscillator: 'sine',
volumeDb: -16,
attack: 0.01,
release: 0.08
},
{
frequency: 1760,
durationMs: 100,
oscillator: 'sine',
volumeDb: -18,
attack: 0.01,
release: 0.08
}
],
layerDelayMs: 40
},
exit: {
layers: [
{
frequency: 1600,
durationMs: 88,
oscillator: 'sine',
volumeDb: -20,
attack: 0.001,
release: 0.03
}
]
}
},
destruction: {
enter: {
layers: [
{
frequency: 1250,
durationMs: 95,
oscillator: 'triangle',
volumeDb: -14,
attack: 0.001,
release: 0.08
},
{
frequency: 913,
durationMs: 57,
oscillator: 'triangle',
volumeDb: -17,
attack: 0.001,
release: 0.1
}
],
layerDelayMs: 25
},
exit: {
layers: [
{
frequency: 750,
endFrequency: 375,
durationMs: 50,
oscillator: 'triangle',
volumeDb: -20,
attack: 0.001,
release: 0.05
}
]
}
},
persistence: {
enter: {
layers: [
{ frequency: 900, durationMs: 400, oscillator: 'sine', volumeDb: -20, filterFreq: 1200 }
],
loop: true,
loopFilterFreq: 1200
},
exit: {
layers: []
}
}
};
export interface PlaySemanticOptions {
/** Multiplicador de volumen (0–1). Por defecto 1. */
@ -241,14 +50,14 @@ export interface PlaySemanticOptions {
* Overrides a nivel de configuración (<Air soundPresets={...}>).
* Se consulta si no hay override de llamada; por encima de los defaults.
*/
presets?: AirSoundPresets;
presets?: SoundPresets;
}
/**
* Reproduce el sonido de una semántica con cadena de resolución de 3 niveles:
* 1. `options.sound` — override en el punto de llamada (máxima prioridad)
* 2. `options.presets[s][p]` — override de configuración (<Air soundPresets>)
* 3. `SOUND_PRESETS[s][p]` — default del sistema
* 3. `DEFAULT_SOUND_PRESETS[s][p]` — default del tema base
*/
export function playSemantic(
semantic: SoundSemantic,
@ -256,7 +65,7 @@ export function playSemantic(
options: PlaySemanticOptions = {}
): void | (() => void) | Promise<void> {
const { volumeMultiplier = 1, intent, sound, presets } = options;
const def = sound ?? presets?.[semantic]?.[phase] ?? SOUND_PRESETS[semantic][phase];
const def = sound ?? presets?.[semantic]?.[phase] ?? DEFAULT_SOUND_PRESETS[semantic][phase];
if (!def.layers.length) return;
const freqMult = intentFreqMultiplier(semantic, intent);
return playSound(def, volumeMultiplier, freqMult);

@ -10,6 +10,20 @@ export function getAudioContext(): AudioContext {
return ctx;
}
/**
* Asegura que el contexto está running antes de ejecutar `fn`.
* Si está suspended (antes de gesto del usuario), llama a resume() y espera.
* Si el contexto aún no existe, no hace nada (sin gesto, sin sonido).
*/
function withRunningContext(fn: (ac: AudioContext) => void): void {
const ac = getAudioContext();
if (ac.state === 'running') {
fn(ac);
} else if (ac.state === 'suspended') {
ac.resume().then(() => fn(ac));
}
}
function dbToLinear(db: number): number {
return Math.pow(10, db / 20);
}
@ -141,27 +155,27 @@ export function playSound(
def: SoundDefinition,
volumeMultiplier = 1,
freqMultiplier = 1
): void | (() => void) | Promise<void> {
if (def.loop) return playLoop(def, volumeMultiplier);
for (let i = 0; i < def.layers.length; i++) {
const layer = def.layers[i];
const delay = (def.layerDelayMs ?? 0) * i;
if (delay > 0 && i > 0) {
setTimeout(() => {
if (isFileLayer(layer)) {
playFileLayer(layer, volumeMultiplier);
} else {
playSyntheticLayer(layer, volumeMultiplier, freqMultiplier);
}
}, delay);
} else {
if (isFileLayer(layer)) {
playFileLayer(layer, volumeMultiplier);
): void | (() => void) {
if (def.loop) {
let stopFn: (() => void) | undefined;
withRunningContext(() => { stopFn = playLoop(def, volumeMultiplier); });
return () => stopFn?.();
}
withRunningContext(() => {
for (let i = 0; i < def.layers.length; i++) {
const layer = def.layers[i];
const delay = (def.layerDelayMs ?? 0) * i;
if (delay > 0 && i > 0) {
setTimeout(() => {
if (isFileLayer(layer)) playFileLayer(layer, volumeMultiplier);
else playSyntheticLayer(layer, volumeMultiplier, freqMultiplier);
}, delay);
} else {
playSyntheticLayer(layer, volumeMultiplier, freqMultiplier);
if (isFileLayer(layer)) playFileLayer(layer, volumeMultiplier);
else playSyntheticLayer(layer, volumeMultiplier, freqMultiplier);
}
}
}
});
}

@ -1,7 +1,7 @@
import type { AirSoundOptions } from './types';
import type { SoundOptions } from './types';
import { playSemantic } from './presets';
import { getAudioContext } from './sound.engine';
import { getAir } from '../../config/air.svelte.js';
import { getContext } from '../context.js';
let _reducedMedia = true;
let _inited = false;
@ -32,24 +32,24 @@ export function isReducedSound(): boolean {
* Para sonidos de evento único (feedback, revelation, etc.) usar `playSemantic` directamente
* desde el handler del evento — no esta action.
*
* Uso: `use:airSound={{ semantic: 'persistence' }}`
* Uso: `use:sound={{ semantic: 'persistence' }}`
*/
export function airSound(node: HTMLElement, initOptions: AirSoundOptions) {
export function sound(node: HTMLElement, initOptions: SoundOptions) {
// $state hace el objeto reactivo; update() lo muta y $effect re-corre
let options = $state(initOptions);
let stopPersistence: (() => void) | null = null;
$effect(() => {
const { semantic, phase = 'enter', volumeMultiplier = 1, sound } = options;
const config = getAir();
const soundEnabled = config.soundEnabled.current;
const volumeScale = config.soundVolume.current;
const config = getContext();
const soundEnabled = config.behavior.soundEnabled.current;
const volumeScale = config.behavior.soundVolume.current;
if (!soundEnabled || isReducedSound()) return;
const result = playSemantic(semantic, phase, {
sound,
presets: config.soundPresets.current,
presets: config.behavior.soundPresets.current,
volumeMultiplier: volumeMultiplier * volumeScale,
});
@ -67,7 +67,7 @@ export function airSound(node: HTMLElement, initOptions: AirSoundOptions) {
});
return {
update(newOptions: AirSoundOptions) {
update(newOptions: SoundOptions) {
// Mutar el $state dispara el $effect
options = newOptions;
},

@ -1,13 +1,11 @@
export type SoundSemantic =
| 'feedback'
| 'revelation'
| 'context'
| 'attention'
| 'emphasis'
| 'destruction'
| 'persistence';
import type { Semantic, SemanticPhase } from '../semantic';
export type SoundPhase = 'enter' | 'exit';
/**
* SoundSemantic es un alias de Semantic.
* El sonido no posee la semántica — la referencia desde el eje central.
*/
export type SoundSemantic = Semantic;
export type SoundPhase = SemanticPhase;
export interface SyntheticLayer {
frequency: number;
@ -38,7 +36,7 @@ export interface SoundDefinition {
}
export interface SoundToken {
semantic: SoundSemantic;
semantic: Semantic;
intent: string;
maxDurationMs: number;
volumeDb: number;
@ -47,40 +45,44 @@ export interface SoundToken {
descending?: boolean;
}
export type SoundPresetMap = Record<
SoundSemantic,
{ enter: SoundDefinition; exit: SoundDefinition }
>;
/** Mapa completo de presets: todas las semánticas deben estar definidas. */
export type SoundPresetMap = Record<Semantic, { enter: SoundDefinition; exit: SoundDefinition }>;
/**
* Override parcial de presets por semántica.
* El diseñador puede reemplazar cualquier combinación de semantic/phase
* con una definición sintética propia o con un archivo WAV.
* Las entradas no declaradas siguen usando los defaults del sistema.
* Override parcial por semántica — solo se declaran las que se quieren sobreescribir.
* Se usa en `<Air soundPresets={...}>` para personalizar el sonido por tema.
*/
export type AirSoundPresets = Partial<
Record<SoundSemantic, { enter?: SoundDefinition; exit?: SoundDefinition }>
export type SoundPresets = Partial<
Record<Semantic, { enter?: SoundDefinition; exit?: SoundDefinition }>
>;
export interface AirSoundOptions {
semantic: SoundSemantic;
phase?: SoundPhase;
export interface SoundOptions {
semantic: Semantic;
phase?: SemanticPhase;
volumeMultiplier?: number;
sound?: SoundDefinition;
}
export const SOUND_TOKENS: Record<SoundSemantic, SoundToken> = {
export const SOUND_TOKENS: Record<Semantic, SoundToken> = {
feedback: {
semantic: 'feedback',
intent: 'respuesta táctil',
intent: 'respuesta táctil inmediata',
maxDurationMs: 80,
volumeDb: -12,
freqRange: [2400, 2800],
envelope: 'dry'
},
selection: {
semantic: 'selection',
intent: 'cambio de estado persistente',
maxDurationMs: 60,
volumeDb: -14,
freqRange: [2200, 2600],
envelope: 'dry'
},
revelation: {
semantic: 'revelation',
intent: 'contenido nuevo aparece',
intent: 'overlay emergente aparece',
maxDurationMs: 180,
volumeDb: -15,
freqRange: [800, 1200],
@ -88,15 +90,39 @@ export const SOUND_TOKENS: Record<SoundSemantic, SoundToken> = {
},
context: {
semantic: 'context',
intent: 'cambio de jerarquía',
intent: 'superficie modal — cambio de jerarquía',
maxDurationMs: 250,
volumeDb: -18,
freqRange: [400, 600],
envelope: 'deep'
},
expansion: {
semantic: 'expansion',
intent: 'contenido inline se expande',
maxDurationMs: 160,
volumeDb: -17,
freqRange: [700, 1100],
envelope: 'rise'
},
navigation: {
semantic: 'navigation',
intent: 'transición de vista o ruta',
maxDurationMs: 280,
volumeDb: -18,
freqRange: [600, 1000],
envelope: 'rise'
},
notification: {
semantic: 'notification',
intent: 'mensaje efímero no bloqueante',
maxDurationMs: 200,
volumeDb: -16,
freqRange: [1400, 1800],
envelope: 'warm-pulse'
},
attention: {
semantic: 'attention',
intent: 'error, alerta',
intent: 'alerta urgente bloqueante',
maxDurationMs: 120,
volumeDb: -10,
freqRange: [1800, 1800],
@ -111,9 +137,17 @@ export const SOUND_TOKENS: Record<SoundSemantic, SoundToken> = {
freqRange: [1600, 1600],
envelope: 'warm-pulse'
},
completion: {
semantic: 'completion',
intent: 'cierre positivo — éxito, tarea terminada',
maxDurationMs: 240,
volumeDb: -13,
freqRange: [1600, 2200],
envelope: 'rise'
},
destruction: {
semantic: 'destruction',
intent: 'eliminar, descartar',
intent: 'eliminación irreversible',
maxDurationMs: 100,
volumeDb: -14,
freqRange: [1200, 1200],
@ -122,7 +156,7 @@ export const SOUND_TOKENS: Record<SoundSemantic, SoundToken> = {
},
persistence: {
semantic: 'persistence',
intent: 'carga, espera activa',
intent: 'espera activa indeterminada',
maxDurationMs: 400,
volumeDb: -20,
freqRange: [900, 900],

@ -0,0 +1,13 @@
import type { AirTheme } from '../types.js';
/**
* Tema base light — personalidad por defecto del sistema.
* No declara presets: sound y motion heredan de BASE_SOUND_PRESETS y BASE_MOTION_PRESETS.
*/
export const BASE_LIGHT_THEME: AirTheme = { id: 'base-light' };
/**
* Tema base dark — misma personalidad sonora y cinética que light.
* Solo cambia la dimensión visual (CSS tokens de color).
*/
export const BASE_DARK_THEME: AirTheme = { id: 'base-dark' };

@ -0,0 +1,6 @@
/**
* Re-exporta los defaults de motion del sistema como BASE_MOTION_PRESETS.
* Los defaults viven en internal/motion/defaults.ts — esta es solo la
* interfaz pública del tema base para que los consumidores puedan importarlos.
*/
export { DEFAULT_MOTION_PRESETS as BASE_MOTION_PRESETS } from '$uix/air/internal/motion/defaults';

@ -0,0 +1,6 @@
/**
* Re-exporta los defaults de sonido del sistema como BASE_SOUND_PRESETS.
* Los defaults viven en internal/sound/defaults.ts — esta es solo la
* interfaz pública del tema base para que los consumidores puedan importarlos.
*/
export { DEFAULT_SOUND_PRESETS as BASE_SOUND_PRESETS } from '$uix/air/internal/sound/defaults';

@ -0,0 +1,19 @@
/* Earth: override de tokens estáticos de motion.
* El resto de tokens estáticos (spacing, tipografía…) heredan de themes/base/_static.css.
* Solo se sobreescriben los que tienen valor distinto en el lenguaje cinético earth.
*/
[data-theme='earth-light'],
[data-theme='earth-dark'] {
/* Distancias — más generosas, más presencia física */
--air-motion-distance-xs: 3px;
--air-motion-distance-sm: 6px;
--air-motion-distance-md: 10px;
--air-motion-distance-lg: 20px;
/* Escalas — más sutiles, más orgánicas */
--air-motion-scale-enter: 0.982;
--air-motion-scale-press: 0.95;
/* Stagger — escalonamiento más perceptible */
--air-motion-stagger: 28ms;
}

@ -0,0 +1,178 @@
/* Earth-dark theme — paleta oscura tierra: suelos profundos, terracota brillante.
* Tokens estáticos (spacing, tipografía, motion, etc.) viven en _static.css.
*/
[data-theme='earth-dark'] {
/* Private primitives: neutral — negros suelo a blancos cálidos */
--air-primitive-neutral-1: #130e09;
--air-primitive-neutral-2: #1c160f;
--air-primitive-neutral-3: #262016;
--air-primitive-neutral-4: #31281b;
--air-primitive-neutral-5: #3b3021;
--air-primitive-neutral-6: #4a3d2a;
--air-primitive-neutral-7: #5e4e38;
--air-primitive-neutral-8: #786250;
--air-primitive-neutral-9: #8a7460;
--air-primitive-neutral-10: #9c876f;
--air-primitive-neutral-11: #c4ae94;
--air-primitive-neutral-12: #f2e6d4;
/* Private primitives: primary — terracota luminosa en oscuro */
--air-primitive-primary-1: #1e1108;
--air-primitive-primary-2: #28170b;
--air-primitive-primary-3: #3e2214;
--air-primitive-primary-4: #572e1a;
--air-primitive-primary-5: #6e3a20;
--air-primitive-primary-6: #8a4928;
--air-primitive-primary-7: #ab5c34;
--air-primitive-primary-8: #cc7044;
--air-primitive-primary-9: #e07a4a;
--air-primitive-primary-10: #e8916a;
--air-primitive-primary-11: #f4bfa0;
--air-primitive-primary-12: #fde8d8;
/* Private primitives: info — pizarra teal oscuro */
--air-primitive-info-1: #091514;
--air-primitive-info-2: #0f1e1d;
--air-primitive-info-3: #142e2c;
--air-primitive-info-4: #193f3c;
--air-primitive-info-5: #204f4b;
--air-primitive-info-6: #296260;
--air-primitive-info-7: #347874;
--air-primitive-info-8: #408f8a;
--air-primitive-info-9: #2a7a72;
--air-primitive-info-10: #4da49d;
--air-primitive-info-11: #88cec8;
--air-primitive-info-12: #c8eeec;
/* Private primitives: success — oliva oscuro */
--air-primitive-success-1: #0d140a;
--air-primitive-success-2: #141d0e;
--air-primitive-success-3: #1c2d14;
--air-primitive-success-4: #243c1a;
--air-primitive-success-5: #2d4c21;
--air-primitive-success-6: #395e2a;
--air-primitive-success-7: #487535;
--air-primitive-success-8: #598d42;
--air-primitive-success-9: #3a781e;
--air-primitive-success-10: #70a850;
--air-primitive-success-11: #a4d080;
--air-primitive-success-12: #d4eebc;
/* Private primitives: warning — ámbar oscuro */
--air-primitive-warning-1: #191008;
--air-primitive-warning-2: #21160c;
--air-primitive-warning-3: #322212;
--air-primitive-warning-4: #432e18;
--air-primitive-warning-5: #553b1f;
--air-primitive-warning-6: #6b4a27;
--air-primitive-warning-7: #8f6230;
--air-primitive-warning-8: #b47a38;
--air-primitive-warning-9: #df9830;
--air-primitive-warning-10: #f0b050;
--air-primitive-warning-11: #fad4a0;
--air-primitive-warning-12: #feecd4;
/* Private primitives: danger — ladrillo oscuro */
--air-primitive-danger-1: #190c0a;
--air-primitive-danger-2: #221210;
--air-primitive-danger-3: #381c18;
--air-primitive-danger-4: #4e251f;
--air-primitive-danger-5: #613028;
--air-primitive-danger-6: #773d33;
--air-primitive-danger-7: #964e42;
--air-primitive-danger-8: #ba6456;
--air-primitive-danger-9: #d97060;
--air-primitive-danger-10: #e48474;
--air-primitive-danger-11: #f4b0a4;
--air-primitive-danger-12: #fce0da;
/* Semantic surfaces */
--air-color-surface-default: var(--air-primitive-neutral-1);
--air-color-surface-raised: var(--air-primitive-neutral-3);
--air-color-surface-overlay: var(--air-primitive-neutral-4);
--air-color-overlay: rgb(0 0 0 / 0.70);
/* Semantic content */
--air-color-content-primary: var(--air-primitive-neutral-12);
--air-color-content-secondary: var(--air-primitive-neutral-11);
--air-color-content-disabled: var(--air-primitive-neutral-7);
--air-color-content-on-solid: #ffffff;
/* Semantic borders */
--air-color-border-subtle: var(--air-primitive-neutral-4);
--air-color-border-default: var(--air-primitive-neutral-6);
--air-color-border-strong: var(--air-primitive-neutral-8);
--air-color-focus-ring: var(--air-primitive-primary-10);
/* Primary palette */
--air-color-primary-track: var(--air-primitive-primary-1);
--air-color-primary-element: var(--air-primitive-primary-3);
--air-color-primary-hover: var(--air-primitive-primary-4);
--air-color-primary-active: var(--air-primitive-primary-5);
--air-color-primary-border: var(--air-primitive-primary-6);
--air-color-primary-solid: var(--air-primitive-primary-9);
--air-color-primary-solid-hover: var(--air-primitive-primary-10);
--air-color-primary-text: var(--air-primitive-primary-11);
--air-color-primary-contrast: var(--air-primitive-primary-12);
/* Neutral palette */
--air-color-neutral-track: var(--air-primitive-neutral-1);
--air-color-neutral-element: var(--air-primitive-neutral-3);
--air-color-neutral-hover: var(--air-primitive-neutral-4);
--air-color-neutral-active: var(--air-primitive-neutral-5);
--air-color-neutral-border: var(--air-primitive-neutral-6);
--air-color-neutral-solid: var(--air-primitive-neutral-9);
--air-color-neutral-solid-hover: var(--air-primitive-neutral-10);
--air-color-neutral-text: var(--air-primitive-neutral-11);
--air-color-neutral-contrast: var(--air-primitive-neutral-12);
/* Success palette */
--air-color-success-track: var(--air-primitive-success-1);
--air-color-success-element: var(--air-primitive-success-3);
--air-color-success-hover: var(--air-primitive-success-4);
--air-color-success-active: var(--air-primitive-success-5);
--air-color-success-border: var(--air-primitive-success-6);
--air-color-success-solid: var(--air-primitive-success-9);
--air-color-success-solid-hover: var(--air-primitive-success-10);
--air-color-success-text: var(--air-primitive-success-11);
--air-color-success-contrast: var(--air-primitive-success-12);
/* Warning palette */
--air-color-warning-track: var(--air-primitive-warning-1);
--air-color-warning-element: var(--air-primitive-warning-3);
--air-color-warning-hover: var(--air-primitive-warning-4);
--air-color-warning-active: var(--air-primitive-warning-5);
--air-color-warning-border: var(--air-primitive-warning-6);
--air-color-warning-solid: var(--air-primitive-warning-9);
--air-color-warning-solid-hover: var(--air-primitive-warning-10);
--air-color-warning-text: var(--air-primitive-warning-11);
--air-color-warning-contrast: var(--air-primitive-warning-12);
/* Danger palette */
--air-color-danger-track: var(--air-primitive-danger-1);
--air-color-danger-element: var(--air-primitive-danger-3);
--air-color-danger-hover: var(--air-primitive-danger-4);
--air-color-danger-active: var(--air-primitive-danger-5);
--air-color-danger-border: var(--air-primitive-danger-6);
--air-color-danger-solid: var(--air-primitive-danger-9);
--air-color-danger-solid-hover: var(--air-primitive-danger-10);
--air-color-danger-text: var(--air-primitive-danger-11);
--air-color-danger-contrast: var(--air-primitive-danger-12);
/* Info palette */
--air-color-info-track: var(--air-primitive-info-1);
--air-color-info-element: var(--air-primitive-info-3);
--air-color-info-hover: var(--air-primitive-info-4);
--air-color-info-active: var(--air-primitive-info-5);
--air-color-info-border: var(--air-primitive-info-6);
--air-color-info-solid: var(--air-primitive-info-9);
--air-color-info-solid-hover: var(--air-primitive-info-10);
--air-color-info-text: var(--air-primitive-info-11);
--air-color-info-contrast: var(--air-primitive-info-12);
/* Shadows — más cálidas que el base-dark */
--air-shadow-0: none;
--air-shadow-1: 0 1px 2px rgb(0 0 0 / 0.30), 0 0 0 1px rgb(255 220 180 / 0.04);
--air-shadow-2: 0 4px 12px rgb(0 0 0 / 0.40), 0 1px 2px rgb(0 0 0 / 0.20);
--air-shadow-3: 0 12px 32px rgb(0 0 0 / 0.55), 0 4px 8px rgb(0 0 0 / 0.28);
}

@ -0,0 +1,3 @@
@import './_static.css';
@import './light.css';
@import './dark.css';

@ -0,0 +1,15 @@
import type { AirTheme } from '../types.js';
import { earthMotionPresets } from './motion.js';
import { earthSoundPresets } from './sound.js';
export const EARTH_LIGHT_THEME = {
id: 'earth-light',
motionPresets: earthMotionPresets,
soundPresets: earthSoundPresets
} satisfies AirTheme;
export const EARTH_DARK_THEME = {
id: 'earth-dark',
motionPresets: earthMotionPresets,
soundPresets: earthSoundPresets
} satisfies AirTheme;

@ -0,0 +1,178 @@
/* Earth-light theme — paleta de colores tierra: terracota, ocre, salvia, pizarra.
* Tokens estáticos (spacing, tipografía, motion, etc.) viven en _static.css.
*/
[data-theme='earth-light'] {
/* Private primitives: neutral — blancos cremosos a marrones cálidos */
--air-primitive-neutral-1: #fdfcf8;
--air-primitive-neutral-2: #f9f5ee;
--air-primitive-neutral-3: #f0ebe0;
--air-primitive-neutral-4: #e6ddd0;
--air-primitive-neutral-5: #d9d0bf;
--air-primitive-neutral-6: #c9bfab;
--air-primitive-neutral-7: #b4a890;
--air-primitive-neutral-8: #978b72;
--air-primitive-neutral-9: #726654;
--air-primitive-neutral-10: #5e5342;
--air-primitive-neutral-11: #42392c;
--air-primitive-neutral-12: #201c12;
/* Private primitives: primary — terracota / siena */
--air-primitive-primary-1: #fdf8f5;
--air-primitive-primary-2: #faede6;
--air-primitive-primary-3: #f5d6c5;
--air-primitive-primary-4: #ecb89a;
--air-primitive-primary-5: #e0956f;
--air-primitive-primary-6: #d07248;
--air-primitive-primary-7: #bc5528;
--air-primitive-primary-8: #a44219;
--air-primitive-primary-9: #c44b18;
--air-primitive-primary-10: #ab3f14;
--air-primitive-primary-11: #8c3210;
--air-primitive-primary-12: #431608;
/* Private primitives: info — pizarra azul verdosa */
--air-primitive-info-1: #f5f9f9;
--air-primitive-info-2: #e8f3f2;
--air-primitive-info-3: #cfe8e5;
--air-primitive-info-4: #b2dad6;
--air-primitive-info-5: #91c9c4;
--air-primitive-info-6: #6eb5ae;
--air-primitive-info-7: #4d9f97;
--air-primitive-info-8: #368881;
--air-primitive-info-9: #2a7a72;
--air-primitive-info-10: #226962;
--air-primitive-info-11: #1a504b;
--air-primitive-info-12: #0c2825;
/* Private primitives: success — verde salvia / oliva */
--air-primitive-success-1: #f7faf3;
--air-primitive-success-2: #edf4e3;
--air-primitive-success-3: #d8eac6;
--air-primitive-success-4: #bedba5;
--air-primitive-success-5: #a1ca83;
--air-primitive-success-6: #82b760;
--air-primitive-success-7: #62a240;
--air-primitive-success-8: #4a8b2a;
--air-primitive-success-9: #3a781e;
--air-primitive-success-10: #316818;
--air-primitive-success-11: #255013;
--air-primitive-success-12: #12280a;
/* Private primitives: warning — ocre / ámbar dorado */
--air-primitive-warning-1: #fffaf2;
--air-primitive-warning-2: #fff3de;
--air-primitive-warning-3: #ffe5b8;
--air-primitive-warning-4: #fed28c;
--air-primitive-warning-5: #f8ba60;
--air-primitive-warning-6: #eca03a;
--air-primitive-warning-7: #d88620;
--air-primitive-warning-8: #c26e12;
--air-primitive-warning-9: #d6821e;
--air-primitive-warning-10: #be7318;
--air-primitive-warning-11: #9a5d12;
--air-primitive-warning-12: #4e2e08;
/* Private primitives: danger — rojo ladrillo / arcilla */
--air-primitive-danger-1: #fefcfb;
--air-primitive-danger-2: #fff4f1;
--air-primitive-danger-3: #fde0d8;
--air-primitive-danger-4: #fbc8bb;
--air-primitive-danger-5: #f8ac9b;
--air-primitive-danger-6: #f48e79;
--air-primitive-danger-7: #ea6f57;
--air-primitive-danger-8: #db5340;
--air-primitive-danger-9: #cf4430;
--air-primitive-danger-10: #b83a27;
--air-primitive-danger-11: #9a2e1f;
--air-primitive-danger-12: #4d160e;
/* Semantic surfaces */
--air-color-surface-default: var(--air-primitive-neutral-1);
--air-color-surface-raised: var(--air-primitive-neutral-3);
--air-color-surface-overlay: var(--air-primitive-neutral-4);
--air-color-overlay: rgb(32 28 18 / 0.42);
/* Semantic content */
--air-color-content-primary: var(--air-primitive-neutral-12);
--air-color-content-secondary: var(--air-primitive-neutral-10);
--air-color-content-disabled: var(--air-primitive-neutral-7);
--air-color-content-on-solid: #ffffff;
/* Semantic borders */
--air-color-border-subtle: var(--air-primitive-neutral-4);
--air-color-border-default: var(--air-primitive-neutral-6);
--air-color-border-strong: var(--air-primitive-neutral-8);
--air-color-focus-ring: var(--air-primitive-primary-8);
/* Primary palette */
--air-color-primary-track: var(--air-primitive-primary-1);
--air-color-primary-element: var(--air-primitive-primary-3);
--air-color-primary-hover: var(--air-primitive-primary-4);
--air-color-primary-active: var(--air-primitive-primary-5);
--air-color-primary-border: var(--air-primitive-primary-6);
--air-color-primary-solid: var(--air-primitive-primary-9);
--air-color-primary-solid-hover: var(--air-primitive-primary-10);
--air-color-primary-text: var(--air-primitive-primary-11);
--air-color-primary-contrast: var(--air-primitive-primary-12);
/* Neutral palette */
--air-color-neutral-track: var(--air-primitive-neutral-1);
--air-color-neutral-element: var(--air-primitive-neutral-3);
--air-color-neutral-hover: var(--air-primitive-neutral-4);
--air-color-neutral-active: var(--air-primitive-neutral-5);
--air-color-neutral-border: var(--air-primitive-neutral-6);
--air-color-neutral-solid: var(--air-primitive-neutral-9);
--air-color-neutral-solid-hover: var(--air-primitive-neutral-10);
--air-color-neutral-text: var(--air-primitive-neutral-11);
--air-color-neutral-contrast: var(--air-primitive-neutral-12);
/* Success palette */
--air-color-success-track: var(--air-primitive-success-1);
--air-color-success-element: var(--air-primitive-success-3);
--air-color-success-hover: var(--air-primitive-success-4);
--air-color-success-active: var(--air-primitive-success-5);
--air-color-success-border: var(--air-primitive-success-6);
--air-color-success-solid: var(--air-primitive-success-9);
--air-color-success-solid-hover: var(--air-primitive-success-10);
--air-color-success-text: var(--air-primitive-success-11);
--air-color-success-contrast: var(--air-primitive-success-12);
/* Warning palette */
--air-color-warning-track: var(--air-primitive-warning-1);
--air-color-warning-element: var(--air-primitive-warning-3);
--air-color-warning-hover: var(--air-primitive-warning-4);
--air-color-warning-active: var(--air-primitive-warning-5);
--air-color-warning-border: var(--air-primitive-warning-6);
--air-color-warning-solid: var(--air-primitive-warning-9);
--air-color-warning-solid-hover: var(--air-primitive-warning-10);
--air-color-warning-text: var(--air-primitive-warning-11);
--air-color-warning-contrast: var(--air-primitive-warning-12);
/* Danger palette */
--air-color-danger-track: var(--air-primitive-danger-1);
--air-color-danger-element: var(--air-primitive-danger-3);
--air-color-danger-hover: var(--air-primitive-danger-4);
--air-color-danger-active: var(--air-primitive-danger-5);
--air-color-danger-border: var(--air-primitive-danger-6);
--air-color-danger-solid: var(--air-primitive-danger-9);
--air-color-danger-solid-hover: var(--air-primitive-danger-10);
--air-color-danger-text: var(--air-primitive-danger-11);
--air-color-danger-contrast: var(--air-primitive-danger-12);
/* Info palette */
--air-color-info-track: var(--air-primitive-info-1);
--air-color-info-element: var(--air-primitive-info-3);
--air-color-info-hover: var(--air-primitive-info-4);
--air-color-info-active: var(--air-primitive-info-5);
--air-color-info-border: var(--air-primitive-info-6);
--air-color-info-solid: var(--air-primitive-info-9);
--air-color-info-solid-hover: var(--air-primitive-info-10);
--air-color-info-text: var(--air-primitive-info-11);
--air-color-info-contrast: var(--air-primitive-info-12);
/* Shadows — calidez marrón en lugar de negro neutro */
--air-shadow-0: none;
--air-shadow-1: 0 1px 2px rgb(32 20 10 / 0.08), 0 0 0 1px rgb(32 20 10 / 0.03);
--air-shadow-2: 0 4px 12px rgb(32 20 10 / 0.10), 0 1px 2px rgb(32 20 10 / 0.04);
--air-shadow-3: 0 12px 32px rgb(32 20 10 / 0.14), 0 4px 8px rgb(32 20 10 / 0.06);
}

@ -0,0 +1,23 @@
import type { AirTheme } from '../types.js';
// Earth solo sobreescribe los valores de transición JS que quiere personalizar.
// Las clases CSS de estado (stateClass) son siempre las mismas — el tema las
// personaliza únicamente via CSS bajo [data-theme='earth-light/dark'].
export const earthMotionPresets = {
revelation: {
enter: { duration: 220, easing: 'sineOut', opacity: true, distance: 6, stagger: 28 },
exit: { duration: 160, easing: 'sineIn', opacity: true, scale: 0.94 }
},
context: {
enter: { duration: 300, easing: 'sineOut', opacity: true, scale: 0.982, distance: 20 },
exit: { duration: 200, easing: 'sineIn', opacity: true, scale: 0.97 }
},
expansion: {
enter: { duration: 260, easing: 'sineOut', opacity: true },
exit: { duration: 200, easing: 'sineIn', opacity: true }
},
destruction: {
exit: { duration: 160, easing: 'sineIn', opacity: true, scale: 0.90 }
}
} satisfies AirTheme['motionPresets'];

@ -0,0 +1,14 @@
import type { AirTheme } from '../types.js';
export const earthSoundPresets = {
completion: {
enter: { layers: [{ src: '/sounds/ding.wav', volumeDb: -10 }] }
},
notification: {
enter: { layers: [{ src: '/sounds/ding.wav', volumeDb: -16, playbackRate: 0.85 }] }
},
attention: {
enter: { layers: [{ src: '/sounds/error.wav', volumeDb: -8 }] },
exit: { layers: [{ src: '/sounds/error.wav', volumeDb: -14, playbackRate: 0.7 }] }
}
} satisfies AirTheme['soundPresets'];

@ -1,5 +1,6 @@
@import './motion.css';
@import './fonts.css';
@import './base/index.css';
@import './earth/index.css';
@import '../tokens/index.css';

@ -1,7 +1,7 @@
export const AIR_THEME_ATTR = 'data-theme';
export const AIR_DEFAULT_THEME = 'base-light';
export type AirThemeId = 'base-light' | 'base-dark' | (string & {});
export type AirThemeId = 'base-light' | 'base-dark' | 'earth-light' | 'earth-dark' | (string & {});
export function setAirTheme(
theme: AirThemeId,
@ -15,3 +15,10 @@ export function getAirTheme(
): AirThemeId {
return (target.getAttribute(AIR_THEME_ATTR) as AirThemeId) ?? AIR_DEFAULT_THEME;
}
export type { AirTheme } from './types.js';
// ── Temas incluidos ──────────────────────────────────────────────────────────
export { BASE_LIGHT_THEME, BASE_DARK_THEME } from './base/index.js';
export { EARTH_LIGHT_THEME, EARTH_DARK_THEME } from './earth/index.js';

@ -0,0 +1,17 @@
import type { SoundPresets } from '$uix/air/internal/sound/types';
import type { MotionPresets } from '$uix/air/internal/motion/types';
/**
* Definición completa de un tema — agrupa las tres dimensiones de personalidad:
* - id: selector CSS (data-theme="...") — activa los tokens de color/sombra
* - soundPresets: personalidad sonora (opcional, hereda del tema base si no se define)
* - motionPresets: personalidad cinética (opcional, hereda del tema base si no se define)
*
* El cambio de tema en caliente es atómico: al actualizar el objeto AirTheme,
* las tres dimensiones cambian de forma sincronizada.
*/
export interface AirTheme {
id: string;
soundPresets?: SoundPresets;
motionPresets?: MotionPresets;
}

Binary file not shown.
Loading…
Cancel
Save

Powered by TurnKey Linux.