feat(layout): Layout Batch 2 — aspect-ratio, auto-grid, banner, float

4 new layout primitives, full canon (morfo + eidos full set + canon
demo + README). `npm run component:audit`: 81 / 81 PASS, 0 NEEDS-WORK.

**AspectRatio** — composes through Box; emits single `--aspect-ratio`
CSS var (uses modern CSS `aspect-ratio` property, drops legacy
padding-bottom hack). Accepts numeric (16/9 → 1.777…) or string
("16/9") values.

**AutoGrid** — composes through Grid. Resolves `templateColumns` from
`minItemWidth` to `repeat(auto-fill, minmax(MIN, 1fr))` in JS so the
recipe stays declarative. Uses `auto-fill` (not auto-fit) to preserve
empty tracks when item count is low.

**Banner** — `<header role="banner">` announcement strip with intent
(full 8-role UIX `ColorRole`), variant (`ChipVariant` soft/solid/
outline/ghost), size (sm/md/lg). Dismissal is composition-driven:
consumer wraps in {#if show} and adds `<Banner.Close>`. No
`dismissible` boolean.

**Float** — CSS `float` primitive with logical `inline-start` /
`inline-end` sides. Redefined from air's 9-zone overlay primitive
(which becomes a future `<Positioned>` component if real demand
surfaces). Useful for inline images / pull-quotes / drop caps.

Each component ships morfo (`scope: ['eidos']`, 1 part, 0 events,
justified) + eidos set + README (Baseline / Comparativa /
Decisiones / Eventos Sema / Gaps with disposition markers /
Referencias / Passive justification) + canon demo with 6 tabs.

Sidebar nav appends the 4 entries to the existing Layout group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 265a3946b5
commit 376f9e33eb

@ -1,5 +1,5 @@
/**
* Layout namespace — re-exports the 8 layout primitives as a single
* Layout namespace — re-exports the layout primitives as a single
* group for consumers who prefer the dotted style:
*
* import { Layout } from '$uix/eidos/components/_layout';
@ -19,6 +19,10 @@ export { default as Group } from '../group';
export { default as Wrap } from '../wrap';
export { default as Container } from '../container';
export { default as Section } from '../section';
export { default as AspectRatio } from '../aspect-ratio';
export { default as AutoGrid } from '../auto-grid';
export { default as Banner } from '../banner';
export { default as Float } from '../float';
export type { BoxProps } from '../box';
export type { FlexProps } from '../flex';
@ -28,6 +32,16 @@ export type { GroupProps } from '../group';
export type { WrapProps } from '../wrap';
export type { ContainerProps, ContainerSize, ContainerAlign } from '../container';
export type { SectionProps, SectionSize } from '../section';
export type { AspectRatioProps, AspectRatioValue } from '../aspect-ratio';
export type { AutoGridProps } from '../auto-grid';
export type {
BannerProps,
BannerCloseProps,
BannerIntent,
BannerVariant,
BannerSize
} from '../banner';
export type { FloatProps, FloatSide } from '../float';
export type {
LayoutSpaceValue,

@ -0,0 +1,114 @@
# Eidos AspectRatio
Constrains its inner content to a fixed width/height ratio. Eidos-native:
the recipe writes a single `--aspect-ratio` custom property on a
`<div data-box data-aspect-ratio>` shell (composed through `<Box>`) and
the CSS `aspect-ratio` property does the rest. Useful for video / image /
iframe containers that must hold their shape regardless of inline size.
## Superficie
```svelte
<AspectRatio ratio={16 / 9}>
<img src="hero.jpg" alt="" />
</AspectRatio>
<AspectRatio ratio="4/3" maxWidth={480}>
<iframe src="https://example.com/embed" title="Embed" />
</AspectRatio>
<!-- Number or fractional string both work -->
<AspectRatio ratio="21 / 9"><video src="…" /></AspectRatio>
```
## Baseline
Origen: `air/components/layout/aspect-ratio` (rama `morfo-runtime`).
Adaptaciones aplicadas durante el port:
- Drop del prefijo `air-` y de la dependencia de Terra. Air delegaba la
geometría en `TerraAspectRatio.Provider`, que envolvía el contenido en
un `<div>` posicionado con el truco padding-bottom y posicionaba el
hijo en absoluto. Eidos usa directamente CSS `aspect-ratio`, soportado
por todos los navegadores modernos.
- Selectores `[data-box][data-aspect-ratio]` en lugar de la clase
`air-aspect-ratio`.
- Variable renombrada de `--air-aspect-ratio-radius` (que no se mapeaba
contra nada de la receta) a `--aspect-ratio` (el ratio en sí mismo).
El `border-radius` no es propio de aspect-ratio: si el consumidor lo
necesita, lo pasa vía `style` o componiendo otro primitive.
- Resolución reactiva del prop `ratio` vía `ActiveEidos.require().resolve()`
para soportar `ResponsiveProp`.
- El componente compone a través de `<Box>`, así que hereda todas las
props del box-model (padding, maxWidth, gridColumn, …).
## Comparativa
| Capacidad | UIX (eidos) | Radix Themes | Chakra UI | Mantine |
| --- | --- | --- | --- | --- |
| `ratio` numeric | Sí (`ratio={16 / 9}`) | Sí (`ratio={16 / 9}`) | Sí (`ratio={16 / 9}`) | Sí (`ratio={16 / 9}`) |
| `ratio` string fraction | Sí (`ratio="16/9"`) | No (sólo number) | No | No |
| Implementation | CSS `aspect-ratio` | CSS `aspect-ratio` | CSS `aspect-ratio` | CSS `aspect-ratio` |
| Composes through Box | Sí (hereda padding/maxWidth/etc.) | Box ancestor | Box ancestor | Box ancestor |
| `maxWidth` / sizing props | Sí (vía Box) | Sí | Sí | Sí |
| Stretches lone child to fill | Sí (`> * { 100% × 100% }`) | Sí | Manual | Manual |
| Responsive `ratio` prop | Sí (`ResponsiveProp`) | Sí | Sí (objeto breakpoint) | Sí (`{ base, sm, md }`) |
## Decisiones
- **`ratio` acepta number y string** — los tres referentes sólo soportan
number. UIX añade la forma string (`"16/9"`) porque deja el valor
intacto en DevTools (`aspect-ratio: 16 / 9` se lee literal) y evita el
cálculo en JS para ratios canónicos. Las dos formas tipadas como
`AspectRatioValue` quedan en `types.ts`.
- **CSS `aspect-ratio` puro, sin padding-bottom** — air vía Terra usaba
el truco padding-bottom + child posicionado en absoluto. Esa técnica
era necesaria antes de 2021; hoy `aspect-ratio` es baseline. Eliminamos
la complejidad del DOM (un wrapper menos, sin position:absolute en el
hijo).
- **Compose through `<Box>`** — patrón consistente con Flex/Grid: el
shell `[data-box]` da el box-model y AspectRatio sólo añade la propiedad
`aspect-ratio` en su capa.
- **`object-fit: cover` por defecto en el hijo** — replicated images,
videos e iframes se ajustan al box sin estirarse. Si el consumidor
quiere `contain`, lo override en el hijo.
- **No `borderRadius` específico** — diferencia con air, que exponía
`--air-aspect-ratio-radius` sin proporcionar prop ni control. UIX
delega border-radius a la cascada normal (se aplica desde un `style=`
o desde la clase del consumidor).
## Eventos Sema
AspectRatio declara 0 eventos. Es una primitiva pasiva: ajusta la
geometría de su único hijo y nada más — sin commit, sin emerge, sin
keyboard. Componentes que animan al cambiar de ratio (raros) componen
AspectRatio con un primitive interactivo (popover, drawer, collapsible)
que sí posee los verbos sema relevantes. Misma justificación que Box,
Flex y Grid.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `as` prop para semantic tag (`figure`, `picture`) | diferir | Hoy se envuelve AspectRatio en el tag semántico. Reconsiderar si llegan ≥2 casos reales. |
| Per-instance `object-fit` prop sobre el hijo | descartar | Hereda de la cascada normal del hijo (img/video/iframe). Si se necesita override one-off, va vía `style` en el hijo. |
| `borderRadius` shorthand | descartar | Border-radius no es propio de aspect-ratio. Se aplica desde fuera, igual que en Box. |
| Detección automática del intrinsic ratio del hijo | diferir | Mantine ofrece esto vía `data-ratio="auto"`. Bajo prioridad: el consumidor casi siempre sabe el ratio. |
| Documentación per-prop con ejemplos visuales | implementar | Pasada de docs final. Hoy la demo cubre los casos comunes. |
## Referencias
- Radix Themes AspectRatio: https://www.radix-ui.com/themes/docs/components/aspect-ratio
- Chakra UI AspectRatio: https://chakra-ui.com/docs/components/aspect-ratio
- Mantine AspectRatio: https://mantine.dev/core/aspect-ratio/
- CSS `aspect-ratio`: https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio
## Passive justification
Visual-only primitive (`scope: ['eidos']` en el morfo). Una sola
part Provider que emite el marker `[data-aspect-ratio]`, sin estados,
sin data-attrs específicos, sin ARIA, sin keyboard. El recipe consume
la variable `--aspect-ratio` que el componente escribe inline. No hay
nada que animar, ningún ciclo de vida que enviar a sema, ningún evento
en el sentido de Morfo. La superficie expuesta es geometría pura.

@ -0,0 +1,26 @@
/*
* AspectRatio recipe — sets `aspect-ratio` on the Box shell and stretches
* the direct child to fill the box. The element itself is laid out by the
* cascade (block-level by default) and shrinks the ratio to its inline
* size, so combining with `maxWidth`, `width` or grid placement on the
* Box works without extra plumbing.
*
* Why not the legacy padding-bottom hack? CSS `aspect-ratio` ships in
* every modern browser and avoids the absolute-positioning of children
* that the padding-bottom trick requires. Cleaner DOM, fewer caveats
* around overflow / scrollbars.
*/
[data-box][data-aspect-ratio] {
aspect-ratio: var(--aspect-ratio, 1);
overflow: hidden;
}
/* Stretch the lone child (img / iframe / video / picture / inner div) to
fill the box without disturbing replaced-element intrinsic ratios when
they happen to match. */
[data-box][data-aspect-ratio] > * {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}

@ -0,0 +1,53 @@
<script lang="ts">
/**
* Eidos `<AspectRatio>` — constrains inner content to a fixed
* width/height ratio. Composes through `<Box>` so every Box prop
* (padding, max-width, gridColumn for placement inside a grid, …)
* still works on the wrapper. The `ratio` prop maps to the modern
* CSS `aspect-ratio` property via a `--aspect-ratio` custom property
* and the recipe sizes a single child element to `100% × 100%`.
*
* <AspectRatio ratio={16 / 9}>
* <img src="hero.jpg" alt="" />
* </AspectRatio>
*
* <AspectRatio ratio="4/3" maxWidth={480}>
* <iframe …></iframe>
* </AspectRatio>
*/
import { ActiveEidos } from '$uix/eidos';
import Box from '../box/box.svelte';
import { composeStyle, pushStyleVar } from '../_layout/shared';
import type { AspectRatioProps, AspectRatioValue } from './types';
let {
ratio = 1,
style,
class: className,
children,
...restProps
}: AspectRatioProps = $props();
const eidos = ActiveEidos.require();
function formatRatio(value: AspectRatioValue | undefined): string | undefined {
if (value === undefined || value === null || value === '') return undefined;
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : undefined;
return String(value);
}
const resolvedStyle = $derived.by(() => {
const decls: string[] = [];
pushStyleVar(decls, '--aspect-ratio', formatRatio(eidos.resolve(ratio)));
return composeStyle(decls, style);
});
</script>
<Box
{...restProps}
class={className}
style={resolvedStyle}
data-aspect-ratio=""
>
{@render children?.()}
</Box>

@ -0,0 +1,12 @@
// AspectRatio — constrains inner content to a width/height ratio.
//
// import { AspectRatio } from '$uix/eidos/components/aspect-ratio';
//
// <AspectRatio ratio={16 / 9}>
// <img src="hero.jpg" alt="" />
// </AspectRatio>
import AspectRatio from './aspect-ratio.svelte';
export { AspectRatio };
export default AspectRatio;
export type { AspectRatioProps, AspectRatioValue } from './types';

@ -0,0 +1,27 @@
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { BoxProps } from '../box/types';
/**
* Aspect-ratio value. Accepts:
* - a number: `ratio={16/9}` → CSS `aspect-ratio: 1.7777…`.
* - a fraction string: `ratio="16/9"`, `ratio="4 / 3"`.
* - a unitless string: `ratio="1.5"`.
*
* The string form is preferred when the consumer wants the value to read
* verbatim in DevTools (`aspect-ratio: 16 / 9`); the number form is easier
* to compute (`ratio={width / height}`).
*/
export type AspectRatioValue = number | string;
export type AspectRatioProps = Omit<BoxProps, 'display'> & {
/**
* Width / height ratio for the box. @default 1 (square).
*
* Examples:
* - `ratio={1}` — square (1:1)
* - `ratio={16 / 9}` — widescreen video
* - `ratio="4/3"` — standard video
* - `ratio="21/9"` — ultrawide
*/
ratio?: ResponsiveProp<AspectRatioValue>;
};

@ -0,0 +1,118 @@
# Eidos AutoGrid
Responsive grid container that fits as many columns as its inline size
allows — no media queries required. Wraps `<Grid>` (which wraps `<Box>`)
and computes `grid-template-columns: repeat(auto-fill, minmax(MIN, 1fr))`
in JS based on the `minChildWidth` prop. Falls back to a fixed
`repeat(N, minmax(0, 1fr))` when `columns` is passed instead.
## Superficie
```svelte
<!-- Fluid: as many columns as fit, each at least 240px wide. -->
<AutoGrid minChildWidth={240} gap={3}>
<Card />
<Card />
<Card />
</AutoGrid>
<!-- Fixed: always 3 columns, regardless of viewport. -->
<AutoGrid columns={3} gap={3}>
…
</AutoGrid>
<!-- Responsive minChildWidth: smaller cards on tiny screens. -->
<AutoGrid minChildWidth={{ base: 160, md: 240 }} gap={3}>
…
</AutoGrid>
```
## Baseline
Origen: `air/components/layout/auto-grid` (rama `morfo-runtime`).
Adaptaciones aplicadas durante el port:
- Resolución reactiva (`ActiveEidos.require().resolve()`) en lugar de
`getAir().dom.resolve()`.
- Marker propio `[data-auto-grid]` además del `data-grid` heredado de
`<Grid>`, para que consumidores puedan estilar el primitive específico
cuando lo necesiten.
- Cambio de `auto-fit` a **`auto-fill`** en el template — `auto-fill`
preserva las pistas vacías cuando hay menos hijos que columnas
disponibles, lo que da un grid más predecible y consistente con la
expectativa del consumidor (Chakra `SimpleGrid`, Mantine `SimpleGrid`).
Si se quiere `auto-fit` (stretch a las pistas pobladas), el consumidor
puede usar `<Grid templateColumns="repeat(auto-fit, minmax(240px, 1fr))">`
directamente.
- Drop del prefijo `air-` y eliminación de la dependencia de `getAir()`.
## Comparativa
| Capacidad | UIX (eidos) | Radix Themes | Chakra UI SimpleGrid | Mantine SimpleGrid |
| --- | --- | --- | --- | --- |
| Fluid fit (`minChildWidth`) | Sí (`minChildWidth={240}`) | No (sólo via raw `templateColumns`) | Sí (`minChildWidth`) | Sí (`minChildWidth`) |
| Fixed columns (`columns`) | Sí (`columns={3}`) | Sí (`columns="3"`) en Grid | Sí (`columns={3}`) | Sí (`cols={3}`) |
| Responsive `columns` | Sí (`ResponsiveProp<number>`) | Sí (`{ initial: 1, sm: 2 }`) | Sí (`{ base: 1, md: 3 }`) | Sí (`{ base: 1, sm: 2 }`) |
| Responsive `minChildWidth` | Sí (`ResponsiveProp<…>`) | n/a | Sí | Sí |
| `gap` shorthand | Sí (vía Grid → Box) | Sí | Sí | Sí (`spacing`) |
| Per-axis gap (`rowGap`, `columnGap`) | Sí (vía Grid) | Sí | Sí | Sí (`verticalSpacing`, `horizontalSpacing`) |
| `auto-fill` vs `auto-fit` | `auto-fill` (preserva tracks vacíos) | n/a | `auto-fit` | `auto-fit` |
| Composes through `<Box>` | Sí | n/a | n/a | n/a |
## Decisiones
- **`minChildWidth` gana sobre `columns`** — cuando se pasan ambos, el
template fluido es la primitiva específica de AutoGrid; `columns` queda
como modo "shortcut" para grids fijos sin necesidad de saltar a
`<Grid>`.
- **`auto-fill` por defecto** — divergencia de Chakra/Mantine, que usan
`auto-fit`. Razón: `auto-fill` deja tracks reservados cuando hay menos
hijos que columnas, lo que evita que el primer hijo se estire hasta
ocupar todo el inline-size cuando hay sólo uno. Si el consumidor
necesita el comportamiento de `auto-fit`, usa `<Grid templateColumns>`
directamente.
- **Composición sobre `<Grid>`** — el primitive es prácticamente puro
`templateColumns` computado; no duplica la lógica del recipe de Grid.
Cualquier mejora futura del recipe Grid (alignContent, place-items, …)
llega "gratis".
- **`minChildWidth` resuelve con `formatLayoutLength`** — números van a
px, strings pasan tal cual (`"15rem"`, `"min(20rem, 50%)"`).
- **No exponemos `templateColumns`** — entra en colisión con el propio
cálculo del primitive. Si se necesita un template custom, se usa
`<Grid>` directamente.
## Eventos Sema
AutoGrid declara 0 eventos. Es una primitiva pasiva: ajusta el template
de columnas según la geometría del contenedor — sin commit, sin emerge,
sin keyboard. Componentes que animan items al entrar/salir del grid
componen AutoGrid con un primitive interactivo (popover, drawer) que sí
posee los verbos sema relevantes. Misma justificación que Box, Flex,
Grid y AspectRatio.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `auto-fit` mode prop | diferir | Hoy se usa `<Grid templateColumns>` directamente cuando se quiere el comportamiento de auto-fit. Subir a prop si llegan ≥2 casos reales. |
| `breakpoints` config (Mantine-style array de fallbacks) | descartar | Lo cubre `ResponsiveProp` sobre `columns` y `minChildWidth`. Duplicaría la API. |
| `spacing` alias para `gap` (Mantine ergonomics) | descartar | Mantenemos `gap` consistente con Box/Flex/Grid. Aliases sólo añaden ruido. |
| Slot para divider entre filas | diferir | Stack tiene `divider` pending; vendría después de Stack. |
| `equalChildHeight` toggle | diferir | Posible utility si surge una necesidad real — hoy se logra con `align="stretch"` heredado de Grid. |
## Referencias
- Chakra UI SimpleGrid: https://chakra-ui.com/docs/components/simple-grid
- Mantine SimpleGrid: https://mantine.dev/core/simple-grid/
- CSS `grid-template-columns`: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns
- Smolnar / Heydon "Every Layout" Grid: https://every-layout.dev/layouts/grid/
## Passive justification
Visual-only primitive (`scope: ['eidos']` en el morfo). Una sola
part Provider que emite el marker `[data-auto-grid]` sobre el shell
`[data-box][data-grid]`. Sin estados, sin data-attrs específicos, sin
ARIA, sin keyboard. Toda la lógica se reduce a computar
`grid-template-columns` y delegar a `<Grid>`. No hay nada que animar
ni ciclo de vida que enviar a sema.

@ -0,0 +1,15 @@
/*
* AutoGrid recipe — AutoGrid is a thin compositional layer over Grid.
* It computes its `templateColumns` in JS (either `repeat(auto-fill,
* minmax(MIN, 1fr))` or `repeat(N, minmax(0, 1fr))`) and forwards every
* other prop to `<Grid>`. The recipe only needs to declare the
* `[data-auto-grid]` marker so callers can target the primitive
* specifically (e.g. to add a section-scoped override).
*/
[data-box][data-grid][data-auto-grid] {
/* No own properties — every visible style comes from the Grid recipe
reading the `--grid-template-columns` variable that AutoGrid emits.
The marker exists so external consumers can write
`:where([data-auto-grid]) { … }` selectors. */
}

@ -0,0 +1,38 @@
<script lang="ts">
/**
* Eidos `<AutoGrid>` — responsive grid that fits columns to its inline
* size without media queries. Composes through `<Grid>` (which composes
* through `<Box>`), so the consumer gets every Box / Grid prop on top
* of the AutoGrid-specific `minChildWidth` / `columns`.
*
* <AutoGrid minChildWidth={240} gap={3}>…</AutoGrid> <!-- fluid -->
* <AutoGrid columns={3} gap={3}>…</AutoGrid> <!-- fixed -->
*
* Computation rule: `minChildWidth` wins over `columns`. When neither
* is set, AutoGrid behaves like a normal grid (no template).
*/
import { ActiveEidos } from '$uix/eidos';
import Grid from '../grid/grid.svelte';
import { formatLayoutLength } from '../_layout/shared';
import type { AutoGridProps } from './types';
let { minChildWidth, columns, children, ...restProps }: AutoGridProps = $props();
const eidos = ActiveEidos.require();
const templateColumns = $derived.by(() => {
const resolvedMinChildWidth = formatLayoutLength(eidos.resolve(minChildWidth));
if (resolvedMinChildWidth) {
return `repeat(auto-fill, minmax(${resolvedMinChildWidth}, 1fr))`;
}
const resolvedColumns = eidos.resolve(columns);
if (typeof resolvedColumns === 'number' && resolvedColumns > 0) {
return `repeat(${resolvedColumns}, minmax(0, 1fr))`;
}
return undefined;
});
</script>
<Grid {...restProps} {templateColumns} data-auto-grid="">
{@render children?.()}
</Grid>

@ -0,0 +1,11 @@
// AutoGrid — responsive grid that fits columns to inline size.
//
// import { AutoGrid } from '$uix/eidos/components/auto-grid';
//
// <AutoGrid minChildWidth={240} gap={3}>…</AutoGrid>
// <AutoGrid columns={3} gap={3}>…</AutoGrid>
import AutoGrid from './auto-grid.svelte';
export { AutoGrid };
export default AutoGrid;
export type { AutoGridProps } from './types';

@ -0,0 +1,28 @@
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { GridProps } from '../grid/types';
import type { LayoutLengthValue } from '../_layout/shared';
/**
* AutoGrid props.
*
* Either pass `minChildWidth` (auto-fill: as many columns as fit) or
* `columns` (fixed N). When both are passed, `minChildWidth` wins — the
* fluid template (`repeat(auto-fill, minmax(MIN, 1fr))`) is the AutoGrid
* usecase; the numeric form is included for parity with consumers who
* already know `<Grid columns={N}>` and just want the AutoGrid prop
* surface (no extra Box props, simpler defaults).
*/
export type AutoGridProps = Omit<GridProps, 'templateColumns' | 'columns'> & {
/**
* Minimum inline size per item before wrapping to a new row. Maps to
* `repeat(auto-fill, minmax(MIN, 1fr))`. Use this for fluid responsive
* grids that need no breakpoints. Number → px.
*/
minChildWidth?: ResponsiveProp<LayoutLengthValue>;
/**
* Number of fixed columns. Maps to `repeat(N, minmax(0, 1fr))`. Use
* when the layout has a strict column count regardless of viewport.
* Ignored when `minChildWidth` is set.
*/
columns?: ResponsiveProp<number>;
};

@ -0,0 +1,141 @@
# Eidos Banner
Full-bleed announcement strip with intent / variant / size visual
treatment. Renders as `<header data-banner role="banner">` so the
landmark works reliably across layouts — HTML spec only grants
`role="banner"` to a `<header>` when it is a top-level child of `<body>`;
nesting it inside `<main>`, `<article>` or `<section>` strips the role.
The explicit role stabilises the announcement intent.
## Superficie
```svelte
<Banner intent="affirm" variant="soft" size="md">
<span>Your changes have been saved.</span>
</Banner>
<!-- Dismissible: consumer owns the visibility state. -->
<script>
let show = $state(true);
</script>
{#if show}
<Banner intent="risk" variant="solid">
<span>Connection lost — reconnecting…</span>
<Banner.Close onclick={() => (show = false)} />
</Banner>
{/if}
```
## Baseline
Origen: `air/components/layout/banner` (rama `morfo-runtime`).
Air shipped a single-prop Banner — `<header role="banner">` + a small
spacing/padding recipe and no variants. The task brief asked for a full
announcement primitive with intent variants and an optional dismiss
button, modelled after Chakra / Mantine / MUI `Alert`. The current shape
keeps air's landmark contract (explicit `role="banner"`) and adds:
- `intent` mapped to the canonical UIX `ColorRole` (8 values:
`primary | secondary | neutral | affirm | fulfill | risk | threat | loss`).
- `variant` from the shared `ChipVariant` vocab (`soft | solid | outline | ghost`).
- `size` narrowed from canonical `Size` to `sm | md | lg` — smaller
doesn't read; larger turns the strip into a hero (out of scope).
- Optional `Banner.Close` part. The dismiss is **composition-driven**:
the consumer wraps Banner in `{#if show}` and wires onclick — Banner
itself does not own the visibility state. See "Decisiones" below.
Air's CSS prefix (`air-banner`) is dropped; selectors target
`[data-banner]` directly. CSS variables renamed from `--air-banner-*` to
`--banner-*` (public) and `--_banner-*` (internal recipe-local).
## Comparativa
| Capacidad | UIX (eidos) | Chakra UI Alert | Mantine Alert | MUI Alert |
| --- | --- | --- | --- | --- |
| Intent / status vocabulary | UIX 8 (`primary, secondary, neutral, affirm, fulfill, risk, threat, loss`) | Chakra 4 (`info, warning, success, error`) | Mantine 6 (`blue, gray, red, …` semantic colors) | MUI 4 (`success, info, warning, error`) |
| Variants | `soft / solid / outline / ghost` | `subtle / left-accent / top-accent / solid` | `filled / light / outline / default / transparent` | `standard / filled / outlined` |
| Sizes | `sm / md / lg` | Single size + custom | Theme spacing scale | Single size + custom |
| Landmark `role="banner"` | Sí (explicit) | No (uses `role="alert"`) | No (`role="alert"`) | No (`role="alert"`) |
| Dismissible | Sí (`Banner.Close` composition) | Sí (`CloseButton` slot) | Sí (`withCloseButton`) | Sí (`onClose`) |
| Icon slot | Composition (consumer's `<Icon>`) | `AlertIcon` shorthand | `icon` prop | `icon` prop |
| Title + description structure | Composition (consumer's markup) | `AlertTitle` / `AlertDescription` | `title` prop | `AlertTitle` |
| ARIA live region | No — Banner is a landmark, not a live region | `role="alert"` (assertive) | `role="alert"` | `role="alert"` |
| Responsive props | Sí (`ResponsiveProp` on intent / variant / size) | Sí (`useColorMode`) | Sí (responsive sx) | Sí (`sx`) |
## Decisiones
- **`role="banner"`, not `role="alert"`** — Banner is a landmark for
page-level announcements (site header, persistent notice, system
status bar). Transient feedback that interrupts the screen reader
belongs to `<Toast>`, which already declares the proper
`role="status"` / `aria-live="polite"` contract. Forcing
`role="alert"` on a static strip would be aggressive overreach.
- **Composition, not boolean `dismissible`** — pattern matches the rest
of UIX (DatePicker, Dialog, Drawer all dropped `*Button` boolean
props 2026-05-21). The consumer wraps Banner in `{#if show}` and
includes `<Banner.Close onclick={() => (show = false)} />` to render
the dismiss button. Banner doesn't own visibility, doesn't reset its
own state, doesn't fire a sema event when closed. The
`feedback_user_design_overrides_canon_vocabulary` rule applies: a
generic landmark strip doesn't carry an intrinsic commit semantic.
- **`intent` uses UIX 8-role vocab, not Chakra's 4** — keeps the
vocabulary consistent with the rest of the system (SearchField,
Toast, Toggle, etc. all use the same `ColorRole`). The mapping from
conventional `info / success / warning / error` is documented in the
intent canon (`src/uix/intent.ts`).
- **`variant` = `ChipVariant`** — reuses the canonical 4-value vocab
instead of inventing a new union per component (Chakra ships 4,
Mantine 5, MUI 3 — none with the same names). The same vocab powers
Chip / Tag / Avatar.
- **No icon / title / description slots** — composition over
configuration. The consumer puts an Icon + heading + body inside
Banner's children; flex/gap from the recipe handles the spacing.
- **Close is eidos-only** — meets the four rules in
`eidos/components/README.md` (no behavior, no aria contract beyond
the user-supplied `aria-label`, no event-target role, no keyboard
beyond a plain `<button>`). The morfo doesn't declare Close as a
part because the close is the consumer's onclick — Banner doesn't
own a commit semantic.
## Eventos Sema
Banner declara 0 eventos. Es una primitiva pasiva que conforma una
superficie de anuncio. La dismissión, cuando aplica, vive en el handler
`onclick` que el consumidor adjunta a `<Banner.Close>` — Banner no
commite ni emerge. Eso evita inflar el contrato Morfo con un evento
genérico que no aplica universalmente (banners persistentes, banners
informativos sin dismiss, banners full-bleed sin botón). Misma
justificación que Box / Flex / Grid: la primitiva no carga semánticas
que no posee.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `role="alert"` automatic when `intent='threat'` | descartar | Banner es landmark; Toast cubre el live-region case. Forzar role=alert sería confuso. |
| Icon slot dedicado | descartar | El consumidor compone `<Icon>` antes del texto; `gap` del recipe espacía. |
| `AlertTitle` / `AlertDescription` parts | diferir | Pendiente si surgen ≥2 casos donde la composición plana sea ergonómicamente pobre. |
| Animación de entrada/salida | diferir | Hoy se compone con `transition:slide` del consumidor o con `<Collapsible>`. Si llegan ≥2 casos donde Banner necesite owned motion, subir a Sema con la verb `surge`. |
| `borderRadius` shorthand | implementar | Hoy via `style=`. Posible añadirlo como `--banner-radius` token público; ya existe en el recipe pero sin prop. |
| Persistent dismissed-state (cookie / localStorage) | descartar | No es responsabilidad del componente de layout. El consumidor implementa la persistencia. |
| `Banner.Close` as a real morfo part with sema close-cancel commit | diferir | Si se decide que la dismissión es semántica universal, subir Close a Morfo + Sema. Hoy es composition only. |
## Referencias
- Chakra UI Alert: https://chakra-ui.com/docs/components/alert
- Mantine Alert: https://mantine.dev/core/alert/
- MUI Alert: https://mui.com/material-ui/react-alert/
- HTML landmark roles: https://www.w3.org/TR/wai-aria-1.2/#banner
- Air Banner (original): morfo-runtime branch — `src/uix/air/components/layout/banner`
## Passive justification
Visual-only primitive (`scope: ['eidos']` en el morfo). Una sola
part Provider que emite el marker `[data-banner]` + `data-intent` +
`data-variant` + `data-size` + `role="banner"`. Sin estados internos, sin
ARIA contracts dependientes de runtime, sin keyboard, sin commit
semantics. La Close children es eidos-only — el consumidor handlea
onclick para visibility. No hay nada que animar como verbo Sema; las
transiciones de aparición/desaparición las orquesta el consumidor con
`{#if}` + transitions de Svelte o componiendo con `<Collapsible>`.

@ -0,0 +1,44 @@
<script lang="ts">
/**
* Eidos `<Banner.Close>` — visually-styled dismiss button. Pure
* eidos-only part: meets the four rules in
* `eidos/components/README.md` (no behavior, no aria contract, no
* event-target role, no keyboard). The consumer wires `onclick` to
* their own visibility state — Banner does not own the dismissal.
*
* Defaults `aria-label="Dismiss"` so consumers don't have to remember,
* but the consumer can override.
*/
import type { BannerCloseProps } from './types';
let {
'aria-label': ariaLabel = 'Dismiss',
class: className,
children,
...restProps
}: BannerCloseProps = $props();
</script>
<button
type="button"
{...restProps}
aria-label={ariaLabel}
class={className}
data-banner-close=""
>
{#if children}{@render children()}{:else}<svg
viewBox="0 0 16 16"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<path
d="M3 3l10 10M13 3L3 13"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>{/if}
</button>

@ -0,0 +1,169 @@
/*
* Banner recipe — full-bleed announcement strip. Reads `data-intent`
* (the standard UIX color-role channel) and `data-variant` (soft / solid
* / outline / ghost) to pick the right palette mix from the foundation
* `--color-{intent}-*` tokens. `data-size` controls vertical density.
*
* Variant cascade:
* soft — `--color-{intent}-track` bg + `--color-{intent}-text` fg
* solid — `--color-{intent}-solid` bg + `--color-{intent}-contrast` fg
* outline — transparent bg, `--color-{intent}-border` ring + text
* ghost — transparent surface — text only
*
* `--banner-*` tokens are public overrides; `--_banner-*` are internal
* recipe-local resolutions.
*/
[data-banner] {
/* Shared structural surface. */
display: flex;
align-items: center;
gap: var(--banner-gap, var(--space-3));
padding-block: var(--_banner-padding-block);
padding-inline: var(--_banner-padding-inline);
inline-size: 100%;
min-inline-size: 0;
box-sizing: border-box;
font-size: var(--_banner-font-size);
line-height: var(--font-line-height-sm, 1.4);
background: var(--_banner-bg, transparent);
color: var(--_banner-fg, inherit);
border: var(--_banner-border-width, 0) solid var(--_banner-border-color, transparent);
border-radius: var(--banner-radius, 0);
}
/* ── Size ──────────────────────────────────────────────────────────── */
[data-banner][data-size='sm'] {
--_banner-padding-block: var(--space-2);
--_banner-padding-inline: var(--space-3);
--_banner-font-size: var(--font-size-sm);
}
[data-banner][data-size='md'] {
--_banner-padding-block: var(--space-3);
--_banner-padding-inline: var(--space-4);
--_banner-font-size: var(--font-size-md);
}
[data-banner][data-size='lg'] {
--_banner-padding-block: var(--space-4);
--_banner-padding-inline: var(--space-5);
--_banner-font-size: var(--font-size-lg);
}
/* ── Intent palette resolution ─────────────────────────────────────── */
[data-banner][data-intent='primary'] {
--_banner-solid: var(--color-primary-solid);
--_banner-solid-contrast: var(--color-primary-contrast);
--_banner-track: var(--color-primary-track);
--_banner-border: var(--color-primary-border);
--_banner-text: var(--color-primary-text);
}
[data-banner][data-intent='secondary'] {
--_banner-solid: var(--color-secondary-solid);
--_banner-solid-contrast: var(--color-secondary-contrast);
--_banner-track: var(--color-secondary-track);
--_banner-border: var(--color-secondary-border);
--_banner-text: var(--color-secondary-text);
}
[data-banner][data-intent='neutral'] {
--_banner-solid: var(--color-neutral-solid);
--_banner-solid-contrast: var(--color-neutral-contrast);
--_banner-track: var(--color-neutral-track);
--_banner-border: var(--color-neutral-border);
--_banner-text: var(--color-neutral-text);
}
[data-banner][data-intent='affirm'] {
--_banner-solid: var(--color-affirm-solid);
--_banner-solid-contrast: var(--color-affirm-contrast);
--_banner-track: var(--color-affirm-track);
--_banner-border: var(--color-affirm-border);
--_banner-text: var(--color-affirm-text);
}
[data-banner][data-intent='fulfill'] {
--_banner-solid: var(--color-fulfill-solid);
--_banner-solid-contrast: var(--color-fulfill-contrast);
--_banner-track: var(--color-fulfill-track);
--_banner-border: var(--color-fulfill-border);
--_banner-text: var(--color-fulfill-text);
}
[data-banner][data-intent='risk'] {
--_banner-solid: var(--color-risk-solid);
--_banner-solid-contrast: var(--color-risk-contrast);
--_banner-track: var(--color-risk-track);
--_banner-border: var(--color-risk-border);
--_banner-text: var(--color-risk-text);
}
[data-banner][data-intent='threat'] {
--_banner-solid: var(--color-threat-solid);
--_banner-solid-contrast: var(--color-threat-contrast);
--_banner-track: var(--color-threat-track);
--_banner-border: var(--color-threat-border);
--_banner-text: var(--color-threat-text);
}
[data-banner][data-intent='loss'] {
--_banner-solid: var(--color-loss-solid);
--_banner-solid-contrast: var(--color-loss-contrast);
--_banner-track: var(--color-loss-track);
--_banner-border: var(--color-loss-border);
--_banner-text: var(--color-loss-text);
}
/* ── Variant treatment ─────────────────────────────────────────────── */
[data-banner][data-variant='soft'] {
--_banner-bg: var(--_banner-track);
--_banner-fg: var(--_banner-text);
--_banner-border-width: 0;
}
[data-banner][data-variant='solid'] {
--_banner-bg: var(--_banner-solid);
--_banner-fg: var(--_banner-solid-contrast);
--_banner-border-width: 0;
}
[data-banner][data-variant='outline'] {
--_banner-bg: transparent;
--_banner-fg: var(--_banner-text);
--_banner-border-width: 1px;
--_banner-border-color: var(--_banner-border);
}
[data-banner][data-variant='ghost'] {
--_banner-bg: transparent;
--_banner-fg: var(--_banner-text);
--_banner-border-width: 0;
}
/* ── Close button (eidos-only part) ────────────────────────────────── */
[data-banner-close] {
display: inline-flex;
align-items: center;
justify-content: center;
margin-inline-start: auto;
inline-size: var(--space-6);
block-size: var(--space-6);
padding: 0;
background: transparent;
color: inherit;
border: 0;
border-radius: var(--radius-sm, var(--space-1));
cursor: pointer;
opacity: 0.8;
transition: opacity 120ms ease, background-color 120ms ease;
}
[data-banner-close]:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
}
[data-banner-close]:focus-visible {
outline: 2px solid currentColor;
outline-offset: 1px;
opacity: 1;
}

@ -0,0 +1,47 @@
<script lang="ts">
/**
* Eidos `<Banner>` — full-bleed announcement strip with intent /
* variant / size visual treatment. Renders as
* `<header data-banner role="banner">` with `data-intent`,
* `data-variant`, and `data-size` driving the recipe. The explicit
* `role="banner"` stabilises the landmark across layouts (HTML spec
* grants the implicit role only to top-level `<header>` children of
* `<body>`).
*
* <Banner intent="affirm" variant="soft" size="md">
* <Icon name="check" />
* <span>Your changes have been saved.</span>
* <Banner.Close onclick={() => (show = false)} />
* </Banner>
*/
import { ActiveEidos } from '$uix/eidos';
import type { BannerProps } from './types';
let {
intent = 'primary',
variant = 'soft',
size = 'md',
class: className,
children,
...restProps
}: BannerProps = $props();
const eidos = ActiveEidos.require();
const resolvedIntent = $derived(eidos.resolve(intent) ?? 'primary');
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'soft');
const resolvedSize = $derived(eidos.resolve(size) ?? 'md');
</script>
<!-- svelte-ignore a11y_no_redundant_roles -->
<header
{...restProps}
class={className}
role="banner"
data-banner=""
data-intent={resolvedIntent}
data-variant={resolvedVariant}
data-size={resolvedSize}
>
{@render children?.()}
</header>

@ -0,0 +1,27 @@
// Banner — full-bleed announcement strip with intent variants.
//
// import { Banner } from '$uix/eidos/components/banner';
//
// <Banner intent="affirm" variant="soft" size="md">
// <span>Your changes have been saved.</span>
// <Banner.Close onclick={() => (show = false)} />
// </Banner>
import BannerComponent from './banner.svelte';
import Close from './banner-close.svelte';
type BannerNamespace = typeof BannerComponent & {
Close: typeof Close;
};
const Banner = BannerComponent as BannerNamespace;
Banner.Close = Close;
export { Banner };
export default Banner;
export type {
BannerProps,
BannerCloseProps,
BannerIntent,
BannerVariant,
BannerSize
} from './types';

@ -0,0 +1,56 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { ChipVariant } from '$uix/eidos/lib/types';
/**
* Banner intent vocabulary. Mirrors the canonical UIX `ColorRole` set
* (Intent + `primary` / `secondary` hierarchy roles). The recipe pulls
* the matching `--color-{intent}-{slot}` tokens for each value.
*/
export type BannerIntent = ColorRole;
/**
* Visual treatment family. Banner reuses the canonical `ChipVariant`
* vocabulary (`soft` / `solid` / `outline` / `ghost`):
* - `soft`: tinted background + matching text (default).
* - `solid`: saturated background, contrasting text.
* - `outline`: transparent background, accented border + text.
* - `ghost`: transparent — text only, no surface tint.
*/
export type BannerVariant = ChipVariant;
/**
* Size scale exposed by Banner. Narrowed from the canonical `Size`
* scale to the values the recipe actually maps. `sm` / `md` / `lg` are
* the meaningful announcement-strip heights; smaller doesn't read,
* larger turns into a hero.
*/
export type BannerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
export type BannerProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/**
* Color intent for the announcement. Drives background, border, and
* text via the matching `--color-{intent}-*` tokens. @default 'primary'
*/
intent?: ResponsiveProp<BannerIntent>;
/**
* Visual treatment family. @default 'soft'
*/
variant?: ResponsiveProp<BannerVariant>;
/**
* Vertical density. @default 'md'
*/
size?: ResponsiveProp<BannerSize>;
/** External accessible name (forwarded to the `<header>`). */
'aria-label'?: string;
/** External label id (forwarded to the `<header>`). */
'aria-labelledby'?: string;
children?: Snippet;
};
export type BannerCloseProps = Omit<HTMLAttributes<HTMLButtonElement>, 'children'> & {
/** Accessible label for the dismiss button. @default 'Dismiss' */
'aria-label'?: string;
children?: Snippet;
};

@ -0,0 +1,130 @@
# Eidos Float
CSS-`float` primitive: pulls a child to the start or end of the text
flow so the surrounding inline content wraps around it. Composes through
`<Box>` so every Box prop (max-width, padding, margin, …) still works
on the wrapper.
Typical use cases:
- Inline images inside long-form articles.
- Pull-quotes.
- Drop caps (an initial letter inside an `<h2>` or paragraph).
- Side-note labels next to a paragraph.
## Superficie
```svelte
<p>
<Float side="start" maxWidth={140} gap={3}>
<img src="diagram.png" alt="" />
</Float>
Long paragraph that wraps around the floated image, with the image
pulled to the start (left in LTR) and the text flowing to the
inline-end side of it.
</p>
<!-- Pull-quote on the end side -->
<article>
<Float side="end" maxWidth={220} gap={4}>
<blockquote>"A quotation pulled to the end of the column."</blockquote>
</Float>
Body copy continues here with the quote on the inline-end side…
</article>
```
## Baseline
Origen nominal: `air/components/layout/float` (rama `morfo-runtime`),
**pero la semántica se ha re-definido en el port**.
Air's `Float` era una primitiva de **posicionamiento absoluto** con un
grid externo de 3×3 (`top-start`, `top-center`, …, `bottom-end`) que
flotaba contenido respecto a un anchor explícito. Equivalente
arquitectónico: una variación local del `floating-layer` sin
collision-aware positioning. Esa primitiva tiene su sitio (badges sobre
avatares, indicadores sobre items) pero NO se llama `float` — es más
cercana a Radix `Float` o `Positioned`.
Cuando se redefinió la batch como "layout primitives", el brief de la
batch eligió `float` para la otra primitiva natural con ese nombre: la
**propiedad CSS `float`**, la herramienta de larga data para que texto
inline wrappee alrededor de imágenes / pull-quotes / drop caps. Las
referencias que Radix Themes `Inset` y MUI `Float` cubren son
exactamente este caso. Mantine no tiene un primitive para esto.
Por eso este wrapper no porta el código de air verbatim: rehace la
primitiva alrededor de `float: inline-start` / `float: inline-end` con
el `gap` lateral configurable. La primitiva air anterior se reconsidera
en el futuro como `<Positioned>` o `<Anchor>` cuando se decida que
merece su propio componente.
## Comparativa
| Capacidad | UIX (eidos) | Radix Themes `Inset` | MUI Pull-quote pattern | Mantine |
| --- | --- | --- | --- | --- |
| Side of text flow | `side='start' / 'end'` (logical) | `side='left' / 'right' / 'top' / 'bottom'` (logical-ish) | Manual `float: left/right` | — (no primitive) |
| RTL mirroring | Automatic (`float: inline-{start|end}`) | Manual | Manual | n/a |
| Inline gap from text | `gap` prop → margin on the side that faces text | `mx` prop | Manual margin | n/a |
| Block-axis margin | Via Box `marginTop` / `marginBottom` | Via `my` / `mb` / `mt` | Manual | n/a |
| Max width / sizing | Inherited from Box | Inherited from Inset/Box | Manual | n/a |
| Responsive `side` | Sí (`ResponsiveProp<FloatSide>`) | Sí | n/a | n/a |
| Composes through Box | Sí | Box ancestor | Manual | n/a |
| ARIA / role | Ninguno (primitiva pasiva) | Ninguno | Ninguno | n/a |
## Decisiones
- **`float: inline-start | inline-end`**, no físico `left | right`. La
propiedad lógica mirrors automáticamente en RTL — un Float que mira
hacia el comienzo del texto sigue mirando hacia el comienzo cuando
el documento es árabe / hebreo.
- **`gap` aplica sólo al lado inline contra el texto**. El margin
block-axis (top / bottom) queda a cargo de las props estándar de Box
(`marginTop`, `marginBottom`) — eso da el control fino que requieren
los floats reales (suelen necesitar un poco de padding-top para
alinear con el ascender del texto vecino).
- **`side='start'`** por defecto: replica la convención editorial
occidental (imagen a la izquierda, texto fluyendo a la derecha) sin
ser hostil a RTL.
- **NO portamos el sistema de 9 placements de air**. Air's `Float` era
posicionamiento absoluto contra un anchor — semántica completamente
diferente. Se reservará para un futuro `<Positioned>` si surge la
necesidad. La actual primitiva es CSS-`float` puro, alineada con
Radix Themes `Inset` y MUI pull-quotes.
- **Compone a través de `<Box>`** — patrón consistente con
Flex/Grid/AspectRatio. Hereda max-width, padding, margin, etc., sin
duplicarlos.
## Eventos Sema
Float declara 0 eventos. Es una primitiva pasiva: cambia cómo su único
hijo participa en el flujo de texto circundante y nada más. Sin
commit, sin emerge, sin keyboard, sin ARIA. Misma justificación que
Box, Flex, Grid, AspectRatio.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| Posiciones físicas `left` / `right` además de logical | descartar | Las logicals son superset; el consumidor puede pasar `style="float:left"` si necesita el físico. |
| `clear` shorthand | diferir | El estado puede vivir como prop si surgen ≥2 casos donde el consumidor necesite que el contenido siguiente NO wrappee. Hoy, vía `style="clear: both"` en el siguiente bloque. |
| Detección automática de drop cap (`:first-letter`) | descartar | El consumidor compone Float alrededor de un `<span>` con la letra inicial. |
| Migración de la 9-zone air Float a `<Positioned>` | diferir | Se evaluará si surge la necesidad. Por ahora se documenta la divergencia. |
| Border-radius / shadow tokens propios | descartar | Float es estructural; el visual treatment lo provee el contenido del hijo. |
## Referencias
- Radix Themes Inset: https://www.radix-ui.com/themes/docs/components/inset
- MUI Float pattern: https://mui.com/material-ui/react-typography/
- CSS `float` (logical values): https://developer.mozilla.org/en-US/docs/Web/CSS/float
- Air Float (the 9-zone absolute-position primitive, NOT this primitive):
morfo-runtime branch — `src/uix/air/components/layout/float`
## Passive justification
Visual-only primitive (`scope: ['eidos']` en el morfo). Una sola
part Provider que emite `[data-float]` + `[data-side]` sobre el shell
`[data-box]`. Sin estados, sin data-attrs específicos, sin ARIA, sin
keyboard. El recipe consume `--float-gap` y aplica `float:inline-{start
| end}` según `[data-side]`. No hay nada que animar como verbo sema; el
text wrap es geometría pura del navegador.

@ -0,0 +1,23 @@
/*
* Float recipe — pulls the child to the start or end of the text flow.
* Uses the modern `float: inline-{start | end}` logical-side values so
* the primitive mirrors automatically in RTL contexts. The block-axis
* margin is left to the standard Box `margin*` props so the consumer
* can fine-tune top / bottom spacing case by case.
*
* `--float-gap` defaults to `var(--space-3)` when the consumer doesn't
* pass `gap`. The margin sits on the side that faces the wrapped text,
* so a `start`-side float gets `margin-inline-end`, and vice versa.
*/
[data-box][data-float] {
float: inline-start;
margin-inline-end: var(--float-gap, var(--space-3));
margin-inline-start: 0;
}
[data-box][data-float][data-side='end'] {
float: inline-end;
margin-inline-end: 0;
margin-inline-start: var(--float-gap, var(--space-3));
}

@ -0,0 +1,49 @@
<script lang="ts">
/**
* Eidos `<Float>` — pulls its child to the start or end of the text
* flow so adjacent inline content wraps around it. Composes through
* `<Box>` and writes a `--float-side` / `--float-gap` pair that the
* recipe consumes via `float: inline-{start | end}` + a logical
* margin against the text. Typical uses: inline images, pull-quotes,
* drop caps.
*
* <p>
* <Float side="start" maxWidth={120}>
* <img src="…" alt="…" />
* </Float>
* Long paragraph that wraps around the float…
* </p>
*/
import { ActiveEidos } from '$uix/eidos';
import Box from '../box/box.svelte';
import { composeStyle, formatLayoutSpace, pushStyleVar } from '../_layout/shared';
import type { FloatProps } from './types';
let {
side = 'start',
gap = 3,
style,
class: className,
children,
...restProps
}: FloatProps = $props();
const eidos = ActiveEidos.require();
const resolvedSide = $derived(eidos.resolve(side) ?? 'start');
const resolvedStyle = $derived.by(() => {
const decls: string[] = [];
pushStyleVar(decls, '--float-gap', formatLayoutSpace(eidos.resolve(gap)));
return composeStyle(decls, style);
});
</script>
<Box
{...restProps}
class={className}
style={resolvedStyle}
data-float=""
data-side={resolvedSide}
>
{@render children?.()}
</Box>

@ -0,0 +1,15 @@
// Float — pulls a child to the start or end of the text flow.
//
// import { Float } from '$uix/eidos/components/float';
//
// <p>
// <Float side="start" maxWidth={120}>
// <img src="…" alt="…" />
// </Float>
// Long paragraph that wraps around the float…
// </p>
import Float from './float.svelte';
export { Float };
export default Float;
export type { FloatProps, FloatSide } from './types';

@ -0,0 +1,26 @@
import type { ResponsiveProp } from '$uix/eidos/lib/types';
import type { BoxProps } from '../box/types';
import type { LayoutSpaceValue } from '../_layout/shared';
/**
* Side the floated element occupies in the text flow.
*
* `start` (logical left in LTR, right in RTL) maps to
* `float: inline-start`; `end` maps to `float: inline-end`. Use
* logical sides so RTL layouts mirror correctly without extra plumbing.
*/
export type FloatSide = 'start' | 'end';
export type FloatProps = Omit<BoxProps, 'position'> & {
/**
* Which side of the text flow the child sits on. @default 'start'
*/
side?: ResponsiveProp<FloatSide>;
/**
* Inline margin between the floated child and the surrounding text.
* Number → `var(--space-N)`. The block-axis margin defaults to zero
* (the consumer adds bottom margin via the standard Box `margin*`
* props when needed). @default 3
*/
gap?: ResponsiveProp<LayoutSpaceValue>;
};

@ -69,6 +69,10 @@
@import './components/wrap/wrap.css';
@import './components/container/container.css';
@import './components/section/section.css';
@import './components/aspect-ratio/aspect-ratio.css';
@import './components/auto-grid/auto-grid.css';
@import './components/banner/banner.css';
@import './components/float/float.css';
@import './components/icon/icon.css';
@import './components/avatar/avatar.css';
@import './components/breadcrumb/breadcrumb.css';

@ -0,0 +1,35 @@
import type { Morfo } from '../types';
/**
* AspectRatio — constrains the inner content to a width/height ratio
* (layout primitive).
*
* Eidos-native: the recipe consumes a single CSS variable (`--aspect-ratio`)
* that the component writes inline on a single `<div data-aspect-ratio>`
* shell. The CSS `aspect-ratio` property does the work — no padding-bottom
* hack, no absolutely-positioned inner element, no DOM gymnastics. The
* inner content fills the box via `width:100%; height:100%`.
*
* Justification for 0-event surface: AspectRatio does not commit, emerge,
* or react to anything. It is a pure visual / structural primitive that
* shapes its child via `aspect-ratio` CSS. Adding events would manufacture
* semantics the primitive doesn't carry. Same justification as `box.ts` /
* `flex.ts` / `grid.ts`.
*/
export const aspectRatioMorfo = {
name: 'AspectRatio',
kebab: 'aspect-ratio',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,33 @@
import type { Morfo } from '../types';
/**
* AutoGrid — responsive grid container that fits columns to inline size
* without media queries (layout primitive).
*
* Eidos-native: composes through `<Grid>` (which already composes through
* `<Box>`), so the provider renders a single `<div data-box data-grid
* data-auto-grid>` shell. Every prop maps to a `--auto-grid-*`,
* `--grid-*` or `--box-*` custom property — same pattern as the rest of
* the layout primitives.
*
* Justification for 0-event surface: AutoGrid is a pure visual /
* structural primitive that arranges children. No commit, no emerge, no
* keyboard, no aria. Same passive justification as `box.ts` / `grid.ts`.
*/
export const autoGridMorfo = {
name: 'AutoGrid',
kebab: 'auto-grid',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,46 @@
import type { Morfo } from '../types';
/**
* Banner — full-bleed announcement strip with intent variants
* (layout primitive).
*
* Eidos-native: the provider is a `<header data-banner role="banner">`
* shell. The intent prop maps to a `data-intent="…"` selector (the
* standard UIX color-role channel for visual treatment) and the recipe
* pulls the matching `--color-{intent}-*` tokens. The explicit role
* stabilises the landmark across layouts — the HTML spec only grants
* `role="banner"` to `<header>` when it is a top-level child of `<body>`;
* nesting it inside `<main>`, `<article>`, or `<section>` loses the role.
*
* Justification for 0-event surface: Banner is a passive surface that
* shapes an announcement. The optional Close child is a composition
* concern (a `<button>` the consumer wires to their own visibility
* state) — Banner itself does not own a commit, an emerge, a keyboard
* contract, or any ARIA beyond the landmark role. Same passive
* justification as `box.ts` / `flex.ts`.
*
* Close is intentionally NOT a morfo part: it is rendered as an
* eidos-only wrapper that only adds visual treatment to a plain
* `<button>` the consumer provides. The four rules in
* `eidos/components/README.md → Partes Eidos-only` are met: no behavior,
* no aria contract (the consumer supplies `aria-label`), no event target
* role, no keyboard. The consumer handles the dismissal via their own
* onclick — Banner does not commit or emerge anything.
*/
export const bannerMorfo = {
name: 'Banner',
kebab: 'banner',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'header',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -0,0 +1,40 @@
import type { Morfo } from '../types';
/**
* Float — CSS-`float` primitive that pulls a child to the start or end
* of a text flow so adjacent inline content wraps around it (layout
* primitive).
*
* Eidos-native: composes through `<Box>` and sets `float:inline-start`
* / `float:inline-end` plus a margin from the surrounding text via
* `--float-gap`. Typical use cases: inline images, pull-quotes, drop
* caps, side notes in long-form content.
*
* Justification for 0-event surface: Float is a pure visual /
* structural primitive. It changes how its child participates in the
* surrounding text flow and nothing else — no commit, no emerge, no
* keyboard, no ARIA. Same passive justification as `box.ts`.
*
* Note on naming: this is NOT the legacy `air/Float` 9-zone external
* placement primitive (that one was an absolutely-positioned overlay
* relative to an anchor). The CSS-`float` semantic is closer to Radix
* Themes `Inset` and MUI's pull-quote pattern — see the README's
* "Baseline" section for the design history.
*/
export const floatMorfo = {
name: 'Float',
kebab: 'float',
scope: ['eidos'],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -208,7 +208,11 @@
{ 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/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/float', label: 'Float' }
]
}
];

@ -0,0 +1,483 @@
<script lang="ts">
import {
AspectRatio,
type AspectRatioProps,
type AspectRatioValue
} from '$uix/eidos/components/aspect-ratio';
import { Box } from '$uix/eidos/components/box';
import { compileMorfo } from '$uix/morfo';
import { aspectRatioMorfo } from '@/uix/morfo/components/aspect-ratio';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ───────────────────────────────────────────────────────
type RatioPreset = '1' | '4/3' | '16/9' | '21/9' | '3/4' | '2/3';
const ratioPresets: RatioPreset[] = ['1', '4/3', '16/9', '21/9', '3/4', '2/3'];
let ratio = $state<RatioPreset>('16/9');
let maxWidth = $state<number>(420);
type Demo = 'panel' | 'image' | 'iframe';
const demoOptions = ['panel', 'image', 'iframe'] as const;
let demo = $state<Demo>('panel');
const ratioValue = $derived<AspectRatioValue>(ratio);
const aspectProps = $derived<Partial<AspectRatioProps>>({
ratio: ratioValue,
maxWidth
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(aspectRatioMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Snippets ─────────────────────────────────────────────────────────
const somaSnippet = $derived(
[
'<!-- AspectRatio is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-box',
' data-aspect-ratio',
` style="aspect-ratio: ${ratio}; overflow: hidden; max-inline-size: ${maxWidth}px;"`,
'>',
' <img src="hero.jpg" alt="" />',
'</div>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { AspectRatio } from '$uix/eidos/components/aspect-ratio';",
'</' + 'script>',
'',
'<AspectRatio',
ratio !== '1' && ` ratio="${ratio}"`,
maxWidth !== 0 && ` maxWidth={${maxWidth}}`,
'>',
demo === 'image' && ' <img src="hero.jpg" alt="" />',
demo === 'iframe' && ' <iframe src="…" title="Embed" />',
demo === 'panel' && ' <div class="panel">…</div>',
'</AspectRatio>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · AspectRatio</div>
<h1 data-uix-page-title>AspectRatio</h1>
<p data-uix-page-lede>
Constrains its inner content to a fixed width/height ratio via the modern CSS
<code>aspect-ratio</code> property. Composes through <a href="/uix/components/box">&lt;Box&gt;</a>,
so every Box prop (<code>padding</code>, <code>maxWidth</code>, <code>gridColumn</code>, …) still
works on the wrapper. Useful for video / image / iframe containers that must hold their shape
regardless of inline size. Eidos-native: no soma backing, no semantic events.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>extends</span>Box
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<AspectRatio {...aspectProps} style="border: 1px dashed var(--color-border-default); border-radius: var(--radius-md);">
{#if demo === 'panel'}
<div
style="display: grid; place-items: center; background: linear-gradient(135deg, var(--color-primary-track), var(--color-affirm-track)); color: var(--color-primary-text); font-weight: 600;"
>
{ratio}
</div>
{:else if demo === 'image'}
<!-- svelte-ignore a11y_img_redundant_alt -->
<img
src="https://picsum.photos/seed/uix-aspect/800/600"
alt="Sample photo placeholder"
/>
{:else}
<iframe
src="about:blank"
title="Aspect ratio preview frame"
style="border: 0;"
></iframe>
{/if}
</AspectRatio>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>ratio</span>
<span>{ratio}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>maxWidth</span>
{maxWidth}px · <span data-uix-stage-trace-key>demo</span> {demo}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>2</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
AspectRatio is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>ratio</code> prop maps to a single <code>--aspect-ratio</code> CSS variable; every
other prop (<code>maxWidth</code>, <code>padding</code>, …) inherits from Box.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>ratio</span>
<span data-uix-chips role="radiogroup">
{#each ratioPresets as opt}
<button data-uix-chip data-active={ratio === opt} onclick={() => (ratio = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>maxWidth <span data-uix-control-hint>pixels — inherited from Box</span></span
>
<input type="range" min="180" max="720" step="20" bind:value={maxWidth} />
<span data-uix-control-value>{maxWidth}px</span>
</label>
<label data-uix-control>
<span data-uix-control-label>demo content</span>
<span data-uix-chips role="radiogroup">
{#each demoOptions as opt}
<button data-uix-chip data-active={demo === opt} onclick={() => (demo = opt)}
>{opt}</button
>
{/each}
</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · aspect-ratio is eidos-native — equivalent markup shown</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · ratio maps to --aspect-ratio</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
The <code>ratio</code> prop accepts a number (e.g. <code>16/9</code> evaluated in JS) or a
string fraction (e.g. <code>"16/9"</code> emitted verbatim into CSS). Every other Box prop
(<code>padding</code>, <code>margin</code>, <code>maxWidth</code>, <code>gridColumn</code>,
…) passes through to the underlying <code>&lt;Box&gt;</code> shell.
</p>
<div data-uix-subsection-head>Aspect ratio</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">ratio</td>
<td class="type">number | string</td>
<td>
Number → CSS <code>aspect-ratio</code> as a decimal; string passes through
verbatim (<code>"16/9"</code>, <code>"4 / 3"</code>, <code>"1.5"</code>).
Default <code>1</code>.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inherited from Box</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">width / minWidth / maxWidth</td>
<td class="type">number | string</td>
<td>Constrains the box's inline size while keeping the ratio.</td>
</tr>
<tr>
<td class="name">padding / margin (and per-side)</td>
<td class="type">number | string</td>
<td>Numbers map to <code>var(--space-N)</code>.</td>
</tr>
<tr>
<td class="name">gridColumn / gridRow / placeSelf</td>
<td class="type">string</td>
<td>Use AspectRatio as a grid item — placement props live on Box.</td>
</tr>
<tr>
<td class="name">…</td>
<td class="type">BoxProps</td>
<td>See <a href="/uix/components/box">&lt;Box&gt;</a> for the full surface.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;AspectRatio&gt;</code></td>
<td>
Radix exposes a numeric <code>ratio</code> only; UIX additionally accepts a
string fraction so the value reads literal in DevTools.
</td>
</tr>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;AspectRatio&gt;</code></td>
<td>
Chakra mixes the prop with the chained style-prop system; UIX keeps it pure
layout — every other concern (background, color) lives elsewhere.
</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;AspectRatio&gt;</code></td>
<td>
Mantine ships the same primitive; UIX adds the string-form ratio + composes
through Box for inherited sizing props.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{aspectRatioMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{aspectRatioMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{aspectRatioMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</th>
<th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
The Provider part emits only the <code>data-aspect-ratio</code> marker on top of the Box
shell — no states, no data properties, no aria attributes, no keyboard. The recipe consumes
the <code>--aspect-ratio</code> CSS variable the component writes inline.
</p>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
AspectRatio declares no semantic events. As a passive layout primitive, it does not commit,
emerge, or react to anything — it just constrains its child's geometry. Components that animate
or change state on appearance should compose AspectRatio inside a primitive (popover, drawer,
collapsible) that owns the relevant sema verbs.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/aspect-ratio/aspect-ratio.css</code>. The
Box shell carries the box-model surface; this recipe only adds the
<code>aspect-ratio</code> property and stretches the lone child to fill the box.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-aspect-ratio]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Emitted by the component on top of the Box shell.</td>
</tr>
<tr>
<td class="name"
><code>[data-box][data-aspect-ratio] {`{ aspect-ratio: var(--aspect-ratio, 1); }`}</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Sets the CSS ratio from the variable the component writes inline.</td>
</tr>
<tr>
<td class="name"
><code>{`[data-box][data-aspect-ratio] > * { 100% × 100%, object-fit: cover }`}</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
Stretches the lone child (img / iframe / video / inner div) to fill the box.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
None implicit. Default element is <code>&lt;div&gt;</code> — semantically
neutral. When wrapping media, supply the appropriate landmark / role on the
child (<code>&lt;img alt&gt;</code>, <code>&lt;iframe title&gt;</code>,
<code>&lt;video&gt;</code> with captions).
</td>
</tr>
<tr>
<td class="name">Label</td>
<td>
Not applicable — AspectRatio has no content semantics. Labels belong to the
child it constrains.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>AspectRatio is not focusable. Tab order follows the child.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>AspectRatio does not paint a focus ring.</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
No motion of its own. If the child is a video / animation, the consumer is
responsible for honouring <code>prefers-reduced-motion</code>.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,497 @@
<script lang="ts">
import { AutoGrid, type AutoGridProps } from '$uix/eidos/components/auto-grid';
import { Box } from '$uix/eidos/components/box';
import { compileMorfo } from '$uix/morfo';
import { autoGridMorfo } from '@/uix/morfo/components/auto-grid';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ───────────────────────────────────────────────────────
type Mode = 'fluid' | 'fixed';
const modeOptions = ['fluid', 'fixed'] as const;
let mode = $state<Mode>('fluid');
let minChildWidth = $state<number>(180);
let columns = $state<number>(3);
let gap = $state<number>(3);
let itemCount = $state<number>(6);
const autoGridProps = $derived<Partial<AutoGridProps>>(
mode === 'fluid'
? { minChildWidth, gap }
: { columns, gap }
);
const items = $derived(
Array.from({ length: itemCount }, (_, i) => i + 1)
);
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(autoGridMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Snippets ─────────────────────────────────────────────────────────
const computedTemplate = $derived(
mode === 'fluid'
? `repeat(auto-fill, minmax(${minChildWidth}px, 1fr))`
: `repeat(${columns}, minmax(0, 1fr))`
);
const somaSnippet = $derived(
[
'<!-- AutoGrid is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-box',
' data-grid',
' data-auto-grid',
` style="display: grid; grid-template-columns: ${computedTemplate}; gap: var(--space-${gap});"`,
'>',
' …',
'</div>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { AutoGrid } from '$uix/eidos/components/auto-grid';",
'</' + 'script>',
'',
'<AutoGrid',
mode === 'fluid' && ` minChildWidth={${minChildWidth}}`,
mode === 'fixed' && ` columns={${columns}}`,
gap > 0 && ` gap={${gap}}`,
'>',
' <Card />',
' <Card />',
' <Card />',
'</AutoGrid>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · AutoGrid</div>
<h1 data-uix-page-title>AutoGrid</h1>
<p data-uix-page-lede>
Responsive grid that fits as many columns as its inline size allows — no media queries. Composes
through <a href="/uix/components/grid">&lt;Grid&gt;</a> (and therefore
<a href="/uix/components/box">&lt;Box&gt;</a>), so every Grid / Box prop still works.
<code>minChildWidth</code> drives the fluid template; <code>columns</code> falls back to a
fixed grid when that's the simpler fit. Eidos-native: no soma backing, no semantic events.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>extends</span>Grid
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<AutoGrid
{...autoGridProps}
style="border: 1px dashed var(--color-border-default); border-radius: var(--radius-md); padding: var(--space-3); inline-size: 100%; min-inline-size: 0;"
>
{#each items as n}
<Box
padding={2}
style="border: 1px solid var(--color-primary-border); border-radius: var(--radius-sm); background: var(--color-primary-track); color: var(--color-primary-text); text-align: center; font-variant-numeric: tabular-nums;"
>
Cell {n}
</Box>
{/each}
</AutoGrid>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>mode</span>
<span>{mode}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>template</span> {computedTemplate}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>2 + Grid</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
AutoGrid is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>minChildWidth</code> prop drives <code>repeat(auto-fill, minmax(MIN, 1fr))</code>;
the <code>columns</code> prop falls back to <code>repeat(N, minmax(0, 1fr))</code>. Resize
the viewport to see the fluid mode reflow.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>mode</span>
<span data-uix-chips role="radiogroup">
{#each modeOptions as opt}
<button data-uix-chip data-active={mode === opt} onclick={() => (mode = opt)}
>{opt}</button
>
{/each}
</span>
</label>
{#if mode === 'fluid'}
<label data-uix-control>
<span data-uix-control-label
>minChildWidth <span data-uix-control-hint
>pixels — minimum inline size per item before wrap</span
></span
>
<input type="range" min="120" max="320" step="10" bind:value={minChildWidth} />
<span data-uix-control-value>{minChildWidth}px</span>
</label>
{:else}
<label data-uix-control>
<span data-uix-control-label
>columns <span data-uix-control-hint>fixed column count</span></span
>
<input type="range" min="1" max="6" step="1" bind:value={columns} />
<span data-uix-control-value>{columns}</span>
</label>
{/if}
<label data-uix-control>
<span data-uix-control-label
>gap <span data-uix-control-hint>0–8 → var(--space-N)</span></span
>
<input type="number" min="0" max="8" step="1" bind:value={gap} style="inline-size: 6rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label
>items <span data-uix-control-hint>number of cells in the demo</span></span
>
<input type="range" min="1" max="12" step="1" bind:value={itemCount} />
<span data-uix-control-value>{itemCount}</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · auto-grid is eidos-native — equivalent markup shown</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · minChildWidth / columns computes templateColumns</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
AutoGrid adds two props on top of <code>&lt;Grid&gt;</code>. When both are set,
<code>minChildWidth</code> wins — the fluid template is the AutoGrid use case.
<code>templateColumns</code> from Grid is intentionally not exposed: passing it would
conflict with AutoGrid's own computation; use <code>&lt;Grid&gt;</code> directly when a
custom track list is required.
</p>
<div data-uix-subsection-head>AutoGrid props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">minChildWidth</td>
<td class="type">number | string</td>
<td>
Minimum inline size per item before wrapping. Number → px. Maps to
<code>repeat(auto-fill, minmax(MIN, 1fr))</code>. Responsive.
</td>
</tr>
<tr>
<td class="name">columns</td>
<td class="type">number</td>
<td>
Fixed column count. Maps to <code>repeat(N, minmax(0, 1fr))</code>. Ignored
when <code>minChildWidth</code> is set. Responsive.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inherited from Grid</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">gap / rowGap / columnGap</td><td class="type">number | string</td><td>Track spacing.</td></tr>
<tr><td class="name">rows / templateRows</td><td class="type">number | string</td><td>Forwarded to Grid.</td></tr>
<tr><td class="name">autoRows / autoColumns / autoFlow</td><td class="type">string</td><td>Auto-track sizing.</td></tr>
<tr><td class="name">align / justify / alignContent</td><td class="type">enum</td><td>Container alignment.</td></tr>
<tr><td class="name">…</td><td class="type">GridProps</td><td>See <a href="/uix/components/grid">&lt;Grid&gt;</a>.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inherited from Box</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">padding / margin / maxWidth / …</td>
<td class="type">number | string</td>
<td>Every Box prop. See <a href="/uix/components/box">&lt;Box&gt;</a>.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;SimpleGrid&gt;</code></td>
<td>
Chakra uses <code>auto-fit</code>; UIX uses <code>auto-fill</code> so single
items don't stretch across the whole inline size.
</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;SimpleGrid&gt;</code></td>
<td>
Mantine names the spacing prop <code>spacing</code>; UIX keeps
<code>gap</code> consistent with Box / Flex / Grid.
</td>
</tr>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;Grid columns="repeat(auto-fill,…)" /&gt;</code></td>
<td>
Radix has no fluid SimpleGrid; the consumer writes the template by hand.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{autoGridMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{autoGridMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{autoGridMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</th>
<th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
The Provider part emits only the <code>data-auto-grid</code> marker on top of the Grid
shell — no states, no data properties, no aria attributes, no keyboard. The recipe
delegates every visible style to the Grid recipe; the marker exists so consumers can
target AutoGrid specifically.
</p>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
AutoGrid declares no semantic events. As a passive layout primitive, it only arranges
children; it does not commit, emerge, or react to anything. Components that animate items
on enter/exit should compose AutoGrid with an interactive primitive (popover, drawer,
collapsible) that owns the relevant sema verbs.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/auto-grid/auto-grid.css</code>. AutoGrid
delegates every visible style to the Grid recipe; the file declares only the
<code>[data-auto-grid]</code> marker so consumers can target it directly. The fluid /
fixed template is computed in JS and emitted as <code>--grid-template-columns</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-auto-grid]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Emitted on top of the Grid shell.</td>
</tr>
<tr>
<td class="name"><code>[data-box][data-grid][data-auto-grid]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
Empty rule (placeholder). Every visible style reads from the Grid recipe
via the <code>--grid-template-columns</code> the component writes inline.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
None implicit. Default element is <code>&lt;div&gt;</code>. If the grid
carries a list (gallery, products), the consumer is responsible for using a
semantic <code>&lt;ul&gt;</code> wrapper around AutoGrid or each child.
</td>
</tr>
<tr>
<td class="name">Label</td>
<td>
Not applicable — AutoGrid has no content semantics. Labels belong to the
items it arranges.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>AutoGrid is not focusable. Tab order follows DOM order of children.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>AutoGrid does not paint a focus ring.</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
No motion of its own. Layout reflows are handled by the browser at full
speed regardless of <code>prefers-reduced-motion</code>.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,591 @@
<script lang="ts">
import {
Banner,
type BannerProps,
type BannerIntent,
type BannerVariant,
type BannerSize
} from '$uix/eidos/components/banner';
import { compileMorfo } from '$uix/morfo';
import { bannerMorfo } from '@/uix/morfo/components/banner';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state — full ColorRole + ChipVariant + narrowed Size ─────────
const intents: BannerIntent[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const variants: BannerVariant[] = ['soft', 'solid', 'outline', 'ghost'];
const sizes: BannerSize[] = ['sm', 'md', 'lg'];
let intent = $state<BannerIntent>('primary');
let variant = $state<BannerVariant>('soft');
let size = $state<BannerSize>('md');
let withIcon = $state<boolean>(true);
let withClose = $state<boolean>(false);
let dismissed = $state<boolean>(false);
let message = $state<string>('Heads up — your changes have been saved.');
const bannerProps = $derived<Partial<BannerProps>>({
intent,
variant,
size
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(bannerMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Snippets ─────────────────────────────────────────────────────────
const somaSnippet = $derived(
[
'<!-- Banner is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<header',
' data-banner',
' role="banner"',
` data-intent="${intent}"`,
` data-variant="${variant}"`,
` data-size="${size}"`,
'>',
withIcon && ' <svg aria-hidden="true">…</svg>',
` <span>${message}</span>`,
withClose && ' <button data-banner-close aria-label="Dismiss">×</button>',
'</header>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Banner } from '$uix/eidos/components/banner';",
withClose && ' let show = $state(true);',
'</' + 'script>',
'',
withClose && '{#if show}',
withClose ? ' <Banner' : '<Banner',
(withClose ? ' ' : ' ') + `intent="${intent}"`,
(withClose ? ' ' : ' ') + `variant="${variant}"`,
(withClose ? ' ' : ' ') + `size="${size}"`,
withClose ? ' >' : '>',
withIcon && (withClose ? ' <Icon name="info" />' : ' <Icon name="info" />'),
(withClose ? ' ' : ' ') + `<span>${message}</span>`,
withClose && ' <Banner.Close onclick={() => (show = false)} />',
withClose ? ' </Banner>' : '</Banner>',
withClose && '{/if}'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · Banner</div>
<h1 data-uix-page-title>Banner</h1>
<p data-uix-page-lede>
Full-bleed announcement strip with intent / variant / size visual treatment. Renders as
<code>&lt;header role="banner"&gt;</code> — the explicit role stabilises the landmark across
layouts (HTML spec only grants the implicit role to a top-level <code>&lt;header&gt;</code>
of <code>&lt;body&gt;</code>). For transient, live-region notifications use
<a href="/uix/components/toast">&lt;Toast&gt;</a> instead — that's where
<code>role="alert"</code> belongs. Dismissal is composition-driven: include
<code>&lt;Banner.Close&gt;</code> and wrap in <code>{`{#if show}`}</code>.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>intents</span>{intents.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>variants</span>{variants.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
{#if !dismissed}
<Banner {...bannerProps}>
{#if withIcon}
<svg
viewBox="0 0 16 16"
width="16"
height="16"
aria-hidden="true"
focusable="false"
>
<circle
cx="8"
cy="8"
r="6.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M8 5v3.5M8 10.5v.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{/if}
<span style="flex: 1 1 auto; min-inline-size: 0;">{message}</span>
{#if withClose}
<Banner.Close onclick={() => (dismissed = true)} />
{/if}
</Banner>
{:else}
<button
type="button"
onclick={() => (dismissed = false)}
style="padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); border: 1px solid var(--color-border-default); background: transparent; color: var(--color-neutral-text); cursor: pointer;"
>
Show banner again
</button>
{/if}
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>intent</span>
<span>{intent}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>variant</span> {variant} ·
<span data-uix-stage-trace-key>size</span> {size}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>4 + Close</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Banner is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. Every
control here drives a <code>data-*</code> attribute on the root that the recipe maps to
the matching <code>--color-{`{intent}`}-*</code> tokens.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>intent</span>
<span data-uix-chips role="radiogroup">
{#each intents as opt}
<button data-uix-chip data-active={intent === opt} onclick={() => (intent = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as opt}
<button
data-uix-chip
data-active={variant === opt}
onclick={() => (variant = opt)}>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as opt}
<button data-uix-chip data-active={size === opt} onclick={() => (size = opt)}
>{opt}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>Demo content</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>message</span>
<input type="text" bind:value={message} style="inline-size: 24rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label>with icon</span>
<input type="checkbox" bind:checked={withIcon} />
</label>
<label data-uix-control>
<span data-uix-control-label>with close (composition)</span>
<input type="checkbox" bind:checked={withClose} />
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · banner is eidos-native — equivalent markup shown</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · intent / variant / size + composition close</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
Three visual props on the root + an optional <code>&lt;Banner.Close&gt;</code> child for
dismissibility. Banner does NOT own the visibility state — the consumer wraps Banner in
<code>{`{#if show}`}</code> and wires <code>onclick</code> on the Close.
</p>
<div data-uix-subsection-head>Banner props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">intent</td>
<td class="type">{intents.join(' | ')}</td>
<td>
Color role. Maps to the matching <code>--color-{`{intent}`}-*</code> tokens.
Default <code>primary</code>. Responsive.
</td>
</tr>
<tr>
<td class="name">variant</td>
<td class="type">{variants.join(' | ')}</td>
<td>
Visual treatment family. <code>soft</code> = tinted track + matching text;
<code>solid</code> = saturated bg + contrast text; <code>outline</code> =
transparent bg + accented border; <code>ghost</code> = text only. Default
<code>soft</code>.
</td>
</tr>
<tr>
<td class="name">size</td>
<td class="type">{sizes.join(' | ')}</td>
<td>Vertical density. Default <code>md</code>. Responsive.</td>
</tr>
<tr>
<td class="name">aria-label / aria-labelledby</td>
<td class="type">string</td>
<td>Accessible name for the landmark.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Banner.Close props (eidos-only)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">onclick</td>
<td class="type">(e: MouseEvent) =&gt; void</td>
<td>
Consumer handles dismissal. Banner does not own visibility; wrap in
<code>{`{#if show}`}</code> at the call site.
</td>
</tr>
<tr>
<td class="name">aria-label</td>
<td class="type">string</td>
<td>Default <code>"Dismiss"</code>; override for localisation.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">chakra-ui</td>
<td><code>&lt;Alert&gt;</code></td>
<td>
Chakra ships 4 statuses (<code>info / success / warning / error</code>) and
<code>role="alert"</code>; UIX uses the 8-role <code>ColorRole</code>
vocabulary + <code>role="banner"</code> (landmark, not live region).
</td>
</tr>
<tr>
<td class="name">mantine</td>
<td><code>&lt;Alert&gt;</code></td>
<td>
Mantine drives variants via the theme color scale; UIX maps to the canonical
UIX intent palette.
</td>
</tr>
<tr>
<td class="name">mui</td>
<td><code>&lt;Alert&gt;</code></td>
<td>
MUI uses <code>standard / filled / outlined</code> variants and a built-in
close button via <code>onClose</code>; UIX favours composition with
<code>&lt;Banner.Close&gt;</code>.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{bannerMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{bannerMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{bannerMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</th>
<th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
Banner declares a single Provider part. <code>Banner.Close</code> is rendered as an
eidos-only wrapper (not a morfo part) because its dismissal is the consumer's onclick —
Banner itself does not own a commit semantic.
</p>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
Banner declares no semantic events. As a passive landmark surface, it does not commit,
emerge, or react to anything — it just renders an announcement strip. Dismissal lives in
the consumer's <code>onclick</code> handler on <code>&lt;Banner.Close&gt;</code>, not in
Banner itself. Transient live-region notifications belong to
<a href="/uix/components/toast">&lt;Toast&gt;</a>, which owns the proper sema vocabulary.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/banner/banner.css</code>. Three orthogonal
selectors: <code>data-intent</code> picks the palette tokens,
<code>data-variant</code> chooses how those tokens compose into background / foreground /
border, and <code>data-size</code> sets density.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-banner]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Emitted on the <code>&lt;header&gt;</code> element.</td>
</tr>
<tr>
<td class="name"><code>[data-banner][data-intent='X']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
Per-intent palette resolution. 8 rules, one per
<code>ColorRole</code>.
</td>
</tr>
<tr>
<td class="name"><code>[data-banner][data-variant='X']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Variant treatment — soft / solid / outline / ghost.</td>
</tr>
<tr>
<td class="name"><code>[data-banner][data-size='X']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Density — padding-block / padding-inline / font-size.</td>
</tr>
<tr>
<td class="name"><code>[data-banner-close]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>
Eidos-only close button. Self-contained styling — currentColor + hover /
focus-visible only.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
<code>role="banner"</code> (landmark) on the root <code>&lt;header&gt;</code>.
Stable across layouts — the implicit role only applies to a top-level
<code>&lt;header&gt;</code> of <code>&lt;body&gt;</code>.
</td>
</tr>
<tr>
<td class="name">Label</td>
<td>
Supply <code>aria-label</code> or <code>aria-labelledby</code> when the
banner's content alone doesn't make its purpose clear.
</td>
</tr>
<tr>
<td class="name">Keyboard (Close)</td>
<td>
<code>&lt;Banner.Close&gt;</code> is a plain <code>&lt;button type="button"&gt;</code>.
Tab to focus, Enter / Space to activate. Default
<code>aria-label="Dismiss"</code>.
</td>
</tr>
<tr>
<td class="name">Live region</td>
<td>
Banner is NOT a live region. Use <a href="/uix/components/toast"
>&lt;Toast&gt;</a
> for transient, screen-reader-interrupting feedback.
</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>
Close button paints a focus ring via <code>:focus-visible</code> with the
current text color.
</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>
No motion of its own. Hover transitions on Close use 120ms — well within the
"sub-perceptual" budget and honoured automatically by the cascade.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

@ -0,0 +1,518 @@
<script lang="ts">
import { Float, type FloatProps, type FloatSide } from '$uix/eidos/components/float';
import { compileMorfo } from '$uix/morfo';
import { floatMorfo } from '@/uix/morfo/components/float';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ───────────────────────────────────────────────────────
const sides: FloatSide[] = ['start', 'end'];
let side = $state<FloatSide>('start');
let gap = $state<number>(3);
let childWidth = $state<number>(140);
type Demo = 'image' | 'pull-quote' | 'drop-cap';
const demoOptions = ['image', 'pull-quote', 'drop-cap'] as const;
let demo = $state<Demo>('image');
const floatProps = $derived<Partial<FloatProps>>({
side,
gap,
maxWidth: childWidth
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(floatMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
// ── Snippets ─────────────────────────────────────────────────────────
const somaSnippet = $derived(
[
'<!-- Float is eidos-native — no soma layer. -->',
'<!-- Equivalent semantic markup (not real soma): -->',
'',
'<div',
' data-box',
' data-float',
` data-side="${side}"`,
` style="float: inline-${side}; margin-inline-${side === 'start' ? 'end' : 'start'}: var(--space-${gap}); max-inline-size: ${childWidth}px;"`,
'>',
' …',
'</div>'
].join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Float } from '$uix/eidos/components/float';",
'</' + 'script>',
'',
'<p>',
' <Float',
side !== 'start' && ` side="${side}"`,
gap !== 3 && ` gap={${gap}}`,
childWidth !== 0 && ` maxWidth={${childWidth}}`,
' >',
demo === 'image' && ' <img src="diagram.png" alt="" />',
demo === 'pull-quote' && ' <blockquote>"…"</blockquote>',
demo === 'drop-cap' && ' <span class="drop">A</span>',
' </Float>',
' Long paragraph that wraps around the floated child…',
'</p>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · Float</div>
<h1 data-uix-page-title>Float</h1>
<p data-uix-page-lede>
CSS-<code>float</code> primitive: pulls a child to the start or end of the text flow so the
surrounding inline content wraps around it. Composes through
<a href="/uix/components/box">&lt;Box&gt;</a>, so every Box prop (max-width, padding, margin,
…) still works. Typical uses: inline images, pull-quotes, drop caps. Uses logical
<code>float: inline-start / inline-end</code> so the primitive mirrors automatically in RTL.
Note: this is NOT the legacy 9-zone absolute-positioning Float — see the README's Baseline
section for the design history.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>0
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>extends</span>Box
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>eidos
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<article
style="max-inline-size: 36rem; padding: var(--space-4); border: 1px dashed var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default); color: var(--color-neutral-text); line-height: 1.55;"
>
<Float {...floatProps}>
{#if demo === 'image'}
<div
style="display: grid; place-items: center; aspect-ratio: 4/3; background: linear-gradient(135deg, var(--color-primary-track), var(--color-affirm-track)); color: var(--color-primary-text); border-radius: var(--radius-sm); font-weight: 600;"
>
{childWidth}×{Math.round((childWidth * 3) / 4)}
</div>
{:else if demo === 'pull-quote'}
<blockquote
style="margin: 0; padding: var(--space-3); border-inline-start: 3px solid var(--color-primary-border); background: var(--color-primary-track); color: var(--color-primary-text); border-radius: var(--radius-sm); font-style: italic;"
>
"A short quotation pulled from the surrounding text to anchor the reader's
attention."
</blockquote>
{:else}
<span
style="display: block; font-size: 4rem; line-height: 0.9; font-weight: 700; color: var(--color-primary-text); padding-block-start: 0.25rem;"
aria-hidden="true">A</span
>
{/if}
</Float>
{#if demo === 'drop-cap'}
ndon a moment to consider how the CSS `float` property has shaped the layout of
long-form articles for two decades. Originally designed exclusively to allow text to
flow around an image, it became the de-facto positioning tool of the early web. Today
it has fallen out of favour for general layout, replaced by flex and grid, but it
remains the right answer for the narrow editorial case it was built for.
{:else}
Long-form prose flowing around the floated child. CSS `float` predates flex and grid
by two decades; it was originally designed exclusively to allow text to wrap around an
inline image. For modern layout, prefer
<code>&lt;Flex&gt;</code> / <code>&lt;Grid&gt;</code>; for inline editorial wraps —
pull-quotes, sidebar notes, drop caps — the CSS-`float` primitive is still the right
tool. The Float wrapper provides logical-side semantics so the same markup mirrors
automatically in RTL contexts.
{/if}
</article>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>side</span>
<span>{side}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>gap</span> {gap} ·
<span data-uix-stage-trace-key>maxWidth</span> {childWidth}px
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>2</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · 0e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Float is eidos-native — no <span data-uix-layer-badge="soma">soma</span> split. The
<code>side</code> prop drives the logical float direction; <code>gap</code> sets the
inline margin against the wrapped text.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>side</span>
<span data-uix-chips role="radiogroup">
{#each sides as opt}
<button data-uix-chip data-active={side === opt} onclick={() => (side = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>gap <span data-uix-control-hint>0–8 → var(--space-N), inline margin from text</span></span
>
<input type="number" min="0" max="8" step="1" bind:value={gap} style="inline-size: 6rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label
>maxWidth <span data-uix-control-hint>pixels (inherited from Box)</span></span
>
<input type="range" min="80" max="320" step="10" bind:value={childWidth} />
<span data-uix-control-value>{childWidth}px</span>
</label>
<label data-uix-control>
<span data-uix-control-label>demo content</span>
<span data-uix-chips role="radiogroup">
{#each demoOptions as opt}
<button data-uix-chip data-active={demo === opt} onclick={() => (demo = opt)}
>{opt}</button
>
{/each}
</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>n/a · float is eidos-native — equivalent markup shown</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · side / gap + inherited Box props</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
Two props on the root + every Box prop inherited. The block-axis margin (top / bottom)
lives on the standard <code>marginTop</code> / <code>marginBottom</code> from Box,
because real-world floats often need a small padding-top to align with the ascender of
the surrounding text.
</p>
<div data-uix-subsection-head>Float props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">side</td>
<td class="type">{sides.join(' | ')}</td>
<td>
Logical side. Maps to <code>float: inline-start / inline-end</code>. Mirrors
in RTL. Default <code>start</code>.
</td>
</tr>
<tr>
<td class="name">gap</td>
<td class="type">number | string</td>
<td>
Inline margin against the wrapped text. Number → <code>var(--space-N)</code>.
Default <code>3</code>.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Inherited from Box</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">maxWidth / width / minWidth</td>
<td class="type">number | string</td>
<td>Constrain the float's inline size — essential for readable wrap behaviour.</td>
</tr>
<tr>
<td class="name">marginTop / marginBottom</td>
<td class="type">number | string</td>
<td>Block-axis margin against the surrounding text.</td>
</tr>
<tr>
<td class="name">padding</td>
<td class="type">number | string</td>
<td>Inner padding (e.g. for pull-quote boxes).</td>
</tr>
<tr>
<td class="name">…</td>
<td class="type">BoxProps</td>
<td>
See <a href="/uix/components/box">&lt;Box&gt;</a>. Note: <code>position</code>
is excluded — Float uses the cascade's `float`, not absolute positioning.
</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">radix-themes</td>
<td><code>&lt;Inset&gt;</code></td>
<td>
Radix Inset takes 4 physical sides; UIX Float uses logical
<code>start / end</code> so it mirrors in RTL.
</td>
</tr>
<tr>
<td class="name">mui</td>
<td>Pull-quote pattern</td>
<td>
MUI documents the pattern with manual <code>float: left</code> styling; UIX
wraps it as a typed primitive with logical sides.
</td>
</tr>
<tr>
<td class="name">mantine</td>
<td>n/a</td>
<td>
Mantine ships no float primitive; consumers write
<code>style={`{{ float: 'left' }}`}</code> manually.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{floatMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{floatMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{floatMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>archetype</th>
<th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.archetype}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
Float declares a single Provider part. The recipe reads <code>data-side</code> to pick
the logical float direction; the <code>--float-gap</code> variable controls the inline
margin against the wrapped text.
</p>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
Float declares no semantic events. As a passive layout primitive, it changes how its
child participates in the surrounding text flow and nothing else. Animations on float
direction changes (e.g. switching <code>side</code>) belong to the consumer's transition
logic, not to Float itself.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/float/float.css</code>. The
<code>data-side</code> attribute switches between <code>float: inline-start</code> and
<code>float: inline-end</code>; the inline margin against the text sits on the side that
faces the wrapped content.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-float]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Emitted on top of the Box shell.</td>
</tr>
<tr>
<td class="name"
><code>{`[data-box][data-float] { float: inline-start; margin-inline-end: var(--float-gap, var(--space-3)); }`}</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Default side — float to start of text flow.</td>
</tr>
<tr>
<td class="name"><code>[data-box][data-float][data-side='end']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Override — float to end of text flow with mirrored gap.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
None implicit. Default element is <code>&lt;div&gt;</code>. When the float
holds a quote, wrap the inner content in <code>&lt;blockquote&gt;</code>;
when it holds an image, the consumer supplies <code>alt</code>.
</td>
</tr>
<tr>
<td class="name">Reading order</td>
<td>
The floated element appears in DOM order but visually wraps with surrounding
text. Screen readers respect DOM order; consider placing pull-quotes
<em>after</em> the paragraph they belong to if visual order matters.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>Float is not focusable. Tab order follows DOM children.</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>Float does not paint a focus ring.</td>
</tr>
<tr>
<td class="name">RTL</td>
<td>
<code>side='start'</code> mirrors automatically — in RTL the float pulls to
the right side. Use logical sides; avoid hardcoded
<code>float: left / right</code>.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.