From 376f9e33eb55c1269ccd92cc1e138cccda9d73cc Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 18:51:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(layout):=20Layout=20Batch=202=20=E2=80=94?= =?UTF-8?q?=20aspect-ratio,=20auto-grid,=20banner,=20float?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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** — `
` 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 ``. 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 `` 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) --- src/uix/eidos/components/_layout/index.ts | 16 +- .../eidos/components/aspect-ratio/README.md | 114 ++++ .../components/aspect-ratio/aspect-ratio.css | 26 + .../aspect-ratio/aspect-ratio.svelte | 53 ++ .../eidos/components/aspect-ratio/index.ts | 12 + .../eidos/components/aspect-ratio/types.ts | 27 + src/uix/eidos/components/auto-grid/README.md | 118 ++++ .../eidos/components/auto-grid/auto-grid.css | 15 + .../components/auto-grid/auto-grid.svelte | 38 ++ src/uix/eidos/components/auto-grid/index.ts | 11 + src/uix/eidos/components/auto-grid/types.ts | 28 + src/uix/eidos/components/banner/README.md | 141 +++++ .../components/banner/banner-close.svelte | 44 ++ src/uix/eidos/components/banner/banner.css | 169 +++++ src/uix/eidos/components/banner/banner.svelte | 47 ++ src/uix/eidos/components/banner/index.ts | 27 + src/uix/eidos/components/banner/types.ts | 56 ++ src/uix/eidos/components/float/README.md | 130 ++++ src/uix/eidos/components/float/float.css | 23 + src/uix/eidos/components/float/float.svelte | 49 ++ src/uix/eidos/components/float/index.ts | 15 + src/uix/eidos/components/float/types.ts | 26 + src/uix/eidos/index.css | 4 + src/uix/morfo/components/aspect-ratio.ts | 35 ++ src/uix/morfo/components/auto-grid.ts | 33 + src/uix/morfo/components/banner.ts | 46 ++ src/uix/morfo/components/float.ts | 40 ++ web/routes/uix/+layout@.svelte | 6 +- .../uix/components/aspect-ratio/+page.svelte | 483 ++++++++++++++ .../uix/components/auto-grid/+page.svelte | 497 +++++++++++++++ web/routes/uix/components/banner/+page.svelte | 591 ++++++++++++++++++ web/routes/uix/components/float/+page.svelte | 518 +++++++++++++++ 32 files changed, 3436 insertions(+), 2 deletions(-) create mode 100644 src/uix/eidos/components/aspect-ratio/README.md create mode 100644 src/uix/eidos/components/aspect-ratio/aspect-ratio.css create mode 100644 src/uix/eidos/components/aspect-ratio/aspect-ratio.svelte create mode 100644 src/uix/eidos/components/aspect-ratio/index.ts create mode 100644 src/uix/eidos/components/aspect-ratio/types.ts create mode 100644 src/uix/eidos/components/auto-grid/README.md create mode 100644 src/uix/eidos/components/auto-grid/auto-grid.css create mode 100644 src/uix/eidos/components/auto-grid/auto-grid.svelte create mode 100644 src/uix/eidos/components/auto-grid/index.ts create mode 100644 src/uix/eidos/components/auto-grid/types.ts create mode 100644 src/uix/eidos/components/banner/README.md create mode 100644 src/uix/eidos/components/banner/banner-close.svelte create mode 100644 src/uix/eidos/components/banner/banner.css create mode 100644 src/uix/eidos/components/banner/banner.svelte create mode 100644 src/uix/eidos/components/banner/index.ts create mode 100644 src/uix/eidos/components/banner/types.ts create mode 100644 src/uix/eidos/components/float/README.md create mode 100644 src/uix/eidos/components/float/float.css create mode 100644 src/uix/eidos/components/float/float.svelte create mode 100644 src/uix/eidos/components/float/index.ts create mode 100644 src/uix/eidos/components/float/types.ts create mode 100644 src/uix/morfo/components/aspect-ratio.ts create mode 100644 src/uix/morfo/components/auto-grid.ts create mode 100644 src/uix/morfo/components/banner.ts create mode 100644 src/uix/morfo/components/float.ts create mode 100644 web/routes/uix/components/aspect-ratio/+page.svelte create mode 100644 web/routes/uix/components/auto-grid/+page.svelte create mode 100644 web/routes/uix/components/banner/+page.svelte create mode 100644 web/routes/uix/components/float/+page.svelte diff --git a/src/uix/eidos/components/_layout/index.ts b/src/uix/eidos/components/_layout/index.ts index 315bc7535..b806f104e 100644 --- a/src/uix/eidos/components/_layout/index.ts +++ b/src/uix/eidos/components/_layout/index.ts @@ -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, diff --git a/src/uix/eidos/components/aspect-ratio/README.md b/src/uix/eidos/components/aspect-ratio/README.md new file mode 100644 index 000000000..4957f498e --- /dev/null +++ b/src/uix/eidos/components/aspect-ratio/README.md @@ -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 +`
` shell (composed through ``) 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 + + + + + + + * + */ + 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); + }); + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/aspect-ratio/index.ts b/src/uix/eidos/components/aspect-ratio/index.ts new file mode 100644 index 000000000..e2c0ccc00 --- /dev/null +++ b/src/uix/eidos/components/aspect-ratio/index.ts @@ -0,0 +1,12 @@ +// AspectRatio — constrains inner content to a width/height ratio. +// +// import { AspectRatio } from '$uix/eidos/components/aspect-ratio'; +// +// +// +// +import AspectRatio from './aspect-ratio.svelte'; + +export { AspectRatio }; +export default AspectRatio; +export type { AspectRatioProps, AspectRatioValue } from './types'; diff --git a/src/uix/eidos/components/aspect-ratio/types.ts b/src/uix/eidos/components/aspect-ratio/types.ts new file mode 100644 index 000000000..5bd81ab28 --- /dev/null +++ b/src/uix/eidos/components/aspect-ratio/types.ts @@ -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 & { + /** + * 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; +}; diff --git a/src/uix/eidos/components/auto-grid/README.md b/src/uix/eidos/components/auto-grid/README.md new file mode 100644 index 000000000..1c8404ef6 --- /dev/null +++ b/src/uix/eidos/components/auto-grid/README.md @@ -0,0 +1,118 @@ +# Eidos AutoGrid + +Responsive grid container that fits as many columns as its inline size +allows — no media queries required. Wraps `` (which wraps ``) +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 + + + + + + + + + + … + + + + + … + +``` + +## 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 + ``, 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 `` + 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`) | 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 `` | 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 + ``. +- **`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 `` + directamente. +- **Composición sobre ``** — 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 + `` 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 `` 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 ``. No hay nada que animar +ni ciclo de vida que enviar a sema. diff --git a/src/uix/eidos/components/auto-grid/auto-grid.css b/src/uix/eidos/components/auto-grid/auto-grid.css new file mode 100644 index 000000000..dd6449e13 --- /dev/null +++ b/src/uix/eidos/components/auto-grid/auto-grid.css @@ -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 ``. 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. */ +} diff --git a/src/uix/eidos/components/auto-grid/auto-grid.svelte b/src/uix/eidos/components/auto-grid/auto-grid.svelte new file mode 100644 index 000000000..e38deb821 --- /dev/null +++ b/src/uix/eidos/components/auto-grid/auto-grid.svelte @@ -0,0 +1,38 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/auto-grid/index.ts b/src/uix/eidos/components/auto-grid/index.ts new file mode 100644 index 000000000..c93a5886b --- /dev/null +++ b/src/uix/eidos/components/auto-grid/index.ts @@ -0,0 +1,11 @@ +// AutoGrid — responsive grid that fits columns to inline size. +// +// import { AutoGrid } from '$uix/eidos/components/auto-grid'; +// +// … +// … +import AutoGrid from './auto-grid.svelte'; + +export { AutoGrid }; +export default AutoGrid; +export type { AutoGridProps } from './types'; diff --git a/src/uix/eidos/components/auto-grid/types.ts b/src/uix/eidos/components/auto-grid/types.ts new file mode 100644 index 000000000..1e4a5f78d --- /dev/null +++ b/src/uix/eidos/components/auto-grid/types.ts @@ -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 `` and just want the AutoGrid prop + * surface (no extra Box props, simpler defaults). + */ +export type AutoGridProps = Omit & { + /** + * 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; + /** + * 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; +}; diff --git a/src/uix/eidos/components/banner/README.md b/src/uix/eidos/components/banner/README.md new file mode 100644 index 000000000..396a4d6c5 --- /dev/null +++ b/src/uix/eidos/components/banner/README.md @@ -0,0 +1,141 @@ +# Eidos Banner + +Full-bleed announcement strip with intent / variant / size visual +treatment. Renders as `
` so the +landmark works reliably across layouts — HTML spec only grants +`role="banner"` to a `
` when it is a top-level child of ``; +nesting it inside `
`, `
` or `
` strips the role. +The explicit role stabilises the announcement intent. + +## Superficie + +```svelte + + Your changes have been saved. + + + + +{#if show} + + Connection lost — reconnecting… + (show = false)} /> + +{/if} +``` + +## Baseline + +Origen: `air/components/layout/banner` (rama `morfo-runtime`). + +Air shipped a single-prop 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 ``) | `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 ``, 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 ` (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 ` diff --git a/src/uix/eidos/components/banner/banner.css b/src/uix/eidos/components/banner/banner.css new file mode 100644 index 000000000..e55f0184d --- /dev/null +++ b/src/uix/eidos/components/banner/banner.css @@ -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; +} diff --git a/src/uix/eidos/components/banner/banner.svelte b/src/uix/eidos/components/banner/banner.svelte new file mode 100644 index 000000000..10ca4d91e --- /dev/null +++ b/src/uix/eidos/components/banner/banner.svelte @@ -0,0 +1,47 @@ + + + + diff --git a/src/uix/eidos/components/banner/index.ts b/src/uix/eidos/components/banner/index.ts new file mode 100644 index 000000000..600cb4367 --- /dev/null +++ b/src/uix/eidos/components/banner/index.ts @@ -0,0 +1,27 @@ +// Banner — full-bleed announcement strip with intent variants. +// +// import { Banner } from '$uix/eidos/components/banner'; +// +// +// Your changes have been saved. +// (show = false)} /> +// +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'; diff --git a/src/uix/eidos/components/banner/types.ts b/src/uix/eidos/components/banner/types.ts new file mode 100644 index 000000000..5d6b8250d --- /dev/null +++ b/src/uix/eidos/components/banner/types.ts @@ -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; + +export type BannerProps = Omit, 'children'> & { + /** + * Color intent for the announcement. Drives background, border, and + * text via the matching `--color-{intent}-*` tokens. @default 'primary' + */ + intent?: ResponsiveProp; + /** + * Visual treatment family. @default 'soft' + */ + variant?: ResponsiveProp; + /** + * Vertical density. @default 'md' + */ + size?: ResponsiveProp; + /** External accessible name (forwarded to the `
`). */ + 'aria-label'?: string; + /** External label id (forwarded to the `
`). */ + 'aria-labelledby'?: string; + children?: Snippet; +}; + +export type BannerCloseProps = Omit, 'children'> & { + /** Accessible label for the dismiss button. @default 'Dismiss' */ + 'aria-label'?: string; + children?: Snippet; +}; diff --git a/src/uix/eidos/components/float/README.md b/src/uix/eidos/components/float/README.md new file mode 100644 index 000000000..161e22da8 --- /dev/null +++ b/src/uix/eidos/components/float/README.md @@ -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 +`` 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 `

` or paragraph). +- Side-note labels next to a paragraph. + +## Superficie + +```svelte +

+ + + + 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. +

+ + +
+ +
"A quotation pulled to the end of the column."
+
+ Body copy continues here with the quote on the inline-end side… +
+``` + +## 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 `` o `` 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`) | 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 `` 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 ``** — 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 `` con la letra inicial. | +| Migración de la 9-zone air Float a `` | 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. diff --git a/src/uix/eidos/components/float/float.css b/src/uix/eidos/components/float/float.css new file mode 100644 index 000000000..8ad120e45 --- /dev/null +++ b/src/uix/eidos/components/float/float.css @@ -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)); +} diff --git a/src/uix/eidos/components/float/float.svelte b/src/uix/eidos/components/float/float.svelte new file mode 100644 index 000000000..d2e5a936d --- /dev/null +++ b/src/uix/eidos/components/float/float.svelte @@ -0,0 +1,49 @@ + + + + {@render children?.()} + diff --git a/src/uix/eidos/components/float/index.ts b/src/uix/eidos/components/float/index.ts new file mode 100644 index 000000000..b4d2e1a5e --- /dev/null +++ b/src/uix/eidos/components/float/index.ts @@ -0,0 +1,15 @@ +// Float — pulls a child to the start or end of the text flow. +// +// import { Float } from '$uix/eidos/components/float'; +// +//

+// +// … +// +// Long paragraph that wraps around the float… +//

+import Float from './float.svelte'; + +export { Float }; +export default Float; +export type { FloatProps, FloatSide } from './types'; diff --git a/src/uix/eidos/components/float/types.ts b/src/uix/eidos/components/float/types.ts new file mode 100644 index 000000000..6f9069c38 --- /dev/null +++ b/src/uix/eidos/components/float/types.ts @@ -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 & { + /** + * Which side of the text flow the child sits on. @default 'start' + */ + side?: ResponsiveProp; + /** + * 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; +}; diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 21abbfce0..0ae600ea9 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -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'; diff --git a/src/uix/morfo/components/aspect-ratio.ts b/src/uix/morfo/components/aspect-ratio.ts new file mode 100644 index 000000000..27fa6d806 --- /dev/null +++ b/src/uix/morfo/components/aspect-ratio.ts @@ -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 `
` + * 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; diff --git a/src/uix/morfo/components/auto-grid.ts b/src/uix/morfo/components/auto-grid.ts new file mode 100644 index 000000000..a6117f026 --- /dev/null +++ b/src/uix/morfo/components/auto-grid.ts @@ -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 `` (which already composes through + * ``), so the provider renders a single `
` 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; diff --git a/src/uix/morfo/components/banner.ts b/src/uix/morfo/components/banner.ts new file mode 100644 index 000000000..2d568ac20 --- /dev/null +++ b/src/uix/morfo/components/banner.ts @@ -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 `
` + * 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 `
` when it is a top-level child of ``; + * nesting it inside `
`, `
`, or `
` 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 ` + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ AspectRatio is eidos-native — no soma split. The + ratio prop maps to a single --aspect-ratio CSS variable; every + other prop (maxWidth, padding, …) inherits from Box. +

+ +
+ eidos props · visual treatment +
+
+ + + +
+ + +
+
+ soma + n/a · aspect-ratio is eidos-native — equivalent markup shown + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · ratio maps to --aspect-ratio + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ The ratio prop accepts a number (e.g. 16/9 evaluated in JS) or a + string fraction (e.g. "16/9" emitted verbatim into CSS). Every other Box prop + (padding, margin, maxWidth, gridColumn, + …) passes through to the underlying <Box> shell. +

+ +
Aspect ratio
+
+ + + + + + + + + +
PropTypeNotes
rationumber | string + Number → CSS aspect-ratio as a decimal; string passes through + verbatim ("16/9", "4 / 3", "1.5"). + Default 1. +
+
+ +
Inherited from Box
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeNotes
width / minWidth / maxWidthnumber | stringConstrains the box's inline size while keeping the ratio.
padding / margin (and per-side)number | stringNumbers map to var(--space-N).
gridColumn / gridRow / placeSelfstringUse AspectRatio as a grid item — placement props live on Box.
…BoxPropsSee <Box> for the full surface.
+
+ +
Reference comparison
+
+ + + + + + + + + + + + + + + + + + + + + +
LibraryClosest equivalentDifference
radix-themes<AspectRatio> + Radix exposes a numeric ratio only; UIX additionally accepts a + string fraction so the value reads literal in DevTools. +
chakra-ui<AspectRatio> + Chakra mixes the prop with the chained style-prop system; UIX keeps it pure + layout — every other concern (background, color) lives elsewhere. +
mantine<AspectRatio> + Mantine ships the same primitive; UIX adds the string-form ratio + composes + through Box for inherited sizing props. +
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+ +
+ + + + + + + + + +
FieldValue
name{aspectRatioMorfo.name}
kebab{aspectRatioMorfo.kebab}
scope{aspectRatioMorfo.scope.join(', ')}
parts{partsList.length}
events0
+
+ +
Parts
+
+ + + + + + + + + + + + {#each partsList as part} + + + + + + + + {/each} + +
kebabmarkerelementarchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype}{part.optional ? 'yes' : 'no'}
+
+ +

+ The Provider part emits only the data-aspect-ratio marker on top of the Box + shell — no states, no data properties, no aria attributes, no keyboard. The recipe consumes + the --aspect-ratio CSS variable the component writes inline. +

+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ 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. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe lives in src/uix/eidos/components/aspect-ratio/aspect-ratio.css. The + Box shell carries the box-model surface; this recipe only adds the + aspect-ratio property and stretches the lone child to fill the box. +

+
+ + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-aspect-ratio]morfoProvider marker. Emitted by the component on top of the Box shell.
[data-box][data-aspect-ratio] {`{ aspect-ratio: var(--aspect-ratio, 1); }`}eidosSets the CSS ratio from the variable the component writes inline.
{`[data-box][data-aspect-ratio] > * { 100% × 100%, object-fit: cover }`}eidos + Stretches the lone child (img / iframe / video / inner div) to fill the box. +
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
ConcernContract
Role + None implicit. Default element is <div> — semantically + neutral. When wrapping media, supply the appropriate landmark / role on the + child (<img alt>, <iframe title>, + <video> with captions). +
Label + Not applicable — AspectRatio has no content semantics. Labels belong to the + child it constrains. +
KeyboardAspectRatio is not focusable. Tab order follows the child.
Focus visibleAspectRatio does not paint a focus ring.
Reduced motion + No motion of its own. If the child is a video / animation, the consumer is + responsible for honouring prefers-reduced-motion. +
+
+
+ {/if} +
diff --git a/web/routes/uix/components/auto-grid/+page.svelte b/web/routes/uix/components/auto-grid/+page.svelte new file mode 100644 index 000000000..73b8510a5 --- /dev/null +++ b/web/routes/uix/components/auto-grid/+page.svelte @@ -0,0 +1,497 @@ + + +
+
+
Layout · AutoGrid
+

AutoGrid

+

+ Responsive grid that fits as many columns as its inline size allows — no media queries. Composes + through <Grid> (and therefore + <Box>), so every Grid / Box prop still works. + minChildWidth drives the fluid template; columns falls back to a + fixed grid when that's the simpler fit. Eidos-native: no soma backing, no semantic events. +

+
+ + parts{compiled.parts.order.length} + + + events0 + + + extendsGrid + + + scopeeidos + +
+
+ + +
+
+ + {#each items as n} + + Cell {n} + + {/each} + +
+
+ trace + {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} + · + mode + {mode} + + template {computedTemplate} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ AutoGrid is eidos-native — no soma split. The + minChildWidth prop drives repeat(auto-fill, minmax(MIN, 1fr)); + the columns prop falls back to repeat(N, minmax(0, 1fr)). Resize + the viewport to see the fluid mode reflow. +

+ +
+ eidos props · visual treatment +
+
+ + {#if mode === 'fluid'} + + {:else} + + {/if} + + +
+ + +
+
+ soma + n/a · auto-grid is eidos-native — equivalent markup shown + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · minChildWidth / columns computes templateColumns + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ AutoGrid adds two props on top of <Grid>. When both are set, + minChildWidth wins — the fluid template is the AutoGrid use case. + templateColumns from Grid is intentionally not exposed: passing it would + conflict with AutoGrid's own computation; use <Grid> directly when a + custom track list is required. +

+ +
AutoGrid props
+
+ + + + + + + + + + + + + + +
PropTypeNotes
minChildWidthnumber | string + Minimum inline size per item before wrapping. Number → px. Maps to + repeat(auto-fill, minmax(MIN, 1fr)). Responsive. +
columnsnumber + Fixed column count. Maps to repeat(N, minmax(0, 1fr)). Ignored + when minChildWidth is set. Responsive. +
+
+ +
Inherited from Grid
+
+ + + + + + + + + +
PropTypeNotes
gap / rowGap / columnGapnumber | stringTrack spacing.
rows / templateRowsnumber | stringForwarded to Grid.
autoRows / autoColumns / autoFlowstringAuto-track sizing.
align / justify / alignContentenumContainer alignment.
…GridPropsSee <Grid>.
+
+ +
Inherited from Box
+
+ + + + + + + + + +
PropTypeNotes
padding / margin / maxWidth / …number | stringEvery Box prop. See <Box>.
+
+ +
Reference comparison
+
+ + + + + + + + + + + + + + + + + + + + + +
LibraryClosest equivalentDifference
chakra-ui<SimpleGrid> + Chakra uses auto-fit; UIX uses auto-fill so single + items don't stretch across the whole inline size. +
mantine<SimpleGrid> + Mantine names the spacing prop spacing; UIX keeps + gap consistent with Box / Flex / Grid. +
radix-themes<Grid columns="repeat(auto-fill,…)" /> + Radix has no fluid SimpleGrid; the consumer writes the template by hand. +
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+ +
+ + + + + + + + + +
FieldValue
name{autoGridMorfo.name}
kebab{autoGridMorfo.kebab}
scope{autoGridMorfo.scope.join(', ')}
parts{partsList.length}
events0
+
+ +
Parts
+
+ + + + + + + + + + + + {#each partsList as part} + + + + + + + + {/each} + +
kebabmarkerelementarchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype}{part.optional ? 'yes' : 'no'}
+
+ +

+ The Provider part emits only the data-auto-grid 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. +

+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ 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. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe lives in src/uix/eidos/components/auto-grid/auto-grid.css. AutoGrid + delegates every visible style to the Grid recipe; the file declares only the + [data-auto-grid] marker so consumers can target it directly. The fluid / + fixed template is computed in JS and emitted as --grid-template-columns. +

+
+ + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-auto-grid]morfoProvider marker. Emitted on top of the Grid shell.
[data-box][data-grid][data-auto-grid]eidos + Empty rule (placeholder). Every visible style reads from the Grid recipe + via the --grid-template-columns the component writes inline. +
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
ConcernContract
Role + None implicit. Default element is <div>. If the grid + carries a list (gallery, products), the consumer is responsible for using a + semantic <ul> wrapper around AutoGrid or each child. +
Label + Not applicable — AutoGrid has no content semantics. Labels belong to the + items it arranges. +
KeyboardAutoGrid is not focusable. Tab order follows DOM order of children.
Focus visibleAutoGrid does not paint a focus ring.
Reduced motion + No motion of its own. Layout reflows are handled by the browser at full + speed regardless of prefers-reduced-motion. +
+
+
+ {/if} +
diff --git a/web/routes/uix/components/banner/+page.svelte b/web/routes/uix/components/banner/+page.svelte new file mode 100644 index 000000000..8dd3812e0 --- /dev/null +++ b/web/routes/uix/components/banner/+page.svelte @@ -0,0 +1,591 @@ + + +
+
+
Layout · Banner
+

Banner

+

+ Full-bleed announcement strip with intent / variant / size visual treatment. Renders as + <header role="banner"> — the explicit role stabilises the landmark across + layouts (HTML spec only grants the implicit role to a top-level <header> + of <body>). For transient, live-region notifications use + <Toast> instead — that's where + role="alert" belongs. Dismissal is composition-driven: include + <Banner.Close> and wrap in {`{#if show}`}. +

+
+ + parts{compiled.parts.order.length} + + + events0 + + + intents{intents.length} + + + variants{variants.length} + + + scopeeidos + +
+
+ + +
+
+ {#if !dismissed} + + {#if withIcon} + + {/if} + {message} + {#if withClose} + (dismissed = true)} /> + {/if} + + {:else} + + {/if} +
+
+ trace + {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} + · + intent + {intent} + + variant {variant} · + size {size} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Banner is eidos-native — no soma split. Every + control here drives a data-* attribute on the root that the recipe maps to + the matching --color-{`{intent}`}-* tokens. +

+ +
+ eidos props · visual treatment +
+
+ + + +
+ +
Demo content
+
+ + + +
+ + +
+
+ soma + n/a · banner is eidos-native — equivalent markup shown + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · intent / variant / size + composition close + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ Three visual props on the root + an optional <Banner.Close> child for + dismissibility. Banner does NOT own the visibility state — the consumer wraps Banner in + {`{#if show}`} and wires onclick on the Close. +

+ +
Banner props
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeNotes
intent{intents.join(' | ')} + Color role. Maps to the matching --color-{`{intent}`}-* tokens. + Default primary. Responsive. +
variant{variants.join(' | ')} + Visual treatment family. soft = tinted track + matching text; + solid = saturated bg + contrast text; outline = + transparent bg + accented border; ghost = text only. Default + soft. +
size{sizes.join(' | ')}Vertical density. Default md. Responsive.
aria-label / aria-labelledbystringAccessible name for the landmark.
+
+ +
Banner.Close props (eidos-only)
+
+ + + + + + + + + + + + + + +
PropTypeNotes
onclick(e: MouseEvent) => void + Consumer handles dismissal. Banner does not own visibility; wrap in + {`{#if show}`} at the call site. +
aria-labelstringDefault "Dismiss"; override for localisation.
+
+ +
Reference comparison
+
+ + + + + + + + + + + + + + + + + + + + + +
LibraryClosest equivalentDifference
chakra-ui<Alert> + Chakra ships 4 statuses (info / success / warning / error) and + role="alert"; UIX uses the 8-role ColorRole + vocabulary + role="banner" (landmark, not live region). +
mantine<Alert> + Mantine drives variants via the theme color scale; UIX maps to the canonical + UIX intent palette. +
mui<Alert> + MUI uses standard / filled / outlined variants and a built-in + close button via onClose; UIX favours composition with + <Banner.Close>. +
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+ +
+ + + + + + + + + +
FieldValue
name{bannerMorfo.name}
kebab{bannerMorfo.kebab}
scope{bannerMorfo.scope.join(', ')}
parts{partsList.length}
events0
+
+ +
Parts
+
+ + + + + + + + + + + + {#each partsList as part} + + + + + + + + {/each} + +
kebabmarkerelementarchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype}{part.optional ? 'yes' : 'no'}
+
+ +

+ Banner declares a single Provider part. Banner.Close 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. +

+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ 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 onclick handler on <Banner.Close>, not in + Banner itself. Transient live-region notifications belong to + <Toast>, which owns the proper sema vocabulary. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe lives in src/uix/eidos/components/banner/banner.css. Three orthogonal + selectors: data-intent picks the palette tokens, + data-variant chooses how those tokens compose into background / foreground / + border, and data-size sets density. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-banner]morfoProvider marker. Emitted on the <header> element.
[data-banner][data-intent='X']eidos + Per-intent palette resolution. 8 rules, one per + ColorRole. +
[data-banner][data-variant='X']eidosVariant treatment — soft / solid / outline / ghost.
[data-banner][data-size='X']eidosDensity — padding-block / padding-inline / font-size.
[data-banner-close]eidos + Eidos-only close button. Self-contained styling — currentColor + hover / + focus-visible only. +
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConcernContract
Role + role="banner" (landmark) on the root <header>. + Stable across layouts — the implicit role only applies to a top-level + <header> of <body>. +
Label + Supply aria-label or aria-labelledby when the + banner's content alone doesn't make its purpose clear. +
Keyboard (Close) + <Banner.Close> is a plain <button type="button">. + Tab to focus, Enter / Space to activate. Default + aria-label="Dismiss". +
Live region + Banner is NOT a live region. Use <Toast> for transient, screen-reader-interrupting feedback. +
Focus visible + Close button paints a focus ring via :focus-visible with the + current text color. +
Reduced motion + No motion of its own. Hover transitions on Close use 120ms — well within the + "sub-perceptual" budget and honoured automatically by the cascade. +
+
+
+ {/if} +
diff --git a/web/routes/uix/components/float/+page.svelte b/web/routes/uix/components/float/+page.svelte new file mode 100644 index 000000000..cadca153b --- /dev/null +++ b/web/routes/uix/components/float/+page.svelte @@ -0,0 +1,518 @@ + + +
+
+
Layout · Float
+

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. Typical uses: inline images, pull-quotes, drop caps. Uses logical + float: inline-start / inline-end 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. +

+
+ + parts{compiled.parts.order.length} + + + events0 + + + extendsBox + + + scopeeidos + +
+
+ + +
+
+
+ + {#if demo === 'image'} +
+ {childWidth}×{Math.round((childWidth * 3) / 4)} +
+ {:else if demo === 'pull-quote'} +
+ "A short quotation pulled from the surrounding text to anchor the reader's + attention." +
+ {:else} + + {/if} +
+ {#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 + <Flex> / <Grid>; 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} +
+
+
+ trace + {trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`} + · + side + {side} + + gap {gap} · + maxWidth {childWidth}px + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Float is eidos-native — no soma split. The + side prop drives the logical float direction; gap sets the + inline margin against the wrapped text. +

+ +
+ eidos props · visual treatment +
+
+ + + + +
+ + +
+
+ soma + n/a · float is eidos-native — equivalent markup shown + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · side / gap + inherited Box props + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ Two props on the root + every Box prop inherited. The block-axis margin (top / bottom) + lives on the standard marginTop / marginBottom from Box, + because real-world floats often need a small padding-top to align with the ascender of + the surrounding text. +

+ +
Float props
+
+ + + + + + + + + + + + + + +
PropTypeNotes
side{sides.join(' | ')} + Logical side. Maps to float: inline-start / inline-end. Mirrors + in RTL. Default start. +
gapnumber | string + Inline margin against the wrapped text. Number → var(--space-N). + Default 3. +
+
+ +
Inherited from Box
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeNotes
maxWidth / width / minWidthnumber | stringConstrain the float's inline size — essential for readable wrap behaviour.
marginTop / marginBottomnumber | stringBlock-axis margin against the surrounding text.
paddingnumber | stringInner padding (e.g. for pull-quote boxes).
…BoxProps + See <Box>. Note: position + is excluded — Float uses the cascade's `float`, not absolute positioning. +
+
+ +
Reference comparison
+
+ + + + + + + + + + + + + + + + + + + + + +
LibraryClosest equivalentDifference
radix-themes<Inset> + Radix Inset takes 4 physical sides; UIX Float uses logical + start / end so it mirrors in RTL. +
muiPull-quote pattern + MUI documents the pattern with manual float: left styling; UIX + wraps it as a typed primitive with logical sides. +
mantinen/a + Mantine ships no float primitive; consumers write + style={`{{ float: 'left' }}`} manually. +
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+ +
+ + + + + + + + + +
FieldValue
name{floatMorfo.name}
kebab{floatMorfo.kebab}
scope{floatMorfo.scope.join(', ')}
parts{partsList.length}
events0
+
+ +
Parts
+
+ + + + + + + + + + + + {#each partsList as part} + + + + + + + + {/each} + +
kebabmarkerelementarchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype}{part.optional ? 'yes' : 'no'}
+
+ +

+ Float declares a single Provider part. The recipe reads data-side to pick + the logical float direction; the --float-gap variable controls the inline + margin against the wrapped text. +

+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ 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 side) belong to the consumer's transition + logic, not to Float itself. +

+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe lives in src/uix/eidos/components/float/float.css. The + data-side attribute switches between float: inline-start and + float: inline-end; the inline margin against the text sits on the side that + faces the wrapped content. +

+
+ + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-float]morfoProvider marker. Emitted on top of the Box shell.
{`[data-box][data-float] { float: inline-start; margin-inline-end: var(--float-gap, var(--space-3)); }`}eidosDefault side — float to start of text flow.
[data-box][data-float][data-side='end']eidosOverride — float to end of text flow with mirrored gap.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
ConcernContract
Role + None implicit. Default element is <div>. When the float + holds a quote, wrap the inner content in <blockquote>; + when it holds an image, the consumer supplies alt. +
Reading order + The floated element appears in DOM order but visually wraps with surrounding + text. Screen readers respect DOM order; consider placing pull-quotes + after the paragraph they belong to if visual order matters. +
KeyboardFloat is not focusable. Tab order follows DOM children.
Focus visibleFloat does not paint a focus ring.
RTL + side='start' mirrors automatically — in RTL the float pulls to + the right side. Use logical sides; avoid hardcoded + float: left / right. +
+
+
+ {/if} +