demo(blocks): la sección tiene cromo de verdad — tema, idioma, dirección, densidad y catálogo

La crítica era exacta: no se podía cambiar a oscuro, ni de idioma, ni había
forma de moverse entre blocks. La sección arrancaba UIX y renderizaba la
página desnuda, con un comentario que decía que el modo seguía al sistema
operativo «para verificar oscuro emulando prefers-color-scheme» — cómodo para
mí, inútil para quien mira la página.

Ahora hay shell: topbar pegajosa con los ejes que de verdad mueven el
framework (idioma ES/EN/FR/DE y dirección como INTENTS de prefs, así que
llegan a langs, formatos y al AGENTS.md    debug.log		routes		  tsconfig.json
CLAUDE.md    docs		scripts		  tsconfig.tsbuildinfo
README.md    node_modules	src		  vite.config.ts
STUMBLES.md  package-lock.json	static		  web
audit	     package.json	svelte.config.js  worker.js
build	     plans		tmp		  workers del documento por el camino del sistema;
densidad proyectada en :root para que la hereden también los overlays
porteados; y el conmutador de tema, que alimenta el  del
ActiveEidos) y un raíl con el catálogo entero de los 27 blocks planificados,
marcando el actual y atenuando los que aún no existen. Todo persiste en
localStorage, así que una recarga te deja donde estabas.

El catálogo pasa a : raíl y galería leen la MISMA fuente, y
un block que aterriza no hay que recordarlo en dos sitios.

Las tres regiones las coloca  — cromo de app-land, el papel que
 hace en las docs de componentes; la regla de «sin CSS» del contrato
B es para el tier (), no para el sitio que lo enseña. Dentro
de esas regiones todo son componentes del canon.

Verificado en navegador: el botón conmuta a oscuro de verdad (fondo
oklch(0.1776 0 0)), el idioma llega a , el raíl lista los 27 y
desaparece por debajo de 64rem sin desbordes, cero errores de consola.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 50574806d6
commit 33328df7ae

@ -1,30 +1,68 @@
<script lang="ts">
// Blocks section bootstrap — a minimal standalone UIX shell (no docs
// chrome). Mirrors the /uix layout's boot chain (createActiveUix → Soma →
// prefs projection → ActiveEidos) without the sema component packs: the
// gallery and block demos only need the visual layer; sound/haptic stay
// off here.
/**
* Blocks section shell. Boots UIX (createActiveUix → Soma → prefs projection
* → ActiveEidos) and puts REAL chrome around it: a rail with the catalog and
* a topbar whose axes actually drive the framework — colour mode, language,
* direction and density. Without them a demo can only be looked at, never
* exercised, which is the whole point of a demo.
*
* The three regions are placed by `blocks.css` (app-land chrome, the role
* `uix.css` plays for the component docs); everything inside them is a canon
* component, so the section dogfoods the framework like the rest of the site.
*/
import { onDestroy } from 'svelte';
import { page } from '$app/state';
import { createActiveUix, setActiveUix } from '$active-uix';
import { createActivePrefsDomProjection } from '$prefs';
import { createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';
import { Soma } from '$soma/core/soma.svelte';
import { siumLangs } from '$sium/langs/langs';
import { secsLangs } from '$libs/secs';
import { ActiveEidos } from '$uix/eidos';
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Text } from '$uix/eidos/components/text';
import { Link } from '$uix/eidos/components/link';
import { Badge } from '$uix/eidos/components/badge';
import { Separator } from '$uix/eidos/components/separator';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { Button } from '$uix/eidos/components/button';
import * as Icon from '$uix/eidos/components/icon';
import { BLOCK_CATALOG } from './_lib/catalog';
import '@/uix/eidos/index.css';
import './blocks.css';
let { children } = $props();
type Language = 'es' | 'en' | 'fr' | 'de';
type Density = 'compact' | 'comfortable' | 'spacious';
const blocksPrefsSchema = standardPrefsDimensions({
languages: ['es', 'en', 'fr', 'de'] satisfies readonly Language[],
locales: ['es-ES', 'en-US', 'en-GB', 'de-DE'] as const,
currencies: ['EUR', 'USD', 'GBP', 'JPY'] as const,
defaults: { language: 'es', locale: 'es-ES', currency: 'EUR' }
});
const uix = createActiveUix({
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const }
langs: { schema: { sium: siumLangs, secs: secsLangs }, defaultLocale: 'es' as const },
prefs: { schema: blocksPrefsSchema }
});
setActiveUix(uix);
Soma.create();
const prefsProjection = createActivePrefsDomProjection({ prefs: uix.prefs, dom: uix.dom });
// Mode follows the OS color scheme (no persisted toggle here — that keeps
// dark-mode verification a matter of emulating `prefers-color-scheme`).
// ── Axes ──────────────────────────────────────────────────────────────
let mode = $state<'light' | 'dark'>('light');
let language = $state<Language>('es');
let dir = $state<'ltr' | 'rtl'>('ltr');
let density = $state<Density>('comfortable');
const DENSITY_SCALES: Record<Density, { space: number; control: number }> = {
compact: { space: 0.85, control: 0.9 },
comfortable: { space: 1, control: 1 },
spacious: { space: 1.15, control: 1.1 }
};
const modeListeners = new Set<(value: 'light' | 'dark') => void>();
const activeEidos = ActiveEidos.create({
applyDom: true,
@ -38,17 +76,53 @@
}
});
// Restore once: a stored choice wins, otherwise the OS scheme decides.
$effect(() => {
if (typeof localStorage === 'undefined') return;
const stored = localStorage.getItem('uix-blocks-prefs');
if (stored) {
try {
const blob = JSON.parse(stored);
if (blob.mode === 'dark' || blob.mode === 'light') mode = blob.mode;
if (blob.language) language = blob.language;
if (blob.dir) dir = blob.dir;
if (blob.density) density = blob.density;
return;
} catch {
/* ignore a corrupt blob */
}
}
mode = window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
$effect(() => {
if (typeof localStorage === 'undefined') return;
localStorage.setItem('uix-blocks-prefs', JSON.stringify({ mode, language, dir, density }));
});
$effect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
mode = media.matches ? 'dark' : 'light';
for (const listener of modeListeners) listener(mode);
};
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
const current = mode;
for (const listener of modeListeners) listener(current);
});
// Language and direction are PREFS intents: they reach langs, formats and the
// document direction through the framework, not through local wiring.
$effect(() => {
uix.prefs.setIntent('language', language);
uix.prefs.setIntent('direction', dir);
});
// Density on :root, so portaled overlays get it too.
$effect(() => {
const d = DENSITY_SCALES[density];
uix.dom.writeStyle(
'uix-blocks-display',
`:root{--density-space-scale:${d.space};--density-control-scale:${d.control};}`
);
});
const currentSlug = $derived(page.url.pathname.replace(/^\/blocks\/?/, ''));
onDestroy(() => {
prefsProjection.dispose();
activeEidos.dispose();
@ -61,4 +135,114 @@
<meta name="color-scheme" content="light dark" />
</svelte:head>
{@render children?.()}
<div data-blocks-shell data-theme={mode} data-mode={mode}>
<header data-blocks-topbar>
<Group align="center" gap={3} justify="start">
<Link href="/blocks" variant="plain" underline="none">
<Group align="center" gap={2} justify="start">
<Icon.Boxes size="sm" />
<Text weight="semibold">Blocks</Text>
</Group>
</Link>
<Badge size="xs" variant="soft">tier</Badge>
</Group>
<Group align="center" gap={4} justify="end">
<Group align="center" gap={2} justify="end">
<Text size="xs" color="muted">idioma</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[language]}
onValueChange={(v) => (language = (v[0] ?? language) as Language)}
aria-label="Idioma"
>
{#each ['es', 'en', 'fr', 'de'] as value (value)}
<ToggleGroup.Item {value}>{value.toUpperCase()}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</Group>
<Group align="center" gap={2} justify="end">
<Text size="xs" color="muted">densidad</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[density]}
onValueChange={(v) => (density = (v[0] ?? density) as Density)}
aria-label="Densidad"
>
<ToggleGroup.Item value="compact">−</ToggleGroup.Item>
<ToggleGroup.Item value="comfortable">·</ToggleGroup.Item>
<ToggleGroup.Item value="spacious">+</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group align="center" gap={2} justify="end">
<Text size="xs" color="muted">dir</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[dir]}
onValueChange={(v) => (dir = (v[0] ?? dir) as 'ltr' | 'rtl')}
aria-label="Dirección"
>
<ToggleGroup.Item value="ltr">LTR</ToggleGroup.Item>
<ToggleGroup.Item value="rtl">RTL</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Button
size="sm"
variant="ghost"
aria-label={mode === 'light' ? 'Cambiar a modo oscuro' : 'Cambiar a modo claro'}
onclick={() => (mode = mode === 'light' ? 'dark' : 'light')}
>
{#if mode === 'light'}
<Icon.Moon size="sm" />
{:else}
<Icon.Sun size="sm" />
{/if}
</Button>
</Group>
</header>
<div data-blocks-body>
<nav data-blocks-rail aria-label="Catálogo de blocks">
<Stack gap={4}>
{#each BLOCK_CATALOG as group, i (group.title)}
<Stack gap={2}>
<Text size="xs" weight="medium" color="muted">{group.title}</Text>
<Stack gap={1}>
{#each group.items as item (item.slug)}
{#if item.shipped}
<Link href={`/blocks/${item.slug}`} variant="plain" underline="none">
<Text
size="sm"
weight={currentSlug === item.slug ? 'semibold' : 'regular'}
color={currentSlug === item.slug ? 'primary' : 'secondary'}
>
{item.label}
</Text>
</Link>
{:else}
<Text size="sm" color="disabled">{item.label}</Text>
{/if}
{/each}
</Stack>
</Stack>
{#if i < BLOCK_CATALOG.length - 1}
<Separator />
{/if}
{/each}
</Stack>
</nav>
<div data-blocks-content {dir}>
{@render children?.()}
</div>
</div>
</div>

@ -10,57 +10,9 @@
import { Text } from '$uix/eidos/components/text';
import { Badge } from '$uix/eidos/components/badge';
import { Separator } from '$uix/eidos/components/separator';
import { BLOCK_CATALOG } from './_lib/catalog';
interface PlannedBlock {
slug: string;
label: string;
shipped: boolean;
}
const groups: { title: string; items: PlannedBlock[] }[] = [
{
title: 'Site',
items: [
{ slug: 'site-header', label: 'Site header', shipped: true },
{ slug: 'hero', label: 'Hero', shipped: false },
{ slug: 'feature-grid', label: 'Feature grid', shipped: false },
{ slug: 'pricing', label: 'Pricing', shipped: false },
{ slug: 'testimonials', label: 'Testimonials', shipped: false },
{ slug: 'faq', label: 'FAQ', shipped: false },
{ slug: 'stats-band', label: 'Stats band', shipped: false },
{ slug: 'cta', label: 'CTA', shipped: false },
{ slug: 'newsletter', label: 'Newsletter', shipped: false },
{ slug: 'site-footer', label: 'Site footer', shipped: false },
{ slug: 'banner', label: 'Banner', shipped: false },
{ slug: 'team', label: 'Team', shipped: false },
{ slug: 'contact', label: 'Contact', shipped: false },
{ slug: 'content-section', label: 'Content section', shipped: false }
]
},
{
title: 'App',
items: [
{ slug: 'app-shell', label: 'App shell', shipped: false },
{ slug: 'auth', label: 'Auth', shipped: false },
{ slug: 'data-table', label: 'Data table', shipped: false },
{ slug: 'dashboard', label: 'Dashboard', shipped: false },
{ slug: 'settings', label: 'Settings', shipped: false },
{ slug: 'user-menu', label: 'User menu', shipped: false },
{ slug: 'notifications', label: 'Notifications', shipped: false },
{ slug: 'wizard', label: 'Wizard', shipped: false },
{ slug: 'error-page', label: 'Error page', shipped: false },
{ slug: 'kanban', label: 'Kanban', shipped: false }
]
},
{
title: 'Docs',
items: [
{ slug: 'docs-shell', label: 'Docs shell', shipped: false },
{ slug: 'code-showcase', label: 'Code showcase', shipped: false },
{ slug: 'props-table', label: 'Props table', shipped: false }
]
}
];
const groups = BLOCK_CATALOG;
</script>
<Section>

@ -0,0 +1,61 @@
/**
* The blocks catalog — one source for the section rail and the gallery index,
* so a block that lands does not have to be remembered in two places.
* Mirrors `docs/process/PLAN-blocks.md` (F2–F4).
*/
export interface BlockEntry {
slug: string;
label: string;
/** A live demo exists at `/blocks/{slug}`. */
shipped: boolean;
}
export interface BlockGroup {
title: string;
items: BlockEntry[];
}
export const BLOCK_CATALOG: BlockGroup[] = [
{
title: 'Site',
items: [
{ slug: 'site-header', label: 'Site header', shipped: true },
{ slug: 'hero', label: 'Hero', shipped: false },
{ slug: 'feature-grid', label: 'Feature grid', shipped: false },
{ slug: 'pricing', label: 'Pricing', shipped: false },
{ slug: 'testimonials', label: 'Testimonials', shipped: false },
{ slug: 'faq', label: 'FAQ', shipped: false },
{ slug: 'stats-band', label: 'Stats band', shipped: false },
{ slug: 'cta', label: 'CTA', shipped: false },
{ slug: 'newsletter', label: 'Newsletter', shipped: false },
{ slug: 'site-footer', label: 'Site footer', shipped: false },
{ slug: 'banner', label: 'Banner', shipped: false },
{ slug: 'team', label: 'Team', shipped: false },
{ slug: 'contact', label: 'Contact', shipped: false },
{ slug: 'content-section', label: 'Content section', shipped: false }
]
},
{
title: 'App',
items: [
{ slug: 'app-shell', label: 'App shell', shipped: false },
{ slug: 'auth', label: 'Auth', shipped: false },
{ slug: 'data-table', label: 'Data table', shipped: false },
{ slug: 'dashboard', label: 'Dashboard', shipped: false },
{ slug: 'settings', label: 'Settings', shipped: false },
{ slug: 'user-menu', label: 'User menu', shipped: false },
{ slug: 'notifications', label: 'Notifications', shipped: false },
{ slug: 'wizard', label: 'Wizard', shipped: false },
{ slug: 'error-page', label: 'Error page', shipped: false },
{ slug: 'kanban', label: 'Kanban', shipped: false }
]
},
{
title: 'Docs',
items: [
{ slug: 'docs-shell', label: 'Docs shell', shipped: false },
{ slug: 'code-showcase', label: 'Code showcase', shipped: false },
{ slug: 'props-table', label: 'Props table', shipped: false }
]
}
];

@ -0,0 +1,63 @@
/*
* Chrome of the /blocks section — the shell only: topbar, rail and content
* column. The CONTROLS inside it are canon components (ToggleGroup, Button,
* Link…), like the rest of the site; this file just places the three regions
* and paints their edges with foundation tokens.
*
* (The B contract's «no CSS» rule is about `src/uix/blocks/`, the tier itself.
* This is app-land chrome for the demo site, the same role `uix.css` plays for
* the component docs.)
*/
[data-blocks-shell] {
min-block-size: 100dvh;
background: var(--color-surface-default);
color: var(--color-content-primary);
}
[data-blocks-topbar] {
position: sticky;
inset-block-start: 0;
z-index: var(--z-index-raised);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-inline: var(--space-4);
padding-block: var(--space-2);
background: color-mix(in srgb, var(--color-surface-default) 88%, transparent);
backdrop-filter: blur(8px);
border-block-end: var(--border-width) solid var(--color-border-subtle);
}
[data-blocks-body] {
display: flex;
align-items: stretch;
min-block-size: 0;
}
[data-blocks-rail] {
flex: none;
inline-size: 15rem;
padding: var(--space-4);
border-inline-end: var(--border-width) solid var(--color-border-subtle);
position: sticky;
inset-block-start: var(--space-12);
align-self: start;
max-block-size: calc(100dvh - var(--space-12));
overflow-y: auto;
overscroll-behavior: contain;
}
/* Below `lg` the rail folds away: the topbar keeps the axes, and the gallery
is one tap away. A demo section does not need a drawer of its own. */
@media (width < 64rem) {
[data-blocks-rail] {
display: none;
}
}
[data-blocks-content] {
flex: 1 1 auto;
min-inline-size: 0;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.