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.
svelte-kit-vice/web/routes/uix/+layout@.svelte

1210 lines
44 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts">
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import {
readActivePrefsSlot,
standardPrefsDimensions
} from '$prefs';
import { page } from '$app/state';
import { Soma } from '$soma/core/soma.svelte';
import { siumLangs } from '$sium/langs/langs';
import { secsLangs } from '$libs/secs';
import { ActiveEidos } from '$uix/eidos';
import type { Gradient } from '$libs/gradient';
import { dialogSema } from '$uix/sema/components/dialog';
import { drawerSema } from '$uix/sema/components/drawer';
import { editableSema } from '$uix/sema/components/editable';
import { fileUploadSema } from '$uix/sema/components/file-upload';
import { floatPanelSema } from '$uix/sema/components/float-panel';
import { formSema } from '$uix/sema/components/form';
import { gradientBuilderSema } from '$uix/sema/components/gradient-builder';
import { gradientPickerSema } from '$uix/sema/components/gradient-picker';
import { imageAdjustmentsSema } from '$uix/sema/components/image-adjustments';
import { imagePickerSema } from '$uix/sema/components/image-picker';
import { knobSema } from '$uix/sema/components/knob';
import { maskFieldSema } from '$uix/sema/components/mask-field';
import type { SemaPreferences } from '$uix/sema';
import { navTreeSema } from '$uix/sema/components/nav-tree';
import { sidebarSema } from '$uix/sema/components/sidebar';
import { numberFieldSema } from '$uix/sema/components/number-field';
import { paginationSema } from '$uix/sema/components/pagination';
import { passwordFieldSema } from '$uix/sema/components/password-field';
import { popoverSema } from '$uix/sema/components/popover';
import { radioGroupSema } from '$uix/sema/components/radio-group';
import { ratingGroupSema } from '$uix/sema/components/rating-group';
import { searchFieldSema } from '$uix/sema/components/search-field';
import { selectSema } from '$uix/sema/components/select';
import { sliderSema } from '$uix/sema/components/slider';
import { splitterSema } from '$uix/sema/components/splitter';
import { stepperSema } from '$uix/sema/components/stepper';
import { tabsSema } from '$uix/sema/components/tabs';
import { tagGroupSema } from '$uix/sema/components/tag-group';
import { tagsInputSema } from '$uix/sema/components/tags-input';
import { textareaSema } from '$uix/sema/components/textarea';
import { mediaPlayerSema } from '$uix/sema/components/media-player';
import { timelineSema } from '$uix/sema/components/timeline';
import { toastSema } from '$uix/sema/components/toast';
import { toolbarSema } from '$uix/sema/components/toolbar';
import { accordionSema } from '$uix/sema/components/accordion';
import { chatComposerSema } from '$uix/sema/components/chat-composer';
import { chatLogSema } from '$uix/sema/components/chat-log';
import { chatMessageSema } from '$uix/sema/components/chat-message';
import { checkboxSema } from '$uix/sema/components/checkbox';
import { calendarSema } from '$uix/sema/components/calendar';
import { colorPickerSema } from '$uix/sema/components/color-picker';
import { comboboxSema } from '$uix/sema/components/combobox';
import { cropperSema } from '$uix/sema/components/cropper';
import { cssFieldSema } from '$uix/sema/components/css-field';
import { dateFieldSema } from '$uix/sema/components/date-field';
// The rest of the pack index, loaded 2026-07-31. The media-player report
// (63f20f345) exposed the class: packs are OPT-IN per app, and every demo
// whose pack was missing here played raw family defaults instead of its
// authored signature.
import { chronosSema } from '$uix/sema/components/chronos';
import { clipboardSema } from '$uix/sema/components/clipboard';
import { collapsibleSema } from '$uix/sema/components/collapsible';
import { colorFieldSema } from '$uix/sema/components/color-field';
import { contextMenuSema } from '$uix/sema/components/context-menu';
import { dragDropSema } from '$uix/sema/components/drag-drop';
import { dropdownMenuSema } from '$uix/sema/components/dropdown-menu';
import { listboxSema } from '$uix/sema/components/listbox';
import { menubarSema } from '$uix/sema/components/menubar';
import { menuDialSema } from '$uix/sema/components/menu-dial';
import { monthGridSema } from '$uix/sema/components/month-grid';
import { navigationMenuSema } from '$uix/sema/components/navigation-menu';
import { onionMenuSema } from '$uix/sema/components/onion-menu';
import { pathTraceSema } from '$uix/sema/components/path-trace';
import { proofOfHumanSema } from '$uix/sema/components/proof-of-human';
import { rangeCalendarSema } from '$uix/sema/components/range-calendar';
import { rotateAlignSema } from '$uix/sema/components/rotate-align';
import { switchSema } from '$uix/sema/components/switch';
import { timeFieldSema } from '$uix/sema/components/time-field';
import { toggleSema } from '$uix/sema/components/toggle';
import { toggleGroupSema } from '$uix/sema/components/toggle-group';
import { tooltipSema } from '$uix/sema/components/tooltip';
import { treeViewSema } from '$uix/sema/components/tree-view';
import { treeGridSema } from '$uix/sema/components/tree-grid';
import { yearGridSema } from '$uix/sema/components/year-grid';
import { Popover } from '$uix/eidos/components/popover';
import '@/uix/eidos/index.css';
// Opt-in full 31-scale donor palette — lets component `color="teal"` /
// `color="plum"` (THEMING §25.5, the Radix per-instance override) resolve
// the raw `--scale-{name}-*` tokens in the docs. The slim foundation
// (`generated/base.css`) ships only role-referenced scales; the demos need
// the whole palette so the System axis can exercise every scale.
import '@/uix/eidos/generated/palette.css';
import './uix.css';
let { children } = $props();
type Language = 'es' | 'en' | 'fr' | 'de' | 'ar';
type Currency = 'EUR' | 'USD' | 'GBP' | 'JPY';
type UnitSystem = 'metric' | 'imperial';
type NumberFormat = 'es-ES' | 'en-US' | 'en-GB' | 'de-DE';
const docsPrefsSchema = standardPrefsDimensions({
// `ar` is here to make the language→direction derivation exercisable:
// picking it flips `prefs.direction` to rtl with no direction intent set.
// Untranslated keys fall back to the default locale (engine-langs.ts:51-56),
// so it degrades to Spanish copy rather than breaking.
languages: ['es', 'en', 'fr', 'de', 'ar'] 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'
}
});
/**
* The perceptual reduction levels the sema channels read. A LIVE object:
* both channels read `preferences.{sound,haptic}` on every emit, so
* mutating it silences immediately with no channel re-registration.
*
* Until 2026-08-06 this was not passed at all, so the channels defaulted to
* `'full'` and the topbar's Sound switch silenced NOTHING — it set the prefs
* intent (which drives the visual/CSS side) and stopped there. A mute that
* does not mute is worse on a page that teaches sound than no mute at all.
*/
const semaPreferences: SemaPreferences = $state({ sound: 'full', haptic: 'full' });
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
prefs: { schema: docsPrefsSchema },
events: {
sound: true,
haptic: true,
preferences: semaPreferences,
components: [
accordionSema,
calendarSema,
chatComposerSema,
chatLogSema,
chatMessageSema,
checkboxSema,
colorPickerSema,
comboboxSema,
cropperSema,
cssFieldSema,
dateFieldSema,
dialogSema,
drawerSema,
editableSema,
fileUploadSema,
floatPanelSema,
formSema,
gradientBuilderSema,
gradientPickerSema,
imageAdjustmentsSema,
imagePickerSema,
knobSema,
maskFieldSema,
mediaPlayerSema,
navTreeSema,
sidebarSema,
numberFieldSema,
paginationSema,
passwordFieldSema,
popoverSema,
radioGroupSema,
ratingGroupSema,
searchFieldSema,
selectSema,
sliderSema,
splitterSema,
stepperSema,
tabsSema,
tagGroupSema,
tagsInputSema,
textareaSema,
timelineSema,
toastSema,
toolbarSema,
// The rest of the index (2026-07-31) — see the import note above.
chronosSema,
clipboardSema,
collapsibleSema,
colorFieldSema,
contextMenuSema,
dragDropSema,
dropdownMenuSema,
listboxSema,
menubarSema,
menuDialSema,
monthGridSema,
navigationMenuSema,
onionMenuSema,
pathTraceSema,
proofOfHumanSema,
rangeCalendarSema,
rotateAlignSema,
switchSema,
timeFieldSema,
toggleSema,
toggleGroupSema,
tooltipSema,
treeViewSema,
treeGridSema,
yearGridSema
]
}
});
setActiveUix(uix);
Soma.create();
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: 'El sonido',
items: [
{ slug: '/uix/docs/sound', label: 'Cómo suena una interfaz' },
{ slug: '/uix/docs/sound/catalogo', label: 'El catálogo' },
{ slug: '/uix/docs/sound/packs', label: 'Sound packs' },
{ slug: '/uix/docs/sound/gestos', label: 'Gestos' }
]
},
{
kind: 'group',
heading: 'Controls',
items: [
{ slug: '/uix/components/toggle', label: 'Toggle' },
{ slug: '/uix/components/toggle-group', label: 'Toggle group' },
{ slug: '/uix/components/switch', label: 'Switch' },
{ slug: '/uix/components/checkbox', label: 'Checkbox' },
{ slug: '/uix/components/radio-group', label: 'Radio group' },
{ slug: '/uix/components/radio-cards', label: 'Radio cards' },
{ slug: '/uix/components/rating-group', label: 'Rating group' },
{ slug: '/uix/components/slider', label: 'Slider' },
{ slug: '/uix/components/collapsible', label: 'Collapsible' },
{ slug: '/uix/components/accordion', label: 'Accordion' },
{ slug: '/uix/components/tabs', label: 'Tabs' },
{ slug: '/uix/components/toolbar', label: 'Toolbar' },
{ slug: '/uix/components/button', label: 'Button' },
{ slug: '/uix/components/button-group', label: 'Button group' },
{ slug: '/uix/components/split-button', label: 'Split button' },
{ slug: '/uix/components/fab', label: 'Fab' }
]
},
{
kind: 'group',
heading: 'Forms',
items: [
{ slug: '/uix/components/form', label: 'Form' },
{ slug: '/uix/components/field', label: 'Field' },
{ slug: '/uix/components/field-langs', label: 'Field langs' },
{ slug: '/uix/components/label', label: 'Label' },
{ slug: '/uix/components/editable', label: 'Editable' },
{ slug: '/uix/components/palabras', label: 'Palabras' },
{ slug: '/uix/components/number-field', label: 'Number field' },
{ slug: '/uix/components/css-field', label: 'CSS field' },
{ slug: '/uix/components/search-field', label: 'Search field' },
{ slug: '/uix/components/password-field', label: 'Password field' },
{ slug: '/uix/components/mask-field', label: 'Mask field' },
{ slug: '/uix/components/prose', label: 'Prose' },
{ slug: '/uix/components/textarea', label: 'Text area' },
{ slug: '/uix/components/select', label: 'Select' },
{ slug: '/uix/components/combobox', label: 'Combobox' },
{ slug: '/uix/components/listbox', label: 'Listbox' },
{ slug: '/uix/components/grid-list', label: 'Grid list' },
{ slug: '/uix/components/calendar', label: 'Calendar' },
{ slug: '/uix/components/range-calendar', label: 'Range calendar' },
{ slug: '/uix/components/month-grid', label: 'Month grid' },
{ slug: '/uix/components/year-grid', label: 'Year grid' },
{ slug: '/uix/components/date-field', label: 'Date field' },
{ slug: '/uix/components/date-range-field', label: 'Date range field' },
{ slug: '/uix/components/date-picker', label: 'Date picker' },
{ slug: '/uix/components/date-range-picker', label: 'Date range picker' },
{ slug: '/uix/components/time-field', label: 'Time field' },
{ slug: '/uix/components/time-range-field', label: 'Time range field' },
{ slug: '/uix/components/time-picker', label: 'Time picker' },
{ slug: '/uix/components/time-range-picker', label: 'Time range picker' },
{ slug: '/uix/components/color-field', label: 'Color field' },
{ slug: '/uix/components/color-picker', label: 'Color picker' },
{ slug: '/uix/components/picker', label: 'Picker (deferValue)' },
{ slug: '/uix/components/natural-time-picker', label: 'Natural time picker' },
{ slug: '/uix/components/stepper', label: 'Stepper' },
{ slug: '/uix/components/tag-group', label: 'Tag group' },
{ slug: '/uix/components/tags-input', label: 'Tags input' },
{ slug: '/uix/components/pin-input', label: 'Pin input' },
{ slug: '/uix/components/file-upload', label: 'File upload' }
]
},
{
kind: 'group',
heading: 'Navigation',
items: [
{ slug: '/uix/components/anchor-nav', label: 'Anchor nav' },
{ slug: '/uix/components/breadcrumb', label: 'Breadcrumb' },
{ slug: '/uix/components/pagination', label: 'Pagination' },
{ slug: '/uix/components/navigation-menu', label: 'Navigation menu' },
{ slug: '/uix/components/nav-tree', label: 'Nav tree' },
{ slug: '/uix/components/sidebar', label: 'Sidebar' }
]
},
{
kind: 'group',
heading: 'Media',
items: [
{ slug: '/uix/components/avatar', label: 'Avatar' },
{ slug: '/uix/components/avatar-group', label: 'AvatarGroup' },
{ slug: '/uix/components/icon', label: 'Icon' },
{ slug: '/uix/components/image', label: 'Image' },
{ slug: '/uix/components/image-adjustments', label: 'Image adjustments' },
{ slug: '/uix/components/image-picker', label: 'Image picker' },
{ slug: '/uix/components/cropper', label: 'Cropper' },
{ slug: '/uix/components/carousel', label: 'Carousel' },
{ slug: '/uix/components/qr-code', label: 'QR code' },
{ slug: '/uix/components/barcode', label: 'Barcode' },
{ slug: '/uix/components/media-player', label: 'Media player' }
]
},
{
kind: 'group',
heading: 'Status',
items: [
{ slug: '/uix/components/badge', label: 'Badge' },
{ slug: '/uix/components/empty-state', label: 'Empty state' },
{ slug: '/uix/components/result', label: 'Result' },
{ slug: '/uix/components/callout', label: 'Callout' },
{ slug: '/uix/components/progress', label: 'Progress' },
{ slug: '/uix/components/meter', label: 'Meter' },
{ slug: '/uix/components/spinner', label: 'Spinner' },
{ slug: '/uix/components/skeleton', label: 'Skeleton' }
]
},
{
kind: 'group',
heading: 'Data',
items: [
{ slug: '/uix/components/metrics', label: 'Metrics' },
{ slug: '/uix/components/table', label: 'Table' },
{ slug: '/uix/components/tree-view', label: 'Tree view' },
{ slug: '/uix/components/tree-grid', label: 'Tree grid' },
{ slug: '/uix/components/chat-composer', label: 'Chat composer' },
{ slug: '/uix/components/chat-list', label: 'Chat list' },
{ slug: '/uix/components/chat-log', label: 'Chat log' },
{ slug: '/uix/components/chat-message', label: 'Chat message' },
{ slug: '/uix/components/chat-typing', label: 'Chat typing' },
{ slug: '/uix/components/emoji-picker', label: 'Emoji picker' },
{ slug: '/uix/components/feed', label: 'Feed' },
{ slug: '/uix/components/timeline', label: 'Timeline' },
{ slug: '/uix/components/virtual-list', label: 'Virtual list' },
{ slug: '/uix/components/virtual-grid', label: 'Virtual grid' }
]
},
{
kind: 'group',
heading: 'Charts',
items: [
{ slug: '/uix/components/chart', label: 'Chart · overview' },
{ slug: '/uix/components/line-chart', label: 'Line chart' },
{ slug: '/uix/components/area-chart', label: 'Area chart' },
{ slug: '/uix/components/bar-chart', label: 'Bar chart' },
{ slug: '/uix/components/scatter-chart', label: 'Scatter chart' },
{ slug: '/uix/components/sparkline', label: 'Sparkline' },
{ slug: '/uix/components/pie-chart', label: 'Pie chart' },
{ slug: '/uix/components/bar-list', label: 'Bar list' },
{ slug: '/uix/components/bar-segment', label: 'Bar segment' },
{ slug: '/uix/components/radar-chart', label: 'Radar chart' },
{ slug: '/uix/components/gauge', label: 'Gauge' },
{ slug: '/uix/components/funnel', label: 'Funnel' },
{ slug: '/uix/components/bubble-chart', label: 'Bubble chart' },
{ slug: '/uix/components/polar-area', label: 'Polar area' },
{ slug: '/uix/components/heatmap', label: 'Heatmap' },
{ slug: '/uix/components/smith-chart', label: 'Smith chart' }
]
},
{
kind: 'group',
heading: 'Utilities',
items: [
{ slug: '/uix/components/announce', label: 'Announce' },
{ slug: '/uix/components/clipboard', label: 'Clipboard' },
{ slug: '/uix/components/drag-drop', label: 'Drag & drop' },
{ slug: '/uix/components/proof-of-human', label: 'Proof of human' },
{ slug: '/uix/components/trans', label: 'Trans' },
{ slug: '/uix/components/format-number', label: 'FormatNumber' },
{ slug: '/uix/components/format-date', label: 'FormatDate' },
{ slug: '/uix/components/relative-time', label: 'RelativeTime' }
]
},
{
kind: 'group',
heading: 'Overlays',
items: [
{ slug: '/uix/components/dialog', label: 'Dialog' },
{ slug: '/uix/components/alert-dialog', label: 'Alert dialog' },
{ slug: '/uix/components/drawer', label: 'Drawer' },
{ slug: '/uix/components/popover', label: 'Popover' },
{ slug: '/uix/components/float-panel', label: 'Float panel' },
{ slug: '/uix/components/dropdown-menu', label: 'Dropdown menu' },
{ slug: '/uix/components/context-menu', label: 'Context menu' },
{ slug: '/uix/components/menubar', label: 'Menubar' },
{ slug: '/uix/components/onion-menu', label: 'Onion menu' },
{ slug: '/uix/components/menu-dial', label: 'Menu dial' },
{ slug: '/uix/components/command', label: 'Command' },
{ slug: '/uix/components/link-preview', label: 'Link preview' },
{ slug: '/uix/components/toast', label: 'Toast' },
{ slug: '/uix/components/tooltip', label: 'Tooltip' }
]
},
{
kind: 'group',
heading: 'Typography',
items: [
{ slug: '/uix/components/text', label: 'Text' },
{ slug: '/uix/components/s-text', label: 'SText' },
{ slug: '/uix/components/s-text-virtual-list', label: 'STextVirtualList' },
{ slug: '/uix/components/heading', label: 'Heading' },
{ slug: '/uix/components/display', label: 'Display' },
{ slug: '/uix/components/code', label: 'Code' },
{ slug: '/uix/components/code-block', label: 'CodeBlock' },
{ slug: '/uix/components/kbd', label: 'Kbd' },
{ slug: '/uix/components/mark', label: 'Mark' },
{ slug: '/uix/components/highlight', label: 'Highlight' },
{ slug: '/uix/components/link', label: 'Link' }
]
},
{
kind: 'group',
heading: 'Text effects',
items: [
{ slug: '/uix/components/count-up', label: 'CountUp' },
{ slug: '/uix/components/text-gradient', label: 'TextGradient' },
{ slug: '/uix/components/text-circular', label: 'TextCircular' },
{ slug: '/uix/components/text-blur', label: 'TextBlur' },
{ slug: '/uix/components/text-focus', label: 'TextFocus' },
{ slug: '/uix/components/text-scramble', label: 'TextScramble' }
]
},
{
kind: 'group',
heading: 'Packs',
items: [{ slug: '/uix/packs/ambient', label: 'Ambient · backgrounds', tag: 'pack' }]
},
{
kind: 'group',
heading: 'Layout',
items: [
{ slug: '/uix/components/box', label: 'Box' },
{ slug: '/uix/components/flex', label: 'Flex' },
{ slug: '/uix/components/grid', label: 'Grid' },
{ slug: '/uix/components/stack', label: 'Stack' },
{ slug: '/uix/components/group', label: 'Group' },
{ slug: '/uix/components/wrap', label: 'Wrap' },
{ slug: '/uix/components/container', label: 'Container' },
{ slug: '/uix/components/section', label: 'Section' },
{ slug: '/uix/components/aspect-ratio', label: 'AspectRatio' },
{ slug: '/uix/components/auto-grid', label: 'AutoGrid' },
{ slug: '/uix/components/banner', label: 'Banner' },
{ slug: '/uix/components/card', label: 'Card' },
{ slug: '/uix/components/card-group', label: 'CardGroup' },
{ slug: '/uix/components/float', label: 'Float' },
{ slug: '/uix/components/scroll-area', label: 'ScrollArea' },
{ slug: '/uix/components/separator', label: 'Separator' },
{ slug: '/uix/components/splitter', label: 'Splitter' },
{ slug: '/uix/components/sticky', label: 'Sticky' },
{ slug: '/uix/components/affix', label: 'Affix' }
]
}
];
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);
}
}
});
// A small canonical named-gradient set for the docs site — role-derived, so
// they re-tint with the seed and flip light/dark for free (the whole point of
// the gradient axis). Components name them: `<TextGradient colors="brand">`,
// `background: var(--gradient-brand)`. Both are LINEAR (a single `<image>`),
// so they're valid `background-image` values and paint through `background-clip:
// text` — a role-derived MESH (aurora) carries a base color and would not.
const brandGradient: Gradient = {
kind: 'linear',
angle: 110,
interpolation: { space: 'oklch' },
stops: [
{ color: { kind: 'role', role: 'primary', slot: 'solid' } },
{ color: { kind: 'role', role: 'secondary', slot: 'solid' } },
{ color: { kind: 'role', role: 'tertiary', slot: 'solid' } }
]
};
const spectrumGradient: Gradient = {
kind: 'linear',
angle: 90,
interpolation: { space: 'oklch', hue: 'longer' },
stops: [
{ color: { kind: 'role', role: 'primary', slot: 'solid' } },
{ color: { kind: 'role', role: 'affirm', slot: 'solid' } },
{ color: { kind: 'role', role: 'secondary', slot: 'solid' } },
{ color: { kind: 'role', role: 'risk', slot: 'solid' } },
{ color: { kind: 'role', role: 'loss', slot: 'solid' } }
]
};
activeEidos.applyGradients({ brand: brandGradient, spectrum: spectrumGradient });
onDestroy(() => {
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.
// Three states, not two. `auto` CLEARS the direction intent so the built-in
// `directionDimension` can derive it from `language` — the chain the
// architecture documents ("if the user sets no intent, it derives from
// prefs.language"). With a two-state toggle the shell set an intent from the
// first frame, which permanently masked that derivation: picking Arabic
// would have changed nothing.
let dirMode = $state<'auto' | 'ltr' | 'rtl'>('auto');
/** Effective direction after prefs resolves intent → derivation → default. */
const dirSlot = readActivePrefsSlot<'ltr' | 'rtl'>(uix.prefs, 'direction');
const effectiveDir = $derived(dirSlot?.get() ?? '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);
// ── Display axes — the interrelated theming knobs (density · scaling · border
// width). Projected onto :root (below) via a managed stylesheet so EVERY demo —
// including portaled overlays (dialog/popover) — reflects the change live.
type Density = 'compact' | 'comfortable' | 'spacious';
const DENSITIES: readonly Density[] = ['compact', 'comfortable', 'spacious'];
const DENSITY_SCALES: Record<Density, { space: number; control: number }> = {
compact: { space: 0.84, control: 0.9 },
comfortable: { space: 1, control: 1 },
spacious: { space: 1.16, control: 1.12 }
};
let density = $state<Density>('comfortable');
let scaling = $state(1);
let borderWidth = $state(1);
const LANGUAGE_LABELS: Record<Language, string> = {
es: 'Español',
en: 'English',
fr: 'Français',
de: 'Deutsch',
ar: 'العربية'
};
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 === 'auto' || p.dir === 'ltr' || p.dir === 'rtl') dirMode = 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;
if (p.density === 'compact' || p.density === 'comfortable' || p.density === 'spacious')
density = p.density;
if (typeof p.scaling === 'number') scaling = p.scaling;
if (typeof p.borderWidth === 'number') borderWidth = p.borderWidth;
} catch {
/* ignore corrupt prefs blob */
}
});
$effect(() => {
if (typeof localStorage === 'undefined') return;
const blob = {
dir: dirMode,
language,
currency,
units,
numberFormat,
soundOn,
motionOn,
hapticOn,
density,
scaling,
borderWidth
};
localStorage.setItem('uix-docs-prefs', JSON.stringify(blob));
});
$effect(() => {
uix.prefs.setIntent('language', language);
// `auto` must CLEAR, not set: intent beats derivation, so setting any
// value here would stop `direction` from following `language`.
if (dirMode === 'auto') uix.prefs.clearIntent('direction');
else uix.prefs.setIntent('direction', dirMode);
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');
// And the channels themselves: the prefs intent drives the visual side,
// this drives whether anything is heard or felt at all.
semaPreferences.sound = soundOn ? 'full' : 'off';
semaPreferences.haptic = hapticOn ? 'full' : 'off';
});
// Project the display axes onto :root through a managed <style> (id-keyed, so
// it replaces in place). :root — not the docs shell — so portaled overlays,
// which render outside the shell, inherit the same density/scaling/border.
$effect(() => {
const d = DENSITY_SCALES[density];
uix.dom.writeStyle(
'uix-docs-display',
`:root{--density-space-scale:${d.space};--density-control-scale:${d.control};--scaling:${scaling};--border-width:${borderWidth}px;}`
);
});
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);
});
const currentBreakpoint = $derived(uix.dom.currentBreakpoint.current);
const compactShell = $derived(
currentBreakpoint === 'base' || currentBreakpoint === 'sm' || currentBreakpoint === 'md'
);
const phoneShell = $derived(currentBreakpoint === 'base');
</script>
<div
data-uix-docs
data-theme={theme}
data-mode={theme}
data-uix-breakpoint={currentBreakpoint}
data-uix-compact={compactShell ? 'true' : 'false'}
data-uix-phone={phoneShell ? 'true' : 'false'}
>
<div data-uix-shell>
<header data-uix-topbar>
<a
href="/uix"
data-uix-brand
data-sveltekit-preload-code="hover"
data-sveltekit-preload-data="hover"
>
<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-sveltekit-preload-code="hover"
data-sveltekit-preload-data="hover"
data-active={current === '/uix' ||
current.startsWith('/uix/architecture') ||
current.startsWith('/uix/getting-started')
? 'true'
: 'false'}>Docs</a
>
<a
href="/uix/components"
data-sveltekit-preload-code="hover"
data-sveltekit-preload-data="hover"
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. -->
<!-- Instrumented for `perm:check`: this is the ONLY control in the
shell that commits a preference, so without it the permutation
runner exercises component state transitions but never a prefs
commit — and the reactive path from `uix.prefs` into the ~64
components that resolve `dir` from it stays untested.
Index 90 so it runs AFTER each demo's own steps (0..N).
Two consequences a future reader should know: the runner shares
one browser context across routes, so this flip persists via
localStorage and routes alternate ltr/rtl through a run; and
because the scan is page-wide, EVERY route under this layout now
carries a step, so the runner no longer skips uninstrumented
demos. Both are deliberate — morfo contracts are direction
agnostic, and a prefs commit on every route is the coverage
this instrumentation exists for. -->
<button
type="button"
data-uix-icon-btn
data-perm-step="90"
data-perm-label="prefs: flip direction"
data-perm-settle="600"
title="Direction: {dirMode}{dirMode === 'auto'
? ` (from language → ${effectiveDir})`
: ''} — click to cycle auto → ltr → rtl"
aria-label="Toggle direction"
onclick={() =>
(dirMode = dirMode === 'auto' ? 'ltr' : dirMode === 'ltr' ? 'rtl' : 'auto')}
>
{#if effectiveDir === '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>
<!-- DISPLAY: density / scaling / border width — the interrelated
theming axes, applied to :root so every demo (including
portaled overlays) reflects the change live. -->
<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"
>
<line x1="4" y1="7" x2="20" y2="7" />
<circle cx="9" cy="7" r="2.5" />
<line x1="4" y1="17" x2="20" y2="17" />
<circle cx="15" cy="17" r="2.5" />
</svg>
Display
<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: 252px;">
<div data-uix-menu-section>
<div data-uix-menu-label>Density</div>
{#each DENSITIES as d}
<button
type="button"
data-uix-menu-item
data-checked={density === d ? 'true' : 'false'}
onclick={() => (density = d)}
>
<span style="text-transform: capitalize;">{d}</span>
<span data-uix-menu-item-meta
>{DENSITY_SCALES[d].space}× · {DENSITY_SCALES[d].control}×</span
>
</button>
{/each}
</div>
<div data-uix-menu-section>
<div data-uix-menu-label>Scaling · {scaling.toFixed(2)}×</div>
<input
data-uix-range
type="range"
min="0.85"
max="1.25"
step="0.05"
bind:value={scaling}
aria-label="Scaling factor"
/>
</div>
<div data-uix-menu-section>
<div data-uix-menu-label>Border width · {borderWidth}px</div>
<input
data-uix-range
type="range"
min="0"
max="4"
step="0.5"
bind:value={borderWidth}
aria-label="Border width"
/>
</div>
<div data-uix-menu-section>
<button
type="button"
data-uix-menu-item
onclick={() => {
density = 'comfortable';
scaling = 1;
borderWidth = 1;
}}
>
<span>Reset</span>
<span data-uix-menu-item-meta>comfortable · 1× · 1px</span>
</button>
</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-sveltekit-preload-code="hover"
data-sveltekit-preload-data="hover"
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>
<!-- The docs copy is written in English, so it is DECLARED as English via
`lang` — but NOT via `dir`. A `dir="ltr"` here used to pin the prose
against the bidi artefact ("What is morfo?" rendering as "?What is
morfo"), and it worked, but it pinned the DEMOS too: a component in
`auto` mode passes no `dir` prop, so it stamps no attribute and
resolves its direction by INHERITANCE from the DOM. This container
was that inheritance, so the shell's RTL switch — and picking
Arabic — reached `<html>` and stopped here, leaving all 51 demos
frozen in LTR. Measured on the sidebar: `html dir="rtl"` with the
panel still on the left, and dropping this attribute alone moved it.
The prose artefact is cosmetic and only shows in RTL, which is an
inspection mode; demos that cannot demonstrate are not. If the
prose needs pinning again, it has to happen AROUND THE PROSE, not
around the canvas that also holds the live examples. -->
<main data-uix-canvas lang="en">
{@render children?.()}
</main>
</div>
</div>

Powered by TurnKey Linux.