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.
480 lines
17 KiB
480 lines
17 KiB
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import { createActiveUix, setActiveUix } from '$active-uix';
|
|
import { createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';
|
|
import { page } from '$app/state';
|
|
import { Soma } from '$soma/core/soma.svelte';
|
|
import { ActiveEidos } from '$uix/eidos';
|
|
import { dialogSema } from '$uix/sema/components/dialog';
|
|
import { drawerSema } from '$uix/sema/components/drawer';
|
|
import { popoverSema } from '$uix/sema/components/popover';
|
|
import { radioGroupSema } from '$uix/sema/components/radio-group';
|
|
import { tabsSema } from '$uix/sema/components/tabs';
|
|
import { toastSema } from '$uix/sema/components/toast';
|
|
import { accordionSema } from '$uix/sema/components/accordion';
|
|
import { checkboxSema } from '$uix/sema/components/checkbox';
|
|
import { Popover } from '$uix/eidos/components/popover';
|
|
import '@/uix/eidos/index.css';
|
|
import './uix.css';
|
|
|
|
let { children } = $props();
|
|
|
|
type Language = 'es' | 'en' | 'fr' | 'de';
|
|
type Currency = 'EUR' | 'USD' | 'GBP' | 'JPY';
|
|
type UnitSystem = 'metric' | 'imperial';
|
|
type NumberFormat = 'es-ES' | 'en-US' | 'en-GB' | 'de-DE';
|
|
|
|
const docsPrefsSchema = standardPrefsDimensions({
|
|
languages: ['es', 'en', 'fr', 'de'] satisfies readonly Language[],
|
|
locales: ['es-ES', 'en-US', 'en-GB', 'de-DE'] satisfies readonly NumberFormat[],
|
|
currencies: ['EUR', 'USD', 'GBP', 'JPY'] satisfies readonly Currency[],
|
|
defaults: {
|
|
language: 'es',
|
|
locale: 'es-ES',
|
|
currency: 'EUR'
|
|
}
|
|
});
|
|
|
|
const uix = createActiveUix({
|
|
langs: { schema: {}, defaultLocale: 'es' as const },
|
|
prefs: { schema: docsPrefsSchema },
|
|
events: {
|
|
sound: true,
|
|
haptic: true,
|
|
components: [accordionSema, checkboxSema, dialogSema, drawerSema, popoverSema, radioGroupSema, tabsSema, toastSema]
|
|
}
|
|
});
|
|
|
|
setActiveUix(uix);
|
|
Soma.create();
|
|
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
|
|
|
|
type Section =
|
|
| { kind: 'top'; slug: string; label: string }
|
|
| { kind: 'group'; heading: string; items: { slug: string; label: string; tag?: string }[] };
|
|
|
|
// Information architecture — single source of truth for sidebar +
|
|
// breadcrumb. Each leaf maps to a route under `/uix/...`.
|
|
const nav: Section[] = [
|
|
{
|
|
kind: 'group',
|
|
heading: 'Get started',
|
|
items: [
|
|
{ slug: '/uix', label: 'Overview' },
|
|
{ slug: '/uix/architecture', label: 'Architecture' },
|
|
{ slug: '/uix/getting-started', label: 'Installation' }
|
|
]
|
|
},
|
|
{
|
|
kind: 'group',
|
|
heading: 'Foundations',
|
|
items: [
|
|
{ slug: '/uix/tokens', label: 'Design tokens' },
|
|
{ slug: '/uix/morfo', label: 'Morfo · contracts' },
|
|
{ slug: '/uix/sema', label: 'Sema · perception' },
|
|
{ slug: '/uix/eidos', label: 'Eidos · visual layer' }
|
|
]
|
|
},
|
|
{
|
|
kind: 'group',
|
|
heading: 'Controls',
|
|
items: [
|
|
{ slug: '/uix/components/toggle', label: 'Toggle' },
|
|
{ slug: '/uix/components/switch', label: 'Switch' },
|
|
{ slug: '/uix/components/checkbox', label: 'Checkbox' },
|
|
{ slug: '/uix/components/radio-group', label: 'Radio group' },
|
|
{ slug: '/uix/components/collapsible', label: 'Collapsible' },
|
|
{ slug: '/uix/components/accordion', label: 'Accordion' },
|
|
{ slug: '/uix/components/tabs', label: 'Tabs' }
|
|
]
|
|
},
|
|
{
|
|
kind: 'group',
|
|
heading: 'Media',
|
|
items: [
|
|
{ slug: '/uix/components/avatar', label: 'Avatar' },
|
|
{ slug: '/uix/components/icon', label: 'Icon' }
|
|
]
|
|
},
|
|
{
|
|
kind: 'group',
|
|
heading: 'Overlays',
|
|
items: [
|
|
{ slug: '/uix/components/dialog', label: 'Dialog' },
|
|
{ slug: '/uix/components/drawer', label: 'Drawer' },
|
|
{ slug: '/uix/components/popover', label: 'Popover' },
|
|
{ slug: '/uix/components/toast', label: 'Toast' },
|
|
{ slug: '/uix/components/tooltip', label: 'Tooltip' }
|
|
]
|
|
}
|
|
];
|
|
|
|
const componentCount = $derived(
|
|
nav
|
|
.filter((s): s is Extract<Section, { kind: 'group' }> => s.kind === 'group')
|
|
.filter((g) => ['Controls', 'Media', 'Overlays'].includes(g.heading))
|
|
.reduce((acc, g) => acc + g.items.length, 0)
|
|
);
|
|
|
|
const current = $derived(page.url.pathname.replace(/\/$/, '') || '/uix');
|
|
|
|
// ── Theme ─────────────────────────────────────────────────────────────
|
|
let theme = $state<'light' | 'dark'>('light');
|
|
const eidosModeListeners = new Set<(value: 'light' | 'dark') => void>();
|
|
const activeEidos = ActiveEidos.create({
|
|
applyDom: true,
|
|
theme: 'base',
|
|
modeSource: {
|
|
get: () => theme,
|
|
onChange(handler) {
|
|
eidosModeListeners.add(handler);
|
|
return () => eidosModeListeners.delete(handler);
|
|
}
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
prefsProjection.dispose();
|
|
activeEidos.dispose();
|
|
uix.dispose();
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof document === 'undefined') return;
|
|
const stored = localStorage.getItem('uix-docs-theme');
|
|
if (stored === 'dark' || stored === 'light') {
|
|
theme = stored;
|
|
return;
|
|
}
|
|
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
theme = prefersDark ? 'dark' : 'light';
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof localStorage === 'undefined') return;
|
|
localStorage.setItem('uix-docs-theme', theme);
|
|
});
|
|
|
|
$effect(() => {
|
|
const currentTheme = theme;
|
|
for (const listener of eidosModeListeners) listener(currentTheme);
|
|
});
|
|
|
|
let search = $state('');
|
|
|
|
// ── Topbar prefs ──────────────────────────────────────────────────────
|
|
// All persisted to localStorage. The sema toggles will wire to the
|
|
// engine's channels once those expose runtime setEnabled APIs; today
|
|
// they store preference + reflect it in the UI.
|
|
|
|
let dir = $state<'ltr' | 'rtl'>('ltr');
|
|
let language = $state<Language>('es');
|
|
let currency = $state<Currency>('EUR');
|
|
let units = $state<UnitSystem>('metric');
|
|
let numberFormat = $state<NumberFormat>('es-ES');
|
|
let soundOn = $state(true);
|
|
let motionOn = $state(true);
|
|
let hapticOn = $state(true);
|
|
|
|
const LANGUAGE_LABELS: Record<Language, string> = { es: 'Español', en: 'English', fr: 'Français', de: 'Deutsch' };
|
|
const CURRENCY_LABELS: Record<Currency, string> = { EUR: 'Euro', USD: 'US Dollar', GBP: 'British Pound', JPY: 'Japanese Yen' };
|
|
const UNIT_LABELS: Record<UnitSystem, string> = { metric: 'Metric', imperial: 'Imperial' };
|
|
const NUMBER_LABELS: Record<NumberFormat, string> = {
|
|
'es-ES': 'Spain · 1.234,56',
|
|
'en-US': 'US · 1,234.56',
|
|
'en-GB': 'UK · 1,234.56',
|
|
'de-DE': 'Germany · 1.234,56'
|
|
};
|
|
|
|
// Persist all topbar prefs in one bag.
|
|
$effect(() => {
|
|
if (typeof localStorage === 'undefined') return;
|
|
const stored = localStorage.getItem('uix-docs-prefs');
|
|
if (!stored) return;
|
|
try {
|
|
const p = JSON.parse(stored);
|
|
if (p.dir === 'ltr' || p.dir === 'rtl') dir = p.dir;
|
|
if (p.language) language = p.language;
|
|
if (p.currency) currency = p.currency;
|
|
if (p.units) units = p.units;
|
|
if (p.numberFormat) numberFormat = p.numberFormat;
|
|
if (typeof p.soundOn === 'boolean') soundOn = p.soundOn;
|
|
if (typeof p.motionOn === 'boolean') motionOn = p.motionOn;
|
|
if (typeof p.hapticOn === 'boolean') hapticOn = p.hapticOn;
|
|
} catch {
|
|
/* ignore corrupt prefs blob */
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (typeof localStorage === 'undefined') return;
|
|
const blob = { dir, language, currency, units, numberFormat, soundOn, motionOn, hapticOn };
|
|
localStorage.setItem('uix-docs-prefs', JSON.stringify(blob));
|
|
});
|
|
|
|
$effect(() => {
|
|
uix.prefs.setIntent('language', language);
|
|
uix.prefs.setIntent('direction', dir);
|
|
uix.prefs.setIntent('locale', numberFormat);
|
|
uix.prefs.setIntent('currency', currency);
|
|
uix.prefs.setIntent('unitSystem', units);
|
|
uix.prefs.setIntent('sound', soundOn ? 'allow' : 'reduce');
|
|
uix.prefs.setIntent('motion', motionOn ? 'allow' : 'reduce');
|
|
uix.prefs.setIntent('haptic', hapticOn ? 'allow' : 'reduce');
|
|
});
|
|
|
|
const filteredNav = $derived.by(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return nav;
|
|
return nav
|
|
.map((s) => {
|
|
if (s.kind !== 'group') return s;
|
|
const items = s.items.filter((i) => i.label.toLowerCase().includes(q));
|
|
return items.length ? { ...s, items } : null;
|
|
})
|
|
.filter((s): s is Section => s !== null);
|
|
});
|
|
</script>
|
|
|
|
<div data-uix-docs data-theme={theme} data-mode={theme}>
|
|
<div data-uix-shell>
|
|
<header data-uix-topbar>
|
|
<a href="/uix" data-uix-brand>
|
|
<span data-uix-brand-mark>U</span>
|
|
<span>UIX</span>
|
|
<span data-uix-brand-version>v0.1</span>
|
|
</a>
|
|
|
|
<nav data-uix-topbar-nav aria-label="Primary">
|
|
<a href="/uix" data-active={current === '/uix' || current.startsWith('/uix/architecture') || current.startsWith('/uix/getting-started') ? 'true' : 'false'}>Docs</a>
|
|
<a href="/uix/components" data-active={current.startsWith('/uix/components') ? 'true' : 'false'}>Components</a>
|
|
<a href="https://github.com/" target="_blank" rel="noreferrer">GitHub ↗</a>
|
|
</nav>
|
|
|
|
<div data-uix-topbar-tools>
|
|
<!-- DIR: simple toggle. RTL flips the entire document direction
|
|
so all eidos components (which use logical properties)
|
|
reverse correctly. -->
|
|
<button
|
|
type="button"
|
|
data-uix-icon-btn
|
|
title={dir === 'ltr' ? 'Switch to RTL' : 'Switch to LTR'}
|
|
aria-label="Toggle direction"
|
|
aria-pressed={dir === 'rtl'}
|
|
onclick={() => (dir = dir === 'ltr' ? 'rtl' : 'ltr')}
|
|
>
|
|
{#if dir === 'ltr'}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M5 12h14M13 6l6 6-6 6" />
|
|
</svg>
|
|
{:else}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M19 12H5M11 6l-6 6 6 6" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- LANG: short uppercase code + popover with full language list -->
|
|
<Popover>
|
|
<Popover.Trigger>
|
|
<span data-uix-label-btn>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" />
|
|
</svg>
|
|
{language.toUpperCase()}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-caret aria-hidden="true">
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</span>
|
|
</Popover.Trigger>
|
|
<Popover.Content side="bottom" align="end" sideOffset={6}>
|
|
<div data-uix-menu>
|
|
<div data-uix-menu-section>
|
|
<div data-uix-menu-label>Language</div>
|
|
{#each Object.entries(LANGUAGE_LABELS) as [code, label]}
|
|
<button
|
|
type="button"
|
|
data-uix-menu-item
|
|
data-checked={language === code ? 'true' : 'false'}
|
|
onclick={() => (language = code as Language)}
|
|
>
|
|
<span>{label}</span>
|
|
<span data-uix-menu-item-meta>{code.toUpperCase()}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</Popover.Content>
|
|
</Popover>
|
|
|
|
<!-- FORMATS: currency + units + number formatting in one popover -->
|
|
<Popover>
|
|
<Popover.Trigger>
|
|
<span data-uix-label-btn>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
|
</svg>
|
|
Formats
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-caret aria-hidden="true">
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</span>
|
|
</Popover.Trigger>
|
|
<Popover.Content side="bottom" align="end" sideOffset={6}>
|
|
<div data-uix-menu style="min-inline-size: 240px;">
|
|
<div data-uix-menu-section>
|
|
<div data-uix-menu-label>Currency</div>
|
|
{#each Object.entries(CURRENCY_LABELS) as [code, label]}
|
|
<button
|
|
type="button"
|
|
data-uix-menu-item
|
|
data-checked={currency === code ? 'true' : 'false'}
|
|
onclick={() => (currency = code as Currency)}
|
|
>
|
|
<span>{label}</span>
|
|
<span data-uix-menu-item-meta>{code}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div data-uix-menu-section>
|
|
<div data-uix-menu-label>Units</div>
|
|
{#each Object.entries(UNIT_LABELS) as [code, label]}
|
|
<button
|
|
type="button"
|
|
data-uix-menu-item
|
|
data-checked={units === code ? 'true' : 'false'}
|
|
onclick={() => (units = code as UnitSystem)}
|
|
>
|
|
<span>{label}</span>
|
|
<span data-uix-menu-item-meta>{code === 'metric' ? 'KM · KG · °C' : 'MI · LB · °F'}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div data-uix-menu-section>
|
|
<div data-uix-menu-label>Numbers</div>
|
|
{#each Object.entries(NUMBER_LABELS) as [code, label]}
|
|
<button
|
|
type="button"
|
|
data-uix-menu-item
|
|
data-checked={numberFormat === code ? 'true' : 'false'}
|
|
onclick={() => (numberFormat = code as NumberFormat)}
|
|
>
|
|
<span>{label}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</Popover.Content>
|
|
</Popover>
|
|
|
|
<!-- SEMANTICS: sound / motion / haptic toggles. These will hook
|
|
into the sema engine channels' enabled state once the
|
|
channels expose runtime setEnabled APIs; today they
|
|
persist preference + reflect in the UI. -->
|
|
<Popover>
|
|
<Popover.Trigger>
|
|
<span data-uix-label-btn>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M12 2a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4zM19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
|
|
</svg>
|
|
Semantics
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-caret aria-hidden="true">
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</span>
|
|
</Popover.Trigger>
|
|
<Popover.Content side="bottom" align="end" sideOffset={6}>
|
|
<div data-uix-menu style="min-inline-size: 240px;">
|
|
<div data-uix-menu-section>
|
|
<div data-uix-menu-label>Perceptual channels</div>
|
|
<label data-uix-menu-toggle>
|
|
<input type="checkbox" bind:checked={soundOn} />
|
|
Sound
|
|
<span data-uix-menu-toggle-meta>{soundOn ? 'on' : 'off'}</span>
|
|
</label>
|
|
<label data-uix-menu-toggle>
|
|
<input type="checkbox" bind:checked={motionOn} />
|
|
Motion
|
|
<span data-uix-menu-toggle-meta>{motionOn ? 'on' : 'off'}</span>
|
|
</label>
|
|
<label data-uix-menu-toggle>
|
|
<input type="checkbox" bind:checked={hapticOn} />
|
|
Haptic
|
|
<span data-uix-menu-toggle-meta>{hapticOn ? 'on' : 'off'}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</Popover.Content>
|
|
</Popover>
|
|
|
|
<!-- THEME -->
|
|
<button
|
|
type="button"
|
|
data-uix-icon-btn
|
|
title={theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
|
|
aria-label="Toggle theme"
|
|
onclick={() => (theme = theme === 'light' ? 'dark' : 'light')}
|
|
>
|
|
{#if theme === 'light'}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
</svg>
|
|
{:else}
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<aside data-uix-rail>
|
|
<div data-uix-rail-search>
|
|
<input type="search" placeholder="Search…" bind:value={search} />
|
|
</div>
|
|
|
|
{#each filteredNav as section}
|
|
{#if section.kind === 'group'}
|
|
<div data-uix-rail-group>
|
|
<div data-uix-rail-group-head>
|
|
<span>{section.heading}</span>
|
|
<span data-uix-rail-group-count>{section.items.length}</span>
|
|
</div>
|
|
<ul>
|
|
{#each section.items as item}
|
|
<li>
|
|
<a
|
|
href={item.slug}
|
|
data-uix-rail-link
|
|
data-active={current === item.slug ? 'true' : 'false'}
|
|
>
|
|
{item.label}
|
|
{#if item.tag}
|
|
<span data-uix-rail-link-tag>{item.tag}</span>
|
|
{/if}
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
|
|
{#if filteredNav.length === 0}
|
|
<div data-uix-rail-group-head>No matches for "{search}"</div>
|
|
{/if}
|
|
</aside>
|
|
|
|
<main data-uix-canvas>
|
|
{@render children?.()}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* This layout owns its UIX context directly so components declared in
|
|
* the same layout hydrate with ActiveUix/Soma available. */
|
|
</style>
|