feat(uix): RadioCards — card-style single-select reusing RadioGroup

New eidos component built ON TOP of RadioGroup with zero duplicated
behaviour. RadioCards reuses RadioGroup's soma wholesale (selection,
roving focus, role=radio, native <form> via HiddenInput) and adds only
the card chrome: icon · title · description + an always-present corner
radio. Vertical, horizontal and grid layouts.

- morfo: scope:['eidos'], thin contract for presentation parts only
  (Provider marker, Item-card, Indicator, Icon, Content, Title,
  Description). The interactive contract + commit-select stay in
  radio-group, reused via its soma. No new soma, no new sema.
- eidos: `radio-cards` recipe (card chrome) + wrappers + radio-cards.css.
  radio-group.css is NOT loaded here, only its foundation tokens — the
  card owns its visual, reusing --_radio-group-palette-* (inlined, since a
  :root token referencing the palette is invalid-at-computed-value).
- demo: canonical structure (always-on stage + trace, tabs, chip/switch
  controls, Untitled-UI example patterns) + nav entry + README.

check 0 · eidos-lint 0 invalid. Browser-verified: select (click + keyboard
roving), disabled, the three layouts, controls drive the preview.

Follow-up: icon/circle sizes are still px (mirroring radio-group's own size
tokens) — making the control-size scale density-aware is a separate,
family-wide change deferred per request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 7633b1360c
commit f59396b51a

@ -0,0 +1,89 @@
# RadioCards
Card-style single-select. **Built on top of `RadioGroup` — zero duplicated
behaviour.** RadioCards reuses RadioGroup's *soma* wholesale (selection state,
roving focus, `role=radio`, native `<form>` participation via `HiddenInput`) and
only adds the card presentation: each card carries content (icon · title ·
description) and an always-present corner radio.
```svelte
<script lang="ts">
import { RadioCards } from '$uix/eidos/components/radio-cards';
let plan = $state('pro');
</script>
<RadioCards bind:value={plan} color="primary" columns={2} name="plan">
<RadioCards.Item value="free">
<RadioCards.Icon>{@render sparkle()}</RadioCards.Icon>
<RadioCards.Content>
<RadioCards.Title>Free</RadioCards.Title>
<RadioCards.Description>For hobby projects.</RadioCards.Description>
</RadioCards.Content>
</RadioCards.Item>
<RadioCards.Item value="pro">
<RadioCards.Content>
<RadioCards.Title>Pro</RadioCards.Title>
<RadioCards.Description>For growing teams.</RadioCards.Description>
</RadioCards.Content>
</RadioCards.Item>
</RadioCards>
```
## Parts
| Part | Marker | Notes |
| --- | --- | --- |
| `RadioCards` (root) | `data-radio-cards` | Reuses `RadioGroup.Provider` (`role=radiogroup`). Adds the card layout marker. |
| `.Item` | `data-radio-cards-item` | The reused `RadioGroup.Item` (`role=radio`) wearing card chrome. Auto-renders the corner circle + `HiddenInput`. |
| `.Icon` | `data-radio-cards-icon` | Leading icon / media slot (decorative, `aria-hidden`). |
| `.Content` | `data-radio-cards-content` | Text column (title + description). |
| `.Title` | `data-radio-cards-title` | Primary label line (the accessible name). |
| `.Description` | `data-radio-cards-description` | Secondary line. |
The corner radio circle (`data-radio-cards-indicator`) is auto-rendered by
`.Item` — an empty ring when unchecked, the inner dot scales in on select.
## Props (root)
`RadioCards` forwards RadioGroup's Provider surface — `bind:value`, `name`,
`orientation`, `disabled`, `readonly`, `required`, `invalid`, `dir`, callbacks —
plus the card visual knobs:
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `size` | `ResponsiveProp<'xs'…'xl'>` | `'md'` | padding · title/desc font · circle size |
| `color` | `'primary' \| 'secondary' \| 'neutral' \| 'affirm'` | `'primary'` | selected border + tint |
| `columns` | `number` | — | CSS grid with N columns |
| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | stack vs wrapping row |
Like RadioGroup, the palette excludes risk/threat/loss (per book §3.1 radio is
not evaluative). Use `invalid` for validation — it paints a threat border
without touching the colour.
## Layout
- **Vertical** (default) — cards stack full-width.
- **Horizontal** — `orientation="horizontal"` lays cards in a wrapping row
(each `flex: 1 1 var(--radio-cards-horizontal-basis)`).
- **Grid** — `columns={N}` for an N-column CSS grid.
## Recipe / cascade
The `radio-cards` recipe (`lib/recipes/base.ts`) owns the card chrome; spacing is
all `--space-*` (density-aware). **`radio-group.css` is NOT loaded on a
RadioCards page** — only RadioGroup's *foundation* (generated `base.css`: palette
TSC, size + focus tokens). So there's no RadioGroup item rule to fight; the card
fully owns its visual, reusing only the tokens and the `--_radio-group-palette-*`
custom properties (in scope on the card via the shared `[data-radio-group]`
host). The selected treatment is **inlined** in `radio-cards.css` rather than
tokenised, because a `:root` token referencing the palette is
invalid-at-computed-value and poisons down the tree.
## Architecture
`morfo` is `scope:['eidos']` — a thin contract for the presentation parts only.
The interactive contract (Provider/Item, events, keyboard, a11y) lives in
[`radio-group`](../radio-group/README.md) and is reused via its soma. No new
soma, no new sema (`commit-select` fires from RadioGroup's runtime + pack).
This mirrors `toggle-group ↔ toggle` and `css-field ↔ spin-field`: share the
machinery, layer a distinct visual.

@ -0,0 +1,57 @@
// RadioCards — eidos compound API. Card-style single-select built on
// RadioGroup's soma (selection / roving / form integration), adding only the
// card chrome. Disciplined option C.
//
// import { RadioCards } from '$uix/eidos/components/radio-cards';
//
// <RadioCards bind:value color="primary" columns={2} name="plan">
// <RadioCards.Item value="free">
// <RadioCards.Icon>{@render zapIcon()}</RadioCards.Icon>
// <RadioCards.Content>
// <RadioCards.Title>Free</RadioCards.Title>
// <RadioCards.Description>For hobby projects.</RadioCards.Description>
// </RadioCards.Content>
// </RadioCards.Item>
// <RadioCards.Item value="pro">
// <RadioCards.Content>
// <RadioCards.Title>Pro</RadioCards.Title>
// <RadioCards.Description>For growing teams.</RadioCards.Description>
// </RadioCards.Content>
// </RadioCards.Item>
// </RadioCards>
import RadioCardsComponent from './radio-cards.svelte';
import Item from './radio-cards-item.svelte';
import Content from './radio-cards-content.svelte';
import Title from './radio-cards-title.svelte';
import Description from './radio-cards-description.svelte';
import Icon from './radio-cards-icon.svelte';
type RadioCardsNamespace = typeof RadioCardsComponent & {
Item: typeof Item;
Content: typeof Content;
Title: typeof Title;
Description: typeof Description;
Icon: typeof Icon;
};
const RadioCards = RadioCardsComponent as RadioCardsNamespace;
RadioCards.Item = Item;
RadioCards.Content = Content;
RadioCards.Title = Title;
RadioCards.Description = Description;
RadioCards.Icon = Icon;
export { RadioCards };
export default RadioCards;
export type {
RadioCardsProps,
RadioCardsItemProps as ItemProps,
RadioCardsContentProps as ContentProps,
RadioCardsTitleProps as TitleProps,
RadioCardsDescriptionProps as DescriptionProps,
RadioCardsIconProps as IconProps,
RadioCardsSize,
RadioCardsColor
} from './types';

@ -0,0 +1,8 @@
<script lang="ts">
/** `<RadioCards.Content>` — text column wrapping Title + Description. */
import type { RadioCardsContentProps } from './types';
let { children, ...rest }: RadioCardsContentProps = $props();
</script>
<span data-radio-cards-content="" {...rest}>{@render children?.()}</span>

@ -0,0 +1,8 @@
<script lang="ts">
/** `<RadioCards.Description>` — secondary line under the title. */
import type { RadioCardsDescriptionProps } from './types';
let { children, ...rest }: RadioCardsDescriptionProps = $props();
</script>
<span data-radio-cards-description="" {...rest}>{@render children?.()}</span>

@ -0,0 +1,8 @@
<script lang="ts">
/** `<RadioCards.Icon>` — leading icon / media slot. Decorative. */
import type { RadioCardsIconProps } from './types';
let { children, ...rest }: RadioCardsIconProps = $props();
</script>
<span data-radio-cards-icon="" aria-hidden="true" {...rest}>{@render children?.()}</span>

@ -0,0 +1,24 @@
<script lang="ts">
/**
* `<RadioCards.Item>` — the reused Soma `<RadioGroup.Item>` (a
* `<button role=radio>` driving selection, roving and form value) wearing
* the card marker `data-radio-cards-item`. Auto-renders the always-present
* corner radio circle + the native HiddenInput so consumers only compose
* content (Icon / Content / Title / Description) inside. The circle is an
* empty ring when unchecked; the inner dot scales in on select via the
* recipe reading the item's `data-state` (RadioGroup's own Indicator is
* checked-only, so the card owns this always-visible affordance).
*/
import * as RadioGroup from '$soma/components/radio-group';
import type { RadioCardsItemProps } from './types';
let { children: bodyContent, ...rest }: RadioCardsItemProps = $props();
</script>
<RadioGroup.Item data-radio-cards-item="" {...rest}>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
<span data-radio-cards-indicator="" aria-hidden="true"></span>
<RadioGroup.HiddenInput />
{/snippet}
</RadioGroup.Item>

@ -0,0 +1,8 @@
<script lang="ts">
/** `<RadioCards.Title>` — primary label line of a card. */
import type { RadioCardsTitleProps } from './types';
let { children, ...rest }: RadioCardsTitleProps = $props();
</script>
<span data-radio-cards-title="" {...rest}>{@render children?.()}</span>

@ -0,0 +1,278 @@
/* RadioCards — card skin over RadioGroup.
*
* The root reuses RadioGroup's layout (flex column / `[data-columns]` grid)
* and palette (`--_radio-group-palette-*`). These rules repaint the reused
* `[data-radio-group-item]` (a small dot-box in RadioGroup) as a full card,
* and restyle the reused `[data-radio-group-indicator]` into a corner radio
* circle. The card carries BOTH markers — `data-radio-group-item`
* (behaviour) + `data-radio-cards-item` (presentation).
*
* Load note: only RadioGroup's FOUNDATION (generated base.css — palette TSC,
* size / focus tokens) is present on a RadioCards page; its component CSS
* (radio-group.css) is imported by the RadioGroup eidos wrapper, which
* RadioCards does not use. So these rules don't fight any RadioGroup item
* styling — the card fully owns its own visual, reusing only the tokens +
* the `--_radio-group-palette-*` custom properties (in scope on the card).
*/
/* ── Root layout — owned here (radio-group.css is not loaded) ───────────── */
[data-radio-cards] {
display: flex;
flex-direction: column;
gap: var(--radio-cards-gap);
}
[data-radio-cards][data-orientation='horizontal'] {
flex-direction: row;
flex-wrap: wrap;
}
/* Cards share the row equally, wrapping to a new line when they can't keep a
* sensible minimum width. */
[data-radio-cards][data-orientation='horizontal'] [data-radio-cards-item] {
flex: 1 1 var(--radio-cards-horizontal-basis);
}
/* Grid when `columns` is set (publishes `--_radio-cards-columns` inline). */
[data-radio-cards][data-columns] {
display: grid;
grid-template-columns: repeat(var(--_radio-cards-columns, 2), minmax(0, 1fr));
align-items: stretch;
}
/* ── Card item ──────────────────────────────────────────────────────────── */
[data-radio-cards] [data-radio-cards-item] {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: var(--radio-cards-content-gap);
/* Width is driven by the layout: the flex column / grid stretch the card
* to fill; a horizontal row flexes each card equally (rule below). */
block-size: auto;
padding: var(--radio-cards-padding-md);
border: var(--radio-cards-border-width) solid var(--radio-cards-border);
border-radius: var(--radio-cards-radius);
background: var(--radio-cards-bg);
text-align: start;
cursor: pointer;
outline: none;
transition:
background var(--radio-cards-transition-duration) var(--radio-cards-transition-ease),
border-color var(--radio-cards-transition-duration) var(--radio-cards-transition-ease),
box-shadow var(--radio-cards-transition-duration) var(--radio-cards-transition-ease);
}
/* ── Sizes — padding + corner-circle size ──────────────────────────────── */
[data-radio-cards][data-size='xs'] [data-radio-cards-item] {
padding: var(--radio-cards-padding-xs);
}
[data-radio-cards][data-size='sm'] [data-radio-cards-item] {
padding: var(--radio-cards-padding-sm);
}
[data-radio-cards][data-size='lg'] [data-radio-cards-item] {
padding: var(--radio-cards-padding-lg);
}
[data-radio-cards][data-size='xl'] [data-radio-cards-item] {
padding: var(--radio-cards-padding-xl);
}
/* ── Hover / focus ──────────────────────────────────────────────────────── */
[data-radio-cards] [data-radio-cards-item]:hover:not([data-disabled]):not([data-readonly]) {
border-color: var(--radio-cards-border-hover);
background: var(--radio-cards-bg-hover);
}
[data-radio-cards] [data-radio-cards-item]:focus-visible {
box-shadow: var(--radio-cards-focus-ring);
}
/* ── Checked — active border + faint tint ────────────────────────────────
* RadioGroup's component CSS (radio-group.css) is NOT loaded on a RadioCards
* page — only its foundation tokens (palette, sizes, focus ring) are. So
* there is no base item rule to fight: a plain (0,3,0) selector wins, and we
* paint the selected state here using RadioGroup's active palette token,
* which is in scope on the card (inherited from the [data-radio-group][data-color]
* host). Inlined rather than tokenised because a :root token referencing the
* palette is invalid-at-computed-value. */
[data-radio-cards] [data-radio-cards-item][data-state='checked'] {
border-color: var(--_radio-group-palette-solid);
background: color-mix(in srgb, var(--_radio-group-palette-solid) 7%, var(--color-surface-default));
}
/* ── Disabled / readonly ────────────────────────────────────────────────── */
[data-radio-cards] [data-radio-cards-item][data-disabled] {
cursor: not-allowed;
opacity: var(--radio-cards-disabled-opacity);
background: var(--radio-cards-bg);
}
/* ── Invalid (form validation) — threat border across the card ─────────── */
[data-radio-cards][data-invalid] [data-radio-cards-item] {
border-color: var(--color-threat-element);
}
/* ── Leading icon / media ──────────────────────────────────────────────── */
[data-radio-cards-icon] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--radio-cards-icon-size-md);
block-size: var(--radio-cards-icon-size-md);
color: var(--radio-cards-icon-color);
}
[data-radio-cards-icon] svg {
inline-size: 100%;
block-size: 100%;
}
[data-radio-cards][data-size='xs'] [data-radio-cards-icon] {
inline-size: var(--radio-cards-icon-size-xs);
block-size: var(--radio-cards-icon-size-xs);
}
[data-radio-cards][data-size='sm'] [data-radio-cards-icon] {
inline-size: var(--radio-cards-icon-size-sm);
block-size: var(--radio-cards-icon-size-sm);
}
[data-radio-cards][data-size='lg'] [data-radio-cards-icon] {
inline-size: var(--radio-cards-icon-size-lg);
block-size: var(--radio-cards-icon-size-lg);
}
[data-radio-cards][data-size='xl'] [data-radio-cards-icon] {
inline-size: var(--radio-cards-icon-size-xl);
block-size: var(--radio-cards-icon-size-xl);
}
[data-radio-cards] [data-radio-cards-item][data-state='checked'] [data-radio-cards-icon] {
color: var(--_radio-group-palette-solid);
}
/* ── Text column ───────────────────────────────────────────────────────── */
[data-radio-cards-content] {
display: flex;
flex-direction: column;
gap: var(--radio-cards-text-gap);
flex: 1 1 auto;
min-inline-size: 0;
}
[data-radio-cards-title] {
font-family: var(--radio-cards-title-font-family);
font-size: var(--radio-cards-title-font-size-md);
font-weight: var(--radio-cards-title-font-weight);
line-height: var(--radio-cards-title-line-height);
color: var(--radio-cards-title-color);
}
[data-radio-cards][data-size='xs'] [data-radio-cards-title] {
font-size: var(--radio-cards-title-font-size-xs);
}
[data-radio-cards][data-size='sm'] [data-radio-cards-title] {
font-size: var(--radio-cards-title-font-size-sm);
}
[data-radio-cards][data-size='lg'] [data-radio-cards-title] {
font-size: var(--radio-cards-title-font-size-lg);
}
[data-radio-cards][data-size='xl'] [data-radio-cards-title] {
font-size: var(--radio-cards-title-font-size-xl);
}
[data-radio-cards-description] {
font-family: var(--radio-cards-title-font-family);
font-size: var(--radio-cards-description-font-size-md);
line-height: var(--radio-cards-description-line-height);
color: var(--radio-cards-description-color);
}
[data-radio-cards][data-size='xs'] [data-radio-cards-description] {
font-size: var(--radio-cards-description-font-size-xs);
}
[data-radio-cards][data-size='sm'] [data-radio-cards-description] {
font-size: var(--radio-cards-description-font-size-sm);
}
[data-radio-cards][data-size='lg'] [data-radio-cards-description] {
font-size: var(--radio-cards-description-font-size-lg);
}
[data-radio-cards][data-size='xl'] [data-radio-cards-description] {
font-size: var(--radio-cards-description-font-size-xl);
}
/* ── Corner radio circle ────────────────────────────────────────────────
* An always-present ring (reusing RadioGroup's border + size tokens) with an
* inner dot (`::after`) that scales in on the item's checked state. The dot
* colour is RadioGroup's active palette token. RadioGroup's own Indicator is
* checked-only, so the card renders this affordance itself. */
[data-radio-cards-indicator] {
position: relative;
flex: 0 0 auto;
align-self: flex-start;
margin-inline-start: auto;
inline-size: var(--radio-group-item-size-md);
block-size: var(--radio-group-item-size-md);
border: var(--radio-group-indicator-border-width) solid var(--radio-group-indicator-border);
border-radius: var(--radius-full);
background: var(--radio-group-indicator-bg);
transition: border-color var(--radio-cards-transition-duration) var(--radio-cards-transition-ease);
}
[data-radio-cards-indicator]::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
inline-size: 46%;
block-size: 46%;
border-radius: var(--radius-full);
background: var(--_radio-group-palette-solid);
transform: scale(0);
transition: transform var(--radio-cards-transition-duration) cubic-bezier(0.4, 0, 0.2, 1);
}
[data-radio-cards][data-size='xs'] [data-radio-cards-indicator] {
inline-size: var(--radio-group-item-size-xs);
block-size: var(--radio-group-item-size-xs);
}
[data-radio-cards][data-size='sm'] [data-radio-cards-indicator] {
inline-size: var(--radio-group-item-size-sm);
block-size: var(--radio-group-item-size-sm);
}
[data-radio-cards][data-size='lg'] [data-radio-cards-indicator] {
inline-size: var(--radio-group-item-size-lg);
block-size: var(--radio-group-item-size-lg);
}
[data-radio-cards][data-size='xl'] [data-radio-cards-indicator] {
inline-size: var(--radio-group-item-size-xl);
block-size: var(--radio-group-item-size-xl);
}
/* Checked: active ring + the inner dot scales in. */
[data-radio-cards] [data-radio-cards-item][data-state='checked'] [data-radio-cards-indicator] {
border-color: var(--_radio-group-palette-solid);
}
[data-radio-cards] [data-radio-cards-item][data-state='checked'] [data-radio-cards-indicator]::after {
transform: scale(1);
}
[data-radio-cards] [data-radio-cards-item][data-disabled] [data-radio-cards-indicator] {
border-color: var(--radio-group-indicator-border-disabled);
background: var(--radio-group-indicator-bg-disabled);
}
@media (prefers-reduced-motion: reduce) {
[data-radio-cards] [data-radio-cards-item],
[data-radio-cards-indicator],
[data-radio-cards-indicator]::after {
transition: none;
}
}

@ -0,0 +1,52 @@
<script lang="ts">
import './radio-cards.css';
/**
* Eidos `<RadioCards>` — card-style single-select. A thin card skin over
* Soma's `<RadioGroup.Provider>`: shares selection state, roving focus,
* `<form>` integration and `role=radiogroup` semantics. Adds only the
* card chrome (size, color, columns) + the `data-radio-cards` marker that
* scopes the card recipe.
*
* <RadioCards bind:value color="primary" columns={2}>
* <RadioCards.Item value="free">
* <RadioCards.Content>
* <RadioCards.Title>Free</RadioCards.Title>
* <RadioCards.Description>For hobby projects.</RadioCards.Description>
* </RadioCards.Content>
* </RadioCards.Item>
* </RadioCards>
*/
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import * as RadioGroup from '$soma/components/radio-group';
import type { RadioCardsProps } from './types';
let {
size = 'md',
color = 'primary',
columns,
value = $bindable(''),
style,
children,
...rest
}: RadioCardsProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// radio-group.css is not loaded on a RadioCards page, so the root owns its
// own flex/grid layout in radio-cards.css, reading this column count.
const columnsStyle = $derived(columns ? `--_radio-cards-columns: ${columns};` : undefined);
const mergedStyle = $derived(composeInlineStyle(style, columnsStyle));
</script>
<RadioGroup.Provider
{...rest}
bind:value
data-radio-cards=""
data-size={resolvedSize}
data-color={color}
data-columns={columns ?? undefined}
style={mergedStyle}
>
{@render children?.()}
</RadioGroup.Provider>

@ -0,0 +1,73 @@
import type { Snippet } from 'svelte';
import type { AffirmativeColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type {
ProviderProps,
ItemProps,
IndicatorProps,
HiddenInputProps
} from '$soma/components/radio-group';
/**
* RadioCards visual size — same five-step subset as RadioGroup. Drives card
* padding, title/description font-size and the corner indicator size.
*/
export type RadioCardsSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
/**
* RadioCards color subset — `primary | secondary | neutral | affirm`,
* identical to RadioGroup (radio carries no risk/threat/loss; validation
* lives in the surrounding form via the `invalid` prop). The active colour
* paints the selected card's border + faint tint.
*/
export type RadioCardsColor = AffirmativeColorRole;
/**
* Root props. Reuses RadioGroup's Provider surface (value, name,
* orientation, disabled/required/readonly/invalid, callbacks) and adds the
* card visual knobs. RadioCards is a card-style skin over RadioGroup — it
* shares the same soma provider, selection state, roving focus and form
* integration, adding only the card chrome.
*/
export type RadioCardsProps = ProviderProps & {
/** Visual size preset. Resolved via `Eidos.resolve`. @default 'md' */
size?: ResponsiveProp<RadioCardsSize>;
/** Colour palette for the selected card. @default 'primary' */
color?: RadioCardsColor;
/**
* Lay the cards out in a CSS grid with this many columns. Without it the
* cards stack vertically (or flow in a row when `orientation='horizontal'`).
* @default undefined
*/
columns?: number;
};
/** Card item props — the reused RadioGroup.Item with card chrome. */
export type RadioCardsItemProps = ItemProps;
/** Text-column wrapper (title + description). */
export type RadioCardsContentProps = {
children?: Snippet;
[key: string]: unknown;
};
/** Card title (primary label line). */
export type RadioCardsTitleProps = {
children?: Snippet;
[key: string]: unknown;
};
/** Card description (secondary line under the title). */
export type RadioCardsDescriptionProps = {
children?: Snippet;
[key: string]: unknown;
};
/** Leading icon / media slot. */
export type RadioCardsIconProps = {
children?: Snippet;
[key: string]: unknown;
};
/** The corner radio indicator — reused from RadioGroup, restyled as a circle. */
export type RadioCardsIndicatorProps = IndicatorProps;
export type RadioCardsHiddenInputProps = HiddenInputProps;

@ -1898,6 +1898,47 @@
--radio-group-label-font-weight: var(--font-weight-medium);
--radio-group-label-line-height: var(--leading-ui);
--radio-group-label-color: var(--color-content-primary);
--radio-cards-gap: var(--space-3);
--radio-cards-horizontal-basis: 12rem;
--radio-cards-padding-xs: var(--space-3);
--radio-cards-padding-sm: var(--space-3);
--radio-cards-padding-md: var(--space-4);
--radio-cards-padding-lg: var(--space-4);
--radio-cards-padding-xl: var(--space-5);
--radio-cards-radius: var(--radius-lg);
--radio-cards-border-width: var(--border-width);
--radio-cards-border: var(--color-border-default);
--radio-cards-border-hover: var(--color-border-strong);
--radio-cards-bg: var(--color-surface-default);
--radio-cards-bg-hover: var(--color-surface-raised);
--radio-cards-content-gap: var(--space-3);
--radio-cards-text-gap: var(--space-0-5);
--radio-cards-title-font-family: var(--font-ui);
--radio-cards-title-font-size-xs: var(--font-size-sm);
--radio-cards-title-font-size-sm: var(--font-size-sm);
--radio-cards-title-font-size-md: var(--font-size-md);
--radio-cards-title-font-size-lg: var(--font-size-md);
--radio-cards-title-font-size-xl: var(--font-size-lg);
--radio-cards-title-font-weight: var(--font-weight-medium);
--radio-cards-title-line-height: var(--leading-ui);
--radio-cards-title-color: var(--color-content-primary);
--radio-cards-description-font-size-xs: var(--font-size-xs);
--radio-cards-description-font-size-sm: var(--font-size-xs);
--radio-cards-description-font-size-md: var(--font-size-sm);
--radio-cards-description-font-size-lg: var(--font-size-sm);
--radio-cards-description-font-size-xl: var(--font-size-md);
--radio-cards-description-line-height: var(--leading-normal);
--radio-cards-description-color: var(--color-content-secondary);
--radio-cards-icon-size-xs: 18px;
--radio-cards-icon-size-sm: 20px;
--radio-cards-icon-size-md: 24px;
--radio-cards-icon-size-lg: 28px;
--radio-cards-icon-size-xl: 32px;
--radio-cards-icon-color: var(--color-content-secondary);
--radio-cards-focus-ring: var(--radio-group-focus-ring);
--radio-cards-transition-duration: var(--duration-fast);
--radio-cards-transition-ease: var(--ease-default);
--radio-cards-disabled-opacity: 0.6;
--rating-group-gap-xs: var(--space-0-5);
--rating-group-gap-sm: var(--space-1);
--rating-group-gap-md: var(--space-1-5);

@ -1937,6 +1937,61 @@ export const THEME_BASE_RECIPE_TOKENS = {
},
'_palette-text': { value: 'var(--radio-group-dot-color)', scope: 'host' }
},
// RadioCards — card-style skin over RadioGroup. Reuses RadioGroup's
// palette (`--_radio-group-palette-solid`, declared on the shared
// [data-radio-group] host) and `--radio-group-focus-ring`; adds only the
// card chrome (padding, radius, surface, title/description type).
'radio-cards': {
gap: 'var(--space-3)',
// Min card width in a horizontal row before wrapping (rem → scales with
// root font-size / zoom).
'horizontal-basis': '12rem',
'padding-xs': 'var(--space-3)',
'padding-sm': 'var(--space-3)',
'padding-md': 'var(--space-4)',
'padding-lg': 'var(--space-4)',
'padding-xl': 'var(--space-5)',
radius: 'var(--radius-lg)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-default)',
'border-hover': 'var(--color-border-strong)',
bg: 'var(--color-surface-default)',
'bg-hover': 'var(--color-surface-raised)',
'content-gap': 'var(--space-3)',
'text-gap': 'var(--space-0-5)',
'title-font-family': 'var(--font-ui)',
'title-font-size-xs': 'var(--font-size-sm)',
'title-font-size-sm': 'var(--font-size-sm)',
'title-font-size-md': 'var(--font-size-md)',
'title-font-size-lg': 'var(--font-size-md)',
'title-font-size-xl': 'var(--font-size-lg)',
'title-font-weight': 'var(--font-weight-medium)',
'title-line-height': 'var(--leading-ui)',
'title-color': 'var(--color-content-primary)',
'description-font-size-xs': 'var(--font-size-xs)',
'description-font-size-sm': 'var(--font-size-xs)',
'description-font-size-md': 'var(--font-size-sm)',
'description-font-size-lg': 'var(--font-size-sm)',
'description-font-size-xl': 'var(--font-size-md)',
'description-line-height': 'var(--leading-normal)',
'description-color': 'var(--color-content-secondary)',
'icon-size-xs': '18px',
'icon-size-sm': '20px',
'icon-size-md': '24px',
'icon-size-lg': '28px',
'icon-size-xl': '32px',
'icon-color': 'var(--color-content-secondary)',
// NOTE: the checked treatment (active border + faint tint + icon tint)
// is NOT a token here. It references `--_radio-group-palette-solid`,
// which is undefined at :root (declared only on [data-radio-group]
// hosts) — a :root token referencing it is invalid-at-computed-value
// and poisons down the tree. It's inlined in radio-cards.css instead,
// applied on the card item where the palette IS in scope.
'focus-ring': 'var(--radio-group-focus-ring)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.6'
},
'rating-group': {
'gap-xs': 'var(--space-0-5)',
'gap-sm': 'var(--space-1)',

@ -0,0 +1,121 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* RadioCards — card-style single-select built ON TOP of RadioGroup.
*
* Reuses RadioGroup's soma wholesale (Provider / Item / Indicator /
* HiddenInput → selection state, roving focus, native `<form>`
* participation, `role=radio` semantics). RadioCards adds ONLY the card
* presentation: each reused Item becomes a clickable card carrying
* `data-radio-cards-item`, with optional Icon / Content / Title /
* Description slots inside.
*
* No new soma, no new events, no new sema — `commit-select` fires from the
* reused RadioGroup item runtime, handled by RadioGroup's sema pack. This
* morfo is `scope:['eidos']`: a thin contract for the card-presentation
* parts the recipe targets. All behaviour + a11y live in `radio-group.ts`;
* declaring them here would duplicate the contract.
*
* 0-event surface (like Badge): the only interactive act is selecting a
* radio, which is RadioGroup's `commit-select`. RadioCards owns no events.
*/
export const radioCardsMorfo = {
name: 'RadioCards',
kebab: 'radio-cards',
scope: ['eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/radio/',
texts: {
label: '#?components.radio-cards.label|Radio cards'
},
parts: [
{
// Root — the SAME element as the reused RadioGroup.Provider.
// Carries `data-radio-cards` so the card recipe scopes its
// box-model overrides to RadioCards instances only (the bare
// `[data-radio-group]` base still computes the palette tokens).
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [],
aria: []
},
{
// Card surface — the reused RadioGroup.Item with a presentation
// marker. `role=radio` / `data-state` / keyboard all come from
// RadioGroup; this part adds only `data-radio-cards-item` so the
// recipe can repaint the dot-box as a full card.
name: 'Item',
kebab: 'item',
archetype: 'item',
kind: 'public',
defaultElement: 'button',
optional: false,
data: [],
aria: []
},
{
// Always-present corner radio circle. Empty ring when unchecked;
// the inner dot (a recipe `::after`) scales in on the item's
// checked state. RadioGroup's own Indicator is checked-only, so the
// card owns this always-visible affordance.
name: 'Indicator',
kebab: 'indicator',
archetype: 'indicator',
kind: 'public',
defaultElement: 'span',
optional: false,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// Leading icon / media slot inside the card. Decorative — the
// Title carries the semantic label.
name: 'Icon',
kebab: 'icon',
archetype: 'indicator',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
// Text column wrapper (title + description) — lets the recipe lay
// out the textual content independent of the icon / indicator.
name: 'Content',
kebab: 'content',
archetype: 'content',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
// Card title — primary label line.
name: 'Title',
kebab: 'title',
archetype: 'title',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
// Secondary descriptive line under the title.
name: 'Description',
kebab: 'description',
archetype: 'description',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
}
]
} as const satisfies Morfo;

@ -145,6 +145,7 @@
{ slug: '/uix/components/switch', label: 'Switch' },
{ slug: '/uix/components/checkbox', label: 'Checkbox' },
{ slug: '/uix/components/radio-group', label: 'Radio group' },
{ slug: '/uix/components/radio-cards', label: 'Radio cards' },
{ slug: '/uix/components/rating-group', label: 'Rating group' },
{ slug: '/uix/components/slider', label: 'Slider' },
{ slug: '/uix/components/collapsible', label: 'Collapsible' },

@ -0,0 +1,414 @@
<script lang="ts">
import {
RadioCards,
type RadioCardsSize,
type RadioCardsColor
} from '$uix/eidos/components/radio-cards';
import type { Orientation } from '$soma/types';
import { compileMorfo } from '$uix/morfo';
import { radioCardsMorfo } from '@/uix/morfo/components/radio-cards';
import { page } from '$app/state';
const params = page.url.searchParams;
type Tab = 'live' | 'examples' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
let value = $state('pro');
let size = $state<RadioCardsSize>('md');
let color = $state<RadioCardsColor>('primary');
let orientation = $state<Orientation>('vertical');
let columns = $state<number | undefined>(undefined);
let disabled = $state(params.get('perm.disabled') === 'true');
let invalid = $state(params.get('perm.invalid') === 'true');
let withIcon = $state(true);
let withDescription = $state(true);
let proItemDisabled = $state(false);
let nameAttr = $state('plan');
const plans = $derived([
{ value: 'free', title: 'Free', desc: 'For hobby projects and weekend experiments.', icon: 'sparkle' },
{ value: 'pro', title: 'Pro', desc: 'For growing teams that need more room.', icon: 'bolt', disabled: proItemDisabled },
{ value: 'enterprise', title: 'Enterprise', desc: 'Advanced controls and dedicated support.', icon: 'building' }
]);
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$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();
});
const compiled = compileMorfo(radioCardsMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { RadioCards } from '$uix/eidos/components/radio-cards';",
' let value = $state("pro");',
'</' + 'script>',
'',
'<RadioCards',
' bind:value',
size !== 'md' && ` size="${size}"`,
color !== 'primary' && ` color="${color}"`,
orientation !== 'vertical' && ` orientation="${orientation}"`,
columns !== undefined && ` columns={${columns}}`,
disabled && ' disabled',
invalid && ' invalid',
nameAttr && ` name="${nameAttr}"`,
'>',
' <RadioCards.Item value="pro">',
withIcon && ' <RadioCards.Icon>{@render bolt()}</RadioCards.Icon>',
' <RadioCards.Content>',
' <RadioCards.Title>Pro</RadioCards.Title>',
withDescription && ' <RadioCards.Description>For growing teams.</RadioCards.Description>',
' </RadioCards.Content>',
' </RadioCards.Item>',
' ...',
'</RadioCards>'
]
.filter(Boolean)
.join('\n')
);
</script>
<svelte:head>
<meta name="perm-axes" content="disabled,invalid" />
</svelte:head>
{#snippet icon(name: string)}
{#if name === 'sparkle'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6L5.6 18.4" /></svg>
{:else if name === 'bolt'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" /></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="1" /><path d="M9 7h.01M15 7h.01M9 11h.01M15 11h.01M9 15h6" /></svg>
{/if}
{/snippet}
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Selection · Radio cards</div>
<h1 data-uix-page-title>Radio cards</h1>
<p data-uix-page-lede>
Card-style single-select built ON TOP of <code>RadioGroup</code> — it reuses RadioGroup's
soma wholesale (selection, roving focus, native <code>&lt;form&gt;</code> integration,
<code>role=radio</code>) and adds only the card chrome: icon · title · description and an
always-present corner radio. Vertical, horizontal and grid layouts.
</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>soma</span>reused</span>
<span data-uix-meta-pill><span data-uix-meta-key>colors</span>4</span>
<span data-uix-meta-pill><span data-uix-meta-key>apg</span><a href={radioCardsMorfo.apg} target="_blank" rel="noreferrer">radio ↗</a></span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<RadioCards
bind:value
{size}
{color}
{orientation}
{columns}
{disabled}
{invalid}
name={nameAttr || undefined}
>
{#each plans as p (p.value)}
<RadioCards.Item value={p.value} disabled={p.disabled}>
{#if withIcon}<RadioCards.Icon>{@render icon(p.icon)}</RadioCards.Icon>{/if}
<RadioCards.Content>
<RadioCards.Title>{p.title}</RadioCards.Title>
{#if withDescription}<RadioCards.Description>{p.desc}</RadioCards.Description>{/if}
</RadioCards.Content>
</RadioCards.Item>
{/each}
</RadioCards>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>select a card to see commit events</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>value</span> {value || '∅'}
</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 === 'examples'} onclick={() => (tab = 'examples')}>Examples</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</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</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>Props grouped by layer: <span data-uix-layer-badge="soma">soma</span> behavior + form (reused from RadioGroup), <span data-uix-layer-badge="eidos">eidos</span> card visual.</p>
<div data-uix-subsection-head><span data-uix-layer-badge="soma">soma</span> props · reused from RadioGroup</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>bindable</span></span>
<span data-uix-chips role="radiogroup">
{#each plans as p (p.value)}
<button data-uix-chip data-active={value === p.value} onclick={() => (value = p.value)}>{p.value}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled (group)</span>
<span data-uix-switch><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label>{disabled ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>invalid</span>
<span data-uix-switch><input type="checkbox" bind:checked={invalid} /><span data-uix-switch-label>{invalid ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>"Pro" item disabled</span>
<span data-uix-switch><input type="checkbox" bind:checked={proItemDisabled} /><span data-uix-switch-label>{proItemDisabled ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each ['vertical', 'horizontal'] as o}
<button data-uix-chip data-active={orientation === o} onclick={() => (orientation = o as Orientation)}>{o}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>name</span>
<input type="text" bind:value={nameAttr} placeholder="(no HiddenInput)" />
</label>
</div>
<div data-uix-subsection-head><span data-uix-layer-badge="eidos">eidos</span> props · card visual</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as RadioCardsSize)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm'] as c}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as RadioCardsColor)}>{c}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>columns</span>
<span data-uix-chips role="radiogroup">
{#each [undefined, 2, 3] as c}
<button data-uix-chip data-active={columns === c} onclick={() => (columns = c)}>{c === undefined ? 'flex' : c + ' cols'}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>with icon</span>
<span data-uix-switch><input type="checkbox" bind:checked={withIcon} /><span data-uix-switch-label>{withIcon ? 'on' : 'off'}</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>with description</span>
<span data-uix-switch><input type="checkbox" bind:checked={withDescription} /><span data-uix-switch-label>{withDescription ? 'on' : 'off'}</span></span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>card visual over RadioGroup's reused soma</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'examples'}
<section data-uix-section>
<h2 data-uix-section-title>Examples</h2>
<p data-uix-section-desc>Common radio-card patterns — vertical with description, horizontal, grid, payment method.</p>
<div data-uix-subsection-head>Horizontal — billing period</div>
<RadioCards value="annual" orientation="horizontal" name="ex-billing">
<RadioCards.Item value="monthly">
<RadioCards.Content>
<RadioCards.Title>Monthly</RadioCards.Title>
<RadioCards.Description>$12 / month, billed monthly.</RadioCards.Description>
</RadioCards.Content>
</RadioCards.Item>
<RadioCards.Item value="annual">
<RadioCards.Content>
<RadioCards.Title>Annual · save 17%</RadioCards.Title>
<RadioCards.Description>$120 / year, billed annually.</RadioCards.Description>
</RadioCards.Content>
</RadioCards.Item>
</RadioCards>
<div data-uix-subsection-head>Grid — 2 columns</div>
<RadioCards value="comfortable" columns={2} name="ex-layout">
{#each [['comfortable', 'Comfortable', 'Roomy spacing for everyday use.'], ['compact', 'Compact', 'Denser layout that fits more.'], ['cozy', 'Cozy', 'A balanced middle ground.'], ['spacious', 'Spacious', 'Maximum breathing room.']] as [v, t, d] (v)}
<RadioCards.Item value={v}>
<RadioCards.Content>
<RadioCards.Title>{t}</RadioCards.Title>
<RadioCards.Description>{d}</RadioCards.Description>
</RadioCards.Content>
</RadioCards.Item>
{/each}
</RadioCards>
<div data-uix-subsection-head>Payment method — with icon, color affirm</div>
<RadioCards value="card" color="affirm" name="ex-pay">
<RadioCards.Item value="card">
<RadioCards.Icon>{@render icon('bolt')}</RadioCards.Icon>
<RadioCards.Content>
<RadioCards.Title>Card ending 4242</RadioCards.Title>
<RadioCards.Description>Visa · expires 09/27</RadioCards.Description>
</RadioCards.Content>
</RadioCards.Item>
<RadioCards.Item value="paypal">
<RadioCards.Icon>{@render icon('sparkle')}</RadioCards.Icon>
<RadioCards.Content>
<RadioCards.Title>PayPal</RadioCards.Title>
<RadioCards.Description>Connected account.</RadioCards.Description>
</RadioCards.Content>
</RadioCards.Item>
</RadioCards>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">string</td><td class="default">''</td><td>Selected value (reused from RadioGroup).</td></tr>
<tr><td class="name">disabled / readonly / required / invalid</td><td class="type">boolean</td><td class="default">false</td><td>Field-integration flags.</td></tr>
<tr><td class="name">orientation</td><td class="type">'vertical'|'horizontal'</td><td class="default">'vertical'</td><td>Stack vs wrapping row.</td></tr>
<tr><td class="name">name</td><td class="type">string</td><td class="default empty">—</td><td>Form field name (auto-renders HiddenInput per card).</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ResponsiveProp&lt;'xs'…'xl'&gt;</td><td class="default">'md'</td><td>Padding · title/desc font · circle size.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'primary'|'secondary'|'neutral'|'affirm'</td><td class="default">'primary'</td><td>Selected border + tint.</td></tr>
<tr><td class="name">columns <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">number</td><td class="default empty">—</td><td>CSS grid columns.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="morfo">morfo</span> · declarative contract</h2>
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/radio-cards.ts</code>. <code>scope:['eidos']</code> — only the card-presentation parts. The interactive contract (Provider/Item, role=radio, keyboard, <code>commit-select</code>) belongs to <a href="/uix/components/radio-group">RadioGroup</a> and is reused via its soma.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</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="default">{part.archetype ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>Selectors at <code>src/uix/eidos/components/radio-cards/radio-cards.css</code>. <strong>radio-group.css is not loaded</strong> here — only RadioGroup's foundation tokens; the card owns its visual, reusing <code>--_radio-group-palette-*</code>. Spacing is all <code>--space-*</code> (density-aware). Honours <code>prefers-reduced-motion</code>.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
<tbody>
<tr><td class="name">[data-radio-cards][data-orientation / data-columns]</td><td><span data-uix-tag data-kind="eidos">eidos · layout</span></td></tr>
<tr><td class="name">[data-radio-cards] [data-radio-cards-item]</td><td><span data-uix-tag data-kind="eidos">eidos · card chrome</span></td></tr>
<tr><td class="name">[data-radio-cards-item][data-state='checked']</td><td><span data-uix-tag data-kind="soma">morfo state</span> · reused</td></tr>
<tr><td class="name">[data-radio-cards-indicator] + ::after</td><td><span data-uix-tag data-kind="eidos">eidos · corner radio</span></td></tr>
<tr><td class="name">[data-radio-cards-title / -description / -icon]</td><td><span data-uix-tag data-kind="eidos">eidos · content</span></td></tr>
<tr><td class="name">@media (prefers-reduced-motion: reduce)</td><td><span data-uix-tag data-kind="eidos">eidos · a11y</span></td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>APG <a href={radioCardsMorfo.apg} target="_blank" rel="noreferrer">radio pattern</a> — inherited from RadioGroup. The whole card is the radio (role=radio); roving tabindex; auto-select on focus. Corner circle + icon are <code>aria-hidden</code>; the Title carries the accessible name.</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>Tab</span></td><td>Focus enters / leaves group at the checked (or first enabled) card.</td></tr>
<tr><td><span data-uix-kbd>↓</span> / <span data-uix-kbd>→</span></td><td>Move focus + select next enabled card.</td></tr>
<tr><td><span data-uix-kbd>↑</span> / <span data-uix-kbd>←</span></td><td>Move focus + select previous enabled card.</td></tr>
<tr><td><span data-uix-kbd>Space</span></td><td>Select focused card.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">root</td><td>role / aria-orientation / aria-invalid</td><td class="type">"radiogroup" / vary</td></tr>
<tr><td class="name">card (item)</td><td>role / aria-checked / tabindex</td><td class="type">"radio" / bool / 0|−1</td></tr>
<tr><td class="name">indicator / icon / hidden-input</td><td>aria-hidden</td><td class="type">"true"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.