16 KiB
air
Arquitectura y diseño de la capa visual para src/uix/air.
1. Propósito y relación con terra
air no es terra con CSS encima. Es la capa visual del sistema: define tokens, aplica estilos y expone una API declarativa para el consumidor. terra resuelve comportamiento y accesibilidad. air resuelve presentación.
La relación entre ambas capas es asimétrica y deliberada:
airconoceterra: consume su anatomía, susdata-*attrs y sus estados.terrano conoceair: no importa nada deair, no depende de ningún token visual.
Regla central. Si algo en
airnecesita queterracambie su comportamiento, hay un error de diseño. La dependencia solo fluye en una dirección.
2. Sistema de tokens en tres capas
El error más común en design systems es tener un solo nivel de tokens donde todo es una variable CSS directa. air usa tres capas con responsabilidades distintas.
2.1 Primitive tokens
Valores absolutos. Son la escala base del sistema. Ningún componente los usa directamente.
/* tokens/primitive.css */
:root {
--blue-1: #eff6ff;
--blue-9: #6e56cf;
--blue-12: #1d1930;
--gray-1: #fcfcfd;
--gray-6: #d9dce3;
--gray-7: #c9ced6;
--gray-12: #111827;
--space-1: 4px;
--space-2: 8px;
--space-4: 16px;
--radius-1: 4px;
--radius-2: 6px;
--radius-3: 8px;
}
2.2 Semantic tokens
Apuntan a primitive tokens y cambian según el tema activo. Son la única capa que conoce el concepto de tema. Los componentes pueden usarlos, pero se prefiere que usen component tokens.
/* tokens/semantic.css */
:root,
[data-theme='light'] {
--color-accent: var(--blue-9);
--color-accent-muted: var(--blue-3);
--color-surface: var(--gray-1);
--color-text-primary: var(--gray-12);
--color-border: var(--gray-6);
--radius-md: var(--radius-2);
}
[data-theme='dark'] {
--color-accent: var(--blue-10);
--color-surface: var(--gray-2);
--color-text-primary: var(--gray-1);
--color-border: var(--gray-7);
}
2.3 Component tokens
Scoped a cada componente. Son el único nivel que el CSS de un componente debe escribir. Permiten override quirúrgico sin tocar la escala global.
/* tokens/component/button.css */
.btn {
height: var(--btn-height);
padding-inline: var(--btn-px);
font-size: var(--btn-font-size);
background: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid var(--btn-border);
border-radius: var(--btn-radius, var(--radius-md));
}
Regla de componentes. El CSS de un componente de
airsolo escribevar(--btn-*),var(--input-*), etc. Nunca toca--blue-9ni tokens primitivos directamente.
3. El contrato con terra: data-* como API pública
terra genera data-* attrs en todos sus estados. Esa es exactamente la superficie que air necesita consumir. El CSS de air no necesita nada más para responder a estados del componente.
3.1 data-* attrs de contrato previstos en terra
| Atributo | Cuándo aparece | Uso típico en air |
|---|---|---|
data-disabled |
Componente deshabilitado | opacity: .5; pointer-events: none |
data-invalid |
Valor no válido | color: var(--color-error) |
data-open / data-closed |
Overlay visible o no | display: block / none |
data-highlighted |
Item bajo cursor o foco | background: var(--color-accent-muted) |
data-selected |
Item seleccionado | font-weight: 500 |
data-placement |
top | bottom | left | right |
transform para animar entrada |
data-orientation |
horizontal | vertical |
flex-direction del contenedor |
3.2 Cómo air consume esos attrs
/* air responde a estados sin saber cómo terra los produce */
[data-disabled] { opacity: .5; pointer-events: none; }
[data-invalid] { --input-border: var(--color-error); }
[data-open] { display: block; }
[data-highlighted] { background: var(--color-accent-muted); }
[data-selected] { font-weight: 500; }
/* posicionamiento de overlays */
[data-placement='top'] { transform-origin: bottom center; }
[data-placement='bottom'] { transform-origin: top center; }
Advertencia crítica. Los
data-*deterrason API pública con el mismo peso que los tipos TypeScript, pero deben formalizarse componente a componente. Siterrarenombradata-openadata-expanded, el CSS deairrompe silenciosamente sin ningún error de compilación. Tratar ese contrato con el mismo rigor que las props tipadas.
4. Anatomía pública por componente
air no solo depende de data-* attrs: también depende de la anatomía de terra. Qué partes existen, cómo se llaman, cuáles son raíz y cuáles hijas.
Para cada componente que air estiliza, debe estar documentado:
- qué partes existen (
Root,Trigger,Content,Item, etc.) - qué
data-*garantiza cada parte - qué parte es el root visual (la que recibe los component tokens)
- qué partes pueden recibir snippets custom del consumidor
4.1 Ejemplo: anatomía de Input / Field
Field.Root ← crea contexto, recibe props globales
Field.Label ← [data-invalid], [data-disabled], [data-required]
Field.Input ← [data-invalid], [data-disabled], [data-readonly]
Field.Description ← ayuda contextual
Field.Error ← visible cuando [data-invalid] en el Root
Esta anatomía es contrato estable solo si terra la declara como pública. air asume que esas partes existen con esos nombres cuando están documentadas como soportadas. Si terra renombra Field.Error a Field.Message, air necesita actualizarse.
5. Variantes: desacoplando dimensiones
El problema de las variantes no es cuántas hay, sino cómo se combinan. Con múltiples dimensiones ortogonales (variant × size × color × state), el número de combinaciones explota si el CSS las trata como casos individuales.
La solución es diseñar los component tokens de forma que cada dimensión sea independiente:
sizedefine tokens de dimensión:height,padding,font-sizevariantdefine tokens de apariencia:background,color,borderstateaplica overrides transversales:disabled,invalid,loading
/* size solo toca dimensiones */
[data-size='1'] { --btn-height: 28px; --btn-px: 10px; --btn-font-size: 12px; }
[data-size='2'] { --btn-height: 36px; --btn-px: 14px; --btn-font-size: 14px; }
[data-size='3'] { --btn-height: 44px; --btn-px: 18px; --btn-font-size: 16px; }
/* variant solo toca apariencia */
[data-variant='solid'] { --btn-bg: var(--color-accent); --btn-fg: white; --btn-border: transparent; }
[data-variant='outline'] { --btn-bg: transparent; --btn-fg: var(--color-accent); --btn-border: var(--color-accent); }
[data-variant='soft'] { --btn-bg: var(--color-accent-muted); --btn-fg: var(--color-accent-text); --btn-border: transparent; }
/* state no necesita conocer variant ni size */
[data-disabled] { opacity: .5; pointer-events: none; }
[data-loading] { --btn-fg: transparent; }
Esto escala a N variantes × M tamaños × K estados sin escribir combinaciones. Cada dimensión toca solo su propio conjunto de tokens.
6. Theming: el tema es un atributo DOM, no estado JS
El store de Svelte es solo el mecanismo para cambiar data-theme en el elemento raíz. El theming real vive en CSS. El store no contiene colores ni tokens: solo el nombre del tema activo.
// theme/index.ts
import { writable } from 'svelte/store';
export const theme = writable<'light' | 'dark'>('light');
export function setTheme(next: 'light' | 'dark') {
theme.set(next);
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
La ventaja de este enfoque es que el tema puede ser local a cualquier contenedor, no solo global. Poner data-theme='dark' en cualquier div crea un subtema sin JS adicional.
6.1 accentColor dinámico
Para paletas de acento personalizadas, el sistema puede derivar la escala desde un color base usando color-mix():
[data-accent='custom'] {
--accent-base: #e54d2e;
--color-accent: var(--accent-base);
--color-accent-muted: color-mix(in oklch, var(--accent-base) 15%, white);
--color-accent-text: color-mix(in oklch, var(--accent-base) 80%, black);
}
Nota de compatibilidad.
color-mix()conoklchtiene soporte amplio en navegadores modernos (Chrome 111+, Firefox 113+, Safari 16.2+). Para proyectos que requieran soporte más amplio, evaluar generación de escalas en build time como alternativa o complemento. No tomar este enfoque como dogma: es la dirección correcta para sistemas nuevos, pero necesita validación de requisitos antes de adoptarlo como base única.
7. CSS scoped de Svelte: la trampa más común
Los <style> scoped de Svelte no atraviesan el boundary de componente. Si air usa <style scoped>, los selectores basados en data-* no llegan a los elementos que renderiza terra.
La regla es clara:
- tokens (primitive, semantic, component): CSS global
- reglas basadas en
data-*: CSS global - estilos verdaderamente locales al nodo del wrapper de
air: scoped
<!-- components/input/input.svelte -->
<script>
import './input.css'; // global: contiene data-* rules y component tokens
let { variant = 'outline', size = '2', ...rest } = $props();
</script>
<!-- terra.Field.Input ya aplica los data-* attrs desde su State -->
<Field.Input data-variant={variant} data-size={size} {...rest} />
<style>
/* solo estilos que no dependen de data-* ni de terra */
</style>
7.1 Contrato formal entre terra y air
Antes de estilizar un componente en air, debe quedar explícito qué parte del componente es pública para consumo visual y qué parte es incidental.
Pública
- anatomía documentada (
Root,Trigger,Content,Item, etc.) data-*attrs documentados para cada parte- atributos de variante y tamaño que
airpuede añadir (data-variant,data-size, etc.)
No pública
- clases generadas por Svelte
- orden incidental del DOM que no esté documentado
- wrappers internos añadidos por comodidad de implementación
- nombres de variables locales o detalles del State de
terra
Regla operativa. Si un selector de
airnecesita una clase interna o un wrapper no documentado deterra, el contrato está mal definido.
7.2 Qué no debe hacer air
Para mantener la separación entre capas, air no debe:
- cambiar comportamiento que pertenece a
terra - depender de clases internas o estructura incidental del DOM
- leer estado interno de clases
State - definir lógica de accesibilidad, foco o keyboard navigation
- introducir tokens visuales dentro de
terra
air sí puede:
- consumir
data-*attrs públicos - añadir
data-variant,data-sizeo attrs equivalentes de presentación - mapear tokens semánticos a component tokens
- ofrecer wrappers finos y declarativos sobre componentes de
terra
8. Estructura de carpetas
src/uix/air/
tokens/
primitive.css ← escala absoluta de colores, espacio, radio
semantic.css ← surface, accent, text, border por tema
component/
button.css ← define --btn-* tokens
input.css ← define --input-* tokens
field.css ← define --field-* tokens
select.css ← define --select-* tokens
index.css ← barrel: importa todos los tokens
theme/
index.ts ← store + setTheme() + setAccent()
components/
button/
button.svelte ← wrapper fino sobre terra.Button
button.css ← aplica tokens a la anatomía real de terra
input/
input.svelte
input.css ← aplica --input-* sobre data-* attrs públicos
field/
field.svelte ← wrapper de Field.Root
field.css ← aplica --field-* sobre data-* attrs públicos
exports.ts
index.ts
Diferencia importante.
tokens/component/*.cssdefine qué tokens existen por componente.components/*/*.cssaplica esos tokens a la anatomía concreta deterra. No deben mezclarse ambas responsabilidades en un mismo archivo por comodidad.
9. Estrategia de implementación incremental
No tokenizar todos los componentes desde el principio. Los component tokens son trabajo de mantenimiento real. Crear solo los que tienen evidencia de que el consumidor va a necesitar override.
Fase 1: piloto con Input / Field
- definir
primitive.cssysemantic.csscompletos - component tokens solo para
FieldyInput - validar que el contrato con
terrafunciona viadata-* - validar theming anidado con
data-themeen contenedor
Fase 2: expandir con evidencia
- añadir component tokens por componente según necesidad real
- no adelantar tokens que nadie ha pedido sobrescribir
- documentar qué tokens son API pública en cada componente
10. Diferencias respecto a Radix Themes
| Aspecto | Radix Themes | air |
|---|---|---|
| Capas de tokens | 2 niveles (primitives + semánticos) | 3 niveles + component tokens scoped |
| Customización de componente | Sobrescribir clases internas | Override de --component-* tokens |
| Temas anidados | className='dark' en contenedor |
data-theme en cualquier contenedor |
| Dependencia de JS | Context + estado para theming | Solo para cambiar atributo DOM |
| AccentColor personalizado | Redefinir escala entera (9+ vars) | Una variable base, escala derivada |
| Contrato con headless | Implícito en clases internas | Explícito via data-* como API pública |
| CSS scoped | N/A (React) | Global para tokens y data-* rules |
11. Checklist: componente Input / Field
Antes de considerar terminado cualquier componente de air, verificar cada punto.
11.1 Tokens
primitive.csstiene la escala completa de colores, espacio y radiosemantic.cssmapea correctamente para light y darkcomponent/field.cssdefine--field-gap,--field-label-size,--field-error-colorcomponent/input.cssdefine--input-height,--input-px,--input-border,--input-bg,--input-radius- Ningún selector en
field.cssoinput.cssusa--blue-9ni tokens primitivos directamente
11.2 Contrato con terra
- El CSS de
airresponde a[data-disabled]enField.Rooty enField.Input - El CSS de
airresponde a[data-invalid]enField.Root(cambia border e icono de error) Field.Errorpuede estilizarse o mostrarse a partir de[data-invalid]del root, sin asumir clases internasField.Labelrefleja[data-required]siterralo expone- No hay ningún selector que dependa de clases generadas por Svelte scoped
11.3 Variantes y tamaños
- Los tokens de
sizesolo tocan dimensiones:height,padding,font-size - Los tokens de
variantsolo tocan apariencia:bg,color,border - El estado
[data-disabled]funciona igual en todas las variantes sin CSS específico por combinación - El estado
[data-invalid]funciona igual en todos los tamaños
11.4 Theming
- El componente cambia correctamente con
data-theme='dark'en un contenedor padre - No hay colores hardcodeados en el CSS del componente
- El tema anidado (dark dentro de light) funciona sin JS adicional
11.5 CSS global vs scoped
- Los archivos
.cssde tokens y variantes son importados como módulos globales - Los selectores
data-*no están dentro de<style scoped>en ningún.svelte - Si hay estilos scoped, son solo para propiedades que no dependen de
data-*ni deterra
11.6 API pública
exports.tsexpone el componente con su nombre correcto- Los component tokens de
InputyFieldestán documentados (qué tokens son override permitido) - Existe una demo en
src/routes/test/field/con: disabled, invalid, readonly, dark theme, tamaños
11.7 Limitaciones conocidas
- Si se usa
color-mix(), está documentado el requisito de navegador mínimo - Si se generan escalas en build time, el pipeline está documentado
- El comportamiento cuando
terrarenombra undata-*está contemplado en la demo
Si actualizamos la arquitectura de air, este documento también debe actualizarse. El contrato con terra es tan estable como queramos que sea.