You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

738 lines
20 KiB

<script lang="ts">
import { onDestroy } from 'svelte';
import { createActiveApp } from '$active-app';
import { cookieAdapter, localAdapter } from '$storage';
import type { SupportedLocale } from '$lang';
import { siumLangs, type Issue } from '$sium';
import { LogLevel, type LogEntry, type Transport } from '$logger';
import { object, pipe, string, email, min, meta } from '$sium/core';
import { translations } from './schema';
// ── Logger UI capture ──────────────────────────────────────────────────────
const captured: LogEntry[] = $state([]);
const captureTransport: Transport = {
name: 'ui-capture',
write(entry) {
captured.push(entry);
}
};
// ── App composition ────────────────────────────────────────────────────────
// Mount lang's catalogue plus sium's catalogue under the `sium.*` namespace
// so EngineSium's `#?sium.errors.*` references resolve through Lang.t().
const langSchema = { ...translations, sium: siumLangs };
let appRoot = $state<HTMLElement | null>(null);
const App = createActiveApp({
lang: { schema: langSchema, defaultLocale: 'es', fallbackChain: ['en'] },
logger: {
level: LogLevel.TRACE,
globalContext: { surface: '/test/aapp' },
transports: [captureTransport]
},
storage: { adapter: localAdapter, namespace: 'test-aapp' },
cache: {
defaultMemoryAdapter: { suppressProductionWarning: true }
},
frontend: {
theme: 'base',
mode: 'auto',
density: 'normal',
target: () => appRoot,
persist: {
// Theme via cookie (server-readable for SSR demos), the rest in
// localStorage with the App's namespace.
overrides: {
theme: {
adapter: cookieAdapter({ path: '/', sameSite: 'lax', maxAge: 31_536_000 }),
namespace: false,
raw: true
}
}
}
}
});
// Sium is page-scoped — `App.createSiumEngine()` wires App.lang +
// App.Logger so issues translate live and validation diagnostics flow
// through the same bus.
const sium = App.createSiumEngine();
onDestroy(() => App.dispose());
// ── Locale switcher ────────────────────────────────────────────────────────
type LocaleOption = { code: SupportedLocale; label: string; flag: string };
const LOCALES: LocaleOption[] = [
{ code: 'es', label: 'Español (base)', flag: '🌐' },
{ code: 'es-MX', label: 'Español (MX)', flag: '🇲🇽' },
{ code: 'es-AR', label: 'Español (AR — base fallback)', flag: '🇦🇷' },
{ code: 'en', label: 'English (base)', flag: '🌐' },
{ code: 'en-GB', label: 'English (GB)', flag: '🇬🇧' },
{ code: 'en-US', label: 'English (US — base fallback)', flag: '🇺🇸' },
{ code: 'ar', label: 'العربية', flag: '🇸🇦' },
{ code: 'pt-BR', label: 'Português (BR)', flag: '🇧🇷' },
{ code: 'fr-CA', label: 'Français (CA — chain fallback)', flag: '🇨🇦' }
];
function setLocale(locale: SupportedLocale): void {
App.setLocale(locale);
App.Logger.info('locale', `Locale switched to ${locale}`, {
context: { locale }
});
}
// ── Greeting + name input ──────────────────────────────────────────────────
let userName = $state('Ada');
let cartCount = $state(1);
// ── Region-only sibling demo ───────────────────────────────────────────────
// Reading 'app.regionOnly' with bare 'es' triggers the ambiguous warning
// because there are two siblings (es-MX, es-AR) and no canonical 'es'.
function probeRegionOnly(): void {
const text = App.lang.t('app.regionOnly', undefined, 'es');
App.Logger.info('demo', `regionOnly with bare 'es' → ${text}`);
}
// ── Sium login schema ──────────────────────────────────────────────────────
const loginSchema = object({
email: pipe(string(), email(), meta({ label: 'Email' })),
password: pipe(string(), min(8), meta({ label: 'Password' }))
});
let loginInput = $state({ email: 'not-an-email', password: '123' });
let loginIssues = $state<readonly Issue[]>([]);
let loginOk = $state(false);
async function runLogin(): Promise<void> {
const result = await sium.validate(loginSchema, loginInput);
if (result.ok) {
loginOk = true;
loginIssues = [];
} else {
loginOk = false;
loginIssues = result.issues;
}
}
// Re-translates issues whenever the locale changes — same `Issue[]`, new
// strings — proving Sium's resolveIssue routes through App.lang.
const translatedIssues = $derived(
loginIssues.map((entry) => ({
path: entry.path.length === 0 ? '<root>' : entry.path.join('.'),
code: entry.code,
message: sium.resolveIssue(entry, App.getLocale())
}))
);
// ── Format demo ───────────────────────────────────────────────────────────
const sampleNumber = 1234567.89;
const sampleAmount = 1500.5;
const sampleDate = new Date(2026, 3, 25, 14, 30);
const formatsView = $derived.by(() => {
// Touch the locale so $derived picks it up reactively.
void App.lang.getLocale();
return {
locale: App.format.getLocale(),
number: App.format.numbers.format(sampleNumber),
currency: App.format.currency.format(sampleAmount),
date: App.format.dates.formatDateTime(sampleDate)
};
});
// ── Frontend view ──────────────────────────────────────────────────────────
const frontendView = $derived.by(() => {
void App.lang.getLocale();
// Re-read on preference change (theme/density). onPreferenceChange would
// be cleaner but for a demo this is enough.
return {
locale: App.frontend.getLocale(),
dir: App.frontend.getDir(),
theme: App.frontend.getTheme(),
mode: App.frontend.getMode(),
density: App.frontend.getDensity()
};
});
const THEMES = ['base', 'forest', 'sunset', 'ink'] as const;
function setTheme(t: string): void {
App.frontend.setTheme(t);
}
// ── Storage demo ──────────────────────────────────────────────────────────
// Demo entry on App.storage (localStorage with namespace 'test-aapp').
const visitCounter = App.storage.entry('visit-counter', 0);
if (typeof window !== 'undefined') visitCounter.update((n) => n + 1);
// ── Logger view ────────────────────────────────────────────────────────────
function clearHistory(): void {
captured.length = 0;
App.Logger.clear();
}
function levelName(l: LogLevel): string {
return LogLevel[l] ?? String(l);
}
const LEVEL_COLORS: Record<number, string> = {
[LogLevel.TRACE]: '#8a8f96',
[LogLevel.DEBUG]: '#6a737d',
[LogLevel.INFO]: '#0366d6',
[LogLevel.WARN]: '#b08800',
[LogLevel.ERROR]: '#d73a49',
[LogLevel.FATAL]: '#6f42c1'
};
</script>
<svelte:head>
<title>/test/aapp</title>
</svelte:head>
<main bind:this={appRoot} class:rtl={frontendView.dir === 'rtl'}>
<header>
<p class="eyebrow">src/arts/aapp</p>
<h1>{App.lang.t('app.title')}</h1>
<p class="subtitle">{App.lang.t('app.subtitle')}</p>
</header>
<section class="locale" dir="ltr">
<h2>1. Locale (single source of truth)</h2>
<p class="hint">
<code>App.setLocale(...)</code> propagates to <strong>Lang</strong>,
<strong>Format</strong> and <strong>Frontend</strong> through a shared
<code>LocaleSource</code>. BCP 47 resolution runs per record on every
<code>Lang.t()</code> call.
</p>
<div class="grid">
{#each LOCALES as { code, label, flag } (code)}
<button
type="button"
class:active={App.getLocale() === code}
onclick={() => setLocale(code)}
>
<span aria-hidden="true">{flag}</span>
<strong>{code}</strong>
<small>{label}</small>
</button>
{/each}
</div>
<p class="active-line">
<strong>Active:</strong> <code>{App.getLocale()}</code>
</p>
</section>
<section class="lang">
<h2>2. Lang — BCP 47 resolution in action</h2>
<div class="row">
<label>
<span>Name</span>
<input type="text" bind:value={userName} />
</label>
<p class="result">
<code>greeting('{userName}')</code> →
<strong>{App.lang.t('app.greeting', { name: userName })}</strong>
</p>
</div>
<div class="row">
<label>
<span>Cart count</span>
<input type="number" min="0" max="20" bind:value={cartCount} />
</label>
<p class="result">
<code>cart({'{count: ' + cartCount + '}'})</code> →
<strong>{App.lang.t('app.cart', { count: cartCount })}</strong>
</p>
</div>
<div class="row">
<button type="button" onclick={probeRegionOnly}>
Probe <code>app.regionOnly</code> with bare <code>'es'</code>
</button>
<p class="hint">
Has only <code>es-MX</code> and <code>es-AR</code> — bare <code>'es'</code> picks the first
sibling and emits <code>AMBIGUOUS_SIBLING_MATCH</code> in DEV. Watch the logger table below.
</p>
</div>
</section>
<section class="formats">
<h2>3. Format — locale flows through</h2>
<p class="hint">
Same locale string reaches <code>Intl.NumberFormat</code> and
<code>Intl.DateTimeFormat</code> via the shared <code>LocaleSource</code>.
</p>
<table class="kv">
<tbody>
<tr>
<td><code>Format.getLocale()</code></td>
<td><code>{formatsView.locale}</code></td>
</tr>
<tr>
<td><code>numbers.format({sampleNumber})</code></td>
<td><strong>{formatsView.number}</strong></td>
</tr>
<tr>
<td><code>currency.format({sampleAmount})</code></td>
<td><strong>{formatsView.currency}</strong></td>
</tr>
<tr>
<td><code>dates.formatDateTime(...)</code></td>
<td><strong>{formatsView.date}</strong></td>
</tr>
</tbody>
</table>
</section>
<section class="frontend">
<h2>4. Frontend — direction + theme (with persistence)</h2>
<p class="hint">
<code>dir</code> is auto-derived from the locale (rtl for <code>ar</code>). Theme persists in
a <strong>cookie</strong> via <code>frontend.persist.overrides.theme</code>; mode/density/dir
persist in localStorage with the App's namespace. Recargá la página para verificar el
round-trip.
</p>
<div class="row">
<strong>Theme:</strong>
{#each THEMES as t (t)}
<button type="button" class:active={frontendView.theme === t} onclick={() => setTheme(t)}>
{t}
</button>
{/each}
</div>
<table class="kv">
<tbody>
<tr><td><code>locale</code></td><td><code>{frontendView.locale}</code></td></tr>
<tr><td><code>dir</code></td><td><strong>{frontendView.dir}</strong></td></tr>
<tr><td><code>theme</code></td><td><code>{frontendView.theme}</code></td></tr>
<tr><td><code>mode</code></td><td><code>{frontendView.mode}</code></td></tr>
<tr><td><code>density</code></td><td><code>{frontendView.density}</code></td></tr>
</tbody>
</table>
</section>
<section class="storage">
<h2>5. Storage — App.storage + page-scoped entries</h2>
<p class="hint">
<code>App.storage</code> usa <code>localAdapter</code> con namespace <code>test-aapp</code>.
El contador se incrementa en cada montaje de la página y persiste entre recargas (mientras no
lo borres).
</p>
<table class="kv">
<tbody>
<tr>
<td><code>visit-counter</code></td>
<td>
<strong>{visitCounter.current}</strong>
<button type="button" onclick={() => visitCounter.reset()}>reset</button>
</td>
</tr>
<tr>
<td><code>fullKey</code></td>
<td><code>{visitCounter.fullKey}</code></td>
</tr>
</tbody>
</table>
<p class="hint">
Para más adapters, raw mode, TTL, version+migrate y errores, mirá la página dedicada
<a href="/test/stor">/test/stor</a>.
</p>
</section>
<section class="sium">
<h2>6. Sium — page-scoped, fed with App's Lang + Logger</h2>
<p class="hint">
<strong>Sium is not part of <code>aapp</code></strong> — this page constructs
<code>{'createEngineSium({ lang: App.lang, logger: App.Logger })'}</code> locally because
validation is page-scoped. Issues stay as <code>{'{ code, params }'}</code> data; switching locale
re-translates them without re-validating.
</p>
<form onsubmit={(e) => e.preventDefault()}>
<label>
<span>Email</span>
<input type="text" bind:value={loginInput.email} />
</label>
<label>
<span>Password</span>
<input type="text" bind:value={loginInput.password} />
</label>
<button type="button" class="primary" onclick={runLogin}>Validate</button>
</form>
{#if loginOk}
<p class="success">✓ Valid input.</p>
{:else if translatedIssues.length > 0}
<ul class="issues">
{#each translatedIssues as issue (issue.path + issue.code)}
<li>
<code class="path">{issue.path}</code>
<code class="code">{issue.code}</code>
<span>{issue.message}</span>
</li>
{/each}
</ul>
{/if}
</section>
<section class="logger">
<div class="logger-head">
<h2>7. Logger — shared sink</h2>
<button type="button" onclick={clearHistory}>Clear</button>
</div>
<p class="hint">
Lang warnings (missing translations, ambiguous siblings) and Sium debug entries (validation
failures) all reach <code>App.Logger</code>. Below are entries captured by a UI transport.
</p>
{#if captured.length === 0}
<p class="empty">No entries yet.</p>
{:else}
<table class="logs">
<thead>
<tr>
<th>#</th>
<th>Level</th>
<th>Cat</th>
<th>Message</th>
<th>Context</th>
</tr>
</thead>
<tbody>
{#each captured as entry, i (entry.id)}
<tr>
<td>{i + 1}</td>
<td>
<span class="level" style:background={LEVEL_COLORS[entry.level]}>
{levelName(entry.level)}
</span>
</td>
<td><code>{entry.category}</code></td>
<td>{entry.message}</td>
<td>
{#if entry.context}
<code class="ctx">{JSON.stringify(entry.context)}</code>
{:else}
<span class="muted">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</main>
<style>
main {
max-width: 980px;
margin: 2rem auto;
padding: 0 1.5rem 4rem;
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.55;
color: #222;
}
main.rtl {
font-family: 'Segoe UI', 'Tahoma', system-ui, sans-serif;
}
header {
border-bottom: 1px solid #e1e4e8;
padding-bottom: 1rem;
margin-bottom: 2rem;
}
.eyebrow {
margin: 0 0 0.4rem;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #586069;
}
h1 {
margin: 0 0 0.4rem;
font-size: clamp(1.8rem, 4vw, 2.4rem);
}
.subtitle {
color: #586069;
margin: 0;
}
h2 {
margin: 0 0 0.6rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #3f6f52;
border-bottom: 1px solid #eee;
padding-bottom: 0.3rem;
}
section {
margin-top: 2rem;
padding: 1.2rem;
background: rgba(255, 255, 255, 0.85);
border: 1px solid #e1e4e8;
border-radius: 12px;
}
:global(main[data-theme='forest']) section {
background: rgba(245, 252, 246, 0.9);
border-color: #c8e0cb;
}
:global(main[data-theme='sunset']) section {
background: rgba(255, 248, 235, 0.9);
border-color: #f0d9b3;
}
:global(main[data-theme='ink']) section {
background: rgba(35, 38, 50, 0.9);
border-color: #4a5066;
color: #f3f7ff;
}
:global(main[data-theme='ink']) h2 {
color: #9fc6ff;
}
.hint {
font-size: 0.85rem;
color: #586069;
margin: 0 0 0.8rem;
}
:global(main[data-theme='ink']) .hint {
color: #aab2c8;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem;
}
.grid button {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4rem 0.5rem;
padding: 0.6rem 0.8rem;
border: 1px solid #d1d5da;
border-radius: 8px;
background: #fff;
cursor: pointer;
text-align: left;
font: inherit;
}
.grid button strong {
grid-column: 2;
font-family: 'SF Mono', Menlo, monospace;
font-size: 0.85rem;
}
.grid button small {
grid-column: 2;
color: #586069;
font-size: 0.7rem;
}
.grid button.active {
background: #1f3b2a;
color: #f4ffe7;
border-color: #1f3b2a;
}
.grid button.active small {
color: rgba(244, 255, 231, 0.7);
}
.active-line {
margin: 0.8rem 0 0;
font-size: 0.9rem;
}
.row {
display: flex;
gap: 0.8rem;
align-items: center;
flex-wrap: wrap;
margin: 0.5rem 0;
}
.row + .row {
border-top: 1px dashed #eee;
padding-top: 0.6rem;
}
label {
display: flex;
align-items: center;
gap: 0.4rem;
}
label span {
font-size: 0.85rem;
font-weight: 500;
min-width: 90px;
}
input[type='text'],
input[type='number'] {
padding: 0.35rem 0.6rem;
border: 1px solid #d1d5da;
border-radius: 4px;
font-family: inherit;
font-size: 0.9rem;
}
.result {
margin: 0;
font-size: 0.95rem;
}
button.primary {
background: #2ea44f;
color: #fff;
border: 1px solid #2a8644;
padding: 0.5rem 1rem;
border-radius: 5px;
cursor: pointer;
font: inherit;
}
.row > button {
padding: 0.4rem 0.8rem;
border: 1px solid #d1d5da;
border-radius: 5px;
background: #fafbfc;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.row > button.active {
background: #0366d6;
color: #fff;
border-color: #0366d6;
}
.kv {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.kv td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #eee;
}
.kv td:first-child {
color: #586069;
width: 40%;
}
.success {
background: #f0fff4;
color: #22863a;
padding: 0.5rem 0.8rem;
border-radius: 6px;
border-left: 3px solid #34d058;
margin: 0.8rem 0 0;
}
.issues {
list-style: none;
padding: 0;
margin: 0.8rem 0 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.issues li {
background: #ffeef0;
color: #86181d;
padding: 0.4rem 0.7rem;
border-radius: 4px;
border-left: 3px solid #d73a49;
display: flex;
gap: 0.5rem;
align-items: baseline;
flex-wrap: wrap;
font-size: 0.85rem;
}
.issues .path {
background: #fff;
color: #24292e;
padding: 0.05em 0.4em;
border-radius: 3px;
font-size: 0.75rem;
}
.issues .code {
background: #fff;
color: #d73a49;
padding: 0.05em 0.4em;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
}
.logger-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.logger-head button {
padding: 0.3rem 0.6rem;
border: 1px solid #d1d5da;
background: #fafbfc;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.empty {
color: #586069;
font-style: italic;
font-size: 0.85rem;
}
.logs {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.logs th,
.logs td {
padding: 0.4rem 0.5rem;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: top;
}
.logs th {
background: #f6f8fa;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #586069;
}
:global(main[data-theme='ink']) .logs th {
background: #2c3144;
color: #aab2c8;
}
.level {
display: inline-block;
padding: 0.05em 0.5em;
border-radius: 3px;
color: #fff;
font-size: 0.7rem;
font-weight: 600;
}
.muted {
color: #bbb;
}
.ctx {
font-size: 0.7rem;
max-width: 320px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
code {
background: rgba(31, 59, 42, 0.08);
padding: 0.05em 0.35em;
border-radius: 4px;
font-family: 'SF Mono', Menlo, monospace;
font-size: 0.85em;
}
:global(main[data-theme='ink']) code {
background: rgba(255, 255, 255, 0.12);
color: #c9d6ff;
}
form {
background: #f6f8fa;
padding: 0.8rem 1rem;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
:global(main[data-theme='ink']) form {
background: rgba(255, 255, 255, 0.06);
}
</style>

Powered by TurnKey Linux.