Retire legacy uiux from actx

main
dev 6 months ago
parent 68e15ef884
commit e9677e9617

@ -1,10 +1,6 @@
import type { LingNode } from '../../ling/lib/types';
import type { GlobLocale } from '../../glob/lib/comn_types';
import type { ACtxConfig, ReactiveACtx } from './types';
import type { UiuxConfig } from '../../uiux/lib';
import { createUiuxState } from '../../uiux/lib/engine.svelte';
import { createNumrState } from '../../glob/lib/numr_engine.svelte';
import { createDatiState } from '../../glob/lib/dati_engine.svelte';
import { createCurrState } from '../../glob/lib/curr_engine.svelte';
@ -53,7 +49,6 @@ export function createACtx<S extends LingNode>(
getLocale,
reactiveLocale
);
const _uiux = createUiuxState(config.uiux ?? {} as UiuxConfig);
// ── Instancia ─────────────────────────────────────────────────────────────
return {
@ -67,7 +62,6 @@ export function createACtx<S extends LingNode>(
dati : _dati,
curr : _curr,
unit : _unit,
uiux : _uiux,
get locale() { return locale; },
};

@ -7,7 +7,6 @@ import { createNumr } from '../../glob/lib/numr_engine';
import { createDati } from '../../glob/lib/dati_engine';
import { createCurr } from '../../glob/lib/curr_engine';
import { createUnit } from '../../glob/lib/unit_engine';
import { createUiux } from '../../uiux/lib';
// ============================================================================
// HELPERS
@ -44,7 +43,6 @@ export function createACtx<S extends LingNode>(
let _dati: ReturnType<typeof createDati> | undefined;
let _curr: ReturnType<typeof createCurr> | undefined;
let _unit: ReturnType<typeof createUnit> | undefined;
let _uiux: ReturnType<typeof createUiux> | undefined;
return {
// Delegates de ling
@ -92,13 +90,5 @@ export function createACtx<S extends LingNode>(
getLocale
);
},
get uiux() {
if (!config.uiux) missingModule('uiux', 'uiux: { theme: { mode: "auto" } }');
if (!_uiux) {
_uiux = createUiux(config.uiux);
}
return _uiux;
},
};
}

@ -79,9 +79,4 @@ export const actx = createACtx({
selectedSystem: 'metric',
// categories: { weight: myCustomWeight }, // sobreescribe si necesitas
},
uiux: {
theme: { mode: 'auto' },
toast: { defaultDuration: 4000 },
},
});

@ -45,9 +45,4 @@ export const actx = createACtx({
unit: {
selectedSystem: 'metric',
},
uiux: {
theme: { mode: 'light' }, // sin 'auto' — no hay window en SSR
toast: { defaultDuration: 4000 },
},
});

@ -9,7 +9,6 @@ import type { UnitInstance, UnitCategoryData, UnitCategoryKey, UnitSystem } from
import type { DatiInstance, DatiConfig } from '../../glob/lib/dati_types';
import type { NumrInstance, NumrConfig } from '../../glob/lib/numr_types';
import type { GlobLocale } from '../../glob/lib/comn_types';
import type { ReactiveUiux, Uiux, UiuxConfig } from '../../uiux/lib/types';
// ============================================================================
@ -30,7 +29,6 @@ import type { ReactiveUiux, Uiux, UiuxConfig } from '../../uiux/lib/types';
* dati : { selectedTimeFormat: '24h' },
* curr : { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
* unit : { selectedSystem: 'metric' },
* uiux : { theme: { mode: 'auto' } },
* });
*/
export interface ACtxConfig<S extends LingNode> {
@ -50,7 +48,6 @@ export interface ACtxConfig<S extends LingNode> {
categories? : Partial<Record<UnitCategoryKey, UnitCategoryData>>;
};
/** Configuración del contexto de UI (tema, breakpoints, toasts, modales, menús). */
uiux? : UiuxConfig;
}
// ============================================================================
@ -69,10 +66,6 @@ export interface ACtxConfig<S extends LingNode> {
* actx.dati.f(new Date())
* actx.curr.f(99.99, 'name')
* actx.unit.weight.fTo(75, 'kg', 'lb')
* actx.uiux.theme.toggle()
* actx.uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
* actx.uiux.modal.open('confirm', { title: '¿Eliminar?' })
* actx.uiux.break.isMobile
*/
export interface ACtx<S extends LingNode = LingNode> {
t : LingInstance<S>['t'];
@ -84,7 +77,6 @@ export interface ACtx<S extends LingNode = LingNode> {
dati : DatiInstance;
curr : CurrInstance;
unit : UnitInstance;
uiux : Uiux;
}
@ -96,9 +88,7 @@ export interface ACtx<S extends LingNode = LingNode> {
/**
* Contexto global reactivo.
* Devuelto por `createACtx` de `actx_engine.svelte.ts`.
* Las propiedades de `uiux` y `locale` están respaldadas por `$state`.
* La propiedad `locale` está respaldada por `$state`.
*/
export interface ReactiveACtx<S extends LingNode = LingNode>
extends Omit<ACtx<S>, 'uiux'> {
uiux: ReactiveUiux;
}
extends ACtx<S> {}

@ -1,321 +0,0 @@
/**
* actx.svelte — test suite (versión reactiva)
*
* Estos tests requieren un entorno Svelte 5 con soporte para runes ($state).
*
* Ejecutar: npx vitest run src/lib/actx/test/actx.svelte.test.ts
*
* Módulos cubiertos:
* - engine.svelte (createACtx reactivo)
* - reactividad de $state
* - sincronización engines → $state
* - uiux reactivo
*/
import type { CurrDefinition } from '../../glob/lib';
import { describe, it, expect } from 'vitest';
import { createLing } from '../../ling/lib';
import { createACtx } from '../lib/engine.svelte';
import { LogLevel } from '../../logr/lib';
// ============================================================================
// FIXTURES
// ============================================================================
const translations = {
common: {
ok: { es: 'Aceptar', en: 'OK' },
cancel: { es: 'Cancelar', en: 'Cancel' },
},
messages: {
welcome: { es: 'Bienvenido', en: 'Welcome' },
},
};
const EUR: CurrDefinition = {
symbol: '€',
code: 'EUR',
name: ({ count }) => ({
es: count === 1 ? 'euro' : 'euros',
en: count === 1 ? 'euro' : 'euros',
}),
conversionValue: 1,
};
const USD: CurrDefinition = {
symbol: '$',
code: 'USD',
name: ({ count }) => ({
es: count === 1 ? 'dólar' : 'dólares',
en: count === 1 ? 'dollar' : 'dollars',
}),
conversionValue: 1.08,
};
// ============================================================================
// CREATE ACTX (REACTIVE)
// ============================================================================
describe('createACtx (reactivo con Svelte 5)', () => {
describe('creación básica', () => {
it('crea una instancia con ling', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ling).toBeDefined();
expect(actx.locale).toBe('es');
});
it('t() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.t('common.ok')).toBe('Aceptar');
});
it('ts() funciona directamente en actx', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
});
});
describe('módulos configurados', () => {
it('crea instancia con todos los módulos configurados', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
logr: { level: LogLevel.WARN },
numr: { maxDecimals: 2 },
dati: { selectedTimeFormat: '24h' },
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
unit: { selectedSystem: 'metric' },
uiuxConfig: { theme: { mode: 'auto' } },
});
// Módulos básicos
expect(actx.ling).toBeDefined();
expect(actx.logr).toBeDefined();
expect(actx.numr).toBeDefined();
expect(actx.dati).toBeDefined();
expect(actx.curr).toBeDefined();
expect(actx.unit).toBeDefined();
// UIX reactivo
expect(actx.uiux).toBeDefined();
expect(actx.uiux.theme).toBeDefined();
expect(actx.uiux.toast).toBeDefined();
expect(actx.uiux.modal).toBeDefined();
expect(actx.uiux.break).toBeDefined();
});
});
describe('reactividad de locale', () => {
it('locale es reactivo y cambia con ling.setLocale()', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.locale).toBe('es');
// El cambio en ling debería reflejarse en el estado reactivo
// (en un entorno Svelte real, esto sería automático)
actx.ling.setLocale('en');
// En el entorno de test, verificamos que ling cambió
expect(actx.ling.getLocale()).toBe('en');
});
});
describe('uiux reactivo', () => {
it('theme tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
uiuxConfig: { theme: { mode: 'dark' } },
});
// Props de lectura (reactivas en Svelte)
expect(actx.uiux.theme.resolved).toBeDefined();
expect(actx.uiux.theme.mode).toBe('dark');
});
it('theme tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
uiuxConfig: { theme: { mode: 'light' } },
});
// Métodos de acción
expect(actx.uiux.theme.set).toBeInstanceOf(Function);
expect(actx.uiux.theme.toggle).toBeInstanceOf(Function);
expect(actx.uiux.theme.onChange).toBeInstanceOf(Function);
});
it('toast tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
uiuxConfig: { toast: { defaultDuration: 4000 } },
});
// Props de lectura
expect(actx.uiux.toast.queue).toBeDefined();
expect(Array.isArray(actx.uiux.toast.queue)).toBe(true);
});
it('toast tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.toast.show).toBeInstanceOf(Function);
expect(actx.uiux.toast.dismiss).toBeInstanceOf(Function);
expect(actx.uiux.toast.clear).toBeInstanceOf(Function);
expect(actx.uiux.toast.onChange).toBeInstanceOf(Function);
});
it('modal tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.modal.stack).toBeDefined();
expect(Array.isArray(actx.uiux.modal.stack)).toBe(true);
});
it('modal tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.modal.open).toBeInstanceOf(Function);
expect(actx.uiux.modal.close).toBeInstanceOf(Function);
expect(actx.uiux.modal.closeAll).toBeInstanceOf(Function);
expect(actx.uiux.modal.isOpen).toBeInstanceOf(Function);
expect(actx.uiux.modal.onChange).toBeInstanceOf(Function);
});
it('break tiene propiedades de lectura', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.break.current).toBeDefined();
// Las propiedades computadas dependen del entorno
expect(typeof actx.uiux.break.isMobile).toBe('boolean');
});
it('break tiene métodos de acción', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(actx.uiux.break.is).toBeInstanceOf(Function);
expect(actx.uiux.break.onChange).toBeInstanceOf(Function);
});
});
describe('integración con módulos', () => {
it('numr funciona correctamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
});
const result = actx.numr.f(1234.5);
expect(result).toBeTruthy();
expect(result).toContain('5');
});
it('curr funciona correctamente con pluralización', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
});
expect(actx.curr.f(1, 'name')).toBe('1,00 euro');
expect(actx.curr.f(2, 'name')).toBe('2,00 euros');
});
it('dati funciona correctamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
dati: { selectedTimeFormat: '24h' },
});
const date = new Date('2024-03-15');
const result = actx.dati.f(date);
expect(result).toBeTruthy();
});
it('unit funciona correctamente', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
unit: { selectedSystem: 'metric' },
});
const result = actx.unit.weight.f(75);
expect(result).toBeTruthy();
expect(result).toContain('75');
});
});
describe('logr integrado con ling', () => {
it('logr usa ling para resolver mensajes', () => {
const ling = createLing(translations, 'es');
const captured: string[] = [];
const actx = createACtx({
ling,
logr: {
level: LogLevel.INFO,
transports: [
{
write: (_entry: any, resolvedMessage: string) => captured.push(resolvedMessage)
}
],
},
});
actx.logr.info('test', '#?common.ok');
expect(captured[0]).toBe('Aceptar');
});
});
describe('casos de uso reales', () => {
it('simula una aplicación multilingüe completa', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({
ling,
numr: { maxDecimals: 2 },
curr: { definitions: { EUR, USD }, selectedCurrency: 'EUR' },
dati: { selectedTimeFormat: '24h' },
unit: { selectedSystem: 'metric' },
uiuxConfig: {
theme: { mode: 'auto' },
toast: { defaultDuration: 4000 },
},
});
// Traducciones
expect(actx.t('common.ok')).toBe('Aceptar');
expect(actx.ts({ es: 'Hola', en: 'Hello' })).toBe('Hola');
// Formateo
expect(actx.numr.f(1000)).toBeTruthy();
expect(actx.curr.f(100)).toBe('100,00 €');
expect(actx.dati.f(new Date())).toBeTruthy();
expect(actx.unit.weight.f(75)).toContain('75');
// Locale
expect(actx.locale).toBe('es');
// UIX
expect(actx.uiux.theme.mode).toBe('auto');
// Cambio de locale
actx.ling.setLocale('en');
expect(actx.t('common.ok')).toBe('OK');
});
});
});

@ -143,12 +143,6 @@ describe('createACtx (no reactivo)', () => {
expect(() => actx.unit).toThrow('[actx] \'unit\' no está configurado');
});
it('lanza error al acceder a uiux si no está configurado', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling });
expect(() => actx.uiux).toThrow('[actx] \'uiux\' no está configurado');
});
});
describe('lazy initialization', () => {
@ -195,14 +189,6 @@ describe('createACtx (no reactivo)', () => {
expect(actx.unit.weight).toBeDefined();
});
it('uiux se inicializa en el primer acceso', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, uiux: { theme: { mode: 'light' } } });
expect(actx.uiux).toBeDefined();
expect(actx.uiux.theme).toBeDefined();
});
it('múltiples accesos devuelven la misma instancia (singleton)', () => {
const ling = createLing(translations, 'es');
const actx = createACtx({ ling, numr: {} });
@ -401,7 +387,6 @@ describe('createACtx (no reactivo)', () => {
decimals: 2,
},
unit: { selectedSystem: 'metric' },
uiux: { theme: { mode: 'dark' } },
});
// Todos los módulos están disponibles
@ -411,7 +396,6 @@ describe('createACtx (no reactivo)', () => {
expect(actx.dati).toBeDefined();
expect(actx.curr).toBeDefined();
expect(actx.unit).toBeDefined();
expect(actx.uiux).toBeDefined();
// Configuración de numr
const numrResult = actx.numr.f(1234);
@ -428,8 +412,6 @@ describe('createACtx (no reactivo)', () => {
// Configuración de unit
expect(actx.unit.getSystem()).toBe('metric');
// Configuración de uiux
expect(actx.uiux.theme.mode).toBe('dark');
// Configuración de logr
expect(() => actx.logr.warn('test', 'msg')).not.toThrow();
@ -446,7 +428,6 @@ describe('createACtx (no reactivo)', () => {
curr: { definitions: { EUR }, selectedCurrency: 'EUR' },
unit: {},
logr: { level: LogLevel.NONE },
uiux: { theme: { mode: 'auto' } },
});
// Traducción directa
@ -473,8 +454,6 @@ describe('createACtx (no reactivo)', () => {
// Unidades
expect(actx.unit.weight.f(75)).toContain('75');
// UIX
expect(actx.uiux.theme.mode).toBe('auto');
// Cambio de locale
actx.ling.setLocale('en');

@ -1 +1,8 @@
// place files you want to import through the `$lib` alias in this folder.
export * from './actx/lib';
export * from './glob/lib';
export * from './ling/lib';
export * from './logr/lib';
export * from './util';

@ -1,79 +0,0 @@
import type { BreakConfig, BreakInstance, BreakpointKey } from './types.ts';
// Breakpoints por defecto — coinciden con Tailwind v3
const DEFAULT_POINTS: Record<string, number> = {
sm : 640,
md : 768,
lg : 1024,
xl : 1280,
'2xl': 1536,
};
/**
* Pre-ordena los breakpoints de mayor a menor una sola vez.
* Evita re-ordenar en cada resize.
*/
function sortedEntries(points: Record<string, number>): [string, number][] {
return Object.entries(points).sort(([, a], [, b]) => b - a);
}
/**
* Resuelve el breakpoint activo dado un ancho y entries pre-ordenados.
* Devuelve la clave del mayor breakpoint que el ancho cumple, o 'xs' si ninguno.
*/
function resolveBreakpoint(width: number, sorted: [string, number][]): BreakpointKey {
for (const [key, value] of sorted) {
if (width >= value) return key;
}
return 'xs';
}
export function createBreak(config: BreakConfig = {}): BreakInstance {
const points = config.points ?? DEFAULT_POINTS;
const sorted = sortedEntries(points);
const getWidth = () => typeof window !== 'undefined' ? window.innerWidth : 0;
let current : BreakpointKey = resolveBreakpoint(getWidth(), sorted);
const listeners = new Set<(breakpoint: BreakpointKey) => void>();
let observer: ResizeObserver | null = null;
// ResizeObserver en browser, noop en SSR
if (typeof window !== 'undefined') {
observer = new ResizeObserver(() => {
const next = resolveBreakpoint(window.innerWidth, sorted);
if (next === current) return;
current = next;
listeners.forEach(fn => fn(current));
});
observer.observe(document.documentElement);
}
function is(breakpoint: BreakpointKey): boolean {
const min = points[breakpoint];
if (min === undefined) return false;
return getWidth() >= min;
}
function onChange(fn: (breakpoint: BreakpointKey) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
function dispose(): void {
observer?.disconnect();
observer = null;
listeners.clear();
}
return {
get current() { return current; },
get isMobile() { return !is('md'); },
get isTablet() { return is('md') && !is('lg'); },
get isDesktop() { return is('lg'); },
is,
onChange,
dispose,
};
}

@ -1,88 +0,0 @@
/**
* uiux_engine.svelte.ts — Factory reactivo del contexto de UI.
*
* Encapsula todo el $state de uiux en su propio módulo,
* igual que numr_engine.ts o curr_engine.ts.
*
* Si uiux crece con nuevos módulos (notifications, shortcuts, etc.)
* solo hay que tocar este fichero y ReactiveUiux — actx no se entera.
*/
import type { UiuxConfig, ReactiveUiux, ThemeMode, Toast, ModalEntry } from './types.ts';
import { createUiux } from './engine.ts';
export function createUiuxState(config: UiuxConfig = {}): ReactiveUiux {
const _uiux = createUiux(config);
// ── $state ────────────────────────────────────────────────────────────────
let themeResolved = $state<'light' | 'dark'>(_uiux.theme.resolved);
let themeMode = $state<ThemeMode>(_uiux.theme.mode);
let toastQueue = $state<Toast[]>([]);
let modalStack = $state<ModalEntry[]>([]);
let breakCurrent = $state<string>(_uiux.break.current);
let openMenus = $state<Set<string>>(new Set());
// ── Sincronización engines → $state ──────────────────────────────────────
_uiux.theme.onChange((mode, resolved) => {
themeMode = mode;
themeResolved = resolved;
});
_uiux.toast.onChange(q => { toastQueue = q; });
_uiux.modal.onChange(s => { modalStack = s; });
_uiux.break.onChange(bp => { breakCurrent = bp; });
_uiux.menu.onChange(ids => { openMenus = new Set(ids); });
// ── ReactiveUiux ──────────────────────────────────────────────────────────
return {
theme: {
get resolved() { return themeResolved; },
get mode() { return themeMode; },
set : _uiux.theme.set.bind(_uiux.theme),
toggle : _uiux.theme.toggle.bind(_uiux.theme),
onChange: _uiux.theme.onChange.bind(_uiux.theme),
},
toast: {
get queue() { return toastQueue; },
show : _uiux.toast.show.bind(_uiux.toast),
dismiss : _uiux.toast.dismiss.bind(_uiux.toast),
clear : _uiux.toast.clear.bind(_uiux.toast),
onChange: _uiux.toast.onChange.bind(_uiux.toast),
},
modal: {
get stack() { return modalStack; },
get active() {
return modalStack.length > 0
? modalStack[modalStack.length - 1]
: null;
},
open : _uiux.modal.open.bind(_uiux.modal),
close : _uiux.modal.close.bind(_uiux.modal),
closeAll: _uiux.modal.closeAll.bind(_uiux.modal),
isOpen : _uiux.modal.isOpen.bind(_uiux.modal),
onChange: _uiux.modal.onChange.bind(_uiux.modal),
},
menu: {
open : _uiux.menu.open.bind(_uiux.menu),
close : _uiux.menu.close.bind(_uiux.menu),
toggle : _uiux.menu.toggle.bind(_uiux.menu),
isOpen : (id: string) => openMenus.has(id),
closeAll: _uiux.menu.closeAll.bind(_uiux.menu),
onChange: _uiux.menu.onChange.bind(_uiux.menu),
},
break: {
get current() { return breakCurrent; },
get isMobile() { return !_uiux.break.is('md'); },
get isTablet() { return _uiux.break.is('md') && !_uiux.break.is('lg'); },
get isDesktop() { return _uiux.break.is('lg'); },
is : _uiux.break.is.bind(_uiux.break),
onChange: _uiux.break.onChange.bind(_uiux.break),
},
};
}

@ -1,41 +0,0 @@
import type { Uiux, UiuxConfig } from './types.ts';
import { createTheme } from './theme_engine.ts';
import { createBreak } from './break_engine.ts';
import { createToast } from './toast_engine.ts';
import { createModal } from './modal_engine.ts';
import { createMenu } from './menu_engine.ts';
/**
* Crea el contexto de UI de la aplicación.
*
* Todos los módulos están siempre disponibles — no requieren config explícita.
* La config solo ajusta los defaults (tema inicial, breakpoints, duración de toasts).
*
* Diseñado para funcionar como módulo independiente o como parte de `ACtx`:
*
* @example
* // Standalone
* const uiux = createUiux({ theme: { mode: 'auto' } });
*
* @example
* // Integrado en ACtx (cuando se añada uiux a ACtxConfig)
* actx.uiux.theme.toggle()
* actx.uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
* actx.uiux.modal.open('confirm', { title: '¿Eliminar?' })
* actx.uiux.menu.toggle('sidebar')
* actx.uiux.break.isMobile
*
* @remarks
* **Persistencia**: el tema se guarda en memoria en esta versión.
* Cuando `stor` esté disponible, `ACtx` conectará `uiux.theme` con
* `stor.local.get/set('uiux:theme')` sin modificar esta librería.
*/
export function createUiux(config: UiuxConfig = {}): Uiux {
return {
theme : createTheme(config.theme),
break : createBreak(config.break),
toast : createToast(config.toast?.defaultDuration),
modal : createModal(),
menu : createMenu(),
};
}

@ -1,7 +0,0 @@
export * from './types.ts';
export * from './engine.ts';
export * from './theme_engine.ts';
export * from './break_engine.ts';
export * from './toast_engine.ts';
export * from './modal_engine.ts';
export * from './menu_engine.ts';

@ -1,44 +0,0 @@
import type { MenuInstance } from './types.ts';
export function createMenu(): MenuInstance {
const openIds = new Set<string>();
const listeners = new Set<(openIds: Set<string>) => void>();
function notify(): void {
const snapshot = new Set(openIds);
listeners.forEach(fn => fn(snapshot));
}
function open(id: string): void {
if (openIds.has(id)) return;
openIds.add(id);
notify();
}
function close(id: string): void {
if (!openIds.has(id)) return;
openIds.delete(id);
notify();
}
function toggle(id: string): void {
openIds.has(id) ? close(id) : open(id);
}
function isOpen(id: string): boolean {
return openIds.has(id);
}
function closeAll(): void {
if (openIds.size === 0) return;
openIds.clear();
notify();
}
function onChange(fn: (openIds: Set<string>) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return { open, close, toggle, isOpen, closeAll, onChange };
}

@ -1,47 +0,0 @@
import type { ModalEntry, ModalInstance } from './types.ts';
export function createModal(): ModalInstance {
let stack: ModalEntry[] = [];
const listeners = new Set<(stack: ModalEntry[]) => void>();
function notify(): void {
const snapshot = [...stack];
listeners.forEach(fn => fn(snapshot));
}
function open<P extends Record<string, any> = Record<string, any>>(id: string, props?: P): void {
stack = [...stack, { id, props: props ?? ({} as P) }];
notify();
}
function close(): void {
if (stack.length === 0) return;
stack = stack.slice(0, -1);
notify();
}
function closeAll(): void {
if (stack.length === 0) return;
stack = [];
notify();
}
function isOpen(id: string): boolean {
return stack.some(m => m.id === id);
}
function onChange(fn: (stack: ModalEntry[]) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return {
get stack() { return stack; },
get active() { return stack.length > 0 ? stack[stack.length - 1] : null; },
open,
close,
closeAll,
isOpen,
onChange,
};
}

@ -1,100 +0,0 @@
import type { ThemeConfig, ThemeInstance, ThemeMode } from './types.ts';
const DEFAULT_DARK_CLASS = 'dark';
/** Resuelve 'auto' al modo del sistema operativo. */
function resolveSystemMode(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(
resolved : 'light' | 'dark',
darkClass : string,
vars? : ThemeConfig['vars']
): void {
if (typeof document === 'undefined') return;
const html = document.documentElement;
// Clase dark de Tailwind
resolved === 'dark'
? html.classList.add(darkClass)
: html.classList.remove(darkClass);
// Variables CSS custom
if (vars) {
const themeVars = vars[resolved] ?? {};
for (const [key, value] of Object.entries(themeVars)) {
html.style.setProperty(key, value);
}
}
}
export function createTheme(config: ThemeConfig = {}): ThemeInstance {
const darkClass = config.darkClass ?? DEFAULT_DARK_CLASS;
let currentMode: ThemeMode = config.mode ?? 'auto';
let resolved : 'light' | 'dark' = currentMode === 'auto'
? resolveSystemMode()
: currentMode;
const listeners = new Set<(mode: ThemeMode, resolved: 'light' | 'dark') => void>();
// Escucha cambios del sistema cuando el modo es 'auto'
let mediaUnsubscribe: (() => void) | undefined;
function subscribeToSystem(): void {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
if (currentMode !== 'auto') return;
resolved = mq.matches ? 'dark' : 'light';
applyTheme(resolved, darkClass, config.vars);
listeners.forEach(fn => fn(currentMode, resolved));
};
mq.addEventListener('change', handler);
mediaUnsubscribe = () => mq.removeEventListener('change', handler);
}
function unsubscribeFromSystem(): void {
mediaUnsubscribe?.();
mediaUnsubscribe = undefined;
}
function set(mode: ThemeMode): void {
currentMode = mode;
if (mode === 'auto') {
resolved = resolveSystemMode();
subscribeToSystem();
} else {
unsubscribeFromSystem();
resolved = mode;
}
applyTheme(resolved, darkClass, config.vars);
listeners.forEach(fn => fn(currentMode, resolved));
}
function toggle(): void {
set(resolved === 'dark' ? 'light' : 'dark');
}
function onChange(fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// Aplicar tema inicial
applyTheme(resolved, darkClass, config.vars);
if (currentMode === 'auto') subscribeToSystem();
return {
get resolved() { return resolved; },
get mode() { return currentMode; },
set,
toggle,
onChange,
};
}

@ -1,68 +0,0 @@
import type { Toast, ToastInstance, ToastOptions } from './types.ts';
import type { LingString } from '@/ling/lib';
const DEFAULT_DURATION = 4000;
let _id = 0;
function nextId(): string { return `toast-${++_id}`; }
export function createToast(defaultDuration = DEFAULT_DURATION): ToastInstance {
let queue: Toast[] = [];
const listeners = new Set<(queue: Toast[]) => void>();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function notify(): void {
const snapshot = [...queue];
listeners.forEach(fn => fn(snapshot));
}
function dismiss(id: string): void {
const timer = timers.get(id);
if (timer) { clearTimeout(timer); timers.delete(id); }
queue = queue.filter(t => t.id !== id);
notify();
}
function show(message: LingString, opts: ToastOptions = {}): string {
const id = nextId();
const duration = opts.duration ?? defaultDuration;
const toast: Toast = {
id,
type : opts.type ?? 'info',
message,
duration,
context : opts.context,
};
queue = [...queue, toast];
notify();
// Auto-dismiss si duration > 0
if (duration > 0) {
timers.set(id, setTimeout(() => dismiss(id), duration));
}
return id;
}
function clear(): void {
timers.forEach(t => clearTimeout(t));
timers.clear();
queue = [];
notify();
}
function onChange(fn: (queue: Toast[]) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
return {
get queue() { return queue; },
show,
dismiss,
clear,
onChange,
};
}

@ -1,208 +0,0 @@
// ==============================
// UIUX TYPES
// ==============================
import type { LingString } from '@/ling/lib';
// ============================================================================
// THEME
// ============================================================================
export type ThemeMode = 'light' | 'dark' | 'auto';
export interface ThemeConfig {
/** Modo inicial. Default: `'auto'`. */
mode? : ThemeMode;
/**
* Clase CSS aplicada a `<html>` en modo dark.
* Debe coincidir con la configuración de Tailwind (`darkMode: 'class'`).
* Default: `'dark'`.
*/
darkClass? : string;
/**
* Variables CSS custom por tema. Se inyectan en `<html>` como `style`.
* Útil para temas custom más allá de light/dark.
* @example
* vars: { light: { '--color-brand': '#3B82F6' }, dark: { '--color-brand': '#60A5FA' } }
*/
vars? : Partial<Record<ThemeMode | string, Record<string, string>>>;
}
export interface ThemeInstance {
/** Modo activo resuelto — nunca `'auto'`, siempre `'light'` o `'dark'`. */
readonly resolved : 'light' | 'dark';
/** Modo configurado — puede ser `'auto'`. */
readonly mode : ThemeMode;
set : (mode: ThemeMode) => void;
toggle : () => void;
onChange : (fn: (mode: ThemeMode, resolved: 'light' | 'dark') => void) => () => void;
}
// ============================================================================
// BREAKPOINTS
// ============================================================================
export type BreakpointKey = string;
export interface BreakConfig {
/**
* Mapa de breakpoints en px. Default: breakpoints de Tailwind v3.
* @example { sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 }
*/
points?: Record<BreakpointKey, number>;
}
export interface BreakInstance {
/** Breakpoint activo según el ancho actual de la ventana. */
readonly current : BreakpointKey;
readonly isMobile : boolean; // < md
readonly isTablet : boolean; // >= md && < lg
readonly isDesktop: boolean; // >= lg
/** Devuelve true si el viewport cumple el breakpoint mínimo indicado. */
is : (breakpoint: BreakpointKey) => boolean;
onChange : (fn: (breakpoint: BreakpointKey) => void) => () => void;
/** Desconecta el ResizeObserver y limpia los listeners. */
dispose : () => void;
}
// ============================================================================
// TOAST
// ============================================================================
export type ToastType = 'success' | 'error' | 'warn' | 'info';
export interface ToastOptions {
/** Tipo visual del toast. Default: `'info'`. */
type? : ToastType;
/**
* Duración en ms antes de auto-dismiss. `0` = permanente.
* Default: `4000`.
*/
duration?: number;
/** Contexto adicional — por ejemplo un código de error. */
context? : Record<string, unknown>;
}
export interface Toast {
id : string;
type : ToastType;
message : LingString;
duration: number;
context?: Record<string, unknown>;
}
export interface ToastInstance {
/** Cola de toasts activos. */
readonly queue : Toast[];
show : (message: LingString, opts?: ToastOptions) => string;
dismiss : (id: string) => void;
clear : () => void;
onChange : (fn: (queue: Toast[]) => void) => () => void;
}
// ============================================================================
// MODAL
// ============================================================================
export interface ModalEntry<P extends Record<string, any> = Record<string, any>> {
id : string;
props : P;
}
export interface ModalInstance {
/** Stack de modales abiertos. El último es el activo. */
readonly stack : ModalEntry[];
/** Modal activo (último del stack) o `null` si no hay ninguno. */
readonly active : ModalEntry | null;
open : <P extends Record<string, any> = Record<string, any>>(id: string, props?: P) => void;
close : () => void;
closeAll : () => void;
isOpen : (id: string) => boolean;
onChange : (fn: (stack: ModalEntry[]) => void) => () => void;
}
// ============================================================================
// MENU
// ============================================================================
export interface MenuInstance {
open : (id: string) => void;
close : (id: string) => void;
toggle : (id: string) => void;
isOpen : (id: string) => boolean;
closeAll: () => void;
onChange: (fn: (openIds: Set<string>) => void) => () => void;
}
// ============================================================================
// UIUX CONFIG & INSTANCE
// ============================================================================
export interface UiuxConfig {
theme? : ThemeConfig;
break? : BreakConfig;
toast? : { defaultDuration?: number };
}
/**
* Contexto de UI de la aplicación.
* Gestiona tema, breakpoints, toasts, modales y menús.
*
* Todos los módulos son siempre disponibles — no requieren config explícita.
* La config solo ajusta los defaults.
*
* @example
* uiux.theme.toggle()
* uiux.toast.show({ es: 'Guardado', en: 'Saved' }, { type: 'success' })
* uiux.modal.open('confirm', { title: '¿Eliminar?' })
* uiux.menu.toggle('sidebar')
* uix.break.isMobile
*/
export interface Uiux {
theme : ThemeInstance;
break : BreakInstance;
toast : ToastInstance;
modal : ModalInstance;
menu : MenuInstance;
}
// ============================================================================
// REACTIVE UIX
// ============================================================================
/**
* Versión reactiva de `Uiux` para el singleton de Svelte (`actx.svelte.ts`).
*
* Idéntica a `Uiux` en métodos y estructura, pero las propiedades de solo
* lectura (`resolved`, `mode`, `queue`, `stack`, `active`, `current`,
* `isMobile`, `isTablet`, `isDesktop`) están respaldadas por `$state` —
* Svelte las observa y re-renderiza automáticamente cuando cambian.
*
* No añade ni elimina ninguna funcionalidad — solo garantiza reactividad
* en las propiedades que los componentes Svelte necesitan leer.
*/
export interface ReactiveUiux {
theme: Omit<ThemeInstance, 'resolved' | 'mode'> & {
readonly resolved: 'light' | 'dark'; // $state
readonly mode : ThemeMode; // $state
};
toast: Omit<ToastInstance, 'queue'> & {
readonly queue: Toast[]; // $state
};
modal: Omit<ModalInstance, 'stack' | 'active'> & {
readonly stack : ModalEntry[]; // $state
readonly active: ModalEntry | null; // $state derivado
};
menu : MenuInstance; // sin estado reactivo
break: Omit<BreakInstance, 'current' | 'isMobile' | 'isTablet' | 'isDesktop'> & {
readonly current : string; // $state
readonly isMobile : boolean; // derivado de $state
readonly isTablet : boolean; // derivado de $state
readonly isDesktop: boolean; // derivado de $state
};
}

@ -42,10 +42,6 @@
<span class="locale-badge" onclick={cycleLocale} onkeydown={onLocaleBadgeKeydown} role="button" tabindex="0">
{actx.locale.toUpperCase()}
</span>
<span class="break-badge">{actx.uiux.break.current}</span>
<button class="theme-btn" onclick={() => actx.uiux.theme.toggle()}>
{actx.uiux.theme.resolved === 'dark' ? '☀' : '◑'}
</button>
</div>
</div>
</header>
@ -302,7 +298,7 @@
.logo em { color: #f59e0b; font-style: normal; }
.header-right { display: flex; align-items: center; gap: .75rem; }
.locale-badge, .break-badge {
.locale-badge {
padding: .2rem .6rem;
border: 1px solid #f59e0b;
border-radius: 2px;

Loading…
Cancel
Save

Powered by TurnKey Linux.