Expand air component system and translator integration

glm-5
dev 6 months ago
parent b30092dd06
commit 5eda0c6aef

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

@ -15,6 +15,7 @@ export type DefaultLocale = 'es';
export type SupportedLocale =
| DefaultLocale
| 'en'
| 'ar'
| 'de'
| 'fr'
| 'it'
@ -194,4 +195,4 @@ export type LingInstance<S extends LingNode = LingNode> = {
export interface LingLogger {
warn : (category: string, message: string) => void;
error: (category: string, message: string) => void;
}
}

@ -1,6 +1,7 @@
<script lang="ts">
import '$uix/air/tokens/index.css';
import '$uix/air/themes/index.css';
import { createLing } from '$lib/ling/lib';
import { setContext } from 'svelte';
import {
AIR_DEFAULT_THEME,
@ -16,15 +17,19 @@
createTerraDateTimeFormatter,
createTerraNumberFormatter,
createTerraTranslator,
extendTerraTranslationModule,
createTerraUnitFormatter,
} from '$uix/terra/config/exports';
import { extendAirTranslationModule } from '$uix/air';
import { AIR_DEMO_CONTEXT_KEY, type AirDemoContext } from './demo-context';
import {
AIR_DEMO_AIR_LING_NODE,
AIR_DEMO_LOCALES,
AIR_DEMO_TERRA_LING_NODE,
type AirDemoLocaleId,
getAirDemoLocale,
translateAirDemoPath,
translateAirDemoValue,
translateAirDemoLingPath,
translateAirDemoLingValue,
} from './demo-locale';
let { children } = $props();
@ -38,13 +43,22 @@
const localeOptions = AIR_DEMO_LOCALES;
const currentLocale = $derived(getAirDemoLocale(localeId));
const ling = createLing({}, 'es');
const themeLabel = (t: AirTheme) => t.id.replace('-', ' ');
extendTerraTranslationModule(ling, AIR_DEMO_TERRA_LING_NODE);
extendAirTranslationModule(ling);
ling.extend('air', AIR_DEMO_AIR_LING_NODE);
const translator = createTerraTranslator({
locale: () => currentLocale.id,
translate: () => (value) => translateAirDemoValue(localeId, value),
translatePath: () => (path, ...args) => translateAirDemoPath(localeId, path, ...args),
// El translator necesita leer una señal reactiva para invalidar el render
// cuando cambia el locale en la demo. `ling.getLocale()` por sí solo no lo es.
locale: () => currentLocale.lingLocale,
translate: () => (value) => translateAirDemoLingValue(ling, currentLocale.lingLocale, value),
translatePath: () => (path, ...args) =>
translateAirDemoLingPath(ling, currentLocale.lingLocale, path, ...args),
runtime: () => ling,
});
const dateTimeFormatter = createTerraDateTimeFormatter({
@ -85,22 +99,33 @@
const airDemoContext: AirDemoContext = {
localeId: () => localeId,
dir: () => currentLocale.dir,
t: (path, ...args) => translateAirDemoPath(localeId, path, ...args),
t: (path, ...args) => translateAirDemoLingPath(ling, currentLocale.lingLocale, path, ...args),
tv: (value) => translateAirDemoLingValue(ling, currentLocale.lingLocale, value),
};
setContext(AIR_DEMO_CONTEXT_KEY, airDemoContext);
const t = (path: string, ...args: unknown[]) =>
translateAirDemoLingPath(ling, currentLocale.lingLocale, path, ...args);
useAir({
get translator() { return translator; },
get dateTimeFormatter() { return dateTimeFormatter; },
get numberFormatter() { return numberFormatter; },
get currencyFormatter() { return currencyFormatter; },
get unitFormatter() { return unitFormatter; },
get dir() { return currentLocale.dir; },
get soundEnabled() { return soundEnabled; },
get motionEnabled() { return motionEnabled; },
get soundPresets() { return currentTheme.soundPresets; },
get motionPresets() { return currentTheme.motionPresets; },
get translator() { return translator; },
get presentation() {
return {
dir: currentLocale.dir,
dateTimeFormatter: dateTimeFormatter,
numberFormatter: numberFormatter,
currencyFormatter: currencyFormatter,
unitFormatter: unitFormatter,
};
},
get behavior() {
return {
soundEnabled,
motionEnabled,
soundPresets: currentTheme.soundPresets,
motionPresets: currentTheme.motionPresets,
};
},
});
$effect(() => {
@ -111,7 +136,7 @@
</script>
<svelte:head>
<title>Air Tests</title>
<title>{t('air.demo.shell.playground')}</title>
</svelte:head>
<div class="air-demo-shell" data-theme={currentTheme.id} lang={currentLocale.id} dir={currentLocale.dir}>
@ -121,17 +146,17 @@
<a href="/test/air" class="air-demo-brand">
<span class="air-demo-brand-eye">Air</span>
<span class="air-demo-brand-sep">/</span>
<span class="air-demo-brand-name">Playground</span>
<span class="air-demo-brand-name">{t('air.demo.shell.playground')}</span>
</a>
<nav class="air-demo-nav" aria-label="Air demo sections">
<a href="/test">Índice</a>
<nav class="air-demo-nav" aria-label={t('air.demo.shell.navLabel')}>
<a href="/test">{t('air.demo.shell.index')}</a>
<a href="/test/terra">Terra</a>
<a href="/test/air">Air</a>
</nav>
<div class="air-demo-controls">
<div class="air-demo-ctrl-group" aria-label="Locale">
<div class="air-demo-ctrl-group" aria-label={t('air.demo.shell.locale')}>
{#each localeOptions as item}
<button
type="button"
@ -142,7 +167,7 @@
{/each}
</div>
<div class="air-demo-ctrl-sep" aria-hidden="true"></div>
<div class="air-demo-ctrl-group" aria-label="Theme">
<div class="air-demo-ctrl-group" aria-label={t('air.demo.shell.theme')}>
{#each THEMES as t}
<button
type="button"
@ -153,21 +178,21 @@
{/each}
</div>
<div class="air-demo-ctrl-sep" aria-hidden="true"></div>
<div class="air-demo-ctrl-group" aria-label="Motion and sound">
<div class="air-demo-ctrl-group" aria-label={t('air.demo.shell.motionAndSound')}>
<button
type="button"
class="air-demo-chip"
class:air-demo-chip--active={motionEnabled}
onclick={() => (motionEnabled = !motionEnabled)}
title="Toggle motion"
>motion</button>
title={t('air.demo.shell.toggleMotion')}
>{t('air.demo.shell.motion')}</button>
<button
type="button"
class="air-demo-chip"
class:air-demo-chip--active={soundEnabled}
onclick={() => (soundEnabled = !soundEnabled)}
title="Toggle sound"
>sound</button>
title={t('air.demo.shell.toggleSound')}
>{t('air.demo.shell.sound')}</button>
</div>
</div>
</header>

@ -1,9 +1,14 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from './demo-context';
import { AIR_DEMO_CONTEXT_KEY } from './demo-context';
type DemoItem = {
name: string;
name: LingString;
path: string;
status?: 'stable' | 'wip' | 'planned';
description?: string;
description?: LingString;
};
type DemoGroup = {
@ -12,16 +17,24 @@
items: DemoItem[];
};
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const t = (path: string, ...args: unknown[]) => demoContext?.t(path, ...args) ?? path;
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
const groups: DemoGroup[] = [
{
title: 'Foundation',
description: 'Piezas base para validar el lenguaje visual, el tema y la estructura de Air.',
title: 'air.demo.hub.group.foundation.title',
description: 'air.demo.hub.group.foundation.description',
items: [
{
name: 'Surfaces',
name: { es: 'Surfaces', en: 'Surfaces', ar: 'الأسطح' },
path: '/test/air/surfaces',
status: 'stable',
description: 'Banco de pruebas para superficies, bordes, alturas visuales y contraste.'
description: {
es: 'Banco de pruebas para superficies, bordes, alturas visuales y contraste.',
en: 'Test bench for surfaces, borders, visual elevation and contrast.',
ar: 'منصة اختبار للأسطح والحدود والارتفاع البصري والتباين.',
}
},
{
name: 'Typography',
@ -65,30 +78,64 @@
path: '/test/air/sounds',
status: 'wip',
description: 'Sistema de sonido: 7 semánticas, enter/exit asimétricos, Web Audio API.'
},
{
name: 'Ling',
path: '/test/air/ling',
status: 'stable',
description: {
es: 'Puente real entre air, terra y ling con el catálogo oficial ya registrado.',
en: 'Real bridge between air, terra and ling with the official catalog already registered.',
ar: 'جسر فعلي بين air و terra و ling مع تسجيل الكتالوج الرسمي بالفعل.',
}
}
]
},
{
title: 'Core Components',
description: 'Segundo bloque de validacion una vez cerrado Button.',
title: 'air.demo.hub.group.core.title',
description: 'air.demo.hub.group.core.description',
items: [
{
name: 'Field/Input',
path: '#',
status: 'planned',
description: 'Valida tipografia, focus, estados y superficie de formulario.'
path: '/test/air/field',
status: 'wip',
description: 'Bloque composable sobre terra.Field: label, control, input, helper, error y adornments.'
},
{
name: 'Select',
path: '/test/air/select',
status: 'wip',
description: 'Select dropdown sobre terra.Select con trigger, groups, viewport e items del sistema.'
},
{
name: 'Switch',
path: '/test/air/switch',
status: 'wip',
description: 'Toggle binario sobre terra.Switch con tamaño, color y feedback del sistema.'
},
{
name: 'Drawer',
path: '/test/air/drawer',
status: 'wip',
description: 'Panel lateral o inferior sobre terra.Drawer con side, tamaño y superficie propia.'
},
{
name: 'Dialog',
path: '#',
status: 'planned',
description: 'Valida overlay, elevacion, motion y jerarquia de superficies.'
path: '/test/air/dialog',
status: 'wip',
description: 'Valida overlay, elevacion, motion contextual y jerarquia de superficies.'
},
{
name: 'Tabs',
path: '#',
status: 'planned',
path: '/test/air/tabs',
status: 'wip',
description: 'Valida navegacion, densidad y estados activos.'
},
{
name: 'Toast',
path: '/test/air/toast',
status: 'wip',
description: 'Notificaciones transitorias con store global, variantes semánticas y acción opcional.'
}
]
}
@ -115,30 +162,27 @@
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air</p>
<h1>Visual demo hub</h1>
<p class="lead">
Base nueva para las demos de `air`, ya montada sobre su propio contrato visual, tokens y
tema. A partir de aqui los componentes nuevos nacen en su ecosistema real.
</p>
<div class="hero-signals" aria-label="Air principles">
<span>Theme-aware</span>
<span>Locale-aware</span>
<span>Motion-aware</span>
<h1>{t('air.demo.hub.title')}</h1>
<p class="lead">{t('air.demo.hub.lead')}</p>
<div class="hero-signals" aria-label={t('air.demo.hub.principles')}>
<span>{t('air.demo.hub.themeAware')}</span>
<span>{t('air.demo.hub.localeAware')}</span>
<span>{t('air.demo.hub.motionAware')}</span>
</div>
</div>
<div class="stats" aria-label="Air demo stats">
<div class="stats" aria-label={t('air.demo.hub.stats')}>
<div class="stat">
<span>Total</span>
<span>{t('air.demo.hub.total')}</span>
<strong>{totals.all}</strong>
</div>
<div class="stat stat--ok">
<span>Activos</span>
<span>{t('air.demo.hub.active')}</span>
<strong>{totals.stable}</strong>
</div>
<div class="stat stat--planned">
<span>Planned</span>
<span>{t('air.demo.hub.planned')}</span>
<strong>{totals.planned}</strong>
</div>
</div>
@ -146,21 +190,18 @@
<section class="intro-notes">
<div class="intro-note">
<h2>Qué valida este hub</h2>
<h2>{t('air.demo.hub.validatesTitle')}</h2>
<ul>
<li>El contrato visual de `air` y su tema base</li>
<li>La tipografía nueva: Instrument Sans + Azeret Mono</li>
<li>La separación entre `air`, `terra` y temas</li>
<li>La futura familia de componentes faro</li>
<li>{t('air.demo.hub.validates.contract')}</li>
<li>{t('air.demo.hub.validates.type')}</li>
<li>{t('air.demo.hub.validates.split')}</li>
<li>{t('air.demo.hub.validates.families')}</li>
</ul>
</div>
<div class="intro-note">
<h2>Componentes faro</h2>
<p>
La base visual se considera validada cuando pase bien por Button, Field/Input, Dialog y
Tabs.
</p>
<h2>{t('air.demo.hub.showsTitle')}</h2>
<p>{t('air.demo.hub.showsLead')}</p>
</div>
</section>
@ -169,8 +210,8 @@
<section class="group-card">
<header class="group-header">
<div>
<p>{group.title}</p>
<h2>{group.description}</h2>
<p>{t(group.title)}</p>
<h2>{t(group.description)}</h2>
</div>
</header>
@ -179,20 +220,20 @@
{#if item.path === '#'}
<div class="demo-link demo-link--static">
<div class="demo-link-copy">
<strong>{item.name}</strong>
<p>{item.description}</p>
<strong>{tv(item.name)}</strong>
<p>{item.description ? tv(item.description) : ''}</p>
</div>
<span class="badge badge--planned">Planned</span>
<span class="badge badge--planned">{t('air.demo.hub.badge.planned')}</span>
</div>
{:else}
<a class="demo-link" href={item.path}>
<div class="demo-link-copy">
<strong>{item.name}</strong>
<p>{item.description}</p>
<strong>{tv(item.name)}</strong>
<p>{item.description ? tv(item.description) : ''}</p>
</div>
<div class="demo-link-meta">
{#if item.status === 'stable'}
<span class="badge badge--ok">Ready</span>
<span class="badge badge--ok">{t('air.demo.hub.badge.ready')}</span>
{:else}
<span class="badge badge--wip">WIP</span>
{/if}
@ -474,8 +515,7 @@
}
@media (max-width: 900px) {
.hero,
.intro-grid {
.hero {
grid-template-columns: 1fr;
}
}

@ -9,7 +9,7 @@
let color = $state<'primary' | 'neutral' | 'success' | 'warning' | 'danger' | 'info'>('primary');
let intent = $state<'' | 'primary' | 'neutral' | 'success' | 'warning' | 'danger' | 'info'>('');
let size = $state<'2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'>('md');
let rounded = $state<'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'>('md');
let rounded = $state<'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'full'>('md');
let block = $state(false);
let iconOnly = $state(false);
let loading = $state(false);
@ -22,7 +22,7 @@
const colors = ['primary', 'neutral', 'success', 'warning', 'danger', 'info'] as const;
const intents = ['', 'primary', 'neutral', 'success', 'warning', 'danger', 'info'] as const;
const sizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl'] as const;
const roundedValues = ['sm', 'md', 'lg', 'xl', '2xl', 'full'] as const;
const roundedValues = ['sm', 'md', 'lg', 'xl', 'xxl', 'full'] as const;
const placements = ['start', 'end'] as const;
type ButtonVariant = (typeof variants)[number];
@ -164,6 +164,14 @@
<span>Resolution</span>
<strong>{resolvedMode}</strong>
</div>
<div>
<span>DOM</span>
<strong>button + optional icon span + label span + spinner slot</strong>
</div>
<div>
<span>API</span>
<strong>variant · color · intent · size · rounded · loading · iconOnly</strong>
</div>
</div>
</header>
@ -672,10 +680,6 @@
color: var(--air-color-text-secondary);
}
.panel--feature {
/* no extra bg needed when panel is just a separator */
}
.eyebrow {
margin: 0;
font-family: var(--air-font-mono);
@ -889,4 +893,3 @@
}
}
</style>

@ -1,7 +1,10 @@
import type { LingString } from '$lib/ling/lib/types';
export const AIR_DEMO_CONTEXT_KEY = Symbol('air-demo-context');
export type AirDemoContext = {
localeId: () => string;
dir: () => 'ltr' | 'rtl';
t: (path: string, ...args: unknown[]) => string;
tv: (value: LingString) => string;
};

@ -1,12 +1,21 @@
import type { DateOrder, HourCycle } from '$uix/terra/external/dates';
import type {
LingInstance,
LingNode,
LingRecord,
SupportedLocale,
LingString,
} from '$lib/ling/lib/types';
type DemoMessages = Record<string, string>;
export type AirDemoLocaleId = 'en-US' | 'es-ES' | 'ar-EG';
export type AirDemoLingLocale = Extract<SupportedLocale, 'en' | 'es' | 'ar'>;
export type AirDemoLocaleOption = {
id: AirDemoLocaleId;
label: string;
lingLocale: AirDemoLingLocale;
dir: 'ltr' | 'rtl';
dateOrder: DateOrder;
timeFormat: HourCycle;
@ -19,6 +28,7 @@ export const AIR_DEMO_LOCALES: AirDemoLocaleOption[] = [
{
id: 'en-US',
label: 'EN',
lingLocale: 'en',
dir: 'ltr',
dateOrder: 'MDY',
timeFormat: 12,
@ -39,17 +49,21 @@ export const AIR_DEMO_LOCALES: AirDemoLocaleOption[] = [
'terra.toast.regionLabel': 'Notifications',
'terra.pagination.previous': 'Previous page',
'terra.pagination.next': 'Next page',
'terra.pagination.page': 'Page {0}',
'terra.pagination.page': 'Page {{0}}',
'terra.numberField.increment': 'Increment',
'terra.numberField.decrement': 'Decrement',
'terra.tagsInput.clearAll': 'Clear all tags',
'terra.tagsInput.remove': 'Remove {0}',
'terra.tagsInput.remove': 'Remove {{0}}',
'terra.command.loading': 'Loading',
'terra.splitter.resizeHandle': 'Resize handle',
'terra.ratingGroup.label': 'Rating',
'air.dialog.close': 'Close dialog',
'air.code-block.copy': 'Copy',
'air.code-block.copied': 'Copied',
'air.code-block.copy-aria': 'Copy code',
'air.spinner.loadingContent': 'Loading content',
'air.spinner.loadingWith': 'Loading with {0}',
'air.spinner.loadingSize': 'Loading {0}',
'air.spinner.loadingWith': 'Loading with {{0}}',
'air.spinner.loadingSize': 'Loading {{0}}',
'air.spinner.publishingRelease': 'Publishing release',
'air.spinner.preparingWorkspace': 'Preparing workspace',
'air.spinner.syncingBackgroundData': 'Syncing background data',
@ -74,14 +88,66 @@ export const AIR_DEMO_LOCALES: AirDemoLocaleOption[] = [
'air.button.addProject': 'Add project',
'air.button.saving': 'Saving',
'air.button.uploading': 'Uploading',
'air.button.buttonSize': 'Button {0}',
'air.button.rounded': 'Rounded {0}',
'air.text.clampHint': '+{0} more {1}',
'air.button.buttonSize': 'Button {{0}}',
'air.button.rounded': 'Rounded {{0}}',
'air.icon.all': 'All',
'air.demo.shell.playground': 'Playground',
'air.demo.shell.navLabel': 'Air demo sections',
'air.demo.shell.index': 'Index',
'air.demo.shell.locale': 'Locale',
'air.demo.shell.theme': 'Theme',
'air.demo.shell.motionAndSound': 'Motion and sound',
'air.demo.shell.toggleMotion': 'Toggle motion',
'air.demo.shell.toggleSound': 'Toggle sound',
'air.demo.shell.motion': 'motion',
'air.demo.shell.sound': 'sound',
'air.demo.hub.title': 'Visual demo hub',
'air.demo.hub.lead': 'Fresh base for `air` demos, already mounted on its own visual contract, tokens and theme. From here, new components are born in their real ecosystem.',
'air.demo.hub.principles': 'Air principles',
'air.demo.hub.themeAware': 'Theme-aware',
'air.demo.hub.localeAware': 'Locale-aware',
'air.demo.hub.motionAware': 'Motion-aware',
'air.demo.hub.stats': 'Air demo stats',
'air.demo.hub.total': 'Total',
'air.demo.hub.active': 'Active',
'air.demo.hub.planned': 'Planned',
'air.demo.hub.validatesTitle': 'What this hub validates',
'air.demo.hub.validates.contract': '`air` visual contract and its base theme',
'air.demo.hub.validates.type': 'Current typography: Instrument Sans + Lora + Azeret Mono',
'air.demo.hub.validates.split': 'The split between `air`, `terra` and themes',
'air.demo.hub.validates.families': 'Current families: foundation, typography, actions, feedback and forms',
'air.demo.hub.showsTitle': 'What demos show',
'air.demo.hub.showsLead': '`air` pages are no longer just playgrounds: they also document public API, DOM shape and the visual contract expected at runtime.',
'air.demo.hub.badge.ready': 'Ready',
'air.demo.hub.badge.planned': 'Planned',
'air.demo.hub.group.foundation.title': 'Foundation',
'air.demo.hub.group.foundation.description': 'Base pieces to validate Air visual language, theme and structure.',
'air.demo.hub.group.core.title': 'Core Components',
'air.demo.hub.group.core.description': 'Second validation block once Button is closed.',
'air.demo.ling.title': 'Official catalog and real translation bridge',
'air.demo.ling.lead': 'This page validates that `air` demos no longer translate through a local mapper, but through a real `ling` instance wired into the `terra` translator.',
'air.demo.ling.uiLocale': 'UI locale',
'air.demo.ling.lingLocale': 'Ling locale',
'air.demo.ling.direction': 'Direction',
'air.demo.ling.namespaces': 'Namespaces',
'air.demo.ling.bridge.title': 'Bridge',
'air.demo.ling.bridge.lead': 'The demo layout boots `ling`, registers the official `air` catalog, adds the demo module and exposes everything to `terra` through `createTerraTranslator(...)`.',
'air.demo.ling.bridge.codeTitle': 'Air demo layout bridge',
'air.demo.ling.result': 'Result',
'air.demo.ling.resultLead': 'Internal components resolve `#?air.*|fallback`, and demo pages resolve `translatePath(\'air.*\')` against the same `ling` instance.',
'air.demo.ling.official.title': 'Official Air keys',
'air.demo.ling.official.lead': 'These are the internal keys `air` already uses at runtime. This checks resolution from inline IDs, not loose strings.',
'air.demo.ling.demo.title': 'Demo extension',
'air.demo.ling.demo.lead': 'Specimen pages add their own keys under the same `air` namespace, but already in real `ling` shape.',
'air.demo.ling.noParams': 'no params',
'air.demo.ling.schemaOfficial': 'Official airLingNode',
'air.demo.ling.schemaDemo': 'Demo air module',
},
},
{
id: 'es-ES',
label: 'ES',
lingLocale: 'es',
dir: 'ltr',
dateOrder: 'DMY',
timeFormat: 24,
@ -102,17 +168,21 @@ export const AIR_DEMO_LOCALES: AirDemoLocaleOption[] = [
'terra.toast.regionLabel': 'Notificaciones',
'terra.pagination.previous': 'Pagina anterior',
'terra.pagination.next': 'Pagina siguiente',
'terra.pagination.page': 'Pagina {0}',
'terra.pagination.page': 'Pagina {{0}}',
'terra.numberField.increment': 'Incrementar',
'terra.numberField.decrement': 'Decrementar',
'terra.tagsInput.clearAll': 'Quitar todas las etiquetas',
'terra.tagsInput.remove': 'Quitar {0}',
'terra.tagsInput.remove': 'Quitar {{0}}',
'terra.command.loading': 'Cargando',
'terra.splitter.resizeHandle': 'Control de redimension',
'terra.ratingGroup.label': 'Valoracion',
'air.dialog.close': 'Cerrar diálogo',
'air.code-block.copy': 'Copiar',
'air.code-block.copied': 'Copiado',
'air.code-block.copy-aria': 'Copiar código',
'air.spinner.loadingContent': 'Cargando contenido',
'air.spinner.loadingWith': 'Cargando con {0}',
'air.spinner.loadingSize': 'Cargando {0}',
'air.spinner.loadingWith': 'Cargando con {{0}}',
'air.spinner.loadingSize': 'Cargando {{0}}',
'air.spinner.publishingRelease': 'Publicando version',
'air.spinner.preparingWorkspace': 'Preparando espacio de trabajo',
'air.spinner.syncingBackgroundData': 'Sincronizando datos en segundo plano',
@ -137,14 +207,66 @@ export const AIR_DEMO_LOCALES: AirDemoLocaleOption[] = [
'air.button.addProject': 'Anadir proyecto',
'air.button.saving': 'Guardando',
'air.button.uploading': 'Subiendo',
'air.button.buttonSize': 'Boton {0}',
'air.button.rounded': 'Radio {0}',
'air.text.clampHint': '+{0} {1} mas',
'air.button.buttonSize': 'Boton {{0}}',
'air.button.rounded': 'Radio {{0}}',
'air.icon.all': 'Todo',
'air.demo.shell.playground': 'Playground',
'air.demo.shell.navLabel': 'Secciones de demo de Air',
'air.demo.shell.index': 'Índice',
'air.demo.shell.locale': 'Locale',
'air.demo.shell.theme': 'Tema',
'air.demo.shell.motionAndSound': 'Motion y sonido',
'air.demo.shell.toggleMotion': 'Activar motion',
'air.demo.shell.toggleSound': 'Activar sonido',
'air.demo.shell.motion': 'motion',
'air.demo.shell.sound': 'sound',
'air.demo.hub.title': 'Hub visual de demos',
'air.demo.hub.lead': 'Base nueva para las demos de `air`, ya montada sobre su propio contrato visual, tokens y tema. A partir de aqui los componentes nuevos nacen en su ecosistema real.',
'air.demo.hub.principles': 'Principios de Air',
'air.demo.hub.themeAware': 'Consciente del tema',
'air.demo.hub.localeAware': 'Consciente del locale',
'air.demo.hub.motionAware': 'Consciente del motion',
'air.demo.hub.stats': 'Estadísticas de demos de Air',
'air.demo.hub.total': 'Total',
'air.demo.hub.active': 'Activos',
'air.demo.hub.planned': 'Planificados',
'air.demo.hub.validatesTitle': 'Qué valida este hub',
'air.demo.hub.validates.contract': 'El contrato visual de `air` y su tema base',
'air.demo.hub.validates.type': 'La tipografía actual: Instrument Sans + Lora + Azeret Mono',
'air.demo.hub.validates.split': 'La separación entre `air`, `terra` y temas',
'air.demo.hub.validates.families': 'Las familias actuales: foundation, typography, actions, feedback y forms',
'air.demo.hub.showsTitle': 'Qué enseñan las demos',
'air.demo.hub.showsLead': 'Las páginas de `air` ya no son solo playgrounds: también documentan API pública, forma del DOM y contrato visual esperado del framework en runtime.',
'air.demo.hub.badge.ready': 'Listo',
'air.demo.hub.badge.planned': 'Planificado',
'air.demo.hub.group.foundation.title': 'Foundation',
'air.demo.hub.group.foundation.description': 'Piezas base para validar el lenguaje visual, el tema y la estructura de Air.',
'air.demo.hub.group.core.title': 'Core Components',
'air.demo.hub.group.core.description': 'Segundo bloque de validacion una vez cerrado Button.',
'air.demo.ling.title': 'Catálogo oficial y bridge real de traducción',
'air.demo.ling.lead': 'Esta página valida que las demos de `air` ya no traducen con un mapper local, sino con una instancia real de `ling` enchufada al translator de `terra`.',
'air.demo.ling.uiLocale': 'Locale UI',
'air.demo.ling.lingLocale': 'Locale ling',
'air.demo.ling.direction': 'Direction',
'air.demo.ling.namespaces': 'Namespaces',
'air.demo.ling.bridge.title': 'Bridge',
'air.demo.ling.bridge.lead': 'El layout de las demos levanta `ling`, registra el catálogo oficial de `air`, añade el módulo de demo y expone todo a `terra` mediante `createTerraTranslator(...)`.',
'air.demo.ling.bridge.codeTitle': 'Bridge del layout de Air',
'air.demo.ling.result': 'Resultado',
'air.demo.ling.resultLead': 'Los componentes internos resuelven `#?air.*|fallback`, y las páginas de demo resuelven `translatePath(\'air.*\')` contra la misma instancia de `ling`.',
'air.demo.ling.official.title': 'Claves oficiales de Air',
'air.demo.ling.official.lead': 'Estas son las claves internas que ya usa `air` en runtime. Aquí se comprueba la resolución desde IDs inline, no desde strings sueltas.',
'air.demo.ling.demo.title': 'Extensión de demo',
'air.demo.ling.demo.lead': 'Las páginas de specimen añaden sus propias claves bajo el mismo namespace `air`, pero ya en esquema `ling` real.',
'air.demo.ling.noParams': 'sin parámetros',
'air.demo.ling.schemaOfficial': 'airLingNode oficial',
'air.demo.ling.schemaDemo': 'Módulo air de demo',
},
},
{
id: 'ar-EG',
label: 'AR',
lingLocale: 'ar',
dir: 'rtl',
dateOrder: 'DMY',
timeFormat: 12,
@ -165,17 +287,21 @@ export const AIR_DEMO_LOCALES: AirDemoLocaleOption[] = [
'terra.toast.regionLabel': 'الاشعارات',
'terra.pagination.previous': 'الصفحة السابقة',
'terra.pagination.next': 'الصفحة التالية',
'terra.pagination.page': 'الصفحة {0}',
'terra.pagination.page': 'الصفحة {{0}}',
'terra.numberField.increment': 'زيادة',
'terra.numberField.decrement': 'انقاص',
'terra.tagsInput.clearAll': 'مسح كل الوسوم',
'terra.tagsInput.remove': 'حذف {0}',
'terra.tagsInput.remove': 'حذف {{0}}',
'terra.command.loading': 'جار التحميل',
'terra.splitter.resizeHandle': 'مقبض تغيير الحجم',
'terra.ratingGroup.label': 'التقييم',
'air.dialog.close': 'اغلاق الحوار',
'air.code-block.copy': 'نسخ',
'air.code-block.copied': 'تم النسخ',
'air.code-block.copy-aria': 'نسخ الكود',
'air.spinner.loadingContent': 'جار تحميل المحتوى',
'air.spinner.loadingWith': 'جار التحميل باستخدام {0}',
'air.spinner.loadingSize': 'جار تحميل {0}',
'air.spinner.loadingWith': 'جار التحميل باستخدام {{0}}',
'air.spinner.loadingSize': 'جار تحميل {{0}}',
'air.spinner.publishingRelease': 'جار نشر الاصدار',
'air.spinner.preparingWorkspace': 'جار تجهيز مساحة العمل',
'air.spinner.syncingBackgroundData': 'جار مزامنة البيانات في الخلفية',
@ -200,9 +326,60 @@ export const AIR_DEMO_LOCALES: AirDemoLocaleOption[] = [
'air.button.addProject': 'اضافة مشروع',
'air.button.saving': 'جار الحفظ',
'air.button.uploading': 'جار الرفع',
'air.button.buttonSize': 'زر {0}',
'air.button.rounded': 'استدارة {0}',
'air.text.clampHint': '+{0} {1} اخرى',
'air.button.buttonSize': 'زر {{0}}',
'air.button.rounded': 'استدارة {{0}}',
'air.icon.all': 'الكل',
'air.demo.shell.playground': 'ساحة العرض',
'air.demo.shell.navLabel': 'أقسام عروض Air',
'air.demo.shell.index': 'الفهرس',
'air.demo.shell.locale': 'اللغة',
'air.demo.shell.theme': 'النسق',
'air.demo.shell.motionAndSound': 'الحركة والصوت',
'air.demo.shell.toggleMotion': 'تبديل الحركة',
'air.demo.shell.toggleSound': 'تبديل الصوت',
'air.demo.shell.motion': 'motion',
'air.demo.shell.sound': 'sound',
'air.demo.hub.title': 'مركز العروض البصرية',
'air.demo.hub.lead': 'قاعدة جديدة لعروض `air`، مبنية بالفعل على عقدها البصري ورموزها ونسقها. من هنا تولد المكونات الجديدة داخل بيئتها الحقيقية.',
'air.demo.hub.principles': 'مبادئ Air',
'air.demo.hub.themeAware': 'واعٍ بالنسق',
'air.demo.hub.localeAware': 'واعٍ باللغة',
'air.demo.hub.motionAware': 'واعٍ بالحركة',
'air.demo.hub.stats': 'إحصاءات عروض Air',
'air.demo.hub.total': 'الإجمالي',
'air.demo.hub.active': 'النشطة',
'air.demo.hub.planned': 'المخطط لها',
'air.demo.hub.validatesTitle': 'ما الذي يتحقق منه هذا المركز',
'air.demo.hub.validates.contract': 'العقد البصري لـ `air` ونسقه الأساسي',
'air.demo.hub.validates.type': 'الطباعة الحالية: Instrument Sans + Lora + Azeret Mono',
'air.demo.hub.validates.split': 'الفصل بين `air` و `terra` والأنساق',
'air.demo.hub.validates.families': 'العائلات الحالية: foundation و typography و actions و feedback و forms',
'air.demo.hub.showsTitle': 'ما الذي تعرضه هذه الصفحات',
'air.demo.hub.showsLead': 'لم تعد صفحات `air` مجرد ساحات تجريب: فهي توثق أيضاً الواجهة العامة وشكل DOM والعقد البصري المتوقع أثناء التشغيل.',
'air.demo.hub.badge.ready': 'جاهز',
'air.demo.hub.badge.planned': 'مخطط له',
'air.demo.hub.group.foundation.title': 'الأساس',
'air.demo.hub.group.foundation.description': 'قطع أساسية للتحقق من اللغة البصرية والنسق وبنية Air.',
'air.demo.hub.group.core.title': 'المكونات الأساسية',
'air.demo.hub.group.core.description': 'الكتلة الثانية من التحقق بعد إغلاق Button.',
'air.demo.ling.title': 'الكتالوج الرسمي وجسر الترجمة الحقيقي',
'air.demo.ling.lead': 'تتحقق هذه الصفحة من أن عروض `air` لم تعد تترجم عبر mapper محلي، بل عبر نسخة حقيقية من `ling` موصولة بمترجم `terra`.',
'air.demo.ling.uiLocale': 'لغة الواجهة',
'air.demo.ling.lingLocale': 'لغة Ling',
'air.demo.ling.direction': 'الاتجاه',
'air.demo.ling.namespaces': 'المجالات',
'air.demo.ling.bridge.title': 'الجسر',
'air.demo.ling.bridge.lead': 'يقوم layout الخاص بالعروض بتشغيل `ling` وتسجيل الكتالوج الرسمي لـ `air` وإضافة وحدة العرض وكشف كل شيء إلى `terra` عبر `createTerraTranslator(...)`.',
'air.demo.ling.bridge.codeTitle': 'جسر layout الخاص بـ Air',
'air.demo.ling.result': 'النتيجة',
'air.demo.ling.resultLead': 'تحل المكونات الداخلية `#?air.*|fallback`، كما تحل صفحات العرض `translatePath(\'air.*\')` مقابل النسخة نفسها من `ling`.',
'air.demo.ling.official.title': 'المفاتيح الرسمية لـ Air',
'air.demo.ling.official.lead': 'هذه هي المفاتيح الداخلية التي يستخدمها `air` بالفعل أثناء التشغيل. هنا نتحقق من الحل عبر معرّفات inline لا عبر سلاسل متناثرة.',
'air.demo.ling.demo.title': 'امتداد العرض',
'air.demo.ling.demo.lead': 'تضيف صفحات الـ specimen مفاتيحها الخاصة تحت المجال نفسه `air`، ولكن بصيغة `ling` حقيقية.',
'air.demo.ling.noParams': 'بلا معاملات',
'air.demo.ling.schemaOfficial': 'airLingNode الرسمي',
'air.demo.ling.schemaDemo': 'وحدة air الخاصة بالعرض',
},
},
];
@ -211,36 +388,89 @@ export function getAirDemoLocale(id: AirDemoLocaleId): AirDemoLocaleOption {
return AIR_DEMO_LOCALES.find((locale) => locale.id === id) ?? AIR_DEMO_LOCALES[0];
}
function formatMessage(template: string, args: unknown[]): string {
return args.reduce<string>(
(result, arg, index) => result.replaceAll(`{${index}}`, String(arg ?? '')),
template,
);
}
function setNestedRecord(
target: Record<string, LingNode>,
path: string,
locale: SupportedLocale,
value: string
) {
const parts = path.split('.');
let cursor: Record<string, LingNode> = target;
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
const isLeaf = index === parts.length - 1;
if (isLeaf) {
const existing = cursor[part];
const record =
existing && typeof existing === 'object' && !Array.isArray(existing)
? ({ ...(existing as LingRecord) } satisfies LingRecord)
: ({ es: '' } satisfies LingRecord);
record[locale] = value;
cursor[part] = record;
return;
}
function getInlineTranslationParts(value: unknown): { path: string; fallback: string } | undefined {
if (typeof value !== 'string' || !value.startsWith('#?')) return undefined;
const raw = value.slice(2);
const [path, fallback = ''] = raw.split('|');
if (!path) return undefined;
return { path, fallback };
const next = cursor[part];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
cursor[part] = {};
}
cursor = cursor[part] as Record<string, LingNode>;
}
}
export function translateAirDemoPath(localeId: AirDemoLocaleId, path: string, ...args: unknown[]): string {
const locale = getAirDemoLocale(localeId);
const message = locale.messages[path];
if (!message) return path;
return formatMessage(message, args);
function buildLingModule(prefix: 'air' | 'terra'): LingNode {
const module: Record<string, LingNode> = {};
for (const locale of AIR_DEMO_LOCALES) {
for (const [fullPath, message] of Object.entries(locale.messages)) {
if (!fullPath.startsWith(`${prefix}.`)) continue;
setNestedRecord(module, fullPath.slice(prefix.length + 1), locale.lingLocale, message);
}
}
return module;
}
export function translateAirDemoValue(localeId: AirDemoLocaleId, value: unknown): string {
const parts = getInlineTranslationParts(value);
if (parts) {
const translated = translateAirDemoPath(localeId, parts.path);
return translated === parts.path ? parts.fallback : translated;
function argsToLingParams(args: unknown[]): Record<string, unknown> | undefined {
if (args.length === 0) return undefined;
if (
args.length === 1 &&
args[0] != null &&
typeof args[0] === 'object' &&
!Array.isArray(args[0])
) {
return args[0] as Record<string, unknown>;
}
if (typeof value === 'string') return value;
return String(value ?? '');
return Object.fromEntries(args.map((arg, index) => [String(index), arg]));
}
export const AIR_DEMO_TERRA_LING_NODE = buildLingModule('terra');
export const AIR_DEMO_AIR_LING_NODE = buildLingModule('air');
export function translateAirDemoLingPath(
ling: Pick<LingInstance, 't' | 'tForLocale'>,
locale: AirDemoLingLocale,
path: string,
...args: unknown[]
): string {
const params = argsToLingParams(args);
const translateForLocale = ling.tForLocale as (
path: string,
locale: AirDemoLingLocale,
params?: Record<string, unknown>
) => string;
return params ? translateForLocale(path, locale, params) : translateForLocale(path, locale);
}
export function translateAirDemoLingValue(
ling: Pick<LingInstance, 'tsForLocale'>,
locale: AirDemoLingLocale,
value: LingString
): string {
return ling.tsForLocale(value, locale);
}
export function formatAirDemoDate(localeId: AirDemoLocaleId): string {
@ -274,4 +504,3 @@ export function formatAirDemoUnit(localeId: AirDemoLocaleId): string {
maximumFractionDigits: 1,
}).format(12.5);
}

@ -0,0 +1,524 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Dialog, Field } from '$uix/air';
import type { AirDialogSize } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
let playgroundOpen = $state(false);
let playgroundSize = $state<AirDialogSize>('md');
let profileOpen = $state(false);
const sizes = ['sm', 'md', 'lg', 'full'] as const;
const playgroundCode = $derived(
[
`<Dialog.Root bind:open={open}>`,
` <Dialog.Trigger class="demo-trigger">Open dialog</Dialog.Trigger>`,
` <Dialog.Portal>`,
` <Dialog.Overlay />`,
` <Dialog.Content size="${playgroundSize}">`,
` <Dialog.Header>`,
` <Dialog.Title>Dialog title</Dialog.Title>`,
` <Dialog.Close />`,
` </Dialog.Header>`,
` <Dialog.Description>Short supporting copy.</Dialog.Description>`,
` <Dialog.Footer>`,
` <Dialog.Close>Cancelar</Dialog.Close>`,
` <button>Continuar</button>`,
` </Dialog.Footer>`,
` </Dialog.Content>`,
` </Dialog.Portal>`,
`</Dialog.Root>`
].join('\n')
);
const sizeCode = `<Dialog.Content size="sm">...</Dialog.Content>
<Dialog.Content size="md">...</Dialog.Content>
<Dialog.Content size="lg">...</Dialog.Content>
<Dialog.Content size="full">...</Dialog.Content>`;
const formCode = `<Dialog.Root bind:open={profileOpen}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content size="lg">
<Dialog.Header>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Close />
</Dialog.Header>
<Dialog.Description>Fields, helper text and actions compose normally inside Dialog.</Dialog.Description>
<Field.Root>...</Field.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>`;
</script>
<svelte:head>
<title>Dialog · Air</title>
</svelte:head>
<div class="dialog-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Dialog</p>
<h1>{tv({ es: 'Dialog sobre terra.Dialog', en: 'Dialog over terra.Dialog', ar: 'Dialog فوق terra.Dialog' })}</h1>
<p class="lead">
{tv({
es: '`Dialog` reutiliza el wiring de `terra` para foco, portal y estado, y `air` aterriza la superficie modal, el overlay y la jerarquia visual del contenido.',
en: '`Dialog` reuses `terra` wiring for focus, portal and state, and `air` lands the modal surface, overlay and content hierarchy.',
ar: 'يعيد `Dialog` استخدام wiring الخاص بـ `terra` للتركيز والبوابة والحالة، بينما يحدد `air` سطح النافذة والطبقة المتراكبة وتسلسل المحتوى.',
})}
</p>
<div class="hero-signals">
<span>{tv({ es: 'portal + overlay', en: 'portal + overlay', ar: 'بوابة + طبقة' })}</span>
<span>{tv({ es: 'motion contextual', en: 'context motion', ar: 'حركة سياقية' })}</span>
<span>{tv({ es: 'size responsive', en: 'responsive size', ar: 'حجم متجاوب' })}</span>
<span>{tv({ es: 'header + footer', en: 'header + footer', ar: 'رأس + تذييل' })}</span>
</div>
</div>
<div class="hero-aside">
<div>
<span>{tv({ es: 'API', en: 'API', ar: 'الواجهة' })}</span>
<strong>Root · Trigger · Portal · Overlay · Content · Header · Footer · Title · Description · Close</strong>
</div>
<div>
<span>{tv({ es: 'DOM', en: 'DOM', ar: 'DOM' })}</span>
<strong>{tv({
es: 'overlay + dialog panel portaled, con title/description enlazados por terra',
en: 'portaled overlay + dialog panel, with title/description linked by terra',
ar: 'طبقة ولوحة حوار عبر بوابة، مع ربط title و description بواسطة terra',
})}</strong>
</div>
</div>
</header>
<section class="panel panel--feature">
<header class="section-header">
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>
{tv({
es: '`size` vive en `Dialog.Content` porque el panel sale a un portal y no puede heredar dimensiones desde `Root`.',
en: '`size` lives on `Dialog.Content` because the panel is portaled and cannot inherit dimensions from `Root`.',
ar: 'توجد `size` داخل `Dialog.Content` لأن اللوحة تخرج عبر بوابة ولا يمكنها وراثة الأبعاد من `Root`.',
})}
</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>{tv({ es: 'Size', en: 'Size', ar: 'الحجم' })}</span>
<div class="chips">
{#each sizes as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundSize === item}
onclick={() => (playgroundSize = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'State', en: 'State', ar: 'الحالة' })}</span>
<div class="chips">
<button
type="button"
class:chip={true}
class:chip--active={playgroundOpen}
onclick={() => (playgroundOpen = !playgroundOpen)}
>{playgroundOpen ? tv({ es: 'abierto', en: 'open', ar: 'مفتوح' }) : tv({ es: 'cerrado', en: 'closed', ar: 'مغلق' })}</button>
</div>
</div>
</div>
<CodeExample title={tv({ es: 'Playground de Dialog', en: 'Dialog playground', ar: 'ساحة Dialog' })} code={playgroundCode}>
<div class="preview-stack">
<Dialog.Root bind:open={playgroundOpen}>
<Dialog.Trigger class="demo-action demo-action--primary">{tv({ es: 'Abrir diálogo', en: 'Open dialog', ar: 'افتح الحوار' })}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content size={playgroundSize}>
<Dialog.Header>
<div class="stack-tight">
<Dialog.Title>{tv({ es: 'Invitar compañero', en: 'Invite teammate', ar: 'دعوة زميل' })}</Dialog.Title>
<Dialog.Description>
{tv({
es: 'Trae un nuevo colaborador al workspace con el mismo lenguaje visual usado por Field y Button.',
en: 'Bring a new collaborator into the workspace with the same visual language used by Field and Button.',
ar: 'أضف متعاوناً جديداً إلى مساحة العمل بنفس اللغة البصرية المستخدمة في Field و Button.',
})}
</Dialog.Description>
</div>
<Dialog.Close />
</Dialog.Header>
<div class="dialog-body">
<p class="body-copy">
{tv({
es: 'El panel conserva foco y escape desde `terra`, mientras `air` controla elevación, spacing y ritmo tipográfico.',
en: 'The panel keeps focus and escape handling from `terra`, while `air` controls elevation, spacing and type rhythm.',
ar: 'تحتفظ اللوحة بالتركيز ومعالجة Escape من `terra`، بينما يتحكم `air` في الارتفاع والمسافات والإيقاع الطباعي.',
})}
</p>
</div>
<Dialog.Footer>
<Dialog.Close>{tv({ es: 'Cancelar', en: 'Cancel', ar: 'إلغاء' })}</Dialog.Close>
<button type="button" class="demo-action demo-action--primary">
{tv({ es: 'Enviar invitación', en: 'Send invite', ar: 'إرسال الدعوة' })}
</button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
</CodeExample>
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Sizes', en: 'Sizes', ar: 'الأحجام' })}</h2>
<p>{tv({ es: 'Los tamaños gobiernan anchura y padding del panel, sin tocar la jerarquia interna.', en: 'Sizes govern panel width and padding without touching internal hierarchy.', ar: 'تتحكم الأحجام في عرض اللوحة وحشوها دون المساس بالهرمية الداخلية.' })}</p>
</header>
<CodeExample title={tv({ es: 'Tamaños de Dialog', en: 'Dialog sizes', ar: 'أحجام Dialog' })} code={sizeCode}>
<div class="size-grid">
{#each sizes as item}
<article class="size-card">
<p class="size-title">{item}</p>
<div class="size-shell" data-size={item}>
<div class="size-surface" data-size={item}>
<div class="stack-tight">
<p class="size-heading">Dialog {item}</p>
<p class="size-copy">
{tv({
es: `Previsualización de superficie para ${item}. El componente real sale a portal; esta card solo replica anchura y spacing.`,
en: `Surface preview for ${item}. The real component is portaled; this card mirrors width and spacing only.`,
ar: `معاينة سطح للحجم ${item}. المكوّن الحقيقي يُعرض عبر بوابة؛ وهذه البطاقة تعكس العرض والمسافات فقط.`,
})}
</p>
</div>
</div>
</div>
</article>
{/each}
</div>
</CodeExample>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Composición con formularios', en: 'Form composition', ar: 'التركيب مع النماذج' })}</h2>
<p>{tv({ es: '`Dialog` compone bien con `Field` porque ambos comparten spacing, bordes y foco del tema.', en: '`Dialog` composes well with `Field` because both share theme spacing, borders and focus.', ar: 'يتكامل `Dialog` جيداً مع `Field` لأنهما يشتركان في المسافات والحدود وحالة التركيز الخاصة بالنسق.' })}</p>
</header>
<CodeExample title={tv({ es: 'Dialog con fields', en: 'Dialog with fields', ar: 'Dialog مع الحقول' })} code={formCode}>
<div class="preview-stack">
<Dialog.Root bind:open={profileOpen}>
<Dialog.Trigger class="demo-action">{tv({ es: 'Editar perfil', en: 'Edit profile', ar: 'تعديل الملف الشخصي' })}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content size="lg">
<Dialog.Header>
<div class="stack-tight">
<Dialog.Title>{tv({ es: 'Editar perfil', en: 'Edit profile', ar: 'تعديل الملف الشخصي' })}</Dialog.Title>
<Dialog.Description>
{tv({ es: 'Actualiza los datos de la cuenta que ve el resto del equipo.', en: 'Update the account details shown to the rest of the team.', ar: 'حدّث تفاصيل الحساب المعروضة لبقية الفريق.' })}
</Dialog.Description>
</div>
<Dialog.Close />
</Dialog.Header>
<div class="form-stack">
<Field.Root>
<Field.Label>{tv({ es: 'Nombre', en: 'Name', ar: 'الاسم' })}</Field.Label>
<Field.Control>
<Field.Input placeholder="Ada Lovelace" />
</Field.Control>
<Field.HelperText>{tv({ es: 'Visible en menciones y actividad.', en: 'Shown in mentions and activity.', ar: 'يظهر في الإشارات والنشاط.' })}</Field.HelperText>
</Field.Root>
<Field.Root>
<Field.Label>Email</Field.Label>
<Field.Control>
<Field.Input type="email" placeholder="ada@engine.example" />
</Field.Control>
<Field.HelperText>{tv({ es: 'Usado para invitaciones y notificaciones.', en: 'Used for invites and notifications.', ar: 'يُستخدم للدعوات والإشعارات.' })}</Field.HelperText>
</Field.Root>
</div>
<Dialog.Footer>
<Dialog.Close>{tv({ es: 'Descartar', en: 'Discard', ar: 'تجاهل' })}</Dialog.Close>
<button type="button" class="demo-action demo-action--primary">
{tv({ es: 'Guardar cambios', en: 'Save changes', ar: 'حفظ التغييرات' })}
</button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
</CodeExample>
</section>
</div>
<style>
.dialog-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-6);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.85fr);
gap: var(--air-space-6);
align-items: start;
}
.eyebrow {
margin: 0 0 var(--air-space-2);
font: 600 var(--air-font-size-xs) / 1 var(--air-font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--air-color-content-secondary);
}
.hero h1,
.section-header h2,
.size-heading,
.size-title {
margin: 0;
}
.lead {
margin: var(--air-space-3) 0 0;
max-width: 62ch;
color: var(--air-color-content-secondary);
font-size: var(--air-font-size-md);
line-height: var(--air-leading-prose);
}
.hero-signals,
.chips,
.preview-stack,
.stack-tight,
.controls,
.form-stack {
display: flex;
}
.hero-signals,
.chips {
flex-wrap: wrap;
}
.hero-signals {
gap: var(--air-space-2);
margin-top: var(--air-space-4);
}
.hero-signals span,
.hero-aside span,
.control > span,
.size-title {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
color: var(--air-color-content-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hero-signals span,
.hero-aside > div,
.panel,
.size-card {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 92%, transparent);
}
.hero-signals span,
.chip,
.hero-aside > div,
.panel,
.size-card,
.size-surface,
.demo-action {
border-radius: var(--air-radius-lg);
}
.hero-signals span {
padding: 0.45rem 0.7rem;
}
.hero-aside {
display: grid;
gap: var(--air-space-3);
}
.hero-aside > div {
display: grid;
gap: var(--air-space-2);
padding: var(--air-space-4);
}
.hero-aside strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-prose);
}
.panel {
display: grid;
gap: var(--air-space-5);
padding: var(--air-space-5);
}
.panel--feature {
background:
linear-gradient(180deg, color-mix(in srgb, var(--air-color-surface-overlay) 80%, transparent), transparent 55%),
color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
}
.section-header {
display: grid;
gap: var(--air-space-2);
}
.section-header p,
.body-copy,
.size-copy {
margin: 0;
color: var(--air-color-content-secondary);
line-height: var(--air-leading-prose);
}
.playground-grid {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: var(--air-space-5);
}
.controls,
.preview-stack,
.form-stack {
flex-direction: column;
}
.controls,
.preview-stack,
.form-stack,
.stack-tight {
gap: var(--air-space-3);
}
.control {
display: grid;
gap: var(--air-space-2);
}
.chips {
gap: var(--air-space-2);
}
.chip {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: transparent;
color: var(--air-color-content-secondary);
padding: 0.45rem 0.8rem;
font: inherit;
cursor: pointer;
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);
}
.chip--active {
background: color-mix(in srgb, var(--air-color-primary-track) 82%, transparent);
border-color: var(--air-color-primary-border);
color: var(--air-color-content-primary);
}
.dialog-body {
display: grid;
gap: var(--air-space-3);
}
.demo-action {
min-height: var(--air-control-height-sm);
padding-inline: var(--air-space-3);
border: var(--air-border-width) solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: transparent;
color: var(--air-color-content-primary);
font: inherit;
cursor: pointer;
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);
}
.demo-action--primary {
background: var(--air-color-primary-solid);
border-color: var(--air-color-primary-solid);
color: var(--air-color-primary-contrast);
}
.size-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--air-space-4);
}
.size-card {
display: grid;
gap: var(--air-space-3);
padding: var(--air-space-4);
}
.size-shell {
display: flex;
justify-content: center;
padding: var(--air-space-3);
background: color-mix(in srgb, var(--air-color-overlay) 18%, transparent);
border-radius: var(--air-radius-xl);
}
.size-surface {
display: grid;
gap: var(--air-space-2);
width: min(100%, 560px);
padding: var(--air-space-5);
border: var(--air-border-width) solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: var(--air-color-surface-overlay);
box-shadow: var(--air-shadow-3);
}
.size-shell[data-size='sm'] .size-surface { max-width: 420px; padding: var(--air-space-4); }
.size-shell[data-size='md'] .size-surface { max-width: 560px; padding: var(--air-space-5); }
.size-shell[data-size='lg'] .size-surface { max-width: 760px; padding: var(--air-space-6); }
.size-shell[data-size='full'] .size-surface { max-width: none; width: 100%; min-height: 240px; }
.size-heading {
font-family: var(--air-font-heading);
font-size: var(--air-font-size-lg);
line-height: var(--air-leading-heading);
}
@media (max-width: 900px) {
.hero,
.playground-grid,
.size-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,423 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Button, Drawer, Field } from '$uix/air';
import type { AirDrawerSide, AirDrawerSize } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
let playgroundOpen = $state(false);
let playgroundSide = $state<AirDrawerSide>('right');
let playgroundSize = $state<AirDrawerSize>('md');
let projectOpen = $state(false);
const sides = ['right', 'left', 'bottom', 'top'] as const;
const sizes = ['sm', 'md', 'lg', 'full'] as const;
const playgroundCode = $derived(
`<Drawer.Root bind:open={open}>
<Drawer.Trigger>Abrir panel</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay />
<Drawer.Content side="${playgroundSide}" size="${playgroundSize}">
<Drawer.Header>
<Drawer.Title>Workspace activity</Drawer.Title>
<Drawer.Close />
</Drawer.Header>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>`
);
const formCode = `<Drawer.Content side="right" size="lg">...</Drawer.Content>`;
</script>
<svelte:head>
<title>Drawer · Air</title>
</svelte:head>
<div class="drawer-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Drawer</p>
<h1>{tv({
es: 'Panel lateral sobre terra.Drawer',
en: 'Panel over terra.Drawer',
ar: 'لوحة فوق terra.Drawer',
})}</h1>
<p class="lead">{tv({
es: '`Drawer` comparte la base accesible con `Dialog`, pero aterriza otra lectura espacial: panel anclado a un borde con side y tamaño explícitos.',
en: '`Drawer` shares the accessible base with `Dialog`, but lands a different spatial reading: a panel anchored to an edge with explicit side and size.',
ar: 'يشترك `Drawer` في الأساس القابل للوصول مع `Dialog`، لكنه يقدّم قراءة مكانية مختلفة: لوحة مثبتة على حافة مع side و size صريحتين.',
})}</p>
<div class="hero-signals">
<span>{tv({ es: 'edge anchored', en: 'edge anchored', ar: 'مثبت على الحافة' })}</span>
<span>{tv({ es: 'top · right · bottom · left', en: 'top · right · bottom · left', ar: 'أعلى · يمين · أسفل · يسار' })}</span>
<span>{tv({ es: 'shared close language', en: 'shared close language', ar: 'لغة إغلاق مشتركة' })}</span>
<span>{tv({ es: 'context motion', en: 'context motion', ar: 'حركة سياقية' })}</span>
</div>
</div>
<div class="hero-aside">
<div>
<span>{tv({ es: 'API', en: 'API', ar: 'الواجهة' })}</span>
<strong>Root · Trigger · Portal · Overlay · Content · Header · Footer · Title · Description · Close</strong>
</div>
<div>
<span>DOM</span>
<strong>{tv({
es: 'panel portaled con data-side y data-state',
en: 'portaled panel with data-side and data-state',
ar: 'لوحة عبر بوابة مع data-side و data-state',
})}</strong>
</div>
<div>
<span>{tv({ es: 'Use case', en: 'Use case', ar: 'الاستخدام' })}</span>
<strong>{tv({
es: 'inspector, settings, quick edit, command sidecar',
en: 'inspector, settings, quick edit, command sidecar',
ar: 'المعاين والإعدادات والتحرير السريع ولوحة الأوامر الجانبية',
})}</strong>
</div>
</div>
</header>
<section class="panel panel--feature">
<header class="section-header">
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>{tv({
es: '`side` y `size` viven en `Drawer.Content` porque el panel real sale a portal y necesita resolver su geometría en esa pieza.',
en: '`side` and `size` live on `Drawer.Content` because the real panel is portaled and needs to resolve its geometry there.',
ar: 'تعيش `side` و `size` داخل `Drawer.Content` لأن اللوحة الحقيقية تُعرض عبر بوابة وتحتاج إلى حل هندستها هناك.',
})}</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>{tv({ es: 'Side', en: 'Side', ar: 'الجهة' })}</span>
<div class="chips">
{#each sides as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundSide === item}
onclick={() => (playgroundSide = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Size', en: 'Size', ar: 'الحجم' })}</span>
<div class="chips">
{#each sizes as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundSize === item}
onclick={() => (playgroundSize = item)}
>{item}</button>
{/each}
</div>
</div>
</div>
<CodeExample title={tv({ es: 'Drawer playground', en: 'Drawer playground', ar: 'ساحة Drawer' })} code={playgroundCode}>
<div class="preview-stack">
<Drawer.Root bind:open={playgroundOpen}>
<Drawer.Trigger>
<Button variant="surface" color="neutral">{tv({ es: 'Abrir panel', en: 'Open panel', ar: 'فتح اللوحة' })}</Button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay />
<Drawer.Content side={playgroundSide} size={playgroundSize}>
<Drawer.Header>
<div class="stack-tight">
<Drawer.Title>{tv({ es: 'Actividad del workspace', en: 'Workspace activity', ar: 'نشاط مساحة العمل' })}</Drawer.Title>
<Drawer.Description>{tv({
es: 'Ideal para contenido que acompaña a la pantalla principal sin bloquearla del todo.',
en: 'Ideal for content that accompanies the main screen without fully blocking it.',
ar: 'مثالي للمحتوى الذي يرافق الشاشة الرئيسية من دون حجبها بالكامل.',
})}</Drawer.Description>
</div>
<Drawer.Close />
</Drawer.Header>
<div class="body-stack">
<div class="activity-card">
<strong>{tv({ es: 'Despliegue completado', en: 'Deployment completed', ar: 'اكتمل النشر' })}</strong>
<p>{tv({ es: 'La build de producción terminó hace 2 minutos sin errores.', en: 'The production build finished 2 minutes ago with no errors.', ar: 'انتهى بناء الإنتاج قبل دقيقتين من دون أخطاء.' })}</p>
</div>
<div class="activity-card">
<strong>{tv({ es: 'Nueva incidencia asignada', en: 'New incident assigned', ar: 'تم إسناد حادثة جديدة' })}</strong>
<p>{tv({ es: 'El panel lateral funciona bien como sidecar de seguimiento.', en: 'The side panel works well as a tracking sidecar.', ar: 'تعمل اللوحة الجانبية جيداً كلوحة متابعة جانبية.' })}</p>
</div>
</div>
<Drawer.Footer>
<Drawer.Close>{tv({ es: 'Cerrar', en: 'Close', ar: 'إغلاق' })}</Drawer.Close>
<Button variant="solid" color="primary">{tv({ es: 'Ver detalle', en: 'View details', ar: 'عرض التفاصيل' })}</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</div>
</CodeExample>
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Composición con formularios', en: 'Form composition', ar: 'التركيب مع النماذج' })}</h2>
<p>{tv({
es: 'Un drawer es un buen contenedor para edición rápida: no te saca del contexto pero admite formularios completos.',
en: 'A drawer is a good container for quick editing: it keeps you in context while still admitting full forms.',
ar: 'يُعد drawer حاوية جيدة للتحرير السريع: يبقيك ضمن السياق مع قدرته على احتواء نماذج كاملة.',
})}</p>
</header>
<CodeExample title={tv({ es: 'Drawer con fields', en: 'Drawer with fields', ar: 'Drawer مع الحقول' })} code={formCode}>
<div class="preview-stack">
<Drawer.Root bind:open={projectOpen}>
<Drawer.Trigger>
<Button variant="surface" color="neutral">{tv({ es: 'Editar proyecto', en: 'Edit project', ar: 'تحرير المشروع' })}</Button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay />
<Drawer.Content side="right" size="lg">
<Drawer.Header>
<div class="stack-tight">
<Drawer.Title>{tv({ es: 'Editar proyecto', en: 'Edit project', ar: 'تحرير المشروع' })}</Drawer.Title>
<Drawer.Description>{tv({ es: 'Los mismos `Field` del sistema componen aquí sin cambiar receta visual.', en: 'The same system `Field`s compose here without changing the visual recipe.', ar: 'تتكوّن حقول النظام نفسها هنا من دون تغيير الوصفة البصرية.' })}</Drawer.Description>
</div>
<Drawer.Close />
</Drawer.Header>
<div class="form-stack">
<Field.Root>
<Field.Label>{tv({ es: 'Nombre', en: 'Name', ar: 'الاسم' })}</Field.Label>
<Field.Control>
<Field.Input placeholder="Apollo Mission Control" />
</Field.Control>
<Field.HelperText>{tv({ es: 'Visible en el sidebar del workspace.', en: 'Shown in the workspace sidebar.', ar: 'يظهر في الشريط الجانبي لمساحة العمل.' })}</Field.HelperText>
</Field.Root>
<Field.Root>
<Field.Label>{tv({ es: 'Slug', en: 'Slug', ar: 'المعرّف' })}</Field.Label>
<Field.Control>
<Field.Input placeholder="apollo-control" />
</Field.Control>
</Field.Root>
</div>
<Drawer.Footer>
<Drawer.Close>{tv({ es: 'Cancelar', en: 'Cancel', ar: 'إلغاء' })}</Drawer.Close>
<Button variant="solid" color="primary">{tv({ es: 'Guardar', en: 'Save', ar: 'حفظ' })}</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</div>
</CodeExample>
</section>
</div>
<style>
.drawer-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-6);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.85fr);
gap: var(--air-space-6);
align-items: start;
}
.eyebrow {
margin: 0 0 var(--air-space-2);
font: 600 var(--air-font-size-xs) / 1 var(--air-font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--air-color-content-secondary);
}
.hero h1,
.section-header h2 {
margin: 0;
}
.lead {
margin: var(--air-space-3) 0 0;
max-width: 62ch;
color: var(--air-color-content-secondary);
font-size: var(--air-font-size-md);
line-height: var(--air-leading-prose);
}
.hero-signals,
.chips,
.preview-stack,
.controls,
.stack-tight,
.body-stack,
.form-stack {
display: flex;
}
.hero-signals,
.chips {
flex-wrap: wrap;
}
.hero-signals {
gap: var(--air-space-2);
margin-top: var(--air-space-4);
}
.hero-signals span,
.hero-aside span,
.control > span {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
color: var(--air-color-content-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hero-signals span,
.hero-aside > div,
.panel,
.activity-card {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 92%, transparent);
}
.hero-signals span,
.chip,
.hero-aside > div,
.panel,
.activity-card {
border-radius: var(--air-radius-lg);
}
.hero-signals span {
padding: 0.45rem 0.7rem;
}
.hero-aside {
display: grid;
gap: var(--air-space-3);
}
.hero-aside > div {
display: grid;
gap: var(--air-space-2);
padding: var(--air-space-4);
}
.hero-aside strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-prose);
}
.panel {
display: grid;
gap: var(--air-space-5);
padding: var(--air-space-5);
}
.panel--feature {
background:
linear-gradient(180deg, color-mix(in srgb, var(--air-color-surface-overlay) 80%, transparent), transparent 55%),
color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
}
.section-header {
display: grid;
gap: var(--air-space-2);
}
.section-header p,
.activity-card p {
margin: 0;
color: var(--air-color-content-secondary);
line-height: var(--air-leading-prose);
}
.playground-grid {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: var(--air-space-5);
}
.controls,
.preview-stack,
.stack-tight,
.body-stack,
.form-stack {
flex-direction: column;
}
.controls,
.preview-stack,
.stack-tight,
.body-stack,
.form-stack {
gap: var(--air-space-3);
}
.control {
display: grid;
gap: var(--air-space-2);
}
.chips {
gap: var(--air-space-2);
}
.chip {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: transparent;
color: var(--air-color-content-secondary);
padding: 0.45rem 0.8rem;
font: inherit;
cursor: pointer;
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);
}
.chip--active {
background: color-mix(in srgb, var(--air-color-primary-track) 82%, transparent);
border-color: var(--air-color-primary-border);
color: var(--air-color-content-primary);
}
.activity-card {
display: grid;
gap: var(--air-space-2);
padding: var(--air-space-4);
}
.activity-card strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-ui);
}
@media (max-width: 960px) {
.hero,
.playground-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,485 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Field, Icon } from '$uix/air';
import type { AirFieldSize, AirFieldVariant } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
type DemoFieldState = 'idle' | 'error' | 'disabled' | 'readonly' | 'loading';
let size = $state<AirFieldSize>('md');
let variant = $state<AirFieldVariant>('outline');
let fieldState = $state<DemoFieldState>('idle');
let required = $state(false);
let value = $state('');
const sizes = ['xs', 'sm', 'md', 'lg'] as const;
const variants = ['outline', 'ghost'] as const;
const states = ['idle', 'error', 'disabled', 'readonly', 'loading'] as const;
const playgroundCode = $derived(
[
`<Field.Root`,
size !== 'md' ? ` size="${size}"` : '',
variant !== 'outline' ? ` variant="${variant}"` : '',
fieldState !== 'idle' ? ` state="${fieldState}"` : '',
required ? ` required` : '',
`>`,
` <Field.Label>Email</Field.Label>`,
` <Field.Control>`,
` <Field.Prefix><Icon.Mail size="sm" /></Field.Prefix>`,
` <Field.Input type="email" placeholder="name@company.com" />`,
` </Field.Control>`,
` <Field.HelperText>Usa tu correo profesional.</Field.HelperText>`,
` <Field.Error>Introduce un email valido.</Field.Error>`,
`</Field.Root>`
]
.filter(Boolean)
.join('\n')
);
const sizesCode = `<Field.Root size="xs">...</Field.Root>
<Field.Root size="sm">...</Field.Root>
<Field.Root size="md">...</Field.Root>
<Field.Root size="lg">...</Field.Root>`;
const statesCode = `<Field.Root state="idle">...</Field.Root>
<Field.Root state="error">...</Field.Root>
<Field.Root state="disabled">...</Field.Root>
<Field.Root state="readonly">...</Field.Root>
<Field.Root state="loading">...</Field.Root>`;
const adornmentsCode = `<Field.Root>
<Field.Label>Price</Field.Label>
<Field.Control>
<Field.Prefix>€</Field.Prefix>
<Field.Input placeholder="0.00" />
<Field.Suffix>EUR</Field.Suffix>
</Field.Control>
</Field.Root>`;
</script>
<svelte:head>
<title>Field · Air</title>
</svelte:head>
<div class="field-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Field</p>
<h1>{tv({ es: 'Field sobre terra.Field', en: 'Field over terra.Field', ar: 'Field فوق terra.Field' })}</h1>
<p class="lead">
{tv({
es: '`Field` compone el layout y la tipografia del campo; `terra.Field` sigue resolviendo ids, aria y estado. La caja visual vive en `Field.Control` y el elemento real en `Field.Input`.',
en: '`Field` composes field layout and typography; `terra.Field` still resolves ids, aria and state. The visual box lives in `Field.Control` and the real element in `Field.Input`.',
ar: 'يقوم `Field` ببناء تخطيط الحقل وطباعة النص، بينما يواصل `terra.Field` حل المعرفات وخصائص aria والحالة. الصندوق البصري يوجد في `Field.Control` والعنصر الحقيقي في `Field.Input`.',
})}
</p>
<div class="hero-signals">
<span>{tv({ es: 'size + variant', en: 'size + variant', ar: 'الحجم + المتغير' })}</span>
<span>{tv({ es: 'terra aria wiring', en: 'terra aria wiring', ar: 'ربط aria من terra' })}</span>
<span>{tv({ es: 'control + prefix/suffix', en: 'control + prefix/suffix', ar: 'control + prefix/suffix' })}</span>
<span>{tv({ es: 'helper + error', en: 'helper + error', ar: 'مساعدة + خطأ' })}</span>
</div>
</div>
<div class="hero-aside">
<div>
<span>{tv({ es: 'API', en: 'API', ar: 'الواجهة' })}</span>
<strong>Root · Label · Control · Input · Prefix · Suffix · HelperText · Error</strong>
</div>
<div>
<span>{tv({ es: 'DOM', en: 'DOM', ar: 'DOM' })}</span>
<strong>{tv({ es: 'div[data-field] > label + div[data-field-control] + p helper/error', en: 'div[data-field] > label + div[data-field-control] + p helper/error', ar: 'div[data-field] > label + div[data-field-control] + p helper/error' })}</strong>
</div>
</div>
</header>
<section class="panel panel--feature">
<header class="section-header">
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>
{tv({
es: '`Field.Root` gobierna densidad y variant; `Field.Control` dibuja la caja visual del control.',
en: '`Field.Root` governs density and variant; `Field.Control` draws the visual control box.',
ar: 'يتحكم `Field.Root` في الكثافة والمتغير، بينما يرسم `Field.Control` الصندوق البصري للحقل.',
})}
</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>{tv({ es: 'Size', en: 'Size', ar: 'الحجم' })}</span>
<div class="chips">
{#each sizes as item}
<button
type="button"
class:chip={true}
class:chip--active={size === item}
onclick={() => (size = item)}>{item}</button
>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Variant', en: 'Variant', ar: 'المتغير' })}</span>
<div class="chips">
{#each variants as item}
<button
type="button"
class:chip={true}
class:chip--active={variant === item}
onclick={() => (variant = item)}>{item}</button
>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'State', en: 'State', ar: 'الحالة' })}</span>
<div class="chips">
{#each states as item}
<button
type="button"
class:chip={true}
class:chip--active={fieldState === item}
onclick={() => (fieldState = item)}>{item}</button
>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Flags', en: 'Flags', ar: 'العلامات' })}</span>
<div class="chips">
<button
type="button"
class:chip={true}
class:chip--active={required}
onclick={() => (required = !required)}
>{tv({ es: 'required', en: 'required', ar: 'إلزامي' })}</button>
</div>
</div>
</div>
<CodeExample title={tv({ es: 'Playground de Field', en: 'Field playground', ar: 'ساحة Field' })} code={playgroundCode}>
<div class="preview-stack">
<Field.Root {size} {variant} {required} state={fieldState}>
<Field.Label>Email</Field.Label>
<Field.Control>
<Field.Prefix><Icon.Mail size="sm" /></Field.Prefix>
<Field.Input type="email" bind:value placeholder="name@company.com" />
<Field.Suffix><Icon.BadgeCheck size="sm" /></Field.Suffix>
</Field.Control>
<Field.HelperText>{tv({ es: 'Usa tu correo profesional.', en: 'Use your work email.', ar: 'استخدم بريدك المهني.' })}</Field.HelperText>
<Field.Error>{tv({ es: 'Introduce un email valido.', en: 'Enter a valid email.', ar: 'أدخل بريداً إلكترونياً صالحاً.' })}</Field.Error>
</Field.Root>
{#if value}
<p class="value-hint">
<span class="value-label">{tv({ es: 'value:', en: 'value:', ar: 'القيمة:' })}</span>
<code class="value-code">{value}</code>
</p>
{/if}
</div>
</CodeExample>
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Sizes', en: 'Sizes', ar: 'الأحجام' })}</h2>
<p>{tv({ es: 'La densidad de `Field` sigue la misma escala que `Input` para no romper composición.', en: '`Field` density follows the same scale as `Input` to preserve composition.', ar: 'تتبع كثافة `Field` نفس مقياس `Input` حتى لا تنكسر البنية.' })}</p>
</header>
<CodeExample title={tv({ es: 'Tamaños de Field', en: 'Field sizes', ar: 'أحجام Field' })} code={sizesCode}>
<div class="stack">
{#each sizes as item}
<Field.Root size={item}>
<Field.Label>{item} {tv({ es: 'field', en: 'field', ar: 'حقل' })}</Field.Label>
<Field.Control>
<Field.Input placeholder={`Field size ${item}`} />
</Field.Control>
<Field.HelperText>{tv({ es: `Texto de ayuda para ${item}.`, en: `Helper text for ${item}.`, ar: `نص مساعد لـ ${item}.` })}</Field.HelperText>
</Field.Root>
{/each}
</div>
</CodeExample>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'States', en: 'States', ar: 'الحالات' })}</h2>
<p>{tv({ es: 'El estado sale de `terra.Field` y `air` solo lo refleja visualmente.', en: 'State comes from `terra.Field` and `air` only reflects it visually.', ar: 'تأتي الحالة من `terra.Field` ويعكسها `air` بصرياً فقط.' })}</p>
</header>
<CodeExample title={tv({ es: 'Estados de Field', en: 'Field states', ar: 'حالات Field' })} code={statesCode}>
<div class="states-grid">
{#each states as item}
<div class="state-card">
<p class="state-title">{item}</p>
<Field.Root state={item}>
<Field.Label>Email</Field.Label>
<Field.Control>
<Field.Input placeholder={`State ${item}`} value={item === 'readonly' ? 'readonly value' : ''} />
</Field.Control>
<Field.HelperText>{tv({ es: 'Texto de ayuda.', en: 'Helper copy.', ar: 'نص مساعد.' })}</Field.HelperText>
<Field.Error>{tv({ es: 'Texto de error.', en: 'Error copy.', ar: 'نص خطأ.' })}</Field.Error>
</Field.Root>
</div>
{/each}
</div>
</CodeExample>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Adornments', en: 'Adornments', ar: 'الزخارف' })}</h2>
<p>
{tv({ es: '`Field.Prefix` y `Field.Suffix` son decorativos y viven dentro de `Field.Control`.', en: '`Field.Prefix` and `Field.Suffix` are decorative and live inside `Field.Control`.', ar: 'يُعد `Field.Prefix` و `Field.Suffix` عناصر زخرفية ويعيشان داخل `Field.Control`.' })}
</p>
</header>
<CodeExample title={tv({ es: 'Adornments de Field', en: 'Field adornments', ar: 'زخارف Field' })} code={adornmentsCode}>
<div class="states-grid">
<Field.Root>
<Field.Label>{tv({ es: 'Precio', en: 'Price', ar: 'السعر' })}</Field.Label>
<Field.Control>
<Field.Prefix>€</Field.Prefix>
<Field.Input placeholder="0.00" />
<Field.Suffix>EUR</Field.Suffix>
</Field.Control>
</Field.Root>
<Field.Root variant="ghost">
<Field.Label>{tv({ es: 'Buscar', en: 'Search', ar: 'بحث' })}</Field.Label>
<Field.Control>
<Field.Prefix><Icon.Search size="sm" /></Field.Prefix>
<Field.Input type="search" placeholder={tv({ es: 'Buscar proyectos', en: 'Search projects', ar: 'ابحث في المشاريع' })} />
<Field.Suffix><Icon.Calendar size="sm" /></Field.Suffix>
</Field.Control>
</Field.Root>
</div>
</CodeExample>
</section>
</div>
<style>
.field-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-5);
}
.hero,
.panel {
padding: var(--air-space-5);
border-radius: var(--air-radius-xl);
border: 1px solid color-mix(in srgb, var(--air-color-border) 84%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
box-shadow: var(--air-shadow-1);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
gap: var(--air-space-5);
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--air-color-accent-muted) 22%, transparent), transparent 40%),
linear-gradient(
180deg,
color-mix(in srgb, var(--air-color-surface-raised) 96%, transparent),
color-mix(in srgb, var(--air-color-surface) 76%, transparent)
);
}
.hero-copy,
.hero-aside,
.controls,
.control,
.preview-stack,
.stack {
display: flex;
flex-direction: column;
}
.hero-copy,
.hero-aside {
gap: var(--air-space-3);
}
.hero-aside > div {
display: flex;
flex-direction: column;
gap: var(--air-space-1);
padding: var(--air-space-4);
border-radius: var(--air-radius-lg);
border: 1px solid color-mix(in srgb, var(--air-color-border) 80%, transparent);
background: color-mix(in srgb, var(--air-color-surface) 84%, transparent);
}
.eyebrow,
.hero-signals span,
.hero-aside span,
.state-title,
.value-label {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
}
.eyebrow {
margin: 0;
font-weight: var(--air-font-weight-medium);
letter-spacing: var(--air-tracking-wider);
text-transform: uppercase;
color: var(--air-color-accent-text);
}
.hero h1,
.section-header h2 {
margin: 0;
font-weight: var(--air-font-weight-semibold);
line-height: var(--air-leading-heading);
}
.hero h1 {
font-size: var(--air-font-size-2xl);
letter-spacing: var(--air-tracking-tight);
}
.lead,
.section-header p {
margin: 0;
font-size: var(--air-font-size-md);
line-height: var(--air-leading-text);
color: var(--air-color-text-secondary);
}
.hero-signals {
display: flex;
flex-wrap: wrap;
gap: var(--air-space-2);
}
.hero-signals span {
padding: var(--air-space-1) var(--air-space-2);
border-radius: var(--air-radius-full);
border: 1px solid color-mix(in srgb, var(--air-color-border) 76%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 88%, transparent);
color: var(--air-color-text-secondary);
}
.hero-aside span {
color: var(--air-color-text-secondary);
}
.hero-aside strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-text);
font-weight: var(--air-font-weight-medium);
}
.panel--feature {
background:
linear-gradient(180deg, color-mix(in srgb, var(--air-color-accent-muted) 10%, transparent), transparent),
color-mix(in srgb, var(--air-color-surface-raised) 95%, transparent);
}
.section-header {
display: flex;
flex-direction: column;
gap: var(--air-space-2);
}
.playground-grid {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: var(--air-space-5);
margin-top: var(--air-space-4);
}
.controls {
gap: var(--air-space-4);
}
.control {
gap: var(--air-space-2);
}
.control > span,
.state-title {
color: var(--air-color-text-secondary);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--air-space-2);
}
.chip {
border: 1px solid var(--air-color-border);
background: color-mix(in srgb, var(--air-color-surface) 82%, transparent);
color: var(--air-color-text-primary);
padding: var(--air-space-2) var(--air-space-3);
border-radius: var(--air-radius-full);
font: inherit;
font-size: var(--air-font-size-xs);
cursor: pointer;
}
.chip--active {
background: var(--air-color-accent);
border-color: var(--air-color-accent);
color: var(--air-color-surface-raised);
}
.preview-stack,
.stack {
gap: var(--air-space-4);
}
.states-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--air-space-4);
}
.state-card {
display: flex;
flex-direction: column;
gap: var(--air-space-3);
padding: var(--air-space-4);
border-radius: var(--air-radius-lg);
border: 1px solid color-mix(in srgb, var(--air-color-border) 84%, transparent);
background: color-mix(in srgb, var(--air-color-surface) 84%, transparent);
}
.value-hint {
display: flex;
flex-wrap: wrap;
gap: var(--air-space-2);
margin: 0;
}
.value-code {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
color: var(--air-color-accent-text);
}
@media (max-width: 900px) {
.hero,
.playground-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -1,12 +1,28 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Icon } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
let size = $state<'2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'>('md');
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
let size = $state<'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>('md');
let strokeWidth = $state<'sm' | 'md' | 'lg'>('md');
const sizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl'] as const;
const sizes = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const;
const strokes = ['sm', 'md', 'lg'] as const;
const sizeTokens = {
xxs: '12px',
xs: '14px',
sm: '16px',
md: '18px',
lg: '20px',
xl: '24px',
xxl: '48px'
} as const;
const glyphs = [
{ name: 'Plus', component: Icon.Plus },
@ -60,39 +76,51 @@
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Icon</p>
<h1>Iconografia base del sistema</h1>
<h1>{tv({ es: 'Iconografía base del sistema', en: 'Base iconography of the system', ar: 'أيقونات النظام الأساسية' })}</h1>
<p class="lead">
`Icon.Root` es la primitive base del sistema y los glyphs viven bajo el mismo namespace:
`Icon.Plus`, `Icon.Search`, `Icon.ChevronRight`.
{tv({
es: '`Icon.Root` es la primitive base del sistema y los glyphs viven bajo el mismo namespace: `Icon.Plus`, `Icon.Search`, `Icon.ChevronRight`.',
en: '`Icon.Root` is the base primitive of the system and glyphs live under the same namespace: `Icon.Plus`, `Icon.Search`, `Icon.ChevronRight`.',
ar: '`Icon.Root` هو البدائية الأساسية للنظام، وتعيش الرموز تحت نفس المساحة الاسمية: `Icon.Plus` و `Icon.Search` و `Icon.ChevronRight`.',
})}
</p>
<div class="hero-signals">
<span>Stroke based</span>
<span>Token sized</span>
<span>{tv({ es: 'basado en stroke', en: 'stroke based', ar: 'مبني على stroke' })}</span>
<span>{tv({ es: 'escalado por tokens', en: 'token sized', ar: 'أحجام عبر tokens' })}</span>
<span>CurrentColor</span>
<span>{tv({ es: '7 tamaños soportados', en: '7 supported sizes', ar: '7 أحجام مدعومة' })}</span>
</div>
</div>
<div class="hero-aside">
<div>
<span>Primitive</span>
<strong>Icon.Root renders the shared SVG grammar</strong>
<span>{tv({ es: 'Primitive', en: 'Primitive', ar: 'البدائية' })}</span>
<strong>{tv({ es: 'Icon.Root renderiza la gramática SVG compartida', en: 'Icon.Root renders the shared SVG grammar', ar: 'يعرض Icon.Root القواعد المشتركة لـ SVG' })}</strong>
</div>
<div>
<span>DOM</span>
<strong>{tv({ es: '<svg> + paths, sin wrapper extra', en: '<svg> + paths, no wrapper element', ar: '<svg> + paths بلا عنصر wrapper' })}</strong>
</div>
<div>
<span>API</span>
<strong>size='xxs|xs|sm|md|lg|xl|xxl|number|string' · strokeWidth='sm|md|lg|number'</strong>
</div>
<div>
<span>Collection</span>
<strong>Curated set with room to grow deliberately</strong>
<span>{tv({ es: 'Collection', en: 'Collection', ar: 'المجموعة' })}</span>
<strong>{tv({ es: 'Set curado con espacio para crecer de forma deliberada', en: 'Curated set with room to grow deliberately', ar: 'مجموعة منسقة مع مساحة للنمو بشكل مقصود' })}</strong>
</div>
</div>
</header>
<section class="panel panel--feature">
<header class="section-header">
<h2>Playground</h2>
<p>Ajusta tamano y grosor del trazo para validar la gramatica base del set.</p>
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>{tv({ es: 'Ajusta tamaño y grosor del trazo para validar la gramática base del set.', en: 'Adjust size and stroke width to validate the base grammar of the set.', ar: 'اضبط الحجم وسماكة الخط للتحقق من القواعد الأساسية للمجموعة.' })}</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>Size</span>
<span>{tv({ es: 'Size', en: 'Size', ar: 'الحجم' })}</span>
<div class="chips">
{#each sizes as item}
<button
@ -105,7 +133,7 @@
</div>
<div class="control">
<span>Stroke width</span>
<span>{tv({ es: 'Stroke width', en: 'Stroke width', ar: 'سماكة الخط' })}</span>
<div class="chips">
{#each strokes as item}
<button
@ -118,7 +146,7 @@
</div>
</div>
<CodeExample title="Primitive glyph" code={playgroundCode}>
<CodeExample title={tv({ es: 'Glyph primitivo', en: 'Primitive glyph', ar: 'الرمز البدائي' })} code={playgroundCode}>
<div class="preview preview--hero">
<Icon.Root size={size} strokeWidth={strokeWidth} decorative={false} title="Arrow right">
<path d="M5 12h14" />
@ -131,10 +159,13 @@
<section class="panel">
<header class="section-header">
<h2>Set fundacional</h2>
<h2>{tv({ es: 'Set fundacional', en: 'Foundation set', ar: 'المجموعة التأسيسية' })}</h2>
<p>
Este es el nucleo actual de `air`. Si mas adelante hace falta ampliar el set,
incorporamos nuevos componentes con la misma gramatica.
{tv({
es: 'Este es el núcleo actual de `air`. Si más adelante hace falta ampliar el set, incorporamos nuevos componentes con la misma gramática.',
en: 'This is the current core of `air`. If the set needs to grow later, we add new components with the same grammar.',
ar: 'هذه هي النواة الحالية لـ `air`. وإذا احتجنا لاحقاً إلى توسيع المجموعة فسنضيف عناصر جديدة ضمن القواعد نفسها.',
})}
</p>
</header>
@ -152,8 +183,8 @@
<section class="panel">
<header class="section-header">
<h2>Escala</h2>
<p>La escala de iconos vive en tokens globales, no en medidas sueltas por componente.</p>
<h2>{tv({ es: 'Escala', en: 'Scale', ar: 'المقياس' })}</h2>
<p>{tv({ es: 'La escala de iconos vive en tokens globales, no en medidas sueltas por componente.', en: 'The icon scale lives in global tokens, not in loose per-component measurements.', ar: 'يعيش مقياس الأيقونات داخل tokens عامة لا كقيم منفصلة لكل مكوّن.' })}</p>
</header>
<div class="scale-row">
@ -161,7 +192,7 @@
<CodeExample title={`Scale ${item}`} code={`<Icon.ChevronRight size="${item}" />`}>
<div class="scale-item">
<Icon.ChevronRight size={item} />
<span>{item}</span>
<span>{item} · {sizeTokens[item]}</span>
</div>
</CodeExample>
{/each}

@ -1,15 +1,15 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Icon } from '$uix/air';
import { AIR_ICON_AUTO_STROKE_BY_SIZE } from '$uix/air/icons/create-icon';
import { iconCategories } from '$uix/air/icons/categories';
import { AIR_ICON_AUTO_STROKE_BY_SIZE } from '$uix/air/components/icon/create-icon';
import { iconCategories } from '$uix/air/components/icon/categories';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
let search = $state('');
let selectedCategory = $state<string | null>(null);
let sizeMode = $state<'token' | 'custom'>('token');
let size = $state<'2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'>('sm');
let size = $state<'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>('sm');
let customSize = $state(18);
let strokeWidth = $state<'auto' | 'sm' | 'md' | 'lg'>('auto');
let tone = $state<'default' | 'accent' | 'neutral' | 'success' | 'warning' | 'danger' | 'info'>(
@ -20,14 +20,23 @@
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const t = (path: string, ...args: unknown[]) => demoContext?.t(path, ...args) ?? path;
const sizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl'] as const;
const sizes = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const;
const strokeWidths = ['auto', 'sm', 'md', 'lg'] as const;
const tones = ['default', 'accent', 'neutral', 'success', 'warning', 'danger', 'info'] as const;
const sizeTokens = {
xxs: '12px',
xs: '14px',
sm: '16px',
md: '18px',
lg: '20px',
xl: '24px',
xxl: '48px'
} as const;
const resolvedPreviewSize = $derived(sizeMode === 'custom' ? customSize : size);
const previewSizeLabel = $derived(sizeMode === 'custom' ? `${customSize}px` : size);
const autoStrokeValue = $derived.by(() => {
if (sizeMode === 'token') return AIR_ICON_AUTO_STROKE_BY_SIZE[size];
if (customSize <= 12) return AIR_ICON_AUTO_STROKE_BY_SIZE['2xs'];
if (customSize <= 12) return AIR_ICON_AUTO_STROKE_BY_SIZE.xxs;
if (customSize <= 14) return AIR_ICON_AUTO_STROKE_BY_SIZE.xs;
if (customSize <= 16) return AIR_ICON_AUTO_STROKE_BY_SIZE.sm;
if (customSize <= 18) return AIR_ICON_AUTO_STROKE_BY_SIZE.md;
@ -92,6 +101,16 @@
<span>{previewStrokeLabel} stroke</span>
<span>{tone} tone</span>
</div>
<div class="hero-facts" aria-label="Icon demo metadata">
<div class="hero-fact">
<span>API</span>
<strong>Icon.Name size='xxs|xs|sm|md|lg|xl|xxl|number|string' · strokeWidth='auto|sm|md|lg|number'</strong>
</div>
<div class="hero-fact">
<span>DOM</span>
<strong>button.icon-card &gt; span.icon-stage &gt; svg</strong>
</div>
</div>
</div>
<div class="hero-side">
@ -219,6 +238,29 @@
</div>
</header>
<section class="scale-reference">
<header class="section-header">
<h2>Token scale reference</h2>
<p>
La escala real del sistema de iconos. Aqui se ven a la vez los seis tamanos canonicos,
el valor en px y el stroke automatico asociado.
</p>
</header>
<div class="scale-reference-grid">
{#each sizes as item}
<div class={`scale-reference-item icon-card--tone-${tone}`}>
<Icon.Search
size={item}
strokeWidth={strokeWidth === 'auto' ? undefined : strokeWidth}
/>
<strong>{item}</strong>
<span>{sizeTokens[item]} · auto {AIR_ICON_AUTO_STROKE_BY_SIZE[item]}</span>
</div>
{/each}
</div>
</section>
<div class="content">
<nav class="categories-nav" aria-label="Icon categories">
<button
@ -372,6 +414,37 @@
color: var(--air-color-text-secondary);
}
.hero-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--air-space-3);
}
.hero-fact {
display: flex;
flex-direction: column;
gap: var(--air-space-1);
padding: var(--air-space-3);
border-radius: var(--air-radius-lg);
border: 1px solid color-mix(in srgb, var(--air-color-border) 82%, transparent);
background: color-mix(in srgb, var(--air-color-surface) 84%, transparent);
}
.hero-fact span {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
letter-spacing: var(--air-tracking-wide);
text-transform: uppercase;
color: var(--air-color-text-secondary);
}
.hero-fact strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-text);
font-weight: var(--air-font-weight-medium);
}
.controls-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -584,6 +657,53 @@
align-items: start;
}
.scale-reference {
display: flex;
flex-direction: column;
gap: var(--air-space-3);
padding: var(--air-space-4);
border-radius: var(--air-radius-xl);
border: 1px solid color-mix(in srgb, var(--air-color-border) 84%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
box-shadow: var(--air-shadow-1);
}
.scale-reference-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--air-space-3);
}
.scale-reference-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--air-space-2);
padding: var(--air-space-4) var(--air-space-3);
border-radius: var(--air-radius-lg);
border: 1px solid color-mix(in srgb, var(--air-color-border) 84%, transparent);
background: color-mix(in srgb, var(--air-color-surface) 84%, transparent);
color: var(--icon-tone-color, var(--air-color-text-primary));
}
.scale-reference-item strong,
.scale-reference-item span {
font-family: var(--air-font-mono);
line-height: var(--air-leading-ui);
text-align: center;
}
.scale-reference-item strong {
font-size: var(--air-font-size-xs);
color: var(--air-color-text-primary);
}
.scale-reference-item span {
font-size: 10px;
color: var(--air-color-text-secondary);
}
.categories-nav {
position: sticky;
top: var(--air-space-4);
@ -716,6 +836,10 @@
grid-template-columns: 1fr;
}
.hero-facts {
grid-template-columns: 1fr;
}
.categories-nav {
position: static;
max-height: none;

@ -1,8 +1,15 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Icon, Input, Textarea } from '$uix/air';
import type { AirInputSize, AirInputVariant } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
let size = $state<AirInputSize>('md');
let variant = $state<AirInputVariant>('outline');
let invalid = $state(false);
@ -76,6 +83,22 @@
const codeTextarea = `<Textarea placeholder="Mensaje..." />
<Textarea placeholder="Sin resize" resize="none" />
<Textarea placeholder="Libre" resize="both" />`;
const textareaMessageId = 'air-demo-textarea-message';
const textareaNotesId = 'air-demo-textarea-notes';
const textareaErrorId = 'air-demo-textarea-error';
const textareaDisabledId = 'air-demo-textarea-disabled';
const textareaReadonlyId = 'air-demo-textarea-readonly';
const adornmentPriceId = 'air-demo-adornment-price';
const adornmentWeightId = 'air-demo-adornment-weight';
const adornmentEmailId = 'air-demo-adornment-email';
const adornmentSearchId = 'air-demo-adornment-search';
const adornmentDateId = 'air-demo-adornment-date';
const adornmentPasswordId = 'air-demo-adornment-password';
const stateIdleId = 'air-demo-state-idle';
const stateErrorId = 'air-demo-state-error';
const stateDisabledId = 'air-demo-state-disabled';
const stateReadonlyId = 'air-demo-state-readonly';
</script>
<svelte:head>
@ -96,35 +119,59 @@
<!-- ── Hero ──────────────────────────────────────────────────────────── -->
<header class="demo-hero">
<p class="demo-eyebrow">Air / Input</p>
<h1>Input · Textarea</h1>
<h1>{tv({ es: 'Input · Textarea', en: 'Input · Textarea', ar: 'Input · Textarea' })}</h1>
<p class="demo-lead">
Primitivo visual standalone. Sin dependencia de terra.
<code>size</code> y <code>variant</code> ortogonales.
Prefix/suffix nativos en el layout del grupo.
{tv({
es: 'Primitivo visual standalone. Sin dependencia de terra. ',
en: 'Standalone visual primitive. No terra dependency. ',
ar: 'عنصر بصري مستقل. من دون اعتماد على terra. ',
})}
<code>size</code> {tv({ es: 'y', en: 'and', ar: 'و' })} <code>variant</code>
{tv({
es: ' son ortogonales. Prefix/suffix nativos en el layout del grupo.',
en: ' are orthogonal. Native prefix/suffix live in the group layout.',
ar: ' متعامدان. ويعيش prefix/suffix الأصليان داخل تخطيط المجموعة.',
})}
</p>
<div class="demo-signals">
<span>{tv({ es: '4 tamaños', en: '4 sizes', ar: '4 أحجام' })}</span>
<span>{tv({ es: '2 variantes', en: '2 variants', ar: 'متغيران' })}</span>
<span>{tv({ es: 'primitiva standalone', en: 'standalone primitive', ar: 'بدائية مستقلة' })}</span>
<span>{tv({ es: 'DOM group + element', en: 'group + element DOM', ar: 'DOM group + element' })}</span>
</div>
<div class="demo-meta" aria-label={tv({ es: 'Metadatos de Input', en: 'Input metadata', ar: 'بيانات Input' })}>
<div class="demo-meta-card">
<span>{tv({ es: 'API', en: 'API', ar: 'الواجهة' })}</span>
<strong>{tv({ es: 'size · variant · invalid · prefix · suffix · atributos nativos del input', en: 'size · variant · invalid · prefix · suffix · native input attrs', ar: 'size · variant · invalid · prefix · suffix · خصائص input الأصلية' })}</strong>
</div>
<div class="demo-meta-card">
<span>{tv({ es: 'DOM', en: 'DOM', ar: 'DOM' })}</span>
<strong>{tv({ es: 'div.air-input-group > input.air-input-element + adornos opcionales', en: 'div.air-input-group > input.air-input-element + optional adornments', ar: 'div.air-input-group > input.air-input-element + زخارف اختيارية' })}</strong>
</div>
</div>
</header>
<!-- ── Playground ────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Playground</h2>
<h2 class="section-title">{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<div class="playground-controls">
<fieldset class="ctrl-group">
<legend class="ctrl-label">size</legend>
<legend class="ctrl-label">{tv({ es: 'size', en: 'size', ar: 'الحجم' })}</legend>
{#each sizes as s}
<button type="button" class="ctrl-chip" class:active={size === s}
onclick={() => (size = s)}>{s}</button>
{/each}
</fieldset>
<fieldset class="ctrl-group">
<legend class="ctrl-label">variant</legend>
<legend class="ctrl-label">{tv({ es: 'variant', en: 'variant', ar: 'المتغير' })}</legend>
{#each variants as v}
<button type="button" class="ctrl-chip" class:active={variant === v}
onclick={() => (variant = v)}>{v}</button>
{/each}
</fieldset>
<fieldset class="ctrl-group">
<legend class="ctrl-label">estado</legend>
<legend class="ctrl-label">{tv({ es: 'estado', en: 'state', ar: 'الحالة' })}</legend>
<button type="button" class="ctrl-chip ctrl-chip--danger" class:active={invalid}
onclick={() => { invalid = !invalid; disabled = false; readonly = false; }}>invalid</button>
<button type="button" class="ctrl-chip" class:active={disabled}
@ -134,13 +181,13 @@
</fieldset>
</div>
<CodeExample title="Playground interactivo" code={playgroundCode}>
<CodeExample title={tv({ es: 'Playground interactivo', en: 'Interactive playground', ar: 'ساحة تجريب تفاعلية' })} code={playgroundCode}>
<div class="playground-preview">
<Input {size} {variant} {invalid} {disabled} {readonly}
placeholder="Escribe algo..." bind:value={playgroundValue} />
placeholder={tv({ es: 'Escribe algo...', en: 'Type something...', ar: 'اكتب شيئاً...' })} bind:value={playgroundValue} />
{#if playgroundValue}
<p class="value-hint">
<span class="value-label">value:</span>
<span class="value-label">{tv({ es: 'value:', en: 'value:', ar: 'القيمة:' })}</span>
<code class="value-code">{playgroundValue}</code>
</p>
{/if}
@ -150,12 +197,15 @@
<!-- ── Sizes ──────────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Sizes</h2>
<h2 class="section-title">{tv({ es: 'Sizes', en: 'Sizes', ar: 'الأحجام' })}</h2>
<p class="section-desc">
xs (26px) · sm (30px) · md (36px) · lg (44px).
Alturas alineadas con la escala de Button para ortogonalidad en Field.
{tv({
es: 'xs (26px) · sm (30px) · md (36px) · lg (44px). Alturas alineadas con la escala de Button para ortogonalidad en Field.',
en: 'xs (26px) · sm (30px) · md (36px) · lg (44px). Heights align with the Button scale to preserve orthogonality in Field.',
ar: 'xs ‏(26px) · sm ‏(30px) · md ‏(36px) · lg ‏(44px). تتوافق الارتفاعات مع مقياس Button للحفاظ على التعامد داخل Field.',
})}
</p>
<CodeExample title="xs · sm · md · lg" code={codeSizes}>
<CodeExample title={tv({ es: 'xs · sm · md · lg', en: 'xs · sm · md · lg', ar: 'xs · sm · md · lg' })} code={codeSizes}>
<div class="sizes-stack">
<div class="size-row">
<span class="size-label">xs</span>
@ -179,12 +229,12 @@
<!-- ── Variants ───────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Variants</h2>
<h2 class="section-title">{tv({ es: 'Variants', en: 'Variants', ar: 'المتغيرات' })}</h2>
<p class="section-desc">
<code>outline</code> — superficie propia, borde visible en reposo.
<code>ghost</code> — fondo transparente, borde y tinte solo en hover/focus.
<code>outline</code> {tv({ es: '— superficie propia, borde visible en reposo.', en: '— own surface, visible border at rest.', ar: '— سطح مستقل وحد ظاهر في وضع السكون.' })}
<code>ghost</code> {tv({ es: '— fondo transparente, borde y tinte solo en hover/focus.', en: '— transparent background, border and tint only on hover/focus.', ar: '— خلفية شفافة وحد ولون فقط عند hover/focus.' })}
</p>
<CodeExample title="outline · ghost" code={codeVariants}>
<CodeExample title={tv({ es: 'outline · ghost', en: 'outline · ghost', ar: 'outline · ghost' })} code={codeVariants}>
<div class="variant-grid">
<div class="variant-col">
<p class="variant-label">outline</p>
@ -202,35 +252,39 @@
<!-- ── Adornments ─────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Adornments</h2>
<h2 class="section-title">{tv({ es: 'Adornments', en: 'Adornments', ar: 'الزخارف' })}</h2>
<p class="section-desc">
Prefix y suffix viven en el flex layout del grupo. Cualquier snippet: icono, texto, moneda.
{tv({
es: 'Prefix y suffix viven en el flex layout del grupo. Cualquier snippet: icono, texto, moneda.',
en: 'Prefix and suffix live in the group flex layout. Any snippet works: icon, text, currency.',
ar: 'يعيش prefix و suffix داخل تخطيط flex للمجموعة. ويمكن أن يكونا أي snippet: أيقونة أو نصاً أو عملة.',
})}
</p>
<CodeExample title="prefix · suffix · combinado" code={codeAdornments}>
<CodeExample title={tv({ es: 'prefix · suffix · combinado', en: 'prefix · suffix · combined', ar: 'prefix · suffix · combined' })} code={codeAdornments}>
<div class="adornment-grid">
<div class="labeled-input">
<label class="demo-label">Precio</label>
<Input prefix={euroPrefix} placeholder="0.00" type="number" />
<label class="demo-label" for={adornmentPriceId}>{tv({ es: 'Precio', en: 'Price', ar: 'السعر' })}</label>
<Input id={adornmentPriceId} prefix={euroPrefix} placeholder="0.00" type="number" />
</div>
<div class="labeled-input">
<label class="demo-label">Peso</label>
<Input suffix={kgSuffix} placeholder="0.0" type="number" />
<label class="demo-label" for={adornmentWeightId}>{tv({ es: 'Peso', en: 'Weight', ar: 'الوزن' })}</label>
<Input id={adornmentWeightId} suffix={kgSuffix} placeholder="0.0" type="number" />
</div>
<div class="labeled-input">
<label class="demo-label">Email</label>
<Input prefix={mailPrefix} placeholder="correo@ejemplo.com" type="email" />
<label class="demo-label" for={adornmentEmailId}>Email</label>
<Input id={adornmentEmailId} prefix={mailPrefix} placeholder={tv({ es: 'correo@ejemplo.com', en: 'name@example.com', ar: 'name@example.com' })} type="email" />
</div>
<div class="labeled-input">
<label class="demo-label">Buscar</label>
<Input prefix={searchPrefix} placeholder="Buscar..." />
<label class="demo-label" for={adornmentSearchId}>{tv({ es: 'Buscar', en: 'Search', ar: 'بحث' })}</label>
<Input id={adornmentSearchId} prefix={searchPrefix} placeholder={tv({ es: 'Buscar...', en: 'Search...', ar: 'ابحث...' })} />
</div>
<div class="labeled-input">
<label class="demo-label">Fecha</label>
<Input suffix={calendarSuffix} placeholder="DD/MM/AAAA" />
<label class="demo-label" for={adornmentDateId}>{tv({ es: 'Fecha', en: 'Date', ar: 'التاريخ' })}</label>
<Input id={adornmentDateId} suffix={calendarSuffix} placeholder={tv({ es: 'DD/MM/AAAA', en: 'MM/DD/YYYY', ar: 'YYYY/MM/DD' })} />
</div>
<div class="labeled-input">
<label class="demo-label">Contraseña</label>
<Input prefix={lockPrefix} suffix={eyeSuffix} type="password" placeholder="••••••••" />
<label class="demo-label" for={adornmentPasswordId}>{tv({ es: 'Contraseña', en: 'Password', ar: 'كلمة المرور' })}</label>
<Input id={adornmentPasswordId} prefix={lockPrefix} suffix={eyeSuffix} type="password" placeholder={tv({ es: '••••••••', en: '••••••••', ar: '••••••••' })} />
</div>
</div>
</CodeExample>
@ -238,29 +292,29 @@
<!-- ── Estados ────────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Estados</h2>
<h2 class="section-title">{tv({ es: 'Estados', en: 'States', ar: 'الحالات' })}</h2>
<p class="section-desc">
<code>invalid</code> activa borde danger y <code>aria-invalid</code>.
<code>disabled</code> bloquea interacción. <code>readonly</code> permite lectura.
<code>invalid</code> {tv({ es: 'activa borde danger y', en: 'activates danger border and', ar: 'يفعّل حد الخطر و' })} <code>aria-invalid</code>.
<code>disabled</code> {tv({ es: 'bloquea interacción.', en: 'blocks interaction.', ar: 'يمنع التفاعل.' })} <code>readonly</code> {tv({ es: 'permite lectura.', en: 'allows reading.', ar: 'يسمح بالقراءة.' })}
</p>
<CodeExample title="idle · error · disabled · readonly" code={codeStates}>
<CodeExample title={tv({ es: 'idle · error · disabled · readonly', en: 'idle · error · disabled · readonly', ar: 'idle · error · disabled · readonly' })} code={codeStates}>
<div class="states-grid">
<div class="labeled-input">
<label class="demo-label">Idle</label>
<Input placeholder="Nombre de usuario" />
<label class="demo-label" for={stateIdleId}>Idle</label>
<Input id={stateIdleId} placeholder={tv({ es: 'Nombre de usuario', en: 'Username', ar: 'اسم المستخدم' })} />
</div>
<div class="labeled-input">
<label class="demo-label demo-label--error">Error</label>
<Input placeholder="Campo requerido" invalid />
<p class="error-hint">Este campo es obligatorio</p>
<label class="demo-label demo-label--error" for={stateErrorId}>{tv({ es: 'Error', en: 'Error', ar: 'خطأ' })}</label>
<Input id={stateErrorId} placeholder={tv({ es: 'Campo requerido', en: 'Required field', ar: 'حقل مطلوب' })} invalid />
<p class="error-hint">{tv({ es: 'Este campo es obligatorio', en: 'This field is required', ar: 'هذا الحقل مطلوب' })}</p>
</div>
<div class="labeled-input">
<label class="demo-label demo-label--disabled">Disabled</label>
<Input placeholder="No disponible" disabled />
<label class="demo-label demo-label--disabled" for={stateDisabledId}>Disabled</label>
<Input id={stateDisabledId} placeholder={tv({ es: 'No disponible', en: 'Unavailable', ar: 'غير متاح' })} disabled />
</div>
<div class="labeled-input">
<label class="demo-label">Readonly</label>
<Input value="admin@empresa.com" readonly />
<label class="demo-label" for={stateReadonlyId}>Readonly</label>
<Input id={stateReadonlyId} value={tv({ es: 'admin@empresa.com', en: 'admin@company.com', ar: 'admin@company.com' })} readonly />
</div>
</div>
</CodeExample>
@ -268,14 +322,14 @@
<!-- ── Types ──────────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Types</h2>
<p class="section-desc">El componente acepta todos los tipos de input nativos.</p>
<CodeExample title="text · email · password · search · url · tel" code={`<Input type="text" placeholder="Texto libre" />\n<Input type="email" placeholder="correo@ejemplo.com" />\n<Input type="password" placeholder="Contraseña" />\n<Input type="search" placeholder="Buscar..." />\n<Input type="url" placeholder="https://" />\n<Input type="tel" placeholder="+34 600 000 000" />`}>
<h2 class="section-title">{tv({ es: 'Types', en: 'Types', ar: 'الأنواع' })}</h2>
<p class="section-desc">{tv({ es: 'El componente acepta todos los tipos de input nativos.', en: 'The component accepts all native input types.', ar: 'يدعم المكوّن جميع أنواع input الأصلية.' })}</p>
<CodeExample title={tv({ es: 'text · email · password · search · url · tel', en: 'text · email · password · search · url · tel', ar: 'text · email · password · search · url · tel' })} code={`<Input type="text" placeholder="Texto libre" />\n<Input type="email" placeholder="correo@ejemplo.com" />\n<Input type="password" placeholder="Contraseña" />\n<Input type="search" placeholder="Buscar..." />\n<Input type="url" placeholder="https://" />\n<Input type="tel" placeholder="+34 600 000 000" />`}>
<div class="types-grid">
<Input type="text" placeholder="Texto libre" />
<Input type="email" placeholder="correo@ejemplo.com" />
<Input type="password" placeholder="Contraseña" />
<Input type="search" placeholder="Buscar..." />
<Input type="text" placeholder={tv({ es: 'Texto libre', en: 'Free text', ar: 'نص حر' })} />
<Input type="email" placeholder={tv({ es: 'correo@ejemplo.com', en: 'name@example.com', ar: 'name@example.com' })} />
<Input type="password" placeholder={tv({ es: 'Contraseña', en: 'Password', ar: 'كلمة المرور' })} />
<Input type="search" placeholder={tv({ es: 'Buscar...', en: 'Search...', ar: 'ابحث...' })} />
<Input type="url" placeholder="https://" />
<Input type="tel" placeholder="+34 600 000 000" />
</div>
@ -284,30 +338,44 @@
<!-- ── Semánticas ────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Semánticas</h2>
<h2 class="section-title">{tv({ es: 'Semánticas', en: 'Semantics', ar: 'الدلالات' })}</h2>
<p class="section-desc">
Input tiene semántica <code>attention</code>: cuando <code>invalid</code> transiciona a
<code>true</code>, el campo emite una vibración horizontal (motion) y un tono descendente
tenso (sound). El consumidor declara el estado — el componente gestiona el resto.
{tv({
es: 'Input tiene semántica ',
en: 'Input exposes the ',
ar: 'يمتلك Input دلالة ',
})}<code>attention</code>{tv({
es: ': cuando ',
en: ' semantic: when ',
ar: ': فعندما تتحول ',
})}<code>invalid</code>{tv({
es: ' transiciona a ',
en: ' transitions to ',
ar: ' إلى ',
})}<code>true</code>{tv({
es: ', el campo emite una vibración horizontal (motion) y un tono descendente tenso (sound). El consumidor declara el estado; el componente gestiona el resto.',
en: ', the field emits a horizontal shake (motion) and a tense descending tone (sound). The consumer declares state; the component handles the rest.',
ar: ' يصدر الحقل اهتزازاً أفقياً (motion) ونغمة هابطة متوترة (sound). يصرّح المستهلك بالحالة ويتولى المكوّن الباقي.',
})}
</p>
<div class="semantics-table">
<div class="semantics-row semantics-row--header">
<span>Semántica</span>
<span>Trigger</span>
<span>Motion</span>
<span>Sound</span>
<span>{tv({ es: 'Semántica', en: 'Semantic', ar: 'الدلالة' })}</span>
<span>{tv({ es: 'Trigger', en: 'Trigger', ar: 'المحفّز' })}</span>
<span>{tv({ es: 'Motion', en: 'Motion', ar: 'الحركة' })}</span>
<span>{tv({ es: 'Sound', en: 'Sound', ar: 'الصوت' })}</span>
</div>
<div class="semantics-row">
<code class="sem-badge sem-badge--attention">attention</code>
<span><code>invalid</code> false → true</span>
<span>Vibración horizontal</span>
<span>Tono descendente tenso (1850 → 1480 Hz)</span>
<span>{tv({ es: 'Vibración horizontal', en: 'Horizontal shake', ar: 'اهتزاز أفقي' })}</span>
<span>{tv({ es: 'Tono descendente tenso (1850 → 1480 Hz)', en: 'Tense descending tone (1850 → 1480 Hz)', ar: 'نغمة هابطة متوترة (1850 ← 1480 هرتز)' })}</span>
</div>
</div>
<CodeExample
title="Attention — error trigger en vivo"
title={tv({ es: 'Attention — error trigger en vivo', en: 'Attention — live error trigger', ar: 'Attention — محفّز الخطأ الحي' })}
code={`<!-- El componente gestiona motion + sound internamente -->\n<Input bind:invalid={isInvalid} placeholder="Email" />\n<Textarea bind:invalid={isInvalid} placeholder="Mensaje..." />\n\n<!-- El consumidor solo cambia el estado -->\n<button onclick={() => isInvalid = true}>Enviar</button>`}
>
<div class="attention-demo">
@ -315,31 +383,34 @@
<div class="labeled-input">
<label for="attn-email" class="demo-label" class:demo-label--error={attentionInvalid}>
{attentionInvalid ? 'Campo requerido' : 'Email'}
{#if false}{/if}
</label>
<Input
id="attn-email"
invalid={attentionInvalid}
bind:value={attentionValue}
placeholder="correo@ejemplo.com"
placeholder={tv({ es: 'correo@ejemplo.com', en: 'name@example.com', ar: 'name@example.com' })}
type="email"
/>
</div>
<div class="labeled-input">
<label for="attn-msg" class="demo-label" class:demo-label--error={attentionTaInvalid}>
{attentionTaInvalid ? 'Mensaje requerido' : 'Mensaje'}
{attentionTaInvalid
? tv({ es: 'Mensaje requerido', en: 'Message required', ar: 'الرسالة مطلوبة' })
: tv({ es: 'Mensaje', en: 'Message', ar: 'الرسالة' })}
</label>
<Textarea
id="attn-msg"
invalid={attentionTaInvalid}
bind:value={attentionTextarea}
placeholder="Escribe tu mensaje..."
placeholder={tv({ es: 'Escribe tu mensaje...', en: 'Write your message...', ar: 'اكتب رسالتك...' })}
resize="none"
/>
</div>
</div>
<div class="attention-actions">
<button type="button" class="demo-submit" onclick={triggerAttention}>
Enviar (dispara attention)
{tv({ es: 'Enviar (dispara attention)', en: 'Submit (triggers attention)', ar: 'إرسال (يحفّز attention)' })}
</button>
{#if attentionInvalid}
<button
@ -347,12 +418,24 @@
class="demo-reset"
onclick={() => { attentionInvalid = false; attentionTaInvalid = false; }}
>
Resetear
{tv({ es: 'Resetear', en: 'Reset', ar: 'إعادة الضبط' })}
</button>
{/if}
</div>
<p class="attention-note">
Activa <strong>motion</strong> y <strong>sound</strong> desde el toolbar si están desactivados.
{tv({
es: 'Activa ',
en: 'Enable ',
ar: 'فعّل ',
})}<strong>motion</strong>{tv({
es: ' y ',
en: ' and ',
ar: ' و ',
})}<strong>sound</strong>{tv({
es: ' desde el toolbar si están desactivados.',
en: ' from the toolbar if they are disabled.',
ar: ' من شريط الأدوات إذا كانا معطّلين.',
})}
</p>
</div>
</CodeExample>
@ -360,36 +443,36 @@
<!-- ── Textarea ───────────────────────────────────────────────────────── -->
<section class="demo-section">
<h2 class="section-title">Textarea</h2>
<p class="section-desc">Mismos tokens que Input. Crece con <code>rows</code>, controla redimensión con <code>resize</code>.</p>
<h2 class="section-title">{tv({ es: 'Textarea', en: 'Textarea', ar: 'Textarea' })}</h2>
<p class="section-desc">{tv({ es: 'Mismos tokens que Input. Crece con ', en: 'Same tokens as Input. Grows with ', ar: 'نفس رموز Input. يتمدد مع ' })}<code>rows</code>{tv({ es: ', controla redimensión con ', en: ', controls resizing with ', ar: ' ويتحكم في تغيير الحجم عبر ' })}<code>resize</code>.</p>
<CodeExample title="vertical · none · both" code={codeTextarea}>
<CodeExample title={tv({ es: 'vertical · none · both', en: 'vertical · none · both', ar: 'vertical · none · both' })} code={codeTextarea}>
<div class="input-stack">
<div class="labeled-input">
<label class="demo-label">Mensaje</label>
<Textarea placeholder="resize: vertical (default)" />
<label class="demo-label" for={textareaMessageId}>{tv({ es: 'Mensaje', en: 'Message', ar: 'الرسالة' })}</label>
<Textarea id={textareaMessageId} placeholder={tv({ es: 'resize: vertical (default)', en: 'resize: vertical (default)', ar: 'resize: vertical (default)' })} />
</div>
<div class="labeled-input">
<label class="demo-label">Notas</label>
<Textarea placeholder="resize: none" resize="none" />
<label class="demo-label" for={textareaNotesId}>{tv({ es: 'Notas', en: 'Notes', ar: 'ملاحظات' })}</label>
<Textarea id={textareaNotesId} placeholder={tv({ es: 'resize: none', en: 'resize: none', ar: 'resize: none' })} resize="none" />
</div>
</div>
</CodeExample>
<CodeExample title="estados — error · disabled · readonly" code={`<Textarea invalid placeholder="Error" />\n<Textarea disabled placeholder="Disabled" />\n<Textarea readonly value="Contenido de solo lectura." />`}>
<CodeExample title={tv({ es: 'estados — error · disabled · readonly', en: 'states — error · disabled · readonly', ar: 'الحالات — error · disabled · readonly' })} code={`<Textarea invalid placeholder="Error" />\n<Textarea disabled placeholder="Disabled" />\n<Textarea readonly value="Contenido de solo lectura." />`}>
<div class="input-stack">
<div class="labeled-input">
<label class="demo-label demo-label--error">Error</label>
<Textarea invalid placeholder="Descripción requerida" />
<p class="error-hint">Mínimo 20 caracteres</p>
<label class="demo-label demo-label--error" for={textareaErrorId}>{tv({ es: 'Error', en: 'Error', ar: 'خطأ' })}</label>
<Textarea id={textareaErrorId} invalid placeholder={tv({ es: 'Descripción requerida', en: 'Description required', ar: 'الوصف مطلوب' })} />
<p class="error-hint">{tv({ es: 'Mínimo 20 caracteres', en: 'Minimum 20 characters', ar: '20 حرفاً على الأقل' })}</p>
</div>
<div class="labeled-input">
<label class="demo-label demo-label--disabled">Disabled</label>
<Textarea disabled placeholder="No disponible" />
<label class="demo-label demo-label--disabled" for={textareaDisabledId}>Disabled</label>
<Textarea id={textareaDisabledId} disabled placeholder={tv({ es: 'No disponible', en: 'Unavailable', ar: 'غير متاح' })} />
</div>
<div class="labeled-input">
<label class="demo-label">Readonly</label>
<Textarea readonly value="Este contenido no es editable." />
<label class="demo-label" for={textareaReadonlyId}>Readonly</label>
<Textarea id={textareaReadonlyId} readonly value={tv({ es: 'Este contenido no es editable.', en: 'This content is not editable.', ar: 'هذا المحتوى غير قابل للتحرير.' })} />
</div>
</div>
</CodeExample>
@ -446,6 +529,56 @@
color: var(--air-color-accent-text);
}
.demo-signals {
display: flex;
flex-wrap: wrap;
gap: var(--air-space-2);
margin-top: var(--air-space-4);
}
.demo-signals span {
padding: var(--air-space-1) var(--air-space-2);
border-radius: var(--air-radius-full);
border: 1px solid color-mix(in srgb, var(--air-color-border) 78%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 88%, transparent);
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
color: var(--air-color-text-secondary);
}
.demo-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--air-space-3);
margin-top: var(--air-space-4);
}
.demo-meta-card {
display: flex;
flex-direction: column;
gap: var(--air-space-1);
padding: var(--air-space-3);
border-radius: var(--air-radius-lg);
border: 1px solid color-mix(in srgb, var(--air-color-border) 80%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 92%, transparent);
}
.demo-meta-card span {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
letter-spacing: var(--air-tracking-wide);
text-transform: uppercase;
color: var(--air-color-text-secondary);
}
.demo-meta-card strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-text);
font-weight: var(--air-font-weight-medium);
}
/* ── Sections ───────────────────────────────────────────────────────── */
.demo-section {
@ -569,8 +702,6 @@
line-height: var(--air-leading-ui);
}
.demo-label--sm { font-size: 11px; }
.demo-label--lg { font-size: var(--air-font-size-sm); }
.demo-label--error { color: var(--air-color-danger-text); }
.demo-label--disabled { color: var(--air-color-content-disabled); }
@ -760,6 +891,7 @@
/* ── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 560px) {
.demo-meta,
.variant-grid,
.adornment-grid,
.states-grid,

@ -0,0 +1,276 @@
<script lang="ts">
import { getContext } from 'svelte';
import { airLingNode } from '$uix/air';
import { getAir } from '$uix/air/config';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import { AIR_DEMO_AIR_LING_NODE } from '../demo-locale';
import CodeExample from '../_components/code-example.svelte';
const air = getAir();
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const t = (path: string, ...args: unknown[]) => demoContext?.t(path, ...args) ?? path;
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
const localeId = $derived(demoContext?.localeId() ?? 'es-ES');
const dir = $derived(demoContext?.dir() ?? 'ltr');
const translatorLocale = $derived(air.translator.current.getLocale());
const officialInlineEntries = [
{ key: 'air.dialog.close', value: '#?air.dialog.close|Cerrar diálogo' },
{ key: 'air.code-block.copy', value: '#?air.code-block.copy|Copiar' },
{ key: 'air.code-block.copied', value: '#?air.code-block.copied|Copiado' },
{ key: 'air.code-block.copy-aria', value: '#?air.code-block.copy-aria|Copiar código' },
] as const;
const demoPathEntries = [
{ key: 'air.button.review', args: [] },
{ key: 'air.button.buttonSize', args: ['md'] },
{ key: 'air.spinner.loadingWith', args: ['bars'] },
{ key: 'air.icon.all', args: [] },
] as const;
const bridgeCode = `const ling = createLing({}, 'es');
extendTerraTranslationModule(ling, AIR_DEMO_TERRA_LING_NODE);
extendAirTranslationModule(ling);
ling.extend('air', AIR_DEMO_AIR_LING_NODE);
const translator = createTerraTranslator({
\tlocale: () => ling.getLocale(),
\ttranslate: () => ling.ts,
\ttranslatePath: () => (path, ...args) => translateAirDemoLingPath(ling, path, ...args),
\truntime: () => ling,
});`;
const officialRows = $derived(
officialInlineEntries.map((entry) => ({
...entry,
resolved: air.translator.current.translate(entry.value),
}))
);
const demoRows = $derived(
demoPathEntries.map((entry) => ({
...entry,
resolved: air.translator.current.translatePath(entry.key, ...entry.args),
}))
);
const officialAirSchema = $derived(JSON.stringify(airLingNode, null, 2));
const demoAirSchema = $derived(JSON.stringify(AIR_DEMO_AIR_LING_NODE, null, 2));
</script>
<svelte:head>
<title>Air Ling</title>
</svelte:head>
<div class="air-ling-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Ling</p>
<h1>{t('air.demo.ling.title')}</h1>
<p class="lead">{t('air.demo.ling.lead')}</p>
</div>
<div class="hero-meta">
<div>
<span>{t('air.demo.ling.uiLocale')}</span>
<strong>{localeId}</strong>
</div>
<div>
<span>{t('air.demo.ling.lingLocale')}</span>
<strong>{translatorLocale}</strong>
</div>
<div>
<span>{t('air.demo.ling.direction')}</span>
<strong>{dir}</strong>
</div>
<div>
<span>{t('air.demo.ling.namespaces')}</span>
<strong>terra + air</strong>
</div>
</div>
</header>
<section class="panel">
<header class="section-header">
<h2>{t('air.demo.ling.bridge.title')}</h2>
<p>{t('air.demo.ling.bridge.lead')}</p>
</header>
<CodeExample title={t('air.demo.ling.bridge.codeTitle')} code={bridgeCode} defaultOpen={true}>
<div class="bridge-note">
<strong>{t('air.demo.ling.result')}</strong>
<p>{t('air.demo.ling.resultLead')}</p>
</div>
</CodeExample>
</section>
<section class="panel">
<header class="section-header">
<h2>{t('air.demo.ling.official.title')}</h2>
<p>{t('air.demo.ling.official.lead')}</p>
</header>
<div class="cards">
{#each officialRows as row}
<article class="card">
<span>{row.key}</span>
<strong>{row.resolved}</strong>
<code>{row.value}</code>
</article>
{/each}
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{t('air.demo.ling.demo.title')}</h2>
<p>{t('air.demo.ling.demo.lead')}</p>
</header>
<div class="cards">
{#each demoRows as row}
<article class="card">
<span>{row.key}</span>
<strong>{row.resolved}</strong>
<code>{row.args.length ? row.args.join(', ') : t('air.demo.ling.noParams')}</code>
</article>
{/each}
</div>
</section>
<section class="schema-grid">
<CodeExample title={t('air.demo.ling.schemaOfficial')} code={officialAirSchema} />
<CodeExample title={t('air.demo.ling.schemaDemo')} code={demoAirSchema} />
</section>
</div>
<style>
.air-ling-page {
display: flex;
flex-direction: column;
gap: var(--air-space-5);
}
.hero,
.panel {
padding: var(--air-space-5);
border-radius: var(--air-radius-xl);
border: 1px solid color-mix(in srgb, var(--air-color-border) 84%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
box-shadow: var(--air-shadow-1);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(240px, 0.8fr);
gap: var(--air-space-5);
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--air-color-accent-muted) 22%, transparent), transparent 40%),
linear-gradient(180deg, color-mix(in srgb, var(--air-color-surface-raised) 96%, transparent), color-mix(in srgb, var(--air-color-surface) 78%, transparent));
}
.hero-copy,
.hero-meta {
display: flex;
flex-direction: column;
gap: var(--air-space-3);
}
.hero-meta div,
.card,
.bridge-note {
display: flex;
flex-direction: column;
gap: var(--air-space-1);
padding: var(--air-space-4);
border-radius: var(--air-radius-lg);
border: 1px solid color-mix(in srgb, var(--air-color-border) 80%, transparent);
background: color-mix(in srgb, var(--air-color-surface) 84%, transparent);
}
.eyebrow {
margin: 0;
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
font-weight: var(--air-font-weight-medium);
letter-spacing: var(--air-tracking-wider);
text-transform: uppercase;
color: var(--air-color-accent-text);
}
.hero h1,
.section-header h2 {
margin: 0;
font-weight: var(--air-font-weight-semibold);
line-height: var(--air-leading-heading);
}
.hero h1 {
font-size: var(--air-font-size-2xl);
letter-spacing: var(--air-tracking-tight);
}
.lead,
.section-header p,
.bridge-note p {
margin: 0;
font-size: var(--air-font-size-md);
line-height: var(--air-leading-text);
color: var(--air-color-text-secondary);
}
.hero-meta span,
.card span,
.card code {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
}
.hero-meta span,
.card span,
.card code {
color: var(--air-color-text-secondary);
}
.hero-meta strong,
.card strong,
.bridge-note strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-text);
font-weight: var(--air-font-weight-medium);
}
.section-header {
display: flex;
flex-direction: column;
gap: var(--air-space-2);
margin-bottom: var(--air-space-4);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: var(--air-space-4);
}
.schema-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--air-space-4);
}
.schema-grid :global(.code-example) {
height: 100%;
}
@media (max-width: 900px) {
.hero,
.schema-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,456 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Select } from '$uix/air';
import type { AirSelectSize, AirSelectVariant, AirSelectMultipleDisplay } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
type Item = { value: string; label: LingString; disabled?: boolean };
type Group = { heading: LingString; items: Item[] };
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
const teams: Item[] = [
{ value: 'design', label: { es: 'Design', en: 'Design', ar: 'التصميم' } },
{ value: 'product', label: { es: 'Producto', en: 'Product', ar: 'المنتج' } },
{ value: 'platform', label: { es: 'Platform', en: 'Platform', ar: 'المنصة' } },
{ value: 'ops', label: { es: 'Ops', en: 'Ops', ar: 'العمليات' }, disabled: true }
];
const stacks: Group[] = [
{
heading: { es: 'Frontend', en: 'Frontend', ar: 'الواجهة الأمامية' },
items: [
{ value: 'svelte', label: 'Svelte' },
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' }
]
},
{
heading: { es: 'Backend', en: 'Backend', ar: 'الخلفية' },
items: [
{ value: 'node', label: 'Node' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' }
]
}
];
let value = $state('product');
let multiCountValue = $state(['svelte', 'node']);
let multiChipsValue = $state(['react', 'go', 'rust']);
let size = $state<AirSelectSize>('md');
let variant = $state<AirSelectVariant>('outline');
let chipsMax = $state(2);
let invalid = $state(false);
let disabled = $state(false);
const sizes = ['xs', 'sm', 'md', 'lg'] as const;
const variants = ['outline', 'ghost'] as const;
const teamItems = $derived(teams.map((item) => ({
value: item.value,
label: tv(item.label),
disabled: item.disabled
})));
function labelFor(items: Item[], currentValue: string) {
return items.find((item) => item.value === currentValue)?.label;
}
function labelForGroups(groups: Group[], currentValue: string) {
return groups.flatMap((group) => group.items).find((item) => item.value === currentValue)?.label;
}
const stackItems = $derived(
stacks.flatMap((group) =>
group.items.map((item) => ({ value: item.value, label: tv(item.label), disabled: item.disabled }))
)
);
const playgroundCode = $derived(
`<Select.Root type="single" value="${value}">
<Select.Trigger size="${size}" variant="${variant}" invalid={${invalid}}>
${String(tv(labelFor(teams, value) ?? { es: 'Selecciona equipo', en: 'Select team', ar: 'اختر الفريق' }))}
</Select.Trigger>
<Select.Portal>
<Select.Content sideOffset={8}>
<Select.Viewport>
<!-- items -->
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>`
);
const groupedCode = `<Select.Root type="multiple" value={['svelte', 'node']} items={stackItems}>
<Select.Trigger size="sm" placeholder="Select stacks" multipleDisplay="count" />
<Select.Portal>
<Select.Content sideOffset={8}>
<Select.Viewport>
<Select.Group>
<Select.GroupHeading>Frontend</Select.GroupHeading>
<Select.Item value="svelte">Svelte</Select.Item>
</Select.Group>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>`;
const chipsCode = $derived(`<Select.Root type="multiple" value={['react', 'go', 'rust']} items={stackItems}>
<Select.Trigger size="sm" placeholder="Select stacks" multipleDisplay="chips" maxChips={${chipsMax}} />
<Select.Portal>
<Select.Content sideOffset={8}>...</Select.Content>
</Select.Portal>
</Select.Root>`);
</script>
<svelte:head>
<title>Select · Air</title>
</svelte:head>
<div class="select-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Select</p>
<h1>{tv({
es: 'Dropdown composable sobre terra.Select',
en: 'Composable dropdown over terra.Select',
ar: 'قائمة منسدلة قابلة للتركيب فوق terra.Select'
})}</h1>
<p class="lead">
{tv({
es: '`Select` reutiliza el wiring accesible de `terra` para trigger, viewport, roving highlight y selección, mientras `air` aterriza el lenguaje visual del control y del panel.',
en: '`Select` reuses `terra` accessible wiring for trigger, viewport, roving highlight and selection, while `air` lands the control and panel visual language.',
ar: 'يعيد `Select` استخدام البنية القابلة للوصول في `terra` للزر ولوحة العرض والإبراز المتحرك والاختيار، بينما يحدد `air` اللغة البصرية للحقل واللوحة.'
})}
</p>
<div class="hero-signals">
<span>{tv({ es: 'trigger + content', en: 'trigger + content', ar: 'زر + محتوى' })}</span>
<span>{tv({ es: 'single select', en: 'single select', ar: 'اختيار مفرد' })}</span>
<span>{tv({ es: 'grouped items', en: 'grouped items', ar: 'عناصر مجمعة' })}</span>
<span>{tv({ es: 'outline · ghost', en: 'outline · ghost', ar: 'outline · ghost' })}</span>
</div>
</div>
</header>
<section class="panel panel--feature">
<header class="section-header">
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>{tv({
es: 'El trigger hereda la gramática de Input, mientras el panel aterriza una superficie de overlay con items navegables.',
en: 'The trigger inherits Input grammar, while the panel lands an overlay surface with navigable items.',
ar: 'يرث الزر قواعد Input، بينما تقدّم اللوحة سطح overlay بعناصر قابلة للتنقل.'
})}</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>{tv({ es: 'Variant', en: 'Variant', ar: 'النمط' })}</span>
<div class="chips">
{#each variants as item}
<button
type="button"
class:chip={true}
class:chip--active={variant === item}
onclick={() => (variant = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Size', en: 'Size', ar: 'الحجم' })}</span>
<div class="chips">
{#each sizes as item}
<button
type="button"
class:chip={true}
class:chip--active={size === item}
onclick={() => (size = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Max chips', en: 'Max chips', ar: 'أقصى عدد للرقائق' })}</span>
<div class="chips">
{#each [1, 2, 3, 4] as item}
<button
type="button"
class:chip={true}
class:chip--active={chipsMax === item}
onclick={() => (chipsMax = item)}
>{item}</button>
{/each}
</div>
</div>
<label class="toggle">
<input type="checkbox" bind:checked={invalid} />
<span>{tv({ es: 'invalid', en: 'invalid', ar: 'غير صالح' })}</span>
</label>
<label class="toggle">
<input type="checkbox" bind:checked={disabled} />
<span>{tv({ es: 'disabled', en: 'disabled', ar: 'معطل' })}</span>
</label>
</div>
<CodeExample title={tv({ es: 'Select playground', en: 'Select playground', ar: 'ساحة Select' })} code={playgroundCode}>
<div class="preview-stack">
<Select.Root type="single" bind:value items={teamItems}>
<Select.Trigger
{size}
{variant}
{invalid}
{disabled}
placeholder={tv({ es: 'Selecciona equipo', en: 'Select team', ar: 'اختر الفريق' })}
/>
<Select.Portal>
<Select.Content sideOffset={8}>
<Select.Viewport>
{#each teams as item}
<Select.Item value={item.value} label={tv(item.label)} disabled={item.disabled}>
{tv(item.label)}
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
</CodeExample>
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Multiple display', en: 'Multiple display', ar: 'عرض الاختيار المتعدد' })}</h2>
<p>{tv({
es: 'El multi-select necesita una estrategia de lectura clara. Aquí se ven dos: contador compacto y chips dentro del trigger.',
en: 'Multi-select needs a clear reading strategy. Here are two: compact count and chips inside the trigger.',
ar: 'يحتاج الاختيار المتعدد إلى طريقة قراءة واضحة. هنا يظهر نمطان: عدّاد مختصر ورقائق داخل الزر.'
})}</p>
</header>
<div class="multi-grid">
<CodeExample title={tv({ es: 'Multiple · count', en: 'Multiple · count', ar: 'متعدد · عداد' })} code={groupedCode}>
<Select.Root type="multiple" bind:value={multiCountValue} allowDeselect items={stackItems}>
<Select.Trigger
size="sm"
placeholder={tv({ es: 'Selecciona stacks', en: 'Select stacks', ar: 'اختر التقنيات' })}
multipleDisplay={'count' satisfies AirSelectMultipleDisplay}
/>
<Select.Portal>
<Select.Content sideOffset={8}>
<Select.Viewport>
{#each stacks as group}
<Select.Group>
<Select.GroupHeading>{tv(group.heading)}</Select.GroupHeading>
{#each group.items as item}
<Select.Item value={item.value} label={tv(item.label)}>
{tv(item.label)}
</Select.Item>
{/each}
</Select.Group>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</CodeExample>
<CodeExample title={tv({ es: 'Multiple · chips', en: 'Multiple · chips', ar: 'متعدد · رقائق' })} code={chipsCode}>
<Select.Root type="multiple" bind:value={multiChipsValue} allowDeselect items={stackItems}>
<Select.Trigger
size="sm"
placeholder={tv({ es: 'Selecciona stacks', en: 'Select stacks', ar: 'اختر التقنيات' })}
multipleDisplay={'chips' satisfies AirSelectMultipleDisplay}
maxChips={chipsMax}
/>
<Select.Portal>
<Select.Content sideOffset={8}>
<Select.Viewport>
{#each stacks as group}
<Select.Group>
<Select.GroupHeading>{tv(group.heading)}</Select.GroupHeading>
{#each group.items as item}
<Select.Item value={item.value} label={tv(item.label)}>
{tv(item.label)}
</Select.Item>
{/each}
</Select.Group>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
</CodeExample>
</div>
</section>
</div>
<style>
.select-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-6);
}
.hero,
.panel {
display: grid;
gap: var(--air-space-5);
}
.hero {
grid-template-columns: minmax(0, 1fr);
}
.eyebrow {
margin: 0 0 var(--air-space-2);
font: 600 var(--air-font-size-xs) / 1 var(--air-font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--air-color-content-secondary);
}
h1,
h2 {
margin: 0;
}
.lead,
.section-header p {
margin: 0;
color: var(--air-color-content-secondary);
line-height: var(--air-leading-prose);
}
.hero-signals,
.controls,
.chips,
.preview-stack {
display: flex;
}
.hero-signals,
.chips {
flex-wrap: wrap;
}
.hero-signals {
gap: var(--air-space-2);
margin-top: var(--air-space-4);
}
.hero-signals span,
.control > span {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
color: var(--air-color-content-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hero-signals span,
.chip,
.panel {
border-radius: var(--air-radius-lg);
}
.hero-signals span,
.panel {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 92%, transparent);
}
.hero-signals span {
padding: 0.45rem 0.7rem;
}
.panel {
padding: var(--air-space-5);
}
.panel--feature {
background:
linear-gradient(180deg, color-mix(in srgb, var(--air-color-surface-overlay) 80%, transparent), transparent 55%),
color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
}
.section-header {
display: grid;
gap: var(--air-space-2);
}
.playground-grid {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: var(--air-space-5);
}
.multi-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--air-space-4);
}
.controls,
.preview-stack {
flex-direction: column;
gap: var(--air-space-3);
}
.control {
display: grid;
gap: var(--air-space-2);
}
.chips {
gap: var(--air-space-2);
}
.chip {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: transparent;
color: var(--air-color-content-secondary);
padding: 0.45rem 0.8rem;
font: inherit;
cursor: pointer;
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);
}
.chip--active {
background: color-mix(in srgb, var(--air-color-primary-track) 82%, transparent);
border-color: var(--air-color-primary-border);
color: var(--air-color-content-primary);
}
.toggle {
display: inline-flex;
align-items: center;
gap: var(--air-space-2);
font-size: var(--air-font-size-sm);
color: var(--air-color-content-secondary);
}
@media (max-width: 960px) {
.playground-grid,
.multi-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -61,8 +61,8 @@
activeSemantic = semantic;
activePhase = phase;
const result = playSemantic(semantic, phase, {
volumeMultiplier: volumeMultiplier * airConfig.soundVolume.current,
presets: airConfig.soundPresets.current,
volumeMultiplier: volumeMultiplier * airConfig.behavior.soundVolume.current,
presets: airConfig.behavior.soundPresets.current,
});
if (semantic === 'persistence' && phase === 'enter' && typeof result === 'function') {
persistenceStop = result;

@ -400,10 +400,6 @@
font-weight: var(--air-font-weight-medium);
}
.panel--feature {
/* no extra bg needed when panel is just a separator */
}
.eyebrow {
margin: 0;
font-family: var(--air-font-mono);

@ -0,0 +1,515 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Button, Switch } from '$uix/air';
import type { AirSwitchColor, AirSwitchSize } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
let playgroundChecked = $state(true);
let playgroundDisabled = $state(false);
let playgroundSize = $state<AirSwitchSize>('md');
let playgroundColor = $state<AirSwitchColor>('primary');
let notifications = $state(true);
let marketing = $state(false);
let security = $state(true);
const sizes = ['sm', 'md', 'lg'] as const;
const colors = ['primary', 'neutral', 'success', 'warning', 'danger', 'info'] as const;
const playgroundCode = $derived(
`<Switch.Root checked={${playgroundChecked}} size="${playgroundSize}" color="${playgroundColor}" disabled={${playgroundDisabled}}>
<Switch.Thumb />
</Switch.Root>`
);
const listCode = `<Switch.Root bind:checked={notifications} color="success">
<Switch.Thumb />
</Switch.Root>`;
</script>
<svelte:head>
<title>Switch · Air</title>
</svelte:head>
<div class="switch-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Switch</p>
<h1>{tv({
es: 'Toggle binario sobre terra.Switch',
en: 'Binary toggle over terra.Switch',
ar: 'مفتاح ثنائي فوق terra.Switch',
})}</h1>
<p class="lead">{tv({
es: '`Switch` reutiliza la semántica accesible de `terra` y `air` aterriza geometría, color, foco y feedback del sistema para cambios on/off.',
en: '`Switch` reuses `terra` accessible semantics and `air` lands geometry, color, focus and system feedback for on/off changes.',
ar: 'يعيد `Switch` استخدام الدلالة القابلة للوصول من `terra` بينما يحدد `air` الهندسة واللون والتركيز وتغذية النظام الراجعة لحالات التشغيل والإيقاف.',
})}</p>
<div class="hero-signals">
<span>{tv({ es: 'feedback sound', en: 'feedback sound', ar: 'صوت التغذية الراجعة' })}</span>
<span>{tv({ es: 'focus ring común', en: 'shared focus ring', ar: 'حلقة تركيز مشتركة' })}</span>
<span>{tv({ es: 'size responsive', en: 'responsive size', ar: 'حجم متجاوب' })}</span>
<span>{tv({ es: '6 familias de color', en: '6 color families', ar: '6 عائلات لونية' })}</span>
</div>
</div>
<div class="hero-aside">
<div>
<span>{tv({ es: 'API', en: 'API', ar: 'الواجهة' })}</span>
<strong>Root · Thumb</strong>
</div>
<div>
<span>DOM</span>
<strong>{tv({
es: 'button[role="switch"] + thumb + input oculto opcional',
en: 'button[role="switch"] + thumb + optional hidden input',
ar: 'button[role="switch"] + thumb + input مخفي اختياري',
})}</strong>
</div>
<div>
<span>{tv({ es: 'Behavior', en: 'Behavior', ar: 'السلوك' })}</span>
<strong>{tv({
es: 'feedback enter/exit al cambiar checked',
en: 'feedback enter/exit on checked change',
ar: 'feedback enter/exit عند تغيير checked',
})}</strong>
</div>
</div>
</header>
<section class="panel panel--feature">
<header class="section-header">
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>{tv({
es: 'El wrapper de `air` añade tamaño responsive, familias de color y feedback, pero el contrato accesible sigue siendo el de `terra.Switch`.',
en: '`air` wrapper adds responsive size, color families and feedback, while the accessible contract stays the one from `terra.Switch`.',
ar: 'يضيف غلاف `air` الحجم المتجاوب والعائلات اللونية والتغذية الراجعة، بينما يبقى العقد القابل للوصول هو عقد `terra.Switch`.',
})}</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>{tv({ es: 'Size', en: 'Size', ar: 'الحجم' })}</span>
<div class="chips">
{#each sizes as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundSize === item}
onclick={() => (playgroundSize = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Color', en: 'Color', ar: 'اللون' })}</span>
<div class="chips">
{#each colors as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundColor === item}
onclick={() => (playgroundColor = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'State', en: 'State', ar: 'الحالة' })}</span>
<div class="chips">
<button
type="button"
class:chip={true}
class:chip--active={playgroundChecked}
onclick={() => (playgroundChecked = !playgroundChecked)}
>{playgroundChecked ? 'on' : 'off'}</button>
<button
type="button"
class:chip={true}
class:chip--active={playgroundDisabled}
onclick={() => (playgroundDisabled = !playgroundDisabled)}
>{playgroundDisabled ? 'disabled' : 'enabled'}</button>
</div>
</div>
</div>
<CodeExample title={tv({ es: 'Switch playground', en: 'Switch playground', ar: 'ساحة Switch' })} code={playgroundCode}>
<div class="preview-stack">
<div class="preview-row">
<Switch.Root
bind:checked={playgroundChecked}
size={playgroundSize}
color={playgroundColor}
disabled={playgroundDisabled}
>
<Switch.Thumb />
</Switch.Root>
<div class="state-copy">
<strong>{playgroundChecked ? 'checked' : 'unchecked'}</strong>
<p>{tv({
es: 'El tamaño mueve geometría y thumb. El color solo aparece en estado checked.',
en: 'Size drives geometry and thumb. Color only appears in the checked state.',
ar: 'يقود الحجم الهندسة والعنصر الداخلي. ويظهر اللون فقط في حالة التفعيل.',
})}</p>
</div>
</div>
</div>
</CodeExample>
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Tamaños y colores', en: 'Sizes and colors', ar: 'الأحجام والألوان' })}</h2>
<p>{tv({
es: 'Los tamaños gobiernan rail y thumb; las familias gobiernan solo el estado activo.',
en: 'Sizes govern rail and thumb; families govern only the active state.',
ar: 'تتحكم الأحجام في المسار والعنصر الداخلي؛ وتتحكم العائلات في الحالة النشطة فقط.',
})}</p>
</header>
<div class="matrix">
{#each sizes as size}
<CodeExample title={`Switch ${size}`} code={`<Switch.Root size="${size}" color="success"><Switch.Thumb /></Switch.Root>`}>
<div class="matrix-row">
{#each colors as color}
<div class="matrix-item">
<Switch.Root checked size={size} {color}>
<Switch.Thumb />
</Switch.Root>
<span>{color}</span>
</div>
{/each}
</div>
</CodeExample>
{/each}
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Composición en listas', en: 'List composition', ar: 'التركيب داخل القوائم' })}</h2>
<p>{tv({
es: 'El caso típico no es un switch flotando solo, sino una preferencia con nombre, descripción y estado persistente.',
en: 'The typical case is not a floating switch alone, but a preference with name, description and persistent state.',
ar: 'الحالة المعتادة ليست مفتاحاً منفرداً، بل تفضيلاً مع اسم ووصف وحالة مستمرة.',
})}</p>
</header>
<CodeExample title={tv({ es: 'Preferencias', en: 'Preferences', ar: 'التفضيلات' })} code={listCode}>
<div class="settings-list">
<div class="setting-row">
<div class="setting-copy">
<strong>{tv({ es: 'Notificaciones push', en: 'Push notifications', ar: 'إشعارات فورية' })}</strong>
<p>{tv({ es: 'Alertas en tiempo real cuando cambian incidencias asignadas.', en: 'Real-time alerts when assigned incidents change.', ar: 'تنبيهات فورية عند تغيّر الحوادث المعيّنة.' })}</p>
</div>
<Switch.Root bind:checked={notifications} color="success" name="notifications">
<Switch.Thumb />
</Switch.Root>
</div>
<div class="setting-row">
<div class="setting-copy">
<strong>{tv({ es: 'Correos de marketing', en: 'Marketing email', ar: 'رسائل التسويق' })}</strong>
<p>{tv({ es: 'Novedades y lanzamientos del producto.', en: 'Product updates and launches.', ar: 'مستجدات وإطلاقات المنتج.' })}</p>
</div>
<Switch.Root bind:checked={marketing} color="warning" name="marketing">
<Switch.Thumb />
</Switch.Root>
</div>
<div class="setting-row">
<div class="setting-copy">
<strong>{tv({ es: 'Alertas de seguridad', en: 'Security alerts', ar: 'تنبيهات الأمان' })}</strong>
<p>{tv({ es: 'Actividad sospechosa, dispositivos nuevos e inicios de sesión.', en: 'Suspicious activity, new devices and sign-ins.', ar: 'نشاط مريب وأجهزة جديدة وتسجيلات الدخول.' })}</p>
</div>
<Switch.Root bind:checked={security} color="danger" name="security">
<Switch.Thumb />
</Switch.Root>
</div>
</div>
</CodeExample>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Control externo', en: 'External control', ar: 'تحكم خارجي' })}</h2>
<p>{tv({
es: 'El switch sigue siendo bindable, así que puede participar en flujos controlados sin perder su semántica.',
en: 'The switch stays bindable, so it can participate in controlled flows without losing its semantics.',
ar: 'يبقى المفتاح قابلاً للربط، لذلك يمكنه المشاركة في تدفقات متحكم بها من دون فقدان دلالته.',
})}</p>
</header>
<div class="external-control">
<Button
variant="surface"
color={playgroundChecked ? 'neutral' : 'primary'}
onclick={() => (playgroundChecked = !playgroundChecked)}
>
{playgroundChecked
? tv({ es: 'Desactivar playground', en: 'Disable playground', ar: 'تعطيل ساحة التجريب' })
: tv({ es: 'Activar playground', en: 'Enable playground', ar: 'تفعيل ساحة التجريب' })}
</Button>
<Switch.Root bind:checked={playgroundChecked} size="lg" color="info">
<Switch.Thumb />
</Switch.Root>
</div>
</section>
</div>
<style>
.switch-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-6);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.85fr);
gap: var(--air-space-6);
align-items: start;
}
.eyebrow {
margin: 0 0 var(--air-space-2);
font: 600 var(--air-font-size-xs) / 1 var(--air-font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--air-color-content-secondary);
}
.hero h1,
.section-header h2 {
margin: 0;
}
.lead {
margin: var(--air-space-3) 0 0;
max-width: 62ch;
color: var(--air-color-content-secondary);
font-size: var(--air-font-size-md);
line-height: var(--air-leading-prose);
}
.hero-signals,
.chips,
.preview-stack,
.controls {
display: flex;
}
.hero-signals,
.chips {
flex-wrap: wrap;
}
.hero-signals {
gap: var(--air-space-2);
margin-top: var(--air-space-4);
}
.hero-signals span,
.hero-aside span,
.control > span,
.matrix-item span {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
color: var(--air-color-content-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hero-signals span,
.hero-aside > div,
.panel {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 92%, transparent);
}
.hero-signals span,
.chip,
.hero-aside > div,
.panel {
border-radius: var(--air-radius-lg);
}
.hero-signals span {
padding: 0.45rem 0.7rem;
}
.hero-aside {
display: grid;
gap: var(--air-space-3);
}
.hero-aside > div {
display: grid;
gap: var(--air-space-2);
padding: var(--air-space-4);
}
.hero-aside strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-prose);
}
.panel {
display: grid;
gap: var(--air-space-5);
padding: var(--air-space-5);
}
.panel--feature {
background:
linear-gradient(180deg, color-mix(in srgb, var(--air-color-surface-overlay) 80%, transparent), transparent 55%),
color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
}
.section-header {
display: grid;
gap: var(--air-space-2);
}
.section-header p,
.state-copy p,
.setting-copy p {
margin: 0;
color: var(--air-color-content-secondary);
line-height: var(--air-leading-prose);
}
.playground-grid {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: var(--air-space-5);
}
.controls,
.preview-stack {
flex-direction: column;
}
.controls,
.preview-stack {
gap: var(--air-space-3);
}
.control {
display: grid;
gap: var(--air-space-2);
}
.chips {
gap: var(--air-space-2);
}
.chip {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: transparent;
color: var(--air-color-content-secondary);
padding: 0.45rem 0.8rem;
font: inherit;
cursor: pointer;
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);
}
.chip--active {
background: color-mix(in srgb, var(--air-color-primary-track) 82%, transparent);
border-color: var(--air-color-primary-border);
color: var(--air-color-content-primary);
}
.preview-row,
.setting-row,
.external-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--air-space-4);
}
.state-copy {
display: grid;
gap: var(--air-space-2);
}
.state-copy strong,
.setting-copy strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-ui);
}
.matrix {
display: grid;
gap: var(--air-space-4);
}
.matrix-row {
display: flex;
flex-wrap: wrap;
gap: var(--air-space-4);
}
.matrix-item {
display: grid;
justify-items: center;
gap: var(--air-space-2);
min-width: 72px;
}
.settings-list {
display: flex;
flex-direction: column;
}
.setting-row {
padding: var(--air-space-3) 0;
border-bottom: 1px solid color-mix(in srgb, var(--air-color-border-default) 72%, transparent);
}
.setting-row:last-child {
border-bottom: none;
}
.setting-copy {
display: grid;
gap: var(--air-space-1);
}
.external-control {
justify-content: flex-start;
}
@media (max-width: 960px) {
.hero,
.playground-grid,
.preview-row,
.setting-row,
.external-control {
grid-template-columns: 1fr;
}
.preview-row,
.setting-row,
.external-control {
flex-direction: column;
align-items: flex-start;
}
}
</style>

@ -0,0 +1,489 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Tabs } from '$uix/air';
import type { AirTabsSize, AirTabsVariant } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
let playgroundValue = $state('overview');
let playgroundSize = $state<AirTabsSize>('md');
let playgroundVariant = $state<AirTabsVariant>('line');
let playgroundOrientation = $state<'horizontal' | 'vertical'>('horizontal');
let playgroundActivation = $state<'automatic' | 'manual'>('automatic');
let verticalValue = $state('metrics');
const sizes = ['xs', 'sm', 'md', 'lg'] as const;
const variants = ['line', 'surface', 'pills'] as const;
const orientations = ['horizontal', 'vertical'] as const;
const activationModes = ['automatic', 'manual'] as const;
const tabsCode = $derived(
`<Tabs.Root
value="${playgroundValue}"
size="${playgroundSize}"
variant="${playgroundVariant}"
orientation="${playgroundOrientation}"
activationMode="${playgroundActivation}"
>
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">...</Tabs.Content>
<Tabs.Content value="activity">...</Tabs.Content>
<Tabs.Content value="settings">...</Tabs.Content>
</Tabs.Root>`
);
const verticalCode = `<Tabs.Root value="metrics" orientation="vertical" variant="surface" size="sm">
<Tabs.List>
<Tabs.Trigger value="metrics">Metrics</Tabs.Trigger>
<Tabs.Trigger value="members">Members</Tabs.Trigger>
<Tabs.Trigger value="logs">Logs</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="metrics">...</Tabs.Content>
<Tabs.Content value="members">...</Tabs.Content>
<Tabs.Content value="logs">...</Tabs.Content>
</Tabs.Root>`;
</script>
<svelte:head>
<title>Tabs · Air</title>
</svelte:head>
<div class="tabs-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Tabs</p>
<h1>{tv({ es: 'Navegación tabulada sobre terra.Tabs', en: 'Tabbed navigation over terra.Tabs', ar: 'تنقل بعلامات تبويب فوق terra.Tabs' })}</h1>
<p class="lead">
{tv({
es: '`Tabs` reutiliza el wiring accesible de `terra` para roving focus, activación y relación trigger/panel, y `air` aterriza densidad, variantes y lectura visual del estado activo.',
en: '`Tabs` reuses `terra` accessible wiring for roving focus, activation and trigger/panel relationships, while `air` lands density, variants and the visual reading of active state.',
ar: 'يعيد `Tabs` استخدام البنية القابلة للوصول في `terra` للتركيز المتحرك والتفعيل وعلاقة الزر باللوحة، بينما يحدد `air` الكثافة والأنماط وقراءة الحالة النشطة.',
})}
</p>
<div class="hero-signals">
<span>{tv({ es: 'navigation sound', en: 'navigation sound', ar: 'صوت التنقل' })}</span>
<span>{tv({ es: 'roving focus', en: 'roving focus', ar: 'تركيز متحرك' })}</span>
<span>{tv({ es: 'horizontal + vertical', en: 'horizontal + vertical', ar: 'أفقي + عمودي' })}</span>
<span>{tv({ es: 'line · surface · pills', en: 'line · surface · pills', ar: 'line · surface · pills' })}</span>
</div>
</div>
<div class="hero-aside">
<div>
<span>{tv({ es: 'API', en: 'API', ar: 'الواجهة' })}</span>
<strong>Root · List · Trigger · Content</strong>
</div>
<div>
<span>DOM</span>
<strong>{tv({
es: 'div[data-tabs] + tablist + tab triggers + tabpanel',
en: 'div[data-tabs] + tablist + tab triggers + tabpanel',
ar: 'div[data-tabs] + tablist + tab triggers + tabpanel',
})}</strong>
</div>
<div>
<span>{tv({ es: 'Semántica', en: 'Semantic', ar: 'الدلالة' })}</span>
<strong>{tv({
es: 'navigation al cambiar de tab',
en: 'navigation on tab change',
ar: 'navigation عند تغيير التبويب',
})}</strong>
</div>
</div>
</header>
<section class="panel panel--feature">
<header class="section-header">
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>
{tv({
es: 'La receta visual vive en `variant` y `size`, mientras `terra` sigue gobernando activación, teclado y relación ARIA.',
en: 'The visual recipe lives in `variant` and `size`, while `terra` still governs activation, keyboard handling and ARIA relationships.',
ar: 'تعيش الوصفة البصرية في `variant` و `size`، بينما يواصل `terra` إدارة التفعيل ولوحة المفاتيح وعلاقات ARIA.',
})}
</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>{tv({ es: 'Variant', en: 'Variant', ar: 'النمط' })}</span>
<div class="chips">
{#each variants as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundVariant === item}
onclick={() => (playgroundVariant = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Size', en: 'Size', ar: 'الحجم' })}</span>
<div class="chips">
{#each sizes as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundSize === item}
onclick={() => (playgroundSize = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Orientation', en: 'Orientation', ar: 'الاتجاه' })}</span>
<div class="chips">
{#each orientations as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundOrientation === item}
onclick={() => (playgroundOrientation = item)}
>{item}</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Activation', en: 'Activation', ar: 'التفعيل' })}</span>
<div class="chips">
{#each activationModes as item}
<button
type="button"
class:chip={true}
class:chip--active={playgroundActivation === item}
onclick={() => (playgroundActivation = item)}
>{item}</button>
{/each}
</div>
</div>
</div>
<CodeExample title={tv({ es: 'Tabs playground', en: 'Tabs playground', ar: 'ساحة Tabs' })} code={tabsCode}>
<div class="preview-stack">
<Tabs.Root
bind:value={playgroundValue}
size={playgroundSize}
variant={playgroundVariant}
orientation={playgroundOrientation}
activationMode={playgroundActivation}
>
<Tabs.List>
<Tabs.Trigger value="overview">{tv({ es: 'Resumen', en: 'Overview', ar: 'نظرة عامة' })}</Tabs.Trigger>
<Tabs.Trigger value="activity">{tv({ es: 'Actividad', en: 'Activity', ar: 'النشاط' })}</Tabs.Trigger>
<Tabs.Trigger value="settings">{tv({ es: 'Ajustes', en: 'Settings', ar: 'الإعدادات' })}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">
<div class="content-stack">
<h3>{tv({ es: 'Resumen del workspace', en: 'Workspace overview', ar: 'نظرة عامة على مساحة العمل' })}</h3>
<p>{tv({
es: 'El estado activo debe leerse rápido sin romper la jerarquía tipográfica del contenido.',
en: 'The active state should read quickly without breaking the content typography hierarchy.',
ar: 'يجب أن تُقرأ الحالة النشطة بسرعة من دون كسر الهرمية الطباعية للمحتوى.',
})}</p>
</div>
</Tabs.Content>
<Tabs.Content value="activity">
<div class="content-stack">
<h3>{tv({ es: 'Actividad reciente', en: 'Recent activity', ar: 'النشاط الأخير' })}</h3>
<p>{tv({
es: 'En modo automático el foco ya cambia la tab; en manual hace falta Enter o Space.',
en: 'In automatic mode focus already changes the tab; in manual mode Enter or Space is required.',
ar: 'في الوضع التلقائي يغيّر التركيز التبويب مباشرة؛ أما في الوضع اليدوي فيلزم Enter أو Space.',
})}</p>
</div>
</Tabs.Content>
<Tabs.Content value="settings">
<div class="content-stack">
<h3>{tv({ es: 'Preferencias', en: 'Preferences', ar: 'التفضيلات' })}</h3>
<p>{tv({
es: 'La receta de `air` toca densidad, fondo, borde y foco; `terra` mantiene el contrato accesible.',
en: '`air` recipe touches density, background, border and focus; `terra` keeps the accessible contract.',
ar: 'تضبط وصفة `air` الكثافة والخلفية والحدود والتركيز؛ بينما يحافظ `terra` على العقد القابل للوصول.',
})}</p>
</div>
</Tabs.Content>
</Tabs.Root>
</div>
</CodeExample>
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Variantes', en: 'Variants', ar: 'الأنماط' })}</h2>
<p>{tv({
es: 'Las variantes no cambian el wiring; solo la lectura visual de la navegación.',
en: 'Variants do not change the wiring; they only change the visual reading of navigation.',
ar: 'لا تغيّر الأنماط البنية الأساسية؛ بل تغيّر فقط القراءة البصرية للتنقل.',
})}</p>
</header>
<div class="variant-grid">
{#each variants as variant}
<CodeExample title={`Tabs ${variant}`} code={`<Tabs.Root variant="${variant}">...</Tabs.Root>`}>
<Tabs.Root value="one" variant={variant} size="sm">
<Tabs.List>
<Tabs.Trigger value="one">{tv({ es: 'Uno', en: 'One', ar: 'واحد' })}</Tabs.Trigger>
<Tabs.Trigger value="two">{tv({ es: 'Dos', en: 'Two', ar: 'اثنان' })}</Tabs.Trigger>
<Tabs.Trigger value="three">{tv({ es: 'Tres', en: 'Three', ar: 'ثلاثة' })}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="one">
<p class="mini-copy">{tv({
es: `Lectura visual de la variante ${variant}.`,
en: `${variant} variant visual reading.`,
ar: `القراءة البصرية للنمط ${variant}.`,
})}</p>
</Tabs.Content>
<Tabs.Content value="two"></Tabs.Content>
<Tabs.Content value="three"></Tabs.Content>
</Tabs.Root>
</CodeExample>
{/each}
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Vertical', en: 'Vertical', ar: 'عمودي' })}</h2>
<p>{tv({
es: 'La orientación vertical debe seguir siendo legible en desktop y caer con dignidad a una columna en móvil.',
en: 'Vertical orientation should stay readable on desktop and gracefully collapse to one column on mobile.',
ar: 'يجب أن تبقى البنية العمودية مقروءة على سطح المكتب وأن تنهار بسلاسة إلى عمود واحد على الجوال.',
})}</p>
</header>
<CodeExample title={tv({ es: 'Tabs verticales', en: 'Vertical tabs', ar: 'علامات تبويب عمودية' })} code={verticalCode}>
<Tabs.Root bind:value={verticalValue} orientation="vertical" variant="surface" size="sm">
<Tabs.List>
<Tabs.Trigger value="metrics">{tv({ es: 'Métricas', en: 'Metrics', ar: 'المقاييس' })}</Tabs.Trigger>
<Tabs.Trigger value="members">{tv({ es: 'Equipo', en: 'Team', ar: 'الفريق' })}</Tabs.Trigger>
<Tabs.Trigger value="logs">{tv({ es: 'Logs', en: 'Logs', ar: 'السجلات' })}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="metrics">
<div class="content-stack">
<h3>{tv({ es: 'Métricas', en: 'Metrics', ar: 'المقاييس' })}</h3>
<p>{tv({ es: 'Ideal para paneles con navegación local persistente.', en: 'Ideal for dashboards with persistent local navigation.', ar: 'مثالي للوحات التحكم ذات التنقل المحلي المستمر.' })}</p>
</div>
</Tabs.Content>
<Tabs.Content value="members">
<div class="content-stack">
<h3>{tv({ es: 'Equipo', en: 'Team', ar: 'الفريق' })}</h3>
<p>{tv({ es: 'El rail lateral mantiene jerarquía sin convertirse en un menú pesado.', en: 'The side rail keeps hierarchy without turning into a heavy menu.', ar: 'يحافظ الشريط الجانبي على الهرمية من دون أن يتحول إلى قائمة ثقيلة.' })}</p>
</div>
</Tabs.Content>
<Tabs.Content value="logs">
<div class="content-stack">
<h3>{tv({ es: 'Logs', en: 'Logs', ar: 'السجلات' })}</h3>
<p>{tv({ es: 'La receta usa las mismas escalas de spacing, borde y foco del resto de controles.', en: 'The recipe uses the same spacing, border and focus scales as the rest of the controls.', ar: 'تستخدم الوصفة مقاييس المسافات والحدود والتركيز نفسها مثل بقية عناصر التحكم.' })}</p>
</div>
</Tabs.Content>
</Tabs.Root>
</CodeExample>
</section>
</div>
<style>
.tabs-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-6);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.85fr);
gap: var(--air-space-6);
align-items: start;
}
.eyebrow {
margin: 0 0 var(--air-space-2);
font: 600 var(--air-font-size-xs) / 1 var(--air-font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--air-color-content-secondary);
}
.hero h1,
.section-header h2,
.content-stack h3 {
margin: 0;
}
.lead {
margin: var(--air-space-3) 0 0;
max-width: 64ch;
color: var(--air-color-content-secondary);
font-size: var(--air-font-size-md);
line-height: var(--air-leading-prose);
}
.hero-signals,
.controls,
.chips,
.preview-stack,
.content-stack,
.hero-aside {
display: flex;
}
.hero-signals,
.chips {
flex-wrap: wrap;
}
.hero-signals {
gap: var(--air-space-2);
margin-top: var(--air-space-4);
}
.hero-signals span,
.hero-aside span,
.control > span {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
color: var(--air-color-content-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hero-signals span,
.hero-aside > div,
.panel {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 92%, transparent);
}
.hero-signals span,
.chip,
.hero-aside > div,
.panel {
border-radius: var(--air-radius-lg);
}
.hero-signals span {
padding: 0.45rem 0.7rem;
}
.hero-aside {
flex-direction: column;
gap: var(--air-space-3);
}
.hero-aside > div {
display: grid;
gap: var(--air-space-2);
padding: var(--air-space-4);
}
.hero-aside strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-prose);
}
.panel {
display: grid;
gap: var(--air-space-5);
padding: var(--air-space-5);
}
.panel--feature {
background:
linear-gradient(180deg, color-mix(in srgb, var(--air-color-surface-overlay) 80%, transparent), transparent 55%),
color-mix(in srgb, var(--air-color-surface-raised) 94%, transparent);
}
.section-header {
display: grid;
gap: var(--air-space-2);
}
.section-header p,
.content-stack p,
.mini-copy {
margin: 0;
color: var(--air-color-content-secondary);
line-height: var(--air-leading-prose);
}
.playground-grid {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: var(--air-space-5);
}
.controls,
.preview-stack,
.content-stack {
flex-direction: column;
}
.controls,
.preview-stack,
.content-stack {
gap: var(--air-space-3);
}
.control {
display: grid;
gap: var(--air-space-2);
}
.chips {
gap: var(--air-space-2);
}
.chip {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: transparent;
color: var(--air-color-content-secondary);
padding: 0.45rem 0.8rem;
font: inherit;
cursor: pointer;
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);
}
.chip--active {
background: color-mix(in srgb, var(--air-color-primary-track) 82%, transparent);
border-color: var(--air-color-primary-border);
color: var(--air-color-content-primary);
}
.variant-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--air-space-4);
}
@media (max-width: 960px) {
.hero,
.playground-grid,
.variant-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,587 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Button, Toast, toast, Icon } from '$uix/air';
import type { AirToastPosition } from '$uix/air';
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import CodeExample from '../_components/code-example.svelte';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
let position = $state<AirToastPosition>('bottom-right');
const positions: AirToastPosition[] = [
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right',
];
function fireDefault() {
toast('Archivo guardado correctamente');
}
function fireSuccess() {
toast.success('Invitación enviada al equipo');
}
function fireError() {
toast.error('No se pudo conectar al servidor');
}
function fireWarning() {
toast.warning('El workspace se está quedando sin espacio');
}
function fireInfo() {
toast.info('Nueva versión disponible');
}
function fireRich() {
toast.success('La subida terminó correctamente', {
title: 'Archivo listo',
description: '2.4 MB transferidos en 1.2 s. Ya puedes compartir el enlace.',
});
}
function fireAction() {
let id = '';
id = toast.error('No se pudo enviar el mensaje', {
title: 'Error de red',
action: {
label: 'Reintentar',
onClick: () => {
toast.dismiss(id);
toast.success('Mensaje enviado');
},
},
});
}
function firePersistent() {
toast('Esta notificación no se cierra sola', {
duration: 0,
title: 'Toast persistente',
description: 'Útil para procesos que necesitan confirmación manual.',
});
}
function clearAll() {
toast.clear();
}
function toastPreviewMeta(type: 'default' | 'success' | 'error' | 'warning' | 'info') {
switch (type) {
case 'success':
return {
title: 'Success',
message: 'Invitación enviada',
icon: Icon.BadgeCheck,
};
case 'error':
return {
title: 'Error',
message: 'No se pudo conectar',
icon: Icon.CircleAlert,
};
case 'warning':
return {
title: 'Warning',
message: 'Queda poco espacio',
icon: Icon.TriangleAlert,
};
case 'info':
return {
title: 'Info',
message: 'Nueva versión disponible',
icon: Icon.BadgeInfo,
};
default:
return {
title: 'Default',
message: 'Archivo guardado',
icon: Icon.BellRing,
};
}
}
const launcherPreviews = [
{ type: 'default', ...toastPreviewMeta('default'), onClick: fireDefault, className: 'toast-launcher--neutral' },
{ type: 'success', ...toastPreviewMeta('success'), onClick: fireSuccess, className: 'toast-launcher--success' },
{ type: 'error', ...toastPreviewMeta('error'), onClick: fireError, className: 'toast-launcher--error' },
{ type: 'warning', ...toastPreviewMeta('warning'), onClick: fireWarning, className: 'toast-launcher--warning' },
{ type: 'info', ...toastPreviewMeta('info'), onClick: fireInfo, className: 'toast-launcher--info' },
] as const;
const apiCode = $derived(`<Toast.Toaster position="${position}" />
toast.success('Invitación enviada');
toast.error('No se pudo conectar', {
title: 'Error de red',
action: { label: 'Reintentar', onClick: retry }
});`);
const customCode = `<Toast.Toaster position="top-right">
{#snippet children({ item })}
<Toast.Message>{item.message}</Toast.Message>
{/snippet}
</Toast.Toaster>`;
</script>
<svelte:head>
<title>Toast · Air</title>
</svelte:head>
<Toast.Toaster {position} />
<div class="toast-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Toast</p>
<h1>{tv({
es: 'Toast sobre terra.Toast',
en: 'Toast over terra.Toast',
ar: 'Toast فوق terra.Toast',
})}</h1>
<p class="lead">
{tv({
es: '`Toast` reutiliza la store y el ciclo de vida de `terra`, y `air` aterriza superficie, color semántico, cierre, acción y jerarquía tipográfica.',
en: '`Toast` reuses the store and lifecycle from `terra`, and `air` lands surface, semantic color, close action and type hierarchy.',
ar: 'يعيد `Toast` استخدام المخزن ودورة الحياة من `terra`، بينما يحدد `air` السطح واللون الدلالي والإغلاق وتسلسل النص.',
})}
</p>
<div class="hero-signals">
<span>{tv({ es: 'store API', en: 'store API', ar: 'واجهة store' })}</span>
<span>{tv({ es: 'notification semantics', en: 'notification semantics', ar: 'دلالات الإشعار' })}</span>
<span>{tv({ es: 'semantic variants', en: 'semantic variants', ar: 'تنويعات دلالية' })}</span>
<span>{tv({ es: 'action + dismiss', en: 'action + dismiss', ar: 'إجراء + إغلاق' })}</span>
</div>
</div>
<div class="hero-aside">
<div>
<span>{tv({ es: 'API', en: 'API', ar: 'الواجهة' })}</span>
<strong>Toaster · Root · Title · Message · Description · Action · Close · toast(...)</strong>
</div>
<div>
<span>{tv({ es: 'DOM', en: 'DOM', ar: 'DOM' })}</span>
<strong>{tv({
es: 'stack portaled con store global, partes tipadas y semántica de notificación ya conectada a behavior',
en: 'portaled stack with global store, typed parts and notification semantics already wired to behavior',
ar: 'مكدس عبر بوابة مع مخزن عام وأجزاء مكتوبة ودلالات إشعار موصولة بالفعل إلى behavior',
})}</strong>
</div>
</div>
</header>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Playground', en: 'Playground', ar: 'ساحة التجريب' })}</h2>
<p>{tv({
es: 'La API principal sigue siendo `toast(...)`. El `Toaster` solo decide dónde vive el stack y cómo se presenta.',
en: 'The main API remains `toast(...)`. `Toaster` only decides where the stack lives and how it looks.',
ar: 'تظل الواجهة الرئيسية هي `toast(...)`. ويحدد `Toaster` فقط مكان المكدس وكيفية عرضه.',
})}</p>
</header>
<div class="playground-grid">
<div class="controls">
<div class="control">
<span>{tv({ es: 'Tipos', en: 'Types', ar: 'الأنواع' })}</span>
<div class="button-cloud">
{#each launcherPreviews as item}
<button type="button" class={`toast-launcher ${item.className}`} onclick={item.onClick}>
<span class="toast-launcher__status" aria-hidden="true">
<item.icon size="sm" />
</span>
<span class="toast-launcher__main">
<span class="toast-launcher__title">{item.title}</span>
<span class="toast-launcher__message">{item.message}</span>
</span>
<span class="toast-launcher__close" aria-hidden="true">
<Icon.X size="sm" />
</span>
</button>
{/each}
</div>
</div>
<div class="control">
<span>{tv({ es: 'Contenido rico', en: 'Rich content', ar: 'محتوى غني' })}</span>
<div class="button-cloud">
<Button variant="surface" color="neutral" onclick={fireRich}>
{tv({ es: 'Título + descripción', en: 'Title + description', ar: 'عنوان + وصف' })}
</Button>
<Button variant="surface" color="danger" onclick={fireAction}>
{tv({ es: 'Acción', en: 'Action', ar: 'إجراء' })}
</Button>
<Button variant="surface" color="neutral" onclick={firePersistent}>
{tv({ es: 'Persistente', en: 'Persistent', ar: 'مستمر' })}
</Button>
<Button variant="plain" color="danger" onclick={clearAll}>
{tv({ es: 'Limpiar todos', en: 'Clear all', ar: 'مسح الكل' })}
</Button>
</div>
</div>
<div class="control">
<span>{tv({ es: 'Posición', en: 'Position', ar: 'الموضع' })}</span>
<div class="chips">
{#each positions as item}
<button
type="button"
class:chip={true}
class:chip--active={position === item}
onclick={() => {
position = item;
toast.info(`Posición: ${item}`);
}}
>{item}</button>
{/each}
</div>
</div>
</div>
<CodeExample
title={tv({ es: 'API de Toast', en: 'Toast API', ar: 'واجهة Toast' })}
code={apiCode}
>
<div class="preview-note">
<p>{tv({
es: 'Dispara varios toasts y cambia la posición. El stack real se renderiza fuera de la página mediante portal.',
en: 'Fire several toasts and change position. The real stack is rendered outside the page through a portal.',
ar: 'أطلق عدة إشعارات وغيّر الموضع. يتم عرض المكدس الحقيقي خارج الصفحة عبر بوابة.',
})}</p>
</div>
</CodeExample>
</div>
</section>
<section class="panel">
<header class="section-header">
<h2>{tv({ es: 'Render custom por item', en: 'Custom item rendering', ar: 'عرض مخصص لكل عنصر' })}</h2>
<p>{tv({
es: 'El `Toaster` también acepta un snippet con `{ item }`, por si una app necesita reordenar la anatomía sin tocar la store.',
en: '`Toaster` also accepts a `{ item }` snippet when an app needs to reorder the anatomy without touching the store.',
ar: 'يقبل `Toaster` أيضاً مقتطف `{ item }` إذا احتاجت التطبيق إلى إعادة ترتيب البنية دون لمس المخزن.',
})}</p>
</header>
<CodeExample
title={tv({ es: 'Snippet custom', en: 'Custom snippet', ar: 'مقتطف مخصص' })}
code={customCode}
>
<div class="preview-note">
<p>{tv({
es: 'La demo principal usa el render por defecto, pero la API deja abierta la puerta a layouts más editoriales o más productivos.',
en: 'The main demo uses the default renderer, but the API still leaves room for more editorial or more product-like layouts.',
ar: 'تستخدم المعاينة الرئيسية العارض الافتراضي، لكن الواجهة تترك الباب مفتوحاً لتخطيطات أكثر تحريريّة أو أكثر إنتاجية.',
})}</p>
</div>
</CodeExample>
</section>
</div>
<style>
.toast-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-6);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.85fr);
gap: var(--air-space-6);
align-items: start;
}
.eyebrow {
margin: 0 0 var(--air-space-2);
font: 600 var(--air-font-size-xs) / 1 var(--air-font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--air-color-content-secondary);
}
.hero h1,
.section-header h2 {
margin: 0;
}
.lead {
margin: var(--air-space-3) 0 0;
max-width: 62ch;
color: var(--air-color-content-secondary);
font-size: var(--air-font-size-md);
line-height: var(--air-leading-prose);
}
.hero-signals,
.button-cloud,
.chips,
.controls {
display: flex;
flex-wrap: wrap;
}
.hero-signals {
gap: var(--air-space-2);
margin-top: var(--air-space-4);
}
.hero-signals span,
.hero-aside span,
.control > span {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
color: var(--air-color-content-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hero-signals span,
.hero-aside > div,
.panel,
.preview-note {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 92%, transparent);
border-radius: var(--air-radius-lg);
}
.hero-signals span {
padding: 0.45rem 0.7rem;
}
.hero-aside {
display: grid;
gap: var(--air-space-3);
}
.hero-aside > div {
display: grid;
gap: var(--air-space-2);
padding: var(--air-space-4);
}
.hero-aside strong {
font-size: var(--air-font-size-sm);
line-height: var(--air-leading-prose);
}
.panel {
display: grid;
gap: var(--air-space-5);
padding: var(--air-space-5);
}
.section-header {
display: grid;
gap: var(--air-space-2);
}
.section-header p,
.preview-note p {
margin: 0;
color: var(--air-color-content-secondary);
line-height: var(--air-leading-prose);
}
.playground-grid {
display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
gap: var(--air-space-5);
}
.controls {
flex-direction: column;
gap: var(--air-space-4);
}
.control {
display: grid;
gap: var(--air-space-2);
}
.button-cloud,
.chips {
gap: var(--air-space-2);
}
.toast-launcher {
--_toast-launcher-surface: var(--air-toast-default-surface);
--_toast-launcher-border: var(--air-toast-default-border);
--_toast-launcher-accent: var(--air-toast-default-accent);
--_toast-launcher-status-bg: var(--air-toast-default-status-bg);
--_toast-launcher-color: var(--air-toast-default-status-color);
--_toast-launcher-action-hover-bg: var(--air-toast-default-action-hover-bg);
inline-size: min(100%, 320px);
min-height: var(--air-control-height-lg);
padding: var(--air-space-3) var(--air-space-4);
display: inline-grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
justify-content: stretch;
gap: var(--air-space-3);
border: var(--air-border-width) solid var(--_toast-launcher-border);
border-inline-start: var(--air-toast-accent-width) solid var(--_toast-launcher-accent);
border-radius: var(--air-toast-root-radius);
background: var(--_toast-launcher-surface);
box-shadow: var(--air-toast-root-shadow);
color: var(--air-toast-message-color);
font: inherit;
font-size: var(--air-toast-message-font-size);
font-weight: var(--air-toast-message-font-weight);
line-height: var(--air-toast-message-line-height);
text-align: start;
cursor: pointer;
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),
transform var(--air-duration-fast) var(--air-ease-default);
}
.toast-launcher__status {
inline-size: var(--air-toast-status-size);
block-size: var(--air-toast-status-size);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--air-toast-status-radius);
background: var(--_toast-launcher-status-bg);
color: var(--_toast-launcher-color);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_toast-launcher-accent) 22%, transparent);
flex: 0 0 auto;
}
.toast-launcher__main {
min-inline-size: 0;
display: grid;
gap: var(--air-toast-root-stack-gap);
}
.toast-launcher__title {
font-family: var(--air-toast-title-font-family);
font-size: var(--air-toast-title-font-size);
font-weight: var(--air-toast-title-font-weight);
line-height: var(--air-toast-title-line-height);
color: var(--air-toast-title-color);
}
.toast-launcher__message {
font-family: var(--air-toast-message-font-family);
font-size: var(--air-toast-message-font-size);
font-weight: var(--air-toast-message-font-weight);
line-height: var(--air-toast-message-line-height);
color: var(--air-toast-message-color);
}
.toast-launcher__close {
inline-size: var(--air-toast-close-size);
block-size: var(--air-toast-close-size);
display: inline-flex;
align-items: center;
justify-content: center;
border: var(--air-toast-close-border-width) solid var(--air-toast-close-border);
border-radius: var(--air-toast-close-radius);
background: var(--air-toast-close-bg);
color: var(--air-toast-close-color);
flex: 0 0 auto;
}
.toast-launcher:hover {
transform: translateY(-1px);
box-shadow: 0 16px 34px rgb(17 14 10 / 0.14), 0 6px 10px rgb(17 14 10 / 0.06);
}
.toast-launcher:focus-visible {
outline: none;
box-shadow:
0 0 0 var(--air-focus-ring-offset) var(--air-color-surface-default),
0 0 0 calc(var(--air-focus-ring-offset) + var(--air-focus-ring-width))
var(--air-focus-ring-color);
}
.toast-launcher--neutral {
--_toast-launcher-surface: var(--air-toast-default-surface);
--_toast-launcher-border: var(--air-toast-default-border);
--_toast-launcher-accent: var(--air-toast-default-accent);
--_toast-launcher-status-bg: var(--air-toast-default-status-bg);
--_toast-launcher-color: var(--air-toast-default-status-color);
--_toast-launcher-action-hover-bg: var(--air-toast-default-action-hover-bg);
}
.toast-launcher--success {
--_toast-launcher-surface: var(--air-toast-success-surface);
--_toast-launcher-border: var(--air-toast-success-border);
--_toast-launcher-accent: var(--air-toast-success-accent);
--_toast-launcher-status-bg: var(--air-toast-success-status-bg);
--_toast-launcher-color: var(--air-toast-success-status-color);
--_toast-launcher-action-hover-bg: var(--air-toast-success-action-hover-bg);
}
.toast-launcher--error {
--_toast-launcher-surface: var(--air-toast-error-surface);
--_toast-launcher-border: var(--air-toast-error-border);
--_toast-launcher-accent: var(--air-toast-error-accent);
--_toast-launcher-status-bg: var(--air-toast-error-status-bg);
--_toast-launcher-color: var(--air-toast-error-status-color);
--_toast-launcher-action-hover-bg: var(--air-toast-error-action-hover-bg);
}
.toast-launcher--warning {
--_toast-launcher-surface: var(--air-toast-warning-surface);
--_toast-launcher-border: var(--air-toast-warning-border);
--_toast-launcher-accent: var(--air-toast-warning-accent);
--_toast-launcher-status-bg: var(--air-toast-warning-status-bg);
--_toast-launcher-color: var(--air-toast-warning-status-color);
--_toast-launcher-action-hover-bg: var(--air-toast-warning-action-hover-bg);
}
.toast-launcher--info {
--_toast-launcher-surface: var(--air-toast-info-surface);
--_toast-launcher-border: var(--air-toast-info-border);
--_toast-launcher-accent: var(--air-toast-info-accent);
--_toast-launcher-status-bg: var(--air-toast-info-status-bg);
--_toast-launcher-color: var(--air-toast-info-status-color);
--_toast-launcher-action-hover-bg: var(--air-toast-info-action-hover-bg);
}
.chip {
border: 1px solid color-mix(in srgb, var(--air-color-border-default) 82%, transparent);
border-radius: var(--air-radius-full);
background: transparent;
color: var(--air-color-content-secondary);
padding: 0.45rem 0.8rem;
font: inherit;
cursor: pointer;
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);
}
.chip--active {
background: color-mix(in srgb, var(--air-color-primary-track) 82%, transparent);
border-color: var(--air-color-primary-border);
color: var(--air-color-content-primary);
}
.preview-note {
padding: var(--air-space-4);
}
@media (max-width: 900px) {
.hero,
.playground-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -703,12 +703,6 @@
gap: var(--air-space-4);
}
.demo-grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--air-space-4);
}
/* ── Specimen rows (label + specimen, side by side) ── */
.specimen-row {
display: flex;
@ -803,7 +797,7 @@
/* ── Responsive ── */
@media (max-width: 900px) {
.cols-2, .cols-3, .reading-body, .demo-grid-2, .demo-grid-3 {
.cols-2, .cols-3, .reading-body, .demo-grid-2 {
grid-template-columns: 1fr;
}
.scale-row {
@ -812,9 +806,6 @@
.weight-row {
grid-template-columns: 90px 1fr;
}
.weight-row > :last-child {
display: none;
}
}
@media (max-width: 600px) {

@ -33,6 +33,9 @@ Este archivo debe permitir que cualquier persona retome `air` sin reconstruir el
5. [AIR_MOTION.md](./AIR_MOTION.md)
motion tokens y comportamiento de interacción
6. [AIR_DOCUMENTATION_AUDIT.md](./AIR_DOCUMENTATION_AUDIT.md)
auditoría editorial del corpus de `air`: qué manda hoy, qué está desalineado y qué conviene deprecar
### Documentos históricos o de apoyo
- [color-system-guide.md](./color-system-guide.md)
@ -56,15 +59,22 @@ La situación real hoy es esta:
- existe una capa de contratos separada de la implementación
- `Button` ya usa una capa 3 de tokens de componente bastante más limpia
- `Spinner` e `Icon` ya tienen tamaño y grosor alineados con el sistema
- `Field` ya existe como wrapper visual sobre `terra.Field`, con `Root`, `Label`, `Control`, `Input`, `HelperText`, `Error`, `Prefix` y `Suffix`
- `Select` ya existe como wrapper visual sobre `terra.Select`, con `Root`, `Trigger`, `Content`, `ContentStatic`, `Item`, `Group`, `GroupHeading`, `Viewport`, `ScrollUpButton` y `ScrollDownButton`
- `Switch` ya existe como wrapper visual sobre `terra.Switch`, con `Root` y `Thumb`
- `Drawer` ya existe como wrapper visual sobre `terra.Drawer`, con `Root`, `Trigger`, `Portal`, `Overlay`, `Content`, `Header`, `Footer`, `Title`, `Description` y `Close`
- `Dialog` ya existe como wrapper visual sobre `terra.Dialog`, con `Root`, `Trigger`, `Portal`, `Overlay`, `Content`, `Header`, `Footer`, `Title`, `Description` y `Close`
- `Tabs` ya existe como wrapper visual sobre `terra.Tabs`, con `Root`, `List`, `Trigger` y `Content`
- `Toast` ya existe como wrapper visual sobre `terra.Toast`, con `Toaster`, `Root`, `Title`, `Message`, `Description`, `Action`, `Close` y la API `toast(...)`
- el módulo tipográfico está completo: `Text`, `Heading`, `Display`, `Code`, `CodeBlock`, `Kbd`, `Mark`, `Highlight`
- las demos de `air` ya permiten validar color, surfaces, buttons, icons, spinner y typography
- las demos de `air` ya permiten validar color, surfaces, buttons, dialog, drawer, field, select, switch, icons, spinner, tabs, toast y typography
Lo que todavía no está cerrado:
- no todos los componentes tienen todavía contrato público separado
- muchas pantallas demo siguen consumiendo semánticos directamente y no una capa 3 local más disciplinada
- el sistema de contratos está creado, pero su adopción todavía no está completa en todos los componentes
- los Core Components (Field/Input, Dialog, Tabs) siguen como planned
- la siguiente ronda razonable ya es endurecer variantes, demos e integración de los core components ya implementados
---
@ -84,6 +94,8 @@ src/uix/air/
index.css
components/
button.css
field.css
input.css
themes/
fonts.css
motion.css
@ -93,11 +105,13 @@ src/uix/air/
light.css
dark.css
components/
field/
dialog/
button/
icon/
input/
spinner/
icons/
create-icon.ts
icon.svelte
typography/
AIR_ARCHITECTURE.md
AIR_COLOR_SYSTEM.md
AIR_CONTRACT_ARCHITECTURE.md
@ -300,6 +314,7 @@ Archivos clave:
- `ButtonLabel` no traduce internamente
- el texto visible debe venir resuelto fuera o envolverse con `Trans`
- las traducciones internas como `aria-label` y `loadingText` pueden resolverse a través del translator de `terra`
- los atributos nativos/ARIA se exponen con su nombre real (`aria-label`), no como props camelCase inventadas; la norma viva está en [AIR_IMPLEMENTATION_GUIDE.md](./AIR_IMPLEMENTATION_GUIDE.md)
- `intent` y `color` no son lo mismo:
- `intent` comunica semántica
@ -331,8 +346,8 @@ Esto vive hoy en el CSS del botón y debe mantenerse coherente con la escala glo
Archivos clave:
- `src/uix/air/icons/create-icon.ts`
- `src/uix/air/icons/icon.svelte`
- `src/uix/air/components/icon/create-icon.ts`
- `src/uix/air/components/icon/icon.svelte`
- `src/routes/test/air/icons/+page.svelte`
### Reglas ya decididas
@ -450,6 +465,9 @@ Las demos actuales importantes para continuar el trabajo son:
- `/test/air/typography`
validación de escala y familias tipográficas
- `/test/air/toast`
validación de notificaciones, store API, acciones y posición
Estas demos son ahora mismo la forma más rápida de revisar si una decisión de sistema realmente funciona.
---
@ -492,8 +510,8 @@ Mientras no formen parte de un objetivo concreto, se pueden considerar ruido con
Los siguientes pasos con más sentido, desde el estado actual, son:
1. Core Components — Field/Input, Dialog, Tabs
siguiente bloque de validación del sistema, ya marcados como `planned` en el hub
1. Core Components — Dialog, Tabs
ya implementados como bloque base; la siguiente tarea es refinarlos con auditoría de variantes, motion/sound y demos
2. completar adopción de contratos de componente
los contratos de `Kbd`, `Mark` y `CodeBlock` ya existen — seguir con los demás

@ -0,0 +1,401 @@
# AIR_DOCUMENTATION_AUDIT
Auditoría editorial y arquitectónica de la documentación de `air`.
Su objetivo es dejar claro:
- qué documentos son normativos hoy
- cuáles son derivados útiles pero no mandan
- cuáles están desalineados con la implementación real
- y qué piezas conviene fusionar, deprecar o mantener como histórico
Este archivo no redefine la arquitectura. Ordena el corpus documental existente.
---
## 1. Diagnóstico general
La documentación de `air` ha mejorado mucho en arquitectura, pero ya empieza a mostrar
un patrón típico de sistemas en consolidación:
- el núcleo conceptual es fuerte
- la continuidad ya está bien resuelta
- pero hay documentos derivados que todavía describen versiones anteriores del sistema
- y algunas piezas nuevas no están todavía integradas en la jerarquía oficial de lectura
La situación no es de caos, pero sí de **ramificación documental no cerrada**.
En términos prácticos:
- hoy ya existe una base normativa suficientemente buena para continuar
- pero no todo documento bajo `src/uix/air/` tiene el mismo nivel de autoridad
- y varias especificaciones han quedado parcialmente obsoletas tras los cambios en contratos, tipografía, motion y estructura de carpetas
---
## 2. Criterio de clasificación
Cada documento se clasifica en una de estas categorías:
- **Normativo**
fuente principal. Debe mantenerse alineado con la implementación real.
- **Derivado activo**
útil y vigente, pero subordinado a los normativos.
- **Histórico / de transición**
conserva contexto, pero no debe usarse como fuente de decisión nueva.
- **Desalineado / candidato a deprecación**
hoy describe una arquitectura o un estado ya superado total o parcialmente.
---
## 3. Resultado de la auditoría
### 3.1 Núcleo normativo recomendado
Estos documentos deben considerarse la base oficial de diseño y arquitectura:
- [AIR_ARCHITECTURE.md](./AIR_ARCHITECTURE.md)
- [AIR_COLOR_SYSTEM.md](./AIR_COLOR_SYSTEM.md)
- [AIR_CONTRACT_ARCHITECTURE.md](./AIR_CONTRACT_ARCHITECTURE.md)
- [AIR_VISUAL_LANGUAGE.md](./AIR_VISUAL_LANGUAGE.md)
- [AIR_CONTINUITY.md](./AIR_CONTINUITY.md)
Razón:
- cubren la frontera `terra -> air -> theme`
- cubren el sistema de color en 3 capas
- cubren la arquitectura de contratos
- cubren el lenguaje visual base
- y cubren el estado real del repo y cómo continuar
### 3.2 Derivados activos útiles
Estos documentos siguen siendo valiosos, pero no deberían mandar por encima del núcleo:
- [AIR_TYPOGRAPHY_SPEC.md](./AIR_TYPOGRAPHY_SPEC.md)
- [color-system-guide.md](./color-system-guide.md)
Razón:
- `AIR_TYPOGRAPHY_SPEC.md` documenta bien el módulo tipográfico y sus decisiones de implementación
- `color-system-guide.md` sigue siendo una buena base conceptual fundacional del sistema de color
### 3.3 Históricos o de transición
Estos documentos pueden conservarse, pero deben quedar explícitamente relegados:
- [air-arquitectura.md](./air-arquitectura.md)
Razón:
- conserva contexto valioso del momento en que `air` todavía estaba más mezclado con motion/sound/config
- pero ya no refleja bien la arquitectura actual de contratos, carpetas y responsabilidades
### 3.4 Desalineados o candidatos claros a deprecación
Estos documentos hoy no deberían guiar decisiones nuevas sin una revisión fuerte:
- [AIR_IMPLEMENTATION_GUIDE.md](./AIR_IMPLEMENTATION_GUIDE.md)
- [AIR_MOTION_V2.md](./AIR_MOTION_V2.md)
- [AIR_SEMANTICS.md](./AIR_SEMANTICS.md)
- [AIR_SOUND.md](./AIR_SOUND.md)
Razón general:
- describen una versión de `air` mucho más centrada en semánticas de motion/sound como capa vertebral
- hablan de APIs, carpetas y mecanismos que no son el centro del sistema actual
- y en varios casos no están integrados en la ruta de lectura ni en la continuidad oficial
No significa que todo su contenido sea inválido. Significa que **ya no son autoridad estable**.
---
## 4. Hallazgos concretos por documento
### [AIR_ARCHITECTURE.md](./AIR_ARCHITECTURE.md)
Estado:
- **Normativo**
Fortalezas:
- define bien la frontera entre `terra`, `air` y tema
- deja claras las responsabilidades de cada capa
- establece reglas fuertes sobre anatomía pública y `data-*`
Problemas detectados:
- todavía nombra [tokens/contract.css](./tokens/contract.css) como forma natural del contrato visual
- la arquitectura real ya se ha movido a [contracts/contract.css](./contracts/contract.css)
Acción recomendada:
- **actualizar**
### [AIR_COLOR_SYSTEM.md](./AIR_COLOR_SYSTEM.md)
Estado:
- **Normativo**
Fortalezas:
- muy alineado con el modelo real de 3 capas
- fija bien la regla de que los componentes no consumen primitives
- documenta correctamente `intent` vs `color`
Problemas detectados:
- el texto dice que los temas “solo deberían tocar Capa 2”, pero con la arquitectura actual existe también una extensión opcional de primitives en contrato avanzado
Acción recomendada:
- **ajuste menor de redacción**
### [AIR_CONTRACT_ARCHITECTURE.md](./AIR_CONTRACT_ARCHITECTURE.md)
Estado:
- **Normativo**
Fortalezas:
- es el documento que más ha madurado la arquitectura real
- separa bien contrato de tema, contrato de componente y contrato avanzado
- formaliza muy bien público vs privado con `--air-*` y `--_air-*`
Problemas detectados:
- mínimos
Acción recomendada:
- **mantener como fuente principal**
### [AIR_VISUAL_LANGUAGE.md](./AIR_VISUAL_LANGUAGE.md)
Estado:
- **Normativo**, pero parcialmente desalineado
Fortalezas:
- fija muy bien el tono visual de `air`
- el apartado de superficies, foco, densidad y jerarquía sigue siendo valioso
Problemas detectados:
- todavía describe la tipografía base como “una sola sans + una sola mono”
- asigna `Instrument Sans` también a headings
- no refleja la decisión ya tomada de usar `Lora` para `heading` y `display`
- sigue hablando de `tokens/contract.css` en lugar de `contracts/contract.css`
Acción recomendada:
- **actualizar pronto**
### [AIR_CONTINUITY.md](./AIR_CONTINUITY.md)
Estado:
- **Normativo operativo**
Fortalezas:
- resuelve muy bien el handoff del sistema
- refleja bastante bien el estado de implementación
- hace de mapa de entrada real para continuar
Problemas detectados:
- sigue diciendo que el estado esperado de `npm run check` son `22 warnings`
- hoy eso ya no coincide con el repo
Acción recomendada:
- **actualizar el estado de warnings y revisar periódicamente**
### [AIR_TYPOGRAPHY_SPEC.md](./AIR_TYPOGRAPHY_SPEC.md)
Estado:
- **Derivado activo**
Fortalezas:
- es bastante útil para el módulo tipográfico
- documenta decisiones de implementación de forma concreta
Problemas detectados:
- su escala alta (`44 / 52 / 60`) ya no coincide con la decisión actual consolidada en tema base (`24 / 36 / 52` en el tramo alto)
- contiene decisiones históricas que ya no reflejan exactamente el estado actual
Acción recomendada:
- **mantener, pero reescribir o recortar la parte de escala para que no compita con la continuidad**
### [AIR_IMPLEMENTATION_GUIDE.md](./AIR_IMPLEMENTATION_GUIDE.md)
Estado:
- **Desalineado**
Problemas detectados:
- empuja una arquitectura en la que motion y sound parecen parte obligatoria del checklist de cualquier componente
- depende de utilidades y carpetas (`getAir()`, `internal/behavior`, semánticas obligatorias) que ya no son el centro del `air` actual
- da demasiado peso a una capa expresiva que hoy no está consolidada como contrato base del sistema
Acción recomendada:
- **deprecar o reescribir desde cero**
### [AIR_MOTION_V2.md](./AIR_MOTION_V2.md)
Estado:
- **Desalineado / excesivamente específico**
Problemas detectados:
- es muy detallado y útil en abstracto, pero ahora mismo no actúa como documento integrado en la arquitectura principal
- habla de `_static.css`, estructuras y rutas que no reflejan exactamente el estado real actual
- compite con `AIR_VISUAL_LANGUAGE.md` en el espacio conceptual de motion
Acción recomendada:
- **fusionar lo esencial en el documento normativo correcto o marcarlo como spec futura**
### [AIR_SEMANTICS.md](./AIR_SEMANTICS.md)
Estado:
- **Desalineado**
Problemas detectados:
- describe un sistema de semánticas muy ambicioso y rico, pero no forma parte hoy del núcleo documental por el que se gobierna `air`
- su nivel de implementación declarada no coincide claramente con lo que la continuidad presenta como estado actual
- puede inducir a pensar que `air` está gobernado principalmente por semánticas de interaction/sound, cuando hoy el núcleo real está en contratos, color, tema y componentes
Acción recomendada:
- **bajar a documento histórico o reencuadrar como visión futura**
### [AIR_SOUND.md](./AIR_SOUND.md)
Estado:
- **Desalineado / exploratorio**
Problemas detectados:
- define un sistema de sonido completo, pero hoy no está integrado en la continuidad como parte central del estado del repo
- su nivel de especificidad es muy alto para una capa cuyo estatus real no está consolidado
Acción recomendada:
- **mover a visión futura o exploración**
### [air-arquitectura.md](./air-arquitectura.md)
Estado:
- **Histórico**
Problemas detectados:
- mezcla una arquitectura previa de `air` con motion/sound/config como centro del sistema
- usa rutas y estructuras que ya no son la referencia real
Acción recomendada:
- **mantener solo como histórico explícito**
---
## 5. Mapa editorial recomendado
La jerarquía documental de `air` debería leerse así:
### Nivel 1 — Normativa real
- [AIR_ARCHITECTURE.md](./AIR_ARCHITECTURE.md)
- [AIR_COLOR_SYSTEM.md](./AIR_COLOR_SYSTEM.md)
- [AIR_CONTRACT_ARCHITECTURE.md](./AIR_CONTRACT_ARCHITECTURE.md)
- [AIR_VISUAL_LANGUAGE.md](./AIR_VISUAL_LANGUAGE.md)
- [AIR_CONTINUITY.md](./AIR_CONTINUITY.md)
### Nivel 2 — Especificaciones derivadas
- [AIR_TYPOGRAPHY_SPEC.md](./AIR_TYPOGRAPHY_SPEC.md)
- [color-system-guide.md](./color-system-guide.md)
### Nivel 3 — Históricas / exploratorias
- [air-arquitectura.md](./air-arquitectura.md)
- [AIR_IMPLEMENTATION_GUIDE.md](./AIR_IMPLEMENTATION_GUIDE.md)
- [AIR_MOTION_V2.md](./AIR_MOTION_V2.md)
- [AIR_SEMANTICS.md](./AIR_SEMANTICS.md)
- [AIR_SOUND.md](./AIR_SOUND.md)
---
## 6. Recomendaciones concretas
### Corto plazo
1. Actualizar [AIR_ARCHITECTURE.md](./AIR_ARCHITECTURE.md)
para que deje de referenciar `tokens/contract.css` como contrato principal.
2. Actualizar [AIR_VISUAL_LANGUAGE.md](./AIR_VISUAL_LANGUAGE.md)
para reflejar:
- `Lora` en `heading/display`
- la ruta `contracts/contract.css`
3. Actualizar [AIR_CONTINUITY.md](./AIR_CONTINUITY.md)
para reflejar el estado real de warnings y cualquier cambio de implementación reciente.
### Medio plazo
4. Reescribir o podar [AIR_TYPOGRAPHY_SPEC.md](./AIR_TYPOGRAPHY_SPEC.md)
para que no compita con la tipografía real ya implementada.
5. Marcar explícitamente como históricos o exploratorios:
- [air-arquitectura.md](./air-arquitectura.md)
- [AIR_IMPLEMENTATION_GUIDE.md](./AIR_IMPLEMENTATION_GUIDE.md)
- [AIR_MOTION_V2.md](./AIR_MOTION_V2.md)
- [AIR_SEMANTICS.md](./AIR_SEMANTICS.md)
- [AIR_SOUND.md](./AIR_SOUND.md)
### Largo plazo
6. Si motion/sound vuelven a ser una prioridad real del sistema:
- reintroducirlos en la jerarquía documental oficial
- pero ya como arquitectura consolidada, no como corpus paralelo
---
## 7. Conclusión
La evolución documental de `air` es buena en arquitectura y regular en edición.
Lo importante ya está conseguido:
- la frontera de capas existe
- el contrato existe
- la continuidad existe
Lo que falta ahora no es “más documentación”.
Lo que falta es **curaduría**:
- un núcleo normativo corto y claro
- especificaciones derivadas realmente alineadas
- y un espacio explícito para documentos históricos o exploratorios
Ese paso editorial es el que hará que `air` deje de sentirse como una arquitectura en conversación y pase a sentirse como un sistema estable.

@ -74,6 +74,7 @@ Crear `tokens/components/[componente].css`. Define los tokens `--air-[componente
- Prefijo siempre `--air-[componente]-`
- Tokens privados de implementación: `--_air-[componente]-` (no son API pública, pueden cambiar)
- Nunca hardcodear colores — siempre `var(--air-color-*)`
- Los rasgos visuales comunes del tema (`--air-border-width`, `--air-focus-ring-*`, radios, shadows, escalas de `space` y `control-height`) no se consumen directamente en el recipe: primero se aterrizan en tokens del componente
---
@ -109,6 +110,8 @@ Crear `components/[componente]/[componente].css`. Responde a los `data-*` de ter
- estados (`[data-disabled]`, `[data-state]`) son transversales — funcionan con cualquier variante sin combinatorias
- Este archivo se importa en el `.svelte`: `import './select.css'`
- Tokens y selectores `data-*` deben estar en archivos `.css` globales, **no** en `<style>` scoped
- Si el tema define una geometría común de borde o focus, el componente debe aplicarla a través de sus tokens (`--air-[componente]-border-width`, `--air-[componente]-focus-ring-*`) en vez de reconstruirla con números crudos
- El recipe no debe leer tokens globales (`--air-space-*`, `--air-shadow-*`, `--air-color-*`, `color-mix(...)` con semánticos, etc.) directamente: primero se aterrizan en `tokens/components/[componente].css` y el CSS del componente solo consume `--air-[componente]-*`
---
@ -292,6 +295,52 @@ export type SelectRootProps = {
};
```
### Convención de props nativas y ARIA
Para `air`, los atributos nativos del DOM y los atributos ARIA **no se renombran** en la API pública del componente.
Regla:
- atributos nativos y ARIA se exponen con su nombre real de HTML:
- `aria-label`
- `aria-describedby`
- `form`
- `type`
- las props propias de `air` usan `camelCase`:
- `loadingText`
- `spinnerPlacement`
- `iconOnly`
Ejemplo correcto:
```svelte
<Button aria-label="Cerrar" loadingText="Guardando..." />
```
Ejemplo incorrecto:
```svelte
<Button ariaLabel="Cerrar" />
```
Motivo:
- mantiene paridad con la plataforma web
- evita inventar una API paralela para atributos estándar
- facilita el passthrough hacia `terra` y hacia el DOM real
- reduce ambigüedad entre “atributo nativo” y “prop de sistema”
Internamente, al desestructurar props en Svelte, sí se permite renombrar para ergonomía local:
```ts
let {
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy
} = $props();
```
Pero esa conversión es solo interna. La convención pública sigue siendo HTML/ARIA real.
---
## Paso 9 — Barrel export

@ -307,15 +307,22 @@ La expresividad visual de botones, tabs, fields o menus no debe depender de que
## 6. Espaciado
La escala base debe apoyarse en multiplos de `4px`.
La escala base debe apoyarse en multiplos de `4px`, pero puede incluir medios pasos
cuando el sistema lo necesite para mantener proporciones opticas consistentes.
```css
--air-space-0: 0px;
--air-space-0-5: 2px;
--air-space-1: 4px;
--air-space-1-5: 6px;
--air-space-2: 8px;
--air-space-2-5: 10px;
--air-space-3: 12px;
--air-space-3-5: 14px;
--air-space-4: 16px;
--air-space-4-5: 18px;
--air-space-5: 20px;
--air-space-5-5: 22px;
--air-space-6: 24px;
--air-space-8: 32px;
--air-space-10: 40px;
@ -334,6 +341,26 @@ La escala base debe apoyarse en multiplos de `4px`.
Los componentes no deben inventar spacing local arbitrario.
Las distancias entre partes deben salir de la escala base o de tokens de componente derivados de ella.
## 6.1 Alturas de control
Las alturas interactivas no pertenecen a la escala de spacing. Deben vivir en una
escala separada porque responden a densidad de UI y touch target:
```css
--air-control-height-2xs: 22px;
--air-control-height-xs: 26px;
--air-control-height-sm: 30px;
--air-control-height-md: 36px;
--air-control-height-lg: 44px;
--air-control-height-xl: 52px;
```
Regla:
- `spacing` compone
- `control-height` dimensiona controles interactivos
- no mezclar ambos ejes en el mismo token
---
## 7. Radios
@ -431,6 +458,28 @@ Reglas:
- el ring debe ser claro y reconocible
- el focus visible es parte del tono del sistema, no un parche de accesibilidad
La geometria del focus no debe decidirla cada componente por separado. El tema debe
publicar una geometria comun:
```css
--air-border-width: 1px;
--air-focus-ring-offset: 1px;
--air-focus-ring-width: 2px;
```
Y cada tema concreto debe resolver el color:
```css
--air-focus-ring-color: ...;
--air-focus-ring-color-error: ...;
```
Regla de consistencia:
- `Button`, `Input`, `Field` y futuros controles deben compartir grosor de borde
- la geometria del ring debe sentirse igual entre componentes
- solo cambia la anatomia del componente, no el lenguaje visual del foco
## 9.2 Estados interactivos
Todo componente interactivo debe resolver al menos:

@ -1,13 +1,11 @@
[data-button].air-button {
--air-button-height: 36px;
--air-button-padding-inline: 14px;
--air-button-gap: 6px;
--air-button-radius: var(--air-radius-md);
--air-button-font-size: 14px;
--air-button-font-weight: var(--air-font-weight-medium);
--air-button-line-height: var(--air-leading-ui);
--air-button-icon-size: 16px;
--air-button-focus-ring-width: var(--air-button-focus-ring-width, 3px);
--air-button-height: var(--air-button-height-md);
--air-button-padding-inline: var(--air-button-px-md);
--air-button-gap: var(--air-button-gap-md);
--air-button-radius: var(--air-button-radius-md);
--air-button-font-size: var(--air-button-font-size-md);
--air-button-font-weight: var(--air-button-font-weight-md);
--air-button-icon-size: var(--air-button-icon-size-md);
--air-button-bg: var(--air-button-palette-solid);
--air-button-fg: var(--air-button-solid-fg);
--air-button-border: var(--air-button-palette-solid);
@ -15,16 +13,12 @@
--air-button-hover-bg: var(--air-button-palette-solid-hover);
--air-button-hover-border: var(--air-button-palette-solid-hover);
--air-button-hover-shadow: none;
--air-button-active-bg: color-mix(in srgb, var(--air-button-palette-solid-hover) 88%, black);
--air-button-active-border: color-mix(in srgb, var(--air-button-palette-solid-hover) 88%, black);
--air-button-active-bg: var(--air-button-variant-solid-active-bg);
--air-button-active-border: var(--air-button-variant-solid-active-border);
--air-button-active-shadow: inset 0 1px 0 rgb(0 0 0 / 0.08);
--air-button-label-color: currentColor;
--air-button-icon-color: currentColor;
--air-button-spinner-color: currentColor;
--air-button-focus-ring:
0 0 0 2px var(--air-color-surface-default),
0 0 0 calc(2px + var(--air-button-focus-ring-width))
color-mix(in srgb, var(--air-color-focus-ring) 24%, transparent);
position: relative;
display: inline-flex;
@ -33,16 +27,16 @@
gap: var(--air-button-gap);
min-height: var(--air-button-height);
padding-inline: var(--air-button-padding-inline);
border: 1px solid var(--air-button-border);
border: var(--air-button-border-width) solid var(--air-button-border);
border-radius: var(--air-button-radius);
background: var(--air-button-bg);
color: var(--air-button-fg);
box-shadow: var(--air-button-shadow);
font-family: var(--air-font-sans);
font-family: var(--air-button-font-family);
font-size: var(--air-button-font-size);
font-weight: var(--air-button-font-weight);
line-height: var(--air-button-line-height);
letter-spacing: var(--air-tracking-normal);
letter-spacing: var(--air-button-letter-spacing);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
@ -133,145 +127,151 @@
}
[data-button].air-button[data-size='2xs'] {
--air-button-height: 22px;
--air-button-padding-inline: 8px;
--air-button-gap: 4px;
--air-button-radius: var(--air-radius-sm);
--air-button-font-size: 11px;
--air-button-font-weight: var(--air-font-weight-medium);
--air-button-icon-size: 12px;
--air-button-height: var(--air-button-height-2xs);
--air-button-padding-inline: var(--air-button-px-2xs);
--air-button-gap: var(--air-button-gap-2xs);
--air-button-radius: var(--air-button-radius-2xs);
--air-button-font-size: var(--air-button-font-size-2xs);
--air-button-font-weight: var(--air-button-font-weight-2xs);
--air-button-icon-size: var(--air-button-icon-size-2xs);
}
[data-button].air-button[data-size='xs'] {
--air-button-height: 26px;
--air-button-padding-inline: 9px;
--air-button-gap: 4px;
--air-button-radius: var(--air-radius-sm);
--air-button-font-size: 12px;
--air-button-font-weight: var(--air-font-weight-medium);
--air-button-icon-size: 13px;
--air-button-height: var(--air-button-height-xs);
--air-button-padding-inline: var(--air-button-px-xs);
--air-button-gap: var(--air-button-gap-xs);
--air-button-radius: var(--air-button-radius-xs);
--air-button-font-size: var(--air-button-font-size-xs);
--air-button-font-weight: var(--air-button-font-weight-xs);
--air-button-icon-size: var(--air-button-icon-size-xs);
}
[data-button].air-button[data-size='sm'] {
--air-button-height: 30px;
--air-button-padding-inline: 11px;
--air-button-gap: 6px;
--air-button-radius: var(--air-radius-sm);
--air-button-font-size: 12px;
--air-button-icon-size: 15px;
--air-button-height: var(--air-button-height-sm);
--air-button-padding-inline: var(--air-button-px-sm);
--air-button-gap: var(--air-button-gap-sm);
--air-button-radius: var(--air-button-radius-sm);
--air-button-font-size: var(--air-button-font-size-sm);
--air-button-font-weight: var(--air-button-font-weight-sm);
--air-button-icon-size: var(--air-button-icon-size-sm);
}
[data-button].air-button[data-size='md'] {
--air-button-height: 36px;
--air-button-padding-inline: 14px;
--air-button-gap: 6px;
--air-button-radius: var(--air-radius-md);
--air-button-font-size: 14px;
--air-button-font-weight: var(--air-font-weight-medium);
--air-button-icon-size: 16px;
--air-button-height: var(--air-button-height-md);
--air-button-padding-inline: var(--air-button-px-md);
--air-button-gap: var(--air-button-gap-md);
--air-button-radius: var(--air-button-radius-md);
--air-button-font-size: var(--air-button-font-size-md);
--air-button-font-weight: var(--air-button-font-weight-md);
--air-button-icon-size: var(--air-button-icon-size-md);
}
[data-button].air-button[data-size='lg'] {
--air-button-height: 44px;
--air-button-padding-inline: 18px;
--air-button-gap: 8px;
--air-button-radius: var(--air-radius-md);
--air-button-font-size: 16px;
--air-button-icon-size: 18px;
--air-button-height: var(--air-button-height-lg);
--air-button-padding-inline: var(--air-button-px-lg);
--air-button-gap: var(--air-button-gap-lg);
--air-button-radius: var(--air-button-radius-lg);
--air-button-font-size: var(--air-button-font-size-lg);
--air-button-font-weight: var(--air-button-font-weight-lg);
--air-button-icon-size: var(--air-button-icon-size-lg);
}
[data-button].air-button[data-size='xl'] {
--air-button-height: 52px;
--air-button-padding-inline: 22px;
--air-button-gap: 8px;
--air-button-radius: var(--air-radius-lg);
--air-button-font-size: 16px;
--air-button-font-weight: var(--air-font-weight-semibold);
--air-button-icon-size: 20px;
--air-button-height: var(--air-button-height-xl);
--air-button-padding-inline: var(--air-button-px-xl);
--air-button-gap: var(--air-button-gap-xl);
--air-button-radius: var(--air-button-radius-xl);
--air-button-font-size: var(--air-button-font-size-xl);
--air-button-font-weight: var(--air-button-font-weight-xl);
--air-button-icon-size: var(--air-button-icon-size-xl);
}
[data-button].air-button[data-rounded='sm'] {
--air-button-radius: var(--air-radius-sm);
--air-button-radius: var(--air-button-rounded-sm);
}
[data-button].air-button[data-rounded='md'] {
--air-button-radius: var(--air-radius-md);
--air-button-radius: var(--air-button-rounded-md);
}
[data-button].air-button[data-rounded='lg'] {
--air-button-radius: var(--air-radius-lg);
--air-button-radius: var(--air-button-rounded-lg);
}
[data-button].air-button[data-rounded='xl'] {
--air-button-radius: var(--air-radius-xl);
--air-button-radius: var(--air-button-rounded-xl);
}
[data-button].air-button[data-rounded='2xl'] {
--air-button-radius: var(--air-radius-2xl);
[data-button].air-button[data-rounded='xxl'] {
--air-button-radius: var(--air-button-rounded-xxl);
}
[data-button].air-button[data-rounded='full'] {
--air-button-radius: var(--air-radius-full);
--air-button-radius: var(--air-button-rounded-full);
}
[data-button].air-button[data-variant='solid'] {
--air-button-bg: var(--air-button-palette-solid);
--air-button-fg: var(--air-button-solid-fg);
--air-button-border: var(--air-button-palette-solid);
--air-button-hover-bg: var(--air-button-palette-solid-hover);
--air-button-hover-border: var(--air-button-palette-solid-hover);
--air-button-active-bg: color-mix(in srgb, var(--air-button-palette-solid-hover) 88%, black);
--air-button-active-border: color-mix(in srgb, var(--air-button-palette-solid-hover) 88%, black);
/*
* La variante no recalcula semántica global.
* Solo elige qué tokens de componente entran en juego.
*/
--air-button-bg: var(--air-button-variant-solid-bg);
--air-button-fg: var(--air-button-variant-solid-fg);
--air-button-border: var(--air-button-variant-solid-border);
--air-button-hover-bg: var(--air-button-variant-solid-hover-bg);
--air-button-hover-border: var(--air-button-variant-solid-hover-border);
--air-button-active-bg: var(--air-button-variant-solid-active-bg);
--air-button-active-border: var(--air-button-variant-solid-active-border);
}
[data-button].air-button[data-variant='soft'] {
--air-button-bg: var(--air-button-palette-element);
--air-button-fg: var(--air-button-palette-text);
--air-button-border: color-mix(in srgb, var(--air-button-palette-border) 58%, var(--air-color-border-subtle));
--air-button-hover-bg: var(--air-button-palette-hover);
--air-button-hover-border: color-mix(in srgb, var(--air-button-palette-border) 76%, var(--air-color-border-default));
--air-button-active-bg: var(--air-button-palette-active);
--air-button-active-border: color-mix(in srgb, var(--air-button-palette-border) 88%, var(--air-color-border-default));
--air-button-bg: var(--air-button-variant-soft-bg);
--air-button-fg: var(--air-button-variant-soft-fg);
--air-button-border: var(--air-button-variant-soft-border);
--air-button-hover-bg: var(--air-button-variant-soft-hover-bg);
--air-button-hover-border: var(--air-button-variant-soft-hover-border);
--air-button-active-bg: var(--air-button-variant-soft-active-bg);
--air-button-active-border: var(--air-button-variant-soft-active-border);
}
[data-button].air-button[data-variant='surface'] {
--air-button-bg: var(--air-color-surface-raised);
--air-button-fg: var(--air-color-content-primary);
--air-button-border: var(--air-color-border-default);
--air-button-hover-bg: color-mix(in srgb, var(--air-color-surface-overlay) 68%, var(--air-button-palette-track));
--air-button-hover-border: color-mix(in srgb, var(--air-button-palette-border) 45%, var(--air-color-border-strong));
--air-button-active-bg: color-mix(in srgb, var(--air-color-surface-overlay) 80%, var(--air-button-palette-track));
--air-button-active-border: color-mix(in srgb, var(--air-button-palette-border) 68%, var(--air-color-border-default));
--air-button-bg: var(--air-button-variant-surface-bg);
--air-button-fg: var(--air-button-variant-surface-fg);
--air-button-border: var(--air-button-variant-surface-border);
--air-button-hover-bg: var(--air-button-variant-surface-hover-bg);
--air-button-hover-border: var(--air-button-variant-surface-hover-border);
--air-button-active-bg: var(--air-button-variant-surface-active-bg);
--air-button-active-border: var(--air-button-variant-surface-active-border);
}
[data-button].air-button[data-variant='outline'] {
--air-button-bg: transparent;
--air-button-fg: var(--air-button-palette-text);
--air-button-border: color-mix(in srgb, var(--air-button-palette-border) 88%, var(--air-color-border-default));
--air-button-hover-bg: var(--air-button-palette-track);
--air-button-hover-border: color-mix(in srgb, var(--air-button-palette-border) 100%, var(--air-color-border-default));
--air-button-active-bg: var(--air-button-palette-element);
--air-button-active-border: color-mix(in srgb, var(--air-button-palette-border) 100%, var(--air-color-border-strong));
--air-button-bg: var(--air-button-variant-outline-bg);
--air-button-fg: var(--air-button-variant-outline-fg);
--air-button-border: var(--air-button-variant-outline-border);
--air-button-hover-bg: var(--air-button-variant-outline-hover-bg);
--air-button-hover-border: var(--air-button-variant-outline-hover-border);
--air-button-active-bg: var(--air-button-variant-outline-active-bg);
--air-button-active-border: var(--air-button-variant-outline-active-border);
}
[data-button].air-button[data-variant='ghost'] {
--air-button-bg: transparent;
--air-button-fg: var(--air-button-palette-text);
--air-button-border: transparent;
--air-button-hover-bg: color-mix(in srgb, var(--air-button-palette-track) 72%, transparent);
--air-button-hover-border: transparent;
--air-button-active-bg: var(--air-button-palette-element);
--air-button-active-border: transparent;
--air-button-bg: var(--air-button-variant-ghost-bg);
--air-button-fg: var(--air-button-variant-ghost-fg);
--air-button-border: var(--air-button-variant-ghost-border);
--air-button-hover-bg: var(--air-button-variant-ghost-hover-bg);
--air-button-hover-border: var(--air-button-variant-ghost-hover-border);
--air-button-active-bg: var(--air-button-variant-ghost-active-bg);
--air-button-active-border: var(--air-button-variant-ghost-active-border);
}
[data-button].air-button[data-variant='plain'] {
--air-button-bg: transparent;
--air-button-fg: var(--air-button-palette-text);
--air-button-border: transparent;
--air-button-hover-bg: transparent;
--air-button-hover-border: transparent;
--air-button-active-bg: transparent;
--air-button-active-border: transparent;
--air-button-bg: var(--air-button-variant-plain-bg);
--air-button-fg: var(--air-button-variant-plain-fg);
--air-button-border: var(--air-button-variant-plain-border);
--air-button-hover-bg: var(--air-button-variant-plain-hover-bg);
--air-button-hover-border: var(--air-button-variant-plain-hover-border);
--air-button-active-bg: var(--air-button-variant-plain-active-bg);
--air-button-active-border: var(--air-button-variant-plain-active-border);
padding-inline: 0;
text-decoration: none;
}
@ -282,27 +282,27 @@
}
[data-button].air-button[data-icon-only][data-size='2xs'] {
--air-button-icon-size: 14px;
--air-button-icon-size: var(--air-button-icon-only-size-2xs);
}
[data-button].air-button[data-icon-only][data-size='xs'] {
--air-button-icon-size: 15px;
--air-button-icon-size: var(--air-button-icon-only-size-xs);
}
[data-button].air-button[data-icon-only][data-size='sm'] {
--air-button-icon-size: 16px;
--air-button-icon-size: var(--air-button-icon-only-size-sm);
}
[data-button].air-button[data-icon-only][data-size='md'] {
--air-button-icon-size: 18px;
--air-button-icon-size: var(--air-button-icon-only-size-md);
}
[data-button].air-button[data-icon-only][data-size='lg'] {
--air-button-icon-size: 20px;
--air-button-icon-size: var(--air-button-icon-only-size-lg);
}
[data-button].air-button[data-icon-only][data-size='xl'] {
--air-button-icon-size: 22px;
--air-button-icon-size: var(--air-button-icon-only-size-xl);
}
[data-button].air-button[data-block] {

@ -1,7 +1,7 @@
<script lang="ts">
import { dev } from '$app/environment';
import '$uix/air/components/button/button.css';
import * as Icon from '$uix/air/icons';
import * as Icon from '$uix/air/components/icon';
import { Button as TerraButton } from '$uix/terra';
import { getAir } from '$uix/air/config';
import type { ButtonRootProps } from './types';
@ -35,22 +35,21 @@
let pressed = $state(false);
$effect(() => {
if (!ref) return;
const node = ref;
if (!node) return;
const controller = new AbortController();
const down = () => {
if (ref!.hasAttribute('disabled') || ref!.hasAttribute('data-disabled')) return;
if (node.hasAttribute('disabled') || node.hasAttribute('data-disabled')) return;
pressed = true;
air.behavior.play('feedback', 'enter', { intent: resolvedColor });
};
const up = () => { pressed = false; };
ref.addEventListener('pointerdown', down);
ref.addEventListener('pointerup', up);
ref.addEventListener('pointerleave', up);
ref.addEventListener('pointercancel', up);
node.addEventListener('pointerdown', down, { signal: controller.signal });
node.addEventListener('pointerup', up, { signal: controller.signal });
node.addEventListener('pointerleave', up, { signal: controller.signal });
node.addEventListener('pointercancel', up, { signal: controller.signal });
return () => {
ref!.removeEventListener('pointerdown', down);
ref!.removeEventListener('pointerup', up);
ref!.removeEventListener('pointerleave', up);
ref!.removeEventListener('pointercancel', up);
controller.abort();
};
});
@ -89,12 +88,7 @@
const translatedLoadingText = $derived(
loadingText === undefined
? undefined
: (air.translator.current?.translate(loadingText) ?? (typeof loadingText === 'string' ? loadingText : ''))
);
const translatedAriaLabel = $derived(
ariaLabel === undefined
? undefined
: (air.translator.current?.translate(ariaLabel) ?? (typeof ariaLabel === 'string' ? ariaLabel : ''))
: air.translator.current.translate(loadingText)
);
$effect(() => {
@ -119,7 +113,7 @@
class={`air-button ${colorClass} ${pressClass}`.trim()}
style={colorStyle}
aria-busy={loading || undefined}
aria-label={translatedAriaLabel}
aria-label={ariaLabel}
bind:ref
{...restProps}
>

@ -1,3 +1,4 @@
import type { TerraTranslationValue } from '$uix/terra/config/types';
import type { RootProps as TerraButtonRootProps } from '$uix/terra/button';
import type { ResponsiveProp } from '$uix/air/internal/dom';
@ -5,7 +6,7 @@ export type AirButtonVariant = 'solid' | 'soft' | 'surface' | 'outline' | 'ghost
export type AirButtonColor = 'primary' | 'neutral' | 'success' | 'warning' | 'danger' | 'info';
export type AirButtonIntent = AirButtonColor;
export type AirButtonSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type AirButtonRounded = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
export type AirButtonRounded = 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'full';
export type AirButtonSpinnerPlacement = 'start' | 'end';
export type ButtonRootProps = TerraButtonRootProps & {
@ -17,6 +18,6 @@ export type ButtonRootProps = TerraButtonRootProps & {
block?: boolean;
iconOnly?: boolean;
loading?: boolean;
loadingText?: string;
loadingText?: TerraTranslationValue;
spinnerPlacement?: AirButtonSpinnerPlacement;
};

@ -1,113 +0,0 @@
/*
* air/component/code-block/code-block.css
*
* Estilos globales del componente CodeBlock.
*
* CodeBlock es el bloque de código con cabecera estructurada: filename,
* badge de lenguaje y botón de copia. Complementa al componente Code,
* que es la pieza base inline/block sin estructura propia.
*
* La cabecera es opcional: solo se renderiza cuando hay filename, language
* o copyable=true.
*/
.air-code-block {
position: relative;
display: flex;
flex-direction: column;
border-radius: var(--air-code-block-radius);
border: 1px solid var(--air-code-block-border);
background: var(--air-code-block-bg);
overflow: hidden;
color: var(--air-code-block-color);
}
/* Cabecera */
.air-code-block-header {
display: flex;
align-items: center;
gap: var(--air-space-2);
padding: var(--air-code-block-header-py) var(--air-code-block-header-px);
background: var(--air-code-block-header-bg);
border-bottom: 1px solid var(--air-code-block-header-border);
}
.air-code-block-filename {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--air-font-code);
font-size: var(--air-text-2-size);
line-height: var(--air-text-2-lh);
font-weight: var(--air-font-weight-medium);
color: var(--air-code-block-header-color);
}
.air-code-block-lang {
font-family: var(--air-font-code);
font-size: var(--air-text-1-size);
line-height: var(--air-text-1-lh);
font-weight: var(--air-font-weight-medium);
color: var(--air-code-block-header-color);
text-transform: lowercase;
opacity: 0.72;
white-space: nowrap;
}
/* Botón de copia */
.air-code-block-copy {
appearance: none;
display: inline-flex;
align-items: center;
gap: var(--air-space-1);
padding: var(--air-space-1) var(--air-space-2);
border: 1px solid var(--air-code-block-header-border);
border-radius: var(--air-radius-sm);
background: transparent;
font-family: var(--air-font-code);
font-size: var(--air-text-1-size);
line-height: var(--air-text-1-lh);
color: var(--air-code-block-header-color);
cursor: pointer;
white-space: nowrap;
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);
}
.air-code-block-copy:hover {
background: var(--air-code-block-bg);
border-color: var(--air-color-border-default);
color: var(--air-color-content-primary);
}
.air-code-block-copy:focus-visible {
outline: 2px solid var(--air-color-focus-ring);
outline-offset: 2px;
}
.air-code-block-copy[data-copied] {
color: var(--air-color-success-text);
border-color: var(--air-color-success-track);
}
/* Pre/code */
.air-code-block-pre {
margin: 0;
padding: var(--air-code-block-padding-y) var(--air-code-block-padding-x);
overflow-x: auto;
tab-size: 2;
}
.air-code-block-code {
display: block;
font-family: var(--air-font-code);
font-size: var(--_air-code-block-size, var(--air-text-3-size));
line-height: var(--_air-code-block-lh, var(--air-text-3-lh));
letter-spacing: var(--_air-code-block-ls, var(--air-text-3-ls));
font-weight: var(--air-font-weight-regular);
color: inherit;
}

@ -1,74 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
import './code-block.css';
import type { CodeBlockProps } from './types';
let {
language,
filename,
copyable = true,
size = 3,
style,
children,
...restProps
}: CodeBlockProps = $props();
let codeRef = $state<HTMLElement | undefined>();
let copied = $state(false);
let copyTimeout: ReturnType<typeof setTimeout>;
const showHeader = $derived(!!filename || !!language || copyable);
const sizeTokens = $derived(
`--_air-code-block-size:var(--air-text-${size}-size);` +
`--_air-code-block-lh:var(--air-text-${size}-lh);` +
`--_air-code-block-ls:var(--air-text-${size}-ls)`
);
const mergedStyle = $derived(style ? `${sizeTokens};${style}` : sizeTokens);
async function handleCopy() {
if (!browser || !codeRef) return;
const text = codeRef.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
copied = true;
clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => {
copied = false;
}, 2000);
} catch {
// Clipboard no disponible — silencioso
}
}
</script>
<div class="air-code-block" style={mergedStyle} {...restProps}>
{#if showHeader}
<div class="air-code-block-header">
{#if filename}
<span class="air-code-block-filename">{filename}</span>
{/if}
{#if language && !filename}
<span class="air-code-block-lang">{language}</span>
{/if}
{#if language && filename}
<span class="air-code-block-lang">{language}</span>
{/if}
{#if copyable}
<button
type="button"
class="air-code-block-copy"
data-copied={copied ? '' : undefined}
onclick={handleCopy}
aria-label={copied ? 'Copied' : 'Copy code'}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
{/if}
</div>
{/if}
<pre class="air-code-block-pre"><code class="air-code-block-code" bind:this={codeRef}>{@render children?.()}</code></pre>
</div>

@ -1,50 +0,0 @@
/*
* air/component/code/code.css
*
* Estilos globales del componente Code.
* Familia siempre --air-font-code. Sin prop family ni weight (siempre regular).
*
* variant='inline': código dentro de texto con fondo sutil.
* variant='block': fragmento de código con fondo, borde y padding.
*/
/* Base compartida */
.air-code {
font-family: var(--air-font-code);
font-size: var(--_air-code-size, var(--air-text-3-size));
line-height: var(--_air-code-lh, var(--air-text-3-lh));
letter-spacing: var(--_air-code-ls, var(--air-text-3-ls));
font-weight: var(--air-font-weight-regular);
color: var(--air-code-color-primary);
}
.air-code[data-color='secondary'] { color: var(--air-code-color-secondary); }
/* Inline */
.air-code--inline {
display: inline;
padding: var(--air-code-inline-py) var(--air-code-inline-px);
background: var(--air-code-inline-bg);
border: 1px solid var(--air-code-inline-border);
border-radius: var(--air-code-inline-radius);
/* Reducir ligeramente el tamaño dentro de prosa para equilibrio óptico */
font-size: calc(var(--_air-code-size, var(--air-text-3-size)) * 0.9);
}
/* Block */
.air-code--block {
display: block;
margin: 0;
padding: var(--air-code-padding-y) var(--air-code-padding-x);
background: var(--air-code-bg);
border: 1px solid var(--air-code-border);
border-radius: var(--air-code-radius);
overflow-x: auto;
tab-size: 2;
}
.air-code--block code {
display: block;
font: inherit;
color: inherit;
}

@ -0,0 +1,37 @@
<script lang="ts">
import { getAir } from '$uix/air/config';
import * as Icon from '$uix/air/components/icon';
import { Dialog as TerraDialog } from '$uix/terra';
import type { DialogCloseProps } from './types';
const air = getAir();
let {
child,
children,
class: className = '',
'aria-label': ariaLabel,
...restProps
}: DialogCloseProps = $props();
const closeLabel = $derived(
ariaLabel ?? air.translator.current.translate('#?air.dialog.close|Cerrar diálogo')
);
const iconOnly = $derived(!children && !child);
</script>
<TerraDialog.Close
class={['air-dialog-close', className].filter(Boolean).join(' ')}
aria-label={closeLabel}
data-icon-only={iconOnly ? '' : undefined}
{child}
{...restProps}
>
{#snippet children()}
{#if children}
{@render children?.()}
{:else}
<Icon.X size="sm" aria-hidden="true" />
{/if}
{/snippet}
</TerraDialog.Close>

@ -0,0 +1,48 @@
<script lang="ts">
import { contextIn, contextOut } from '$uix/air/internal/motion';
import { getAir } from '$uix/air/config';
import { styleValueToString } from '$uix/air/internal/style';
import { Dialog as TerraDialog } from '$uix/terra';
import type { DialogContentProps } from './types';
const air = getAir();
let {
size = 'md',
child,
children,
class: className = '',
style,
...restProps
}: DialogContentProps = $props();
const resolvedSize = $derived(air.dom.resolve(size) ?? 'md');
function withClassAndAttrs(props: Record<string, unknown>) {
const currentClass = typeof props.class === 'string' ? props.class : '';
const currentStyle = styleValueToString(props.style);
return {
...props,
class: ['air-dialog-content', currentClass, className].filter(Boolean).join(' '),
style: [currentStyle, style].filter(Boolean).join(';'),
'data-size': resolvedSize
};
}
</script>
<TerraDialog.Content {...restProps}>
{#snippet child(snippetProps)}
{@const props = withClassAndAttrs(snippetProps.props)}
{#if child}
{@render child({ ...snippetProps, props })}
{:else}
<div
{...props}
in:contextIn={{ side: resolvedSize === 'full' ? 'bottom' : 'center' }}
out:contextOut={{ side: resolvedSize === 'full' ? 'bottom' : 'center' }}
>
{@render children?.(snippetProps)}
</div>
{/if}
{/snippet}
</TerraDialog.Content>

@ -0,0 +1,15 @@
<script lang="ts">
import { Dialog as TerraDialog } from '$uix/terra';
import type { DialogDescriptionProps } from './types';
let { children, class: className = '', ...restProps }: DialogDescriptionProps = $props();
</script>
<TerraDialog.Description
class={['air-dialog-description', className].filter(Boolean).join(' ')}
{...restProps}
>
{#snippet children()}
{@render children?.()}
{/snippet}
</TerraDialog.Description>

@ -0,0 +1,9 @@
<script lang="ts">
import type { DialogFooterProps } from './types';
let { children, class: className = '', ...restProps }: DialogFooterProps = $props();
</script>
<div class={['air-dialog-footer', className].filter(Boolean).join(' ')} data-dialog-footer {...restProps}>
{@render children?.()}
</div>

@ -0,0 +1,9 @@
<script lang="ts">
import type { DialogHeaderProps } from './types';
let { children, class: className = '', ...restProps }: DialogHeaderProps = $props();
</script>
<div class={['air-dialog-header', className].filter(Boolean).join(' ')} data-dialog-header {...restProps}>
{@render children?.()}
</div>

@ -0,0 +1,28 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { Dialog as TerraDialog } from '$uix/terra';
import type { DialogOverlayProps } from './types';
let { child, children, class: className = '', ...restProps }: DialogOverlayProps = $props();
function withClass(props: Record<string, unknown>) {
const current = typeof props.class === 'string' ? props.class : '';
return {
...props,
class: ['air-dialog-overlay', current, className].filter(Boolean).join(' ')
};
}
</script>
<TerraDialog.Overlay {...restProps}>
{#snippet child(snippetProps)}
{@const props = withClass(snippetProps.props)}
{#if child}
{@render child({ ...snippetProps, props })}
{:else}
<div {...props} in:fade={{ duration: 160 }} out:fade={{ duration: 120 }}>
{@render children?.(snippetProps)}
</div>
{/if}
{/snippet}
</TerraDialog.Overlay>

@ -0,0 +1,12 @@
<script lang="ts">
import { Dialog as TerraDialog } from '$uix/terra';
import type { DialogTitleProps } from './types';
let { children, class: className = '', ...restProps }: DialogTitleProps = $props();
</script>
<TerraDialog.Title class={['air-dialog-title', className].filter(Boolean).join(' ')} {...restProps}>
{#snippet children()}
{@render children?.()}
{/snippet}
</TerraDialog.Title>

@ -0,0 +1,12 @@
<script lang="ts">
import { Dialog as TerraDialog } from '$uix/terra';
import type { DialogTriggerProps } from './types';
let { children, class: className = '', ...restProps }: DialogTriggerProps = $props();
</script>
<TerraDialog.Trigger class={className} {...restProps}>
{#snippet children()}
{@render children?.()}
{/snippet}
</TerraDialog.Trigger>

@ -0,0 +1,146 @@
[data-dialog-overlay].air-dialog-overlay {
position: fixed;
inset: 0;
background: var(--air-dialog-overlay-bg);
backdrop-filter: blur(var(--air-dialog-overlay-blur));
z-index: var(--air-dialog-overlay-z);
opacity: 1;
}
[data-dialog-content].air-dialog-content {
--_air-dialog-width: var(--air-dialog-content-width-md);
--_air-dialog-padding: var(--air-dialog-content-padding-md);
position: fixed;
left: 50%;
top: 50%;
z-index: calc(var(--air-dialog-overlay-z) + 1);
display: flex;
flex-direction: column;
gap: var(--air-dialog-stack-gap);
width: min(calc(100vw - var(--air-space-6)), var(--_air-dialog-width));
max-height: var(--air-dialog-content-max-height);
padding: var(--_air-dialog-padding);
overflow: auto;
border: var(--air-dialog-content-border-width) solid var(--air-dialog-content-border);
border-radius: var(--air-dialog-content-radius);
background: var(--air-dialog-content-bg);
color: var(--air-dialog-content-color);
box-shadow: var(--air-dialog-content-shadow);
outline: none;
}
[data-dialog-content].air-dialog-content[data-size='sm'] {
--_air-dialog-width: var(--air-dialog-content-width-sm);
--_air-dialog-padding: var(--air-dialog-content-padding-sm);
}
[data-dialog-content].air-dialog-content[data-size='md'] {
--_air-dialog-width: var(--air-dialog-content-width-md);
--_air-dialog-padding: var(--air-dialog-content-padding-md);
}
[data-dialog-content].air-dialog-content[data-size='lg'] {
--_air-dialog-width: var(--air-dialog-content-width-lg);
--_air-dialog-padding: var(--air-dialog-content-padding-lg);
}
[data-dialog-content].air-dialog-content[data-size='full'] {
inset: var(--air-space-3);
left: auto;
top: auto;
width: auto;
max-height: calc(100dvh - (var(--air-space-3) * 2));
border-radius: var(--air-radius-2xl);
--_air-dialog-padding: var(--air-dialog-content-padding-full);
}
[data-dialog-header].air-dialog-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--air-dialog-header-gap);
}
[data-dialog-footer].air-dialog-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: end;
gap: var(--air-dialog-footer-gap);
margin-top: var(--air-space-2);
}
[data-dialog-title].air-dialog-title {
margin: 0;
font-family: var(--air-dialog-title-font-family);
font-size: var(--air-dialog-title-font-size);
font-weight: var(--air-dialog-title-font-weight);
line-height: var(--air-dialog-title-line-height);
color: var(--air-dialog-title-color);
text-wrap: balance;
}
[data-dialog-description].air-dialog-description {
margin: 0;
font-family: var(--air-dialog-description-font-family);
font-size: var(--air-dialog-description-font-size);
line-height: var(--air-dialog-description-line-height);
color: var(--air-dialog-description-color);
}
[data-dialog-close].air-dialog-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-inline-size: var(--air-dialog-close-size);
block-size: var(--air-dialog-close-size);
padding-inline: var(--air-space-2);
border: var(--air-dialog-close-border-width) solid var(--air-dialog-close-border);
border-radius: var(--air-dialog-close-radius);
background: var(--air-dialog-close-bg);
color: var(--air-dialog-close-color);
cursor: pointer;
transition:
background var(--air-duration-fast) var(--air-ease-default),
color var(--air-duration-fast) var(--air-ease-default),
border-color var(--air-duration-fast) var(--air-ease-default),
box-shadow var(--air-duration-fast) var(--air-ease-default);
}
[data-dialog-close].air-dialog-close[data-icon-only] {
inline-size: var(--air-dialog-close-size);
padding-inline: 0;
}
[data-dialog-close].air-dialog-close:hover {
background: var(--air-dialog-close-bg-hover);
color: var(--air-dialog-close-color-hover);
}
[data-dialog-close].air-dialog-close:focus-visible {
outline: none;
box-shadow:
0 0 0 1px var(--air-dialog-content-bg),
0 0 0 calc(1px + var(--air-focus-ring-width)) var(--air-dialog-close-focus-ring-color);
}
[data-dialog-close].air-dialog-close:disabled,
[data-dialog-close].air-dialog-close[data-disabled] {
cursor: default;
opacity: 0.5;
}
@media (max-width: 720px) {
[data-dialog-content].air-dialog-content {
width: calc(100vw - var(--air-space-4));
max-height: calc(100dvh - var(--air-space-4));
padding: var(--air-space-4);
}
[data-dialog-content].air-dialog-content[data-size='full'] {
inset: var(--air-space-2);
max-height: calc(100dvh - (var(--air-space-2) * 2));
}
}

@ -0,0 +1,13 @@
<script lang="ts">
import './dialog.css';
import { Dialog as TerraDialog } from '$uix/terra';
import type { DialogRootProps } from './types';
let { open = $bindable(false), children, ...restProps }: DialogRootProps = $props();
</script>
<TerraDialog.Root bind:open {...restProps}>
{#snippet children()}
{@render children?.()}
{/snippet}
</TerraDialog.Root>

@ -0,0 +1,24 @@
export { default as Root } from './dialog.svelte';
export { default as Trigger } from './dialog-trigger.svelte';
export { Portal } from '$uix/terra/dialog';
export { default as Overlay } from './dialog-overlay.svelte';
export { default as Content } from './dialog-content.svelte';
export { default as Header } from './dialog-header.svelte';
export { default as Footer } from './dialog-footer.svelte';
export { default as Title } from './dialog-title.svelte';
export { default as Description } from './dialog-description.svelte';
export { default as Close } from './dialog-close.svelte';
export type {
AirDialogSize,
DialogRootProps as RootProps,
DialogTriggerProps as TriggerProps,
DialogPortalProps as PortalProps,
DialogOverlayProps as OverlayProps,
DialogContentProps as ContentProps,
DialogHeaderProps as HeaderProps,
DialogFooterProps as FooterProps,
DialogTitleProps as TitleProps,
DialogDescriptionProps as DescriptionProps,
DialogCloseProps as CloseProps,
} from './types';

@ -0,0 +1,22 @@
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/air/internal/dom';
import type { WithChildren } from '$uix/terra/utils';
import type { Dialog as TerraDialog } from '$uix/terra';
export type AirDialogSize = 'sm' | 'md' | 'lg' | 'full';
export type DialogRootProps = TerraDialog.RootProps;
export type DialogTriggerProps = TerraDialog.TriggerProps;
export type DialogPortalProps = TerraDialog.PortalProps;
export type DialogOverlayProps = TerraDialog.OverlayProps;
export type DialogContentProps = TerraDialog.ContentProps & {
size?: ResponsiveProp<AirDialogSize>;
};
export type DialogTitleProps = TerraDialog.TitleProps;
export type DialogDescriptionProps = TerraDialog.DescriptionProps;
export type DialogCloseProps = TerraDialog.CloseProps;
export type DialogHeaderProps = WithChildren<HTMLAttributes<HTMLDivElement>>;
export type DialogFooterProps = WithChildren<HTMLAttributes<HTMLDivElement>>;

@ -0,0 +1,38 @@
<script lang="ts">
import './drawer.css';
import { getAir } from '$uix/air/config';
import * as Icon from '$uix/air/components/icon';
import { Drawer as TerraDrawer } from '$uix/terra';
import type { DrawerCloseProps } from './types';
const air = getAir();
let {
child,
children,
class: className = '',
'aria-label': ariaLabel,
...restProps
}: DrawerCloseProps = $props();
const closeLabel = $derived(
ariaLabel ?? air.translator.current.translate('#?air.drawer.close|Cerrar panel')
);
const iconOnly = $derived(!children && !child);
</script>
<TerraDrawer.Close
class={['air-drawer-close', className].filter(Boolean).join(' ')}
aria-label={closeLabel}
data-icon-only={iconOnly ? '' : undefined}
{child}
{...restProps}
>
{#snippet children()}
{#if children}
{@render children?.()}
{:else}
<Icon.X size="sm" aria-hidden="true" />
{/if}
{/snippet}
</TerraDrawer.Close>

@ -0,0 +1,49 @@
<script lang="ts">
import './drawer.css';
import { contextIn, contextOut } from '$uix/air/internal/motion';
import { styleValueToString } from '$uix/air/internal/style';
import { getAir } from '$uix/air/config';
import { Drawer as TerraDrawer } from '$uix/terra';
import type { DrawerContentProps } from './types';
const air = getAir();
let {
size = 'md',
child,
children,
class: className = '',
style,
...restProps
}: DrawerContentProps = $props();
const resolvedSize = $derived(air.dom.resolve(size) ?? 'md');
function withClassAndAttrs(props: Record<string, unknown>) {
const currentClass = typeof props.class === 'string' ? props.class : '';
const currentStyle = styleValueToString(props.style);
return {
...props,
class: ['air-drawer-content', currentClass, className].filter(Boolean).join(' '),
style: [currentStyle, style].filter(Boolean).join(';'),
'data-size': resolvedSize,
};
}
</script>
<TerraDrawer.Content {...restProps}>
{#snippet child(snippetProps)}
{@const props = withClassAndAttrs(snippetProps.props)}
{#if child}
{@render child({ ...snippetProps, props })}
{:else}
<div
{...props}
in:contextIn={{ side: snippetProps.side }}
out:contextOut={{ side: snippetProps.side }}
>
{@render children?.(snippetProps)}
</div>
{/if}
{/snippet}
</TerraDrawer.Content>

@ -0,0 +1,15 @@
<script lang="ts">
import './drawer.css';
import { Drawer as TerraDrawer } from '$uix/terra';
import type { DrawerDescriptionProps } from './types';
let {
class: className = '',
...restProps
}: DrawerDescriptionProps = $props();
</script>
<TerraDrawer.Description
class={['air-drawer-description', className].filter(Boolean).join(' ')}
{...restProps}
/>

@ -0,0 +1,18 @@
<script lang="ts">
import './drawer.css';
import type { DrawerFooterProps } from './types';
let {
children,
class: className = '',
...restProps
}: DrawerFooterProps = $props();
</script>
<div
data-drawer-footer
class={['air-drawer-footer', className].filter(Boolean).join(' ')}
{...restProps}
>
{@render children?.()}
</div>

@ -0,0 +1,18 @@
<script lang="ts">
import './drawer.css';
import type { DrawerHeaderProps } from './types';
let {
children,
class: className = '',
...restProps
}: DrawerHeaderProps = $props();
</script>
<div
data-drawer-header
class={['air-drawer-header', className].filter(Boolean).join(' ')}
{...restProps}
>
{@render children?.()}
</div>

@ -0,0 +1,12 @@
<script lang="ts">
import './drawer.css';
import { Drawer as TerraDrawer } from '$uix/terra';
import type { DrawerOverlayProps } from './types';
let {
class: className = '',
...restProps
}: DrawerOverlayProps = $props();
</script>
<TerraDrawer.Overlay class={['air-drawer-overlay', className].filter(Boolean).join(' ')} {...restProps} />

@ -0,0 +1,12 @@
<script lang="ts">
import './drawer.css';
import { Drawer as TerraDrawer } from '$uix/terra';
import type { DrawerTitleProps } from './types';
let {
class: className = '',
...restProps
}: DrawerTitleProps = $props();
</script>
<TerraDrawer.Title class={['air-drawer-title', className].filter(Boolean).join(' ')} {...restProps} />

@ -0,0 +1,8 @@
<script lang="ts">
import { Drawer as TerraDrawer } from '$uix/terra';
import type { DrawerTriggerProps } from './types';
let props: DrawerTriggerProps = $props();
</script>
<TerraDrawer.Trigger {...props} />

@ -0,0 +1,166 @@
[data-drawer-overlay].air-drawer-overlay {
position: fixed;
inset: 0;
background: var(--air-drawer-overlay-bg);
backdrop-filter: blur(var(--air-drawer-overlay-blur));
z-index: var(--air-drawer-overlay-z);
opacity: 1;
}
[data-drawer-content].air-drawer-content {
--_air-drawer-panel-width: var(--air-drawer-content-width-md);
--_air-drawer-panel-height: var(--air-drawer-content-height-md);
--_air-drawer-padding: var(--air-drawer-content-padding-md);
position: fixed;
z-index: calc(var(--air-drawer-overlay-z) + 1);
display: flex;
flex-direction: column;
gap: var(--air-drawer-stack-gap);
padding: var(--_air-drawer-padding);
overflow: auto;
border: var(--air-drawer-content-border-width) solid var(--air-drawer-content-border);
border-radius: var(--air-drawer-content-radius);
background: var(--air-drawer-content-bg);
color: var(--air-drawer-content-color);
box-shadow: var(--air-drawer-content-shadow);
outline: none;
}
[data-drawer-content].air-drawer-content[data-size='sm'] {
--_air-drawer-panel-width: var(--air-drawer-content-width-sm);
--_air-drawer-panel-height: var(--air-drawer-content-height-sm);
--_air-drawer-padding: var(--air-drawer-content-padding-sm);
}
[data-drawer-content].air-drawer-content[data-size='md'] {
--_air-drawer-panel-width: var(--air-drawer-content-width-md);
--_air-drawer-panel-height: var(--air-drawer-content-height-md);
--_air-drawer-padding: var(--air-drawer-content-padding-md);
}
[data-drawer-content].air-drawer-content[data-size='lg'] {
--_air-drawer-panel-width: var(--air-drawer-content-width-lg);
--_air-drawer-panel-height: var(--air-drawer-content-height-lg);
--_air-drawer-padding: var(--air-drawer-content-padding-lg);
}
[data-drawer-content].air-drawer-content[data-size='full'] {
--_air-drawer-padding: var(--air-drawer-content-padding-full);
}
[data-drawer-content].air-drawer-content[data-side='right'] {
top: var(--air-drawer-content-inset);
right: var(--air-drawer-content-inset);
bottom: var(--air-drawer-content-inset);
width: min(calc(100vw - (var(--air-drawer-content-inset) * 2)), var(--_air-drawer-panel-width));
}
[data-drawer-content].air-drawer-content[data-side='left'] {
top: var(--air-drawer-content-inset);
left: var(--air-drawer-content-inset);
bottom: var(--air-drawer-content-inset);
width: min(calc(100vw - (var(--air-drawer-content-inset) * 2)), var(--_air-drawer-panel-width));
}
[data-drawer-content].air-drawer-content[data-side='top'] {
top: var(--air-drawer-content-inset);
left: var(--air-drawer-content-inset);
right: var(--air-drawer-content-inset);
height: min(calc(100dvh - (var(--air-drawer-content-inset) * 2)), var(--_air-drawer-panel-height));
}
[data-drawer-content].air-drawer-content[data-side='bottom'] {
right: var(--air-drawer-content-inset);
bottom: var(--air-drawer-content-inset);
left: var(--air-drawer-content-inset);
height: min(calc(100dvh - (var(--air-drawer-content-inset) * 2)), var(--_air-drawer-panel-height));
}
[data-drawer-content].air-drawer-content[data-size='full'][data-side='right'],
[data-drawer-content].air-drawer-content[data-size='full'][data-side='left'] {
width: calc(100vw - (var(--air-drawer-content-inset) * 2));
}
[data-drawer-content].air-drawer-content[data-size='full'][data-side='top'],
[data-drawer-content].air-drawer-content[data-size='full'][data-side='bottom'] {
height: calc(100dvh - (var(--air-drawer-content-inset) * 2));
}
[data-drawer-header].air-drawer-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--air-drawer-header-gap);
}
[data-drawer-footer].air-drawer-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: end;
gap: var(--air-drawer-footer-gap);
margin-top: var(--air-drawer-footer-margin-top);
}
[data-drawer-title].air-drawer-title {
margin: 0;
font-family: var(--air-drawer-title-font-family);
font-size: var(--air-drawer-title-font-size);
font-weight: var(--air-drawer-title-font-weight);
line-height: var(--air-drawer-title-line-height);
color: var(--air-drawer-title-color);
text-wrap: balance;
}
[data-drawer-description].air-drawer-description {
margin: 0;
font-family: var(--air-drawer-description-font-family);
font-size: var(--air-drawer-description-font-size);
line-height: var(--air-drawer-description-line-height);
color: var(--air-drawer-description-color);
}
[data-drawer-close].air-drawer-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-inline-size: var(--air-drawer-close-size);
block-size: var(--air-drawer-close-size);
padding-inline: var(--air-drawer-close-px);
border: var(--air-drawer-close-border-width) solid var(--air-drawer-close-border);
border-radius: var(--air-drawer-close-radius);
background: var(--air-drawer-close-bg);
color: var(--air-drawer-close-color);
cursor: pointer;
transition:
background var(--air-duration-fast) var(--air-ease-default),
color var(--air-duration-fast) var(--air-ease-default),
border-color var(--air-duration-fast) var(--air-ease-default),
box-shadow var(--air-duration-fast) var(--air-ease-default);
}
[data-drawer-close].air-drawer-close[data-icon-only] {
inline-size: var(--air-drawer-close-size);
padding-inline: 0;
}
[data-drawer-close].air-drawer-close:hover {
background: var(--air-drawer-close-bg-hover);
color: var(--air-drawer-close-color-hover);
}
[data-drawer-close].air-drawer-close:focus-visible {
outline: none;
box-shadow:
0 0 0 var(--air-drawer-close-focus-ring-offset) var(--air-drawer-content-bg),
0 0 0 calc(var(--air-drawer-close-focus-ring-offset) + var(--air-focus-ring-width))
var(--air-drawer-close-focus-ring-color);
}
[data-drawer-close].air-drawer-close:disabled,
[data-drawer-close].air-drawer-close[data-disabled] {
cursor: default;
opacity: var(--air-drawer-close-disabled-opacity);
}

@ -0,0 +1,11 @@
<script lang="ts">
import { Drawer as TerraDrawer } from '$uix/terra';
import type { DrawerRootProps } from './types';
let {
open = $bindable(false),
...restProps
}: DrawerRootProps = $props();
</script>
<TerraDrawer.Root bind:open {...restProps} />

@ -0,0 +1,25 @@
export { default as Root } from './drawer.svelte';
export { default as Trigger } from './drawer-trigger.svelte';
export { Portal } from '$uix/terra/drawer';
export { default as Overlay } from './drawer-overlay.svelte';
export { default as Content } from './drawer-content.svelte';
export { default as Header } from './drawer-header.svelte';
export { default as Footer } from './drawer-footer.svelte';
export { default as Title } from './drawer-title.svelte';
export { default as Description } from './drawer-description.svelte';
export { default as Close } from './drawer-close.svelte';
export type {
AirDrawerSize,
AirDrawerSide,
DrawerRootProps as RootProps,
DrawerTriggerProps as TriggerProps,
DrawerPortalProps as PortalProps,
DrawerOverlayProps as OverlayProps,
DrawerContentProps as ContentProps,
DrawerHeaderProps as HeaderProps,
DrawerFooterProps as FooterProps,
DrawerTitleProps as TitleProps,
DrawerDescriptionProps as DescriptionProps,
DrawerCloseProps as CloseProps,
} from './types';

@ -0,0 +1,23 @@
import type { HTMLAttributes } from 'svelte/elements';
import type { ResponsiveProp } from '$uix/air/internal/dom';
import type { WithChildren } from '$uix/terra/utils';
import type { Drawer as TerraDrawer } from '$uix/terra';
export type AirDrawerSize = 'sm' | 'md' | 'lg' | 'full';
export type AirDrawerSide = TerraDrawer.DrawerSide;
export type DrawerRootProps = TerraDrawer.RootProps;
export type DrawerTriggerProps = TerraDrawer.TriggerProps;
export type DrawerPortalProps = TerraDrawer.PortalProps;
export type DrawerOverlayProps = TerraDrawer.OverlayProps;
export type DrawerContentProps = TerraDrawer.ContentProps & {
size?: ResponsiveProp<AirDrawerSize>;
};
export type DrawerTitleProps = TerraDrawer.TitleProps;
export type DrawerDescriptionProps = TerraDrawer.DescriptionProps;
export type DrawerCloseProps = TerraDrawer.CloseProps;
export type DrawerHeaderProps = WithChildren<HTMLAttributes<HTMLDivElement>>;
export type DrawerFooterProps = WithChildren<HTMLAttributes<HTMLDivElement>>;

@ -0,0 +1,11 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import type { FieldControlProps } from './types';
let { class: className = '', children, ...restProps }: FieldControlProps = $props();
</script>
<TerraField.Control class={['air-field-control', className].filter(Boolean).join(' ')} {...restProps}>
{@render children?.()}
</TerraField.Control>

@ -0,0 +1,11 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import type { FieldErrorProps } from './types';
let { class: className = '', children, ...restProps }: FieldErrorProps = $props();
</script>
<TerraField.Error class={['air-field-error', className].filter(Boolean).join(' ')} {...restProps}>
{@render children?.()}
</TerraField.Error>

@ -0,0 +1,14 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import type { FieldHelperTextProps } from './types';
let { class: className = '', children, ...restProps }: FieldHelperTextProps = $props();
</script>
<TerraField.HelperText
class={['air-field-helper', className].filter(Boolean).join(' ')}
{...restProps}
>
{@render children?.()}
</TerraField.HelperText>

@ -0,0 +1,17 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import type { FieldInputProps } from './types';
let {
value = $bindable(''),
class: className = '',
...restProps
}: FieldInputProps = $props();
</script>
<TerraField.Input
bind:value
class={['air-field-input', className].filter(Boolean).join(' ')}
{...restProps}
/>

@ -0,0 +1,11 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import type { FieldLabelProps } from './types';
let { class: className = '', children, ...restProps }: FieldLabelProps = $props();
</script>
<TerraField.Label class={['air-field-label', className].filter(Boolean).join(' ')} {...restProps}>
{@render children?.()}
</TerraField.Label>

@ -0,0 +1,11 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import type { FieldPrefixProps } from './types';
let { class: className = '', children, ...restProps }: FieldPrefixProps = $props();
</script>
<TerraField.Prefix class={['air-field-prefix', className].filter(Boolean).join(' ')} {...restProps}>
{@render children?.()}
</TerraField.Prefix>

@ -0,0 +1,11 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import type { FieldSuffixProps } from './types';
let { class: className = '', children, ...restProps }: FieldSuffixProps = $props();
</script>
<TerraField.Suffix class={['air-field-suffix', className].filter(Boolean).join(' ')} {...restProps}>
{@render children?.()}
</TerraField.Suffix>

@ -0,0 +1,200 @@
/*
* air/component/field/field.css
*
* Recipe visual de Field sobre la anatomía pública de terra.Field.
* Root resuelve densidad y variant; Control dibuja la caja; Input es el
* elemento real sin borde propio; Label/Helper/Error completan el bloque.
*/
[data-field].air-field {
display: flex;
flex-direction: column;
gap: var(--_air-field-gap, var(--air-field-gap-md));
width: 100%;
}
[data-field-label].air-field-label {
display: inline-flex;
align-items: center;
gap: var(--air-field-label-required-gap);
width: fit-content;
font-family: var(--air-field-font-family);
font-size: var(--_air-field-label-size, var(--air-field-label-size-md));
font-weight: var(--air-field-label-weight);
line-height: var(--air-field-line-height);
color: var(--air-field-label-color);
}
[data-field-label].air-field-label[data-disabled] {
color: var(--air-field-label-color-disabled);
}
[data-field-label].air-field-label[data-error] {
color: var(--air-field-label-color-error);
}
[data-field-required-indicator] {
color: var(--air-field-required-color);
}
[data-field-control].air-field-control {
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
gap: var(--_air-input-gap, var(--air-input-adornment-gap-md));
height: var(--_air-input-height, var(--air-input-height-md));
padding-inline: var(--_air-input-px, var(--air-input-px-md));
font-family: var(--air-field-font-family);
font-size: var(--_air-input-fsize, var(--air-input-font-size-md));
font-weight: var(--air-input-font-weight);
line-height: var(--air-field-line-height);
color: var(--air-input-color);
background: var(--air-input-bg);
border: var(--air-input-border-width) solid var(--_air-input-border, var(--air-input-outline-border));
border-radius: var(--air-input-radius);
box-shadow: var(--air-input-shadow);
outline: none;
transition:
border-color var(--air-duration-fast) var(--air-ease-default),
box-shadow var(--air-duration-fast) var(--air-ease-default),
background var(--air-duration-fast) var(--air-ease-default);
}
[data-field].air-field[data-variant='ghost'] [data-field-control].air-field-control {
background: var(--air-input-ghost-bg);
box-shadow: none;
}
[data-field-input].air-field-input {
flex: 1 1 0;
align-self: stretch;
min-width: 0;
padding: 0;
margin: 0;
border: none;
background: transparent;
font: inherit;
line-height: inherit;
color: inherit;
caret-color: var(--air-input-caret-color);
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
box-shadow: none;
border-radius: 0;
outline: 0;
}
[data-field-input].air-field-input:focus,
[data-field-input].air-field-input:focus-visible,
[data-field-input].air-field-input:invalid,
[data-field-input].air-field-input:-moz-ui-invalid {
outline: 0;
box-shadow: none;
}
[data-field-input].air-field-input::placeholder {
color: var(--air-input-placeholder-color);
}
[data-field-input].air-field-input::selection {
background: var(--air-input-selection-bg);
}
[data-field-prefix].air-field-prefix,
[data-field-suffix].air-field-suffix {
display: inline-flex;
align-items: center;
flex-shrink: 0;
color: var(--air-input-adornment-color);
user-select: none;
}
[data-field-control].air-field-control:hover:not([data-disabled]):not(:has([readonly])) {
border-color: var(--_air-input-border-hover, var(--air-input-outline-border-hover));
}
[data-field].air-field[data-variant='ghost']
[data-field-control].air-field-control:hover:not([data-disabled]):not(:has([readonly])) {
background: var(--air-input-ghost-bg-hover);
border-color: var(--air-input-ghost-border-hover);
}
[data-field-control].air-field-control:focus-within:not([data-disabled]),
[data-field-control].air-field-control:focus-within:not([data-disabled]):not(:has([readonly])) {
border-color: var(--_air-input-border-focus, var(--air-input-outline-border-focus));
box-shadow: var(--air-input-focus-ring);
}
[data-field].air-field[data-variant='ghost']
[data-field-control].air-field-control:focus-within:not([data-disabled]) {
background: var(--air-input-ghost-bg-hover);
border-color: var(--air-input-ghost-border-focus);
box-shadow: var(--air-input-focus-ring);
}
[data-field].air-field[data-error] [data-field-control].air-field-control,
[data-field].air-field[data-state='error'] [data-field-control].air-field-control {
border-color: var(--air-input-error-border);
box-shadow: var(--air-input-shadow);
}
[data-field].air-field[data-error] [data-field-control].air-field-control:focus-within,
[data-field].air-field[data-state='error'] [data-field-control].air-field-control:focus-within {
border-color: var(--air-input-error-border-focus);
box-shadow: var(--air-input-error-focus-ring);
}
[data-field-control].air-field-control[data-disabled],
[data-field-control].air-field-control:has(:disabled) {
background: var(--air-input-disabled-bg);
color: var(--air-input-disabled-color);
border-color: var(--air-input-disabled-border);
box-shadow: none;
cursor: not-allowed;
}
[data-field-control].air-field-control[data-disabled] [data-field-input].air-field-input,
[data-field-control].air-field-control:has(:disabled) [data-field-input].air-field-input,
[data-field-control].air-field-control[data-disabled] [data-field-prefix].air-field-prefix,
[data-field-control].air-field-control[data-disabled] [data-field-suffix].air-field-suffix,
[data-field-control].air-field-control:has(:disabled) [data-field-prefix].air-field-prefix,
[data-field-control].air-field-control:has(:disabled) [data-field-suffix].air-field-suffix {
cursor: not-allowed;
color: var(--air-input-disabled-color);
}
[data-field-control].air-field-control:has([readonly]) {
background: var(--air-input-readonly-bg);
border-color: var(--air-input-readonly-border);
color: var(--air-input-readonly-color);
box-shadow: none;
cursor: default;
}
[data-field-control].air-field-control:has([readonly]) [data-field-prefix].air-field-prefix,
[data-field-control].air-field-control:has([readonly]) [data-field-suffix].air-field-suffix {
color: var(--air-input-readonly-color);
}
[data-field-helper-text].air-field-helper,
[data-field-error].air-field-error {
margin: 0;
font-family: var(--air-field-font-family);
font-size: var(--air-field-helper-size);
line-height: var(--air-field-line-height);
}
[data-field-helper-text].air-field-helper {
color: var(--air-field-helper-color);
}
[data-field-helper-text].air-field-helper[data-disabled] {
color: var(--air-field-helper-color-disabled);
}
[data-field-error].air-field-error {
font-size: var(--air-field-error-size);
color: var(--air-field-error-color);
}

@ -0,0 +1,51 @@
<script lang="ts">
import './field.css';
import { Field as TerraField } from '$uix/terra';
import { getAir } from '$uix/air/config';
import type { FieldRootProps } from './types';
let {
size = 'md',
variant = 'outline',
style,
class: className = '',
children,
...restProps
}: FieldRootProps = $props();
const air = getAir();
const resolvedSize = $derived(air.dom.resolve(size) ?? 'md');
// Field resuelve solo la capa exterior del control. La caja visual
// sigue reutilizando los tokens de Input para mantener densidad y foco
// consistentes entre Input standalone y Field.Input.
const sizeTokens = $derived(
`--_air-field-gap:var(--air-field-gap-${resolvedSize});` +
`--_air-field-label-size:var(--air-field-label-size-${resolvedSize});` +
`--_air-input-height:var(--air-input-height-${resolvedSize});` +
`--_air-input-px:var(--air-input-px-${resolvedSize});` +
`--_air-input-py:var(--air-input-py-${resolvedSize});` +
`--_air-input-gap:var(--air-input-adornment-gap-${resolvedSize});` +
`--_air-input-fsize:var(--air-input-font-size-${resolvedSize})`
);
const variantTokens = $derived(
`--_air-input-border:var(--air-input-${variant}-border);` +
`--_air-input-border-hover:var(--air-input-${variant}-border-hover);` +
`--_air-input-border-focus:var(--air-input-${variant}-border-focus)`
);
const mergedStyle = $derived([sizeTokens, variantTokens, style].filter(Boolean).join(';'));
</script>
<TerraField.Root
class={['air-field', className].filter(Boolean).join(' ')}
data-size={resolvedSize}
data-variant={variant}
style={mergedStyle}
{...restProps}
>
{#snippet children(snippetProps)}
{@render children?.(snippetProps)}
{/snippet}
</TerraField.Root>

@ -0,0 +1,21 @@
export { default as Root } from './field.svelte';
export { default as Label } from './field-label.svelte';
export { default as Control } from './field-control.svelte';
export { default as Input } from './field-input.svelte';
export { default as HelperText } from './field-helper.svelte';
export { default as Error } from './field-error.svelte';
export { default as Prefix } from './field-prefix.svelte';
export { default as Suffix } from './field-suffix.svelte';
export type {
AirFieldSize,
AirFieldVariant,
FieldRootProps as RootProps,
FieldLabelProps as LabelProps,
FieldControlProps as ControlProps,
FieldInputProps as InputProps,
FieldHelperTextProps as HelperTextProps,
FieldErrorProps as ErrorProps,
FieldPrefixProps as PrefixProps,
FieldSuffixProps as SuffixProps,
} from './types';

@ -0,0 +1,18 @@
import type { ResponsiveProp } from '$uix/air/internal/dom';
import type { Field as TerraField } from '$uix/terra';
export type AirFieldSize = 'xs' | 'sm' | 'md' | 'lg';
export type AirFieldVariant = 'outline' | 'ghost';
export type FieldRootProps = TerraField.RootProps & {
size?: ResponsiveProp<AirFieldSize>;
variant?: AirFieldVariant;
};
export type FieldLabelProps = TerraField.LabelProps;
export type FieldControlProps = TerraField.ControlProps;
export type FieldInputProps = TerraField.InputProps;
export type FieldHelperTextProps = TerraField.HelperTextProps;
export type FieldErrorProps = TerraField.ErrorProps;
export type FieldPrefixProps = TerraField.PrefixProps;
export type FieldSuffixProps = TerraField.SuffixProps;

@ -0,0 +1,76 @@
import type { AirIconSize, AirIconStrokeWidth } from './types';
const ICON_SIZE_PX_MAP = {
xxs: 12,
xs : 14,
sm : 16,
md : 18,
lg : 20,
xl : 24,
xxl: 48,
} as const;
const ICON_SIZE_MAP = {
xxs: 'var(--air-icon-size-xxs)',
xs : 'var(--air-icon-size-xs)',
sm : 'var(--air-icon-size-sm)',
md : 'var(--air-icon-size-md)',
lg : 'var(--air-icon-size-lg)',
xl : 'var(--air-icon-size-xl)',
xxl: 'var(--air-icon-size-xxl)',
} as const;
const ICON_STROKE_WIDTH_MAP = {
sm: 'var(--air-icon-stroke-width-sm)',
md: 'var(--air-icon-stroke-width-md)',
lg: 'var(--air-icon-stroke-width-lg)',
} as const;
export const AIR_ICON_AUTO_STROKE_BY_SIZE = {
xxs: 1.125,
xs : 1.25,
sm : 1.375,
md : 1.5,
lg : 1.625,
xl : 1.75,
xxl: 2.0,
} as const;
function resolveAirIconSizePx(size: AirIconSize = 'md'): number | null {
if (typeof size === 'number') return size;
if (typeof size === 'string' && /^\d+(\.\d+)?$/.test(size)) return Number(size);
if (typeof size === 'string' && /^\d+(\.\d+)?px$/.test(size)) return Number.parseFloat(size);
return ICON_SIZE_PX_MAP[size as keyof typeof ICON_SIZE_PX_MAP] ?? null;
}
function resolveAutoAirIconStrokeWidth(size: AirIconSize = 'md'): number {
const sizePx = resolveAirIconSizePx(size);
if (sizePx === null) return 1.5;
if (sizePx <= 12) return AIR_ICON_AUTO_STROKE_BY_SIZE.xxs;
if (sizePx <= 14) return AIR_ICON_AUTO_STROKE_BY_SIZE.xs;
if (sizePx <= 16) return AIR_ICON_AUTO_STROKE_BY_SIZE.sm;
if (sizePx <= 18) return AIR_ICON_AUTO_STROKE_BY_SIZE.md;
if (sizePx <= 20) return AIR_ICON_AUTO_STROKE_BY_SIZE.lg;
if (sizePx <= 24) return AIR_ICON_AUTO_STROKE_BY_SIZE.xl;
if (sizePx <= 48) return AIR_ICON_AUTO_STROKE_BY_SIZE.xxl;
return AIR_ICON_AUTO_STROKE_BY_SIZE.xxl;
}
export function resolveAirIconSize(size: AirIconSize = 'md'): string {
if (typeof size === 'number') return `${size}px`;
if (typeof size === 'string' && /^\d+(\.\d+)?$/.test(size)) return `${size}px`;
return ICON_SIZE_MAP[size as keyof typeof ICON_SIZE_MAP] ?? size;
}
export function resolveAirIconStrokeWidth(
strokeWidth: AirIconStrokeWidth = 'auto',
size: AirIconSize = 'md'
): string | number {
if (strokeWidth === 'auto') return resolveAutoAirIconStrokeWidth(size);
if (typeof strokeWidth === 'number') return strokeWidth;
if (typeof strokeWidth === 'string' && /^\d+(\.\d+)?$/.test(strokeWidth)) {
return Number(strokeWidth);
}
return ICON_STROKE_WIDTH_MAP[strokeWidth as keyof typeof ICON_STROKE_WIDTH_MAP] ?? strokeWidth;
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.