fix(color): un solo vocabulario de rol, y la documentación del color reconciliada

Había DOS tipos exportados con el mismo nombre. config-types.ts:39 declara
`ColorRole = HierarchyColorRole | Intent` derivado del const (9 roles, con
tertiary) y existía desde el 2026-05-13; eidos/lib/types.ts:125 lo re-escribió
a mano siete días después con 8 miembros —sin tertiary— y nada lo justifica:
ni el commit (fb5dd0919, «Advance Eidos demos and form validation docs»), ni un
comentario, ni un documento. No fue una decisión de excluirlo: fue una omisión,
y el 2026-06-27 se fosilizó al añadir HierarchyColorRole AL LADO en vez de
arreglarla. Qué fichero importara de dónde decidía si tertiary era legal.

La deriva era SÓLO de tipos: el runtime nunca divergió. resolveComponentColor
une COLOR_ROLES + PALETTE_SCALES (42), la capa compartida emite la fila
[data-color='tertiary'] iterando COLOR_ROLES, y 94 ficheros pasan por ese
resolvedor. O sea que el tipo rechazaba en compilación un valor que el motor
resuelve. Por eso ensanchar 8 -> 9 es aditivo: check queda en la línea base
exacta (73 errores / 53 warnings) y los 362 tests de eidos pasan.

- eidos/lib/types.ts re-exporta ambos tipos de config-types, donde derivan del
  const. El docblock deja escrito el porqué para que no vuelva por ignorancia.
- Guard nuevo en recipe-css-contract.test.ts: falla si un fichero de eidos/lib
  vuelve a deletrear >=2 nombres de rol como literales. Verificado con control
  negativo — caza el texto del bug original y el HierarchyColorRole a mano;
  permite el Extract<> legítimo, el re-export y uniones ajenas al color.

Documentación del color, reconciliada contra el código:

- reference.md §4 «Per-component subset» seguía enseñando los subconjuntos por
  componente que §25 derogó el 2026-07-18, con tabla y justificación, sin marca.
  Marcada REVOKED; conserva el arbitraje (intent evaluativo gana), que es lo que
  sigue vivo.
- `tertiary` figuraba como RESERVADO y no consumido en dos sitios (reference §4
  y el comentario de themes/base.ts) cuando entra en ComponentColor, lo acepta
  todo prop color abierto y tiene su fila en la capa compartida.
- rfc-color-engine §13 decía «the 9 --color-{role}-{slot} slots»; son 12
  (COLOR_ROLE_SLOTS). Ahora enlaza el const en vez de copiar el número.
- El frontmatter del mismo RFC daba la fase 5 por «gated» cuando su `border`
  6->7 aterrizó el 2026-06-05 (reference §28); y §9 leía como lista de defectos
  vivos cuando sus cuatro filas están hechas.
- reference §40 se contradecía en una línea: llamaba a CONTRAST_PAIRS fuente
  compartida «con el Stage-2 solver» y 40 líneas después declaraba que Stage 2
  cerró SIN solver.
- 8 citas a «THEMING §25.5» apuntadas a §25: esa numeración sobrevivió en la
  crónica (changelog §25.5) pero no en la referencia tras la escisión.
- JSDoc de avatar y card documentaba su prop como «Canonical ColorRole» (8)
  cuando el tipo real es ComponentColorProp (42 + CSS crudo) — justo lo que lee
  un consumidor.
- «8 roles» hardcodeado en callout/README.md (error de docs:check),
  callout.svelte y mark/README.md: enlazan el const, como manda authoring.md.
  docs:check baja de 2 errores a 1.

Y una anotación, no un arreglo: banner/types.ts declara `BannerIntent =
ColorRole`, fundiendo el eje evaluativo con el de pintura (primary/secondary no
son evaluativos), y Banner no tiene prop `color` en absoluto. Queda marcado en
el docblock como decisión pendiente con sus citas — resolverlo es el otro frente
(un solo eje de color arbitrado por intent), y no está decidido.

Fuera de este commit: src/uix/blocks/banner/ es un directorio entero sin
trackear (trabajo en vuelo), y ahí quedan las dos últimas instancias del conteo
hardcodeado, incluido el error que le queda a docs:check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent c0a7050f54
commit 15621632be

@ -131,7 +131,7 @@ mandatory (the snippet shows the active prop values; see §12).
The v1 norm "every chip enumerates the role/variant/size union" was too narrow:
it hid the palette. The component `color` prop accepts **the full surface**
([`theming/reference.md`](../theming/reference.md) §4 / §25.5):
([`theming/reference.md`](../theming/reference.md) §4 / §25):
- **intent** (`neutral · affirm · fulfill · risk · threat · loss`) — evaluative.
- **color** — under `intent='neutral'`: a **hierarchy** role

@ -2,7 +2,7 @@
title: RFC — Next-generation color engine (OKLCH · P3 · APCA · 1-seed generator)
type: rfc
audience: human + agent
status: implemented — phases landed through 4-bis; slot tuning (phase 5) gated
status: implemented — phases landed through 4-bis; phase 5's `border` 6→7 landed 2026-06-05 (reference §28)
source: migrated from src/uix/eidos/COLOR_ENGINE_RFC.md (2026-07-02, docs-book F7.4)
---
@ -518,6 +518,13 @@ regression on the dark ones (purple/red/blue keep white).
## 9. Cleanup of the current model (included in the sweep)
> **All four rows are DONE** (verify in `themes/base.ts`: `primary: 'purple'`,
> `loss: 'plum'`, `tertiary: 'indigo'`; and `border: '7'` in
> `DEFAULT_COLOR_ROLE_SLOT_STEPS`). They landed separately rather than via the
> base→seeds migration this section assumed — see §11 phase 3. Kept as the
> record of what was wrong and why it was fixed; do not read it as a live
> defect list.
| Defect | Fix | Anchor |
|---|---|---|
| `primary` ≡ `loss` = purple in the base | Migrate the base to seeds; `loss` → its own seed (plum). Auto-derives if omitted. | `themes/base.ts:18,31` |
@ -597,7 +604,12 @@ bump if the authored `scales` shape changes; old 12-hex scales read the same.
live (build-time via an endpoint or precomputed).
- **Verify**: an arbitrary brand generates an AA scale without authoring hex.
### Phase 5 — slot→step tuning (P3-3), optional, gated behind a probe
### Phase 5 — slot→step tuning (P3-3) — ✅ LANDED (2026-06-05)
> The conservative move shipped: `border` = **step 7**
> (`DEFAULT_COLOR_ROLE_SLOT_STEPS`), standing doctrine in
> [`theming/reference.md §28`](../theming/reference.md). The slot set also grew
> past the original nine — `bg2`/`separator`/`textStrong` re-expose steps
> 2/6/12 (rationale in `config-types.ts`, `COLOR_ROLE_SLOTS`).
- Only if the visual probe supports it. Conservative (`border` 6→7).
### Phase 6 — Docs
@ -661,7 +673,7 @@ good part **without** their regressions (see the runtime-vs-build analysis):
|---|---|---|
| "1 base variable instead of 12" | **1-seed generator** (§6), isomorphic: author one color, 12 come out — at build **or** JS runtime | …fidelity (tuned curve), introspectable contrast, alpha, universal support |
| "fewer bytes" | Brand apps author N seeds (not N×12 hex) + **purge** + compact direct-OKLCH | …the `data-color` override (the scales stay available) |
| "semantic reduction to ~6 states" | **Already exists**: the 9 `--color-{role}-{slot}` slots (§25). Untouched. | — |
| "semantic reduction to ~6 states" | **Already exists**: the `--color-{role}-{slot}` slots — `COLOR_ROLE_SLOTS` (§25). Untouched. | — |
| OKLCH / vanguard | **Authoring and source space** + wide-gamut output | …breaking the static computation |
Agreement on the goal (OKLCH, less authoring); the right mechanism is

@ -479,8 +479,9 @@ neutral — gray default threat — active negative consequence
**Mapping to physical scales** (in the base theme): the standing assignment
lives in the code — **single source**: `THEME_BASE_COLOR_ROLES`
(`lib/themes/base.ts`), with each choice's rationale in its comments (e.g.
`tertiary: 'indigo'` is a RESERVED hierarchy slot no component consumes
yet; `loss: 'plum'` to avoid colliding with `primary: 'purple'`). This
`tertiary: 'indigo'` is the saturated third hierarchy accent, in a hue band
no intent occupies; `loss: 'plum'` to avoid colliding with
`primary: 'purple'`). This
table was copied here twice and diverged both times (primary, risk) — hence
it is now a pointer.
@ -508,18 +509,21 @@ Any system with fewer loses perceptual resolution. Any system with more
falls into redundancy (success vs fulfill, danger vs threat — they are not
the same).
### Per-component subset
Each component exposes its own subset of the 9. Examples:
### Per-component subset — REVOKED (2026-07-18)
| Component | Subset | Excludes |
|---|---|---|
| Toggle | primary, secondary, neutral, affirm, risk, threat | fulfill, loss (doesn't apply) |
| Button | all 9 | — |
| Badge | primary, secondary, neutral, affirm, fulfill, risk, threat, loss | tertiary (not canonical for it) |
> **There are no per-component subsets.** `color` accepts the FULL system on
> **every** component — role / intent / 33 donor scales / raw CSS value
> (`ComponentColorProp`) — per the design decision in **§25, "Reversión de los
> subconjuntos"**. This section used to publish a subset table (Toggle
> excluding fulfill/loss, Badge excluding tertiary) and argue that exposing
> them "would be semantically wrong". That argument is precisely what §25
> revoked, and the guard *"keeps every component `*Color` prop open"*
> (`recipe-css-contract.test.ts`) now breaks the build on any narrowing.
Why subsets: a toggle is neither completion nor irreversible loss. Exposing
fulfill/loss in its API would be semantically wrong.
What survives is the **arbitration**, not the narrowing: identity (`color`)
stays decoupled from evaluation, so an evaluative `intent` still wins over
`color` (the rule above). A component does not decide *which* colors exist for
it; it decides nothing — the system is open and the intent arbitrates.
---
@ -1767,7 +1771,7 @@ initiative registry [`next-features.md §1`](../next-features.md). Standing
doctrine — which slot pairs the framework promises to clear, and which are
intentionally subtle. The ratified pair table now lives as DATA (`$color` →
`CONTRAST_PAIRS`, `arts/color/contrast-contract.ts`), a single source shared by
the audit and the Stage-2 solver. Measured by `scripts/contrast-audit.ts` (WCAG
the audit and the CI guard (Stage 2 shipped no solver — see the closing note). Measured by `scripts/contrast-audit.ts` (WCAG
2 gate + APCA Lc, over the 33 scales × 2 modes, plus a morph-generated
regression bank), reusing the on-solid math (§8 of `rfc-color-engine.md`,
`lib/on-solid.ts`).

@ -39,7 +39,8 @@ export type AvatarProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
/** Visual variant. @default 'soft' */
variant?: AvatarVariant;
/**
* Accent palette. Canonical `ColorRole` OR any CSS color string.
* Accent palette. The full system — any canonical role / intent / donor
* scale (`ComponentColor`) OR any raw CSS color string.
* @default 'neutral'
*/
color?: AvatarColor;
@ -54,7 +55,7 @@ export type AvatarProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
ring?: AvatarRing;
/**
* Ring color override. Falls back to the avatar's `color` when
* unset. Accepts canonical `ColorRole` or any CSS color string.
* unset. Same open surface as `color` (`ComponentColorProp`).
*/
ringColor?: AvatarColor;
/** Ring stroke thickness. @default 'md' */

@ -5,9 +5,17 @@ import type { ChipVariant } from '$uix/eidos/lib/types';
import type { IconButtonProps } from '$uix/eidos/components/icon-button';
/**
* 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.
* Banner intent vocabulary. Currently the whole `ColorRole` set (the
* hierarchy roles + the canonical intents); the recipe pulls the matching
* `--color-{intent}-{slot}` tokens for each value.
*
* ⚠️ This conflates two axes the doctrine separates: `intent` is the
* EVALUATIVE axis (the 6 `INTENTS`), while `primary`/`secondary`/`tertiary`
* are non-evaluative hierarchy roles that belong to `color` — the paint axis
* (`reference.md` §4, `active-architecture.md` DOM table: `data-color` =
* per-token recipe, `data-intent` = persistent state). Banner also has no
* `color` prop, so it is the one painted component outside the open colour
* system. Resolving this is a pending decision, not an oversight.
*/
export type BannerIntent = ColorRole;

@ -68,7 +68,7 @@ font-size/font-weight/icon-size` por talla (`-xs`…`-xl`), `font-family`,
`transition-*`, `disabled-opacity`, y las paletas de 7 slots por color
(`--button-{color}-{track,element,border,solid,solid-hover,text,contrast}`)
que la cascada `--button-palette-*` resuelve según `data-color` (piloto del
patrón; THEMING §25.5 admite escalas donantes per-instance).
patrón; THEMING §25 admite escalas donantes per-instance).
## Comparativa

@ -76,7 +76,7 @@ export type ButtonProps = Omit<ProviderProps, 'color' | 'child'> & {
/**
* Color accent. Hierarchy (`primary`/`secondary`/`tertiary`/`neutral`),
* an evaluative intent, OR any donor palette scale (`teal`, `plum`, …) —
* the Radix `color="grass"` per-instance override (THEMING §25.5). Per
* the Radix `color="grass"` per-instance override (THEMING §25). Per
* the doctrine, an evaluative `intent` wins; under `intent='neutral'`
* the `color` is free (hierarchy or palette).
*

@ -20,9 +20,9 @@ rule (parity floor = v1).
- **Two axes** (doctrine §4): `intent` (neutral | affirm | risk | threat —
the admonition type, drives tint + icon + title defaults) and `color`
(paint override under `intent="neutral"` ONLY — evaluative intent wins).
Paint rides the C6 private-palette slots → the 8 roles, the 33 donor
scales AND raw CSS colors resolve through the THM-2 shared layer with
zero extra CSS.
Paint rides the C6 private-palette slots → the canonical color roles, the
33 donor scales AND raw CSS colors resolve through the THM-2 shared layer
with zero extra CSS.
## Comparativa

@ -57,7 +57,7 @@
// Paint doctrine (§4): an evaluative intent WINS — the `color` override
// only applies under `intent="neutral"` (the IMPORTANT case). The
// intent values themselves belong to the canonical 8 color roles.
// intent values themselves are drawn from the canonical color roles.
const effectiveColor = $derived(intent !== 'neutral' ? intent : (color ?? 'neutral'));
const colorAttrs = $derived(resolveComponentColor(effectiveColor));
const mergedStyle = $derived(composeInlineStyle(style, colorAttrs.customStyle));

@ -34,7 +34,8 @@ export type CardSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
*
* Two valid shapes:
*
* - **Canonical** `ColorRole`. Wins over consumer overrides for
* - **Canonical** name — a role, an intent or a donor scale
* (`ComponentColor`). Wins over consumer overrides for
* evaluative intents (affirm / fulfill / risk / threat / loss) and
* hierarchy promotion (primary / secondary). The recipe pulls the
* matching `--color-{role}-{slot}` palette.
@ -68,8 +69,9 @@ export type CardProps = Omit<BoxProps, 'display'> & {
/** Sizing scale (padding / gap / radius). @default 'md' */
size?: ResponsiveProp<CardSize>;
/**
* Accent palette. Accepts a canonical `ColorRole` OR any CSS color
* string (hex / rgb / oklch / named). @default 'neutral'
* Accent palette. The full system — any canonical role / intent / donor
* scale (`ComponentColor`) OR any raw CSS color string
* (hex / rgb / oklch / named). @default 'neutral'
*/
color?: CardColor;
/** Corner radius (magnitude). @default 'md' */

@ -37,7 +37,7 @@ Adaptaciones para eidos:
| Capacidad | UIX Mark | Radix Themes Mark | Chakra Mark | Mantine Mark |
| --- | --- | --- | --- | --- |
| Inline `<mark>` element | Sí | Sí | uses Highlight | Sí |
| Color palette | Intent (8 roles) | accent + intent | colorScheme | intent |
| Color palette | Intent (canonical color roles) | accent + intent | colorScheme | intent |
| Default color | fulfill | yellow accent | yellow | yellow |
| Inherits typography | Sí | Sí | Sí | Sí |
| `as` polymorphism | Sí | Sí | Sí | Sí |

@ -6,7 +6,7 @@ import type {
} from '../config-types';
import { defineRecipes } from './define';
// ── Per-instance palette cascade (THEMING §25.5) ──────────────────────────
// ── Per-instance palette cascade (THEMING §25) ────────────────────────────
//
// `palette-{slot}` is RESERVED recipe vocabulary: the GENERATOR appends the
// full per-scale `color:{scale}` cascade to any token with that name

@ -1845,7 +1845,7 @@ interface NormalizedDeclaration {
* Normalize a component's tokens: convert each entry to its list of
* declarations, infer var()-driven dependencies, merge explicit deps.
*/
// ── Universal per-instance palette cascade (THEMING §25.5) ────────────────
// ── Universal per-instance palette cascade (THEMING §25) ──────────────────
//
// `palette-{slot}` is RESERVED recipe vocabulary: any recipe token named
// `palette-track` / `-surface` / `-surface-hover` / `-element` / `-border` /
@ -2052,7 +2052,7 @@ function renderRecipeGradientFinish(
return blocks
}
// ── Shared per-instance palette layer (THEMING §25.5 · THM-2 2026-07-12) ──
// ── Shared per-instance palette layer (THEMING §25 · THM-2 2026-07-12) ────
//
// The component-agnostic SOURCE of the per-instance palette. ONE cascade for
// the whole catalog: `[data-color='{role|scale}'] { --palette-{slot}: … }`.

@ -26,9 +26,11 @@ export const THEME_BASE_COLOR_ROLES: ColorRoleMap = {
// and the gray neutral. Indigo is purple's cool ~60° neighbor (Material 3's
// tertiary-hue rule) and sits in a hue band no intent occupies, so it reads as
// hierarchy, not a signal. (Was `gray` — collided with neutral.)
// RESERVED (2026-06-15 audit): defined + emitted (`--color-tertiary-*`) but no
// component consumes it yet — a deliberate third-hierarchy slot apps/themes can
// reach for. Not dead code; not to be removed.
// CONSUMABLE: `tertiary` is part of `HierarchyColorRole`, so it enters
// `ComponentColor` and every open `color` prop accepts it, and the shared
// palette layer emits its `[data-color='tertiary']` row along with the rest of
// `COLOR_ROLES`. (It was marked RESERVED by the 2026-06-15 audit; the note
// outlived the wiring that made it reachable.)
tertiary: 'indigo',
// `gray-9` (el sólido neutral) es un gris medio: ni blanco ni negro
// contrastan por defecto. El slot `contrast` se fija al step 12 — casi-negro

@ -8,6 +8,7 @@
*/
import type { Intent } from '$uix/intent';
import type { ColorRole, HierarchyColorRole } from './config-types';
export type { Breakpoint, ResponsiveProp } from '$adom';
@ -119,10 +120,18 @@ export const SHAPE_FAMILIES = ['rounded', 'continuous', 'cut', 'scoop'] as const
export type ShapeFamily = (typeof SHAPE_FAMILIES)[number];
/**
* Shared Eidos color role vocabulary. `primary` and `secondary` are
* hierarchy roles; the remaining values come from the UIX intent canon.
* Shared Eidos color role vocabulary — re-exported from `config-types`, where
* both types derive from the `COLOR_ROLES` / `HIERARCHY_COLOR_ROLES` consts.
*
* They used to be re-spelled here by hand, and drifted: this `ColorRole` lost
* `tertiary` (8 members) while the canonical one kept 9 — two exported types
* with the SAME name, and the import path decided which you got. The runtime
* never diverged (`resolveComponentColor` reads `COLOR_ROLES`), so the drift
* was type-only: it rejected at compile time a value the engine resolves.
* Derive from the const; never re-spell a closed vocabulary by hand.
*/
export type ColorRole = 'primary' | 'secondary' | Intent;
export type { ColorRole, HierarchyColorRole } from './config-types';
export type AffirmativeColorRole = Extract<ColorRole, 'primary' | 'secondary' | 'neutral' | 'affirm'>;
export type ProgressiveColorRole = Extract<
ColorRole,
@ -133,17 +142,10 @@ export type EditableColorRole = Extract<
'primary' | 'secondary' | 'neutral' | 'affirm' | 'risk' | 'threat'
>;
/**
* The hierarchy color roles — non-evaluative promotion levels. `tertiary`
* is the saturated third accent (was RESERVED; exposed for component
* `color` overrides per THEMING §4 / §25.5).
*/
export type HierarchyColorRole = 'primary' | 'secondary' | 'tertiary';
/**
* The full donor palette — every `--scale-{name}-*` scale the theme ships
* (33 scales). These are NOT roles: they are the raw scales a
* component instance can borrow via `color="teal"` (THEMING §25.5, the
* component instance can borrow via `color="teal"` (THEMING §25, the
* per-instance `color` override, e.g. `<Button color="grass">`). The role↔scale mapping is the
* theme's job; this is the per-instance override surface.
*

@ -3,7 +3,12 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { THEME_BASE_RECIPE_TOKENS } from './lib/recipes/base'
import type { EidosConfig, RecipeTokenSet, RecipeTokenValue } from './lib/config-types'
import {
COLOR_ROLES,
type EidosConfig,
type RecipeTokenSet,
type RecipeTokenValue
} from './lib/config-types'
import { createEidosCssContract } from './lib/contract'
import { renderStaticCss } from './lib/render-css'
import { createThemeBaseEidosConfig } from './lib/themes/base'
@ -282,6 +287,44 @@ describe('Eidos recipe CSS contract', () => {
expect(violations).toEqual([])
})
it('derives the shared colour vocabulary from the const (no hand-spelled role unions)', () => {
// C1 (2026-07-30): `lib/types.ts` re-spelled `ColorRole` by hand and drifted
// to 8 members — `tertiary` fell out — while `config-types.ts` kept the
// const-derived 9. TWO exported types with the SAME name; the import path
// decided which one a file got. The runtime never diverged
// (`resolveComponentColor` unions `COLOR_ROLES` + `PALETTE_SCALES`), so the
// drift was type-only: it rejected at compile time a value the engine
// resolves and the shared palette layer already emits a row for.
//
// A shared vocabulary DERIVES from its const. Re-spelling one as a literal
// union is how it silently falls behind. `config-types.ts` is the canonical
// declaration site and is therefore exempt.
const LIB_DIR = 'src/uix/eidos/lib'
const roleNames = COLOR_ROLES as readonly string[]
const violations: string[] = []
for (const file of readdirSync(LIB_DIR)) {
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue
if (file === 'config-types.ts') continue
const source = readFileSync(join(LIB_DIR, file), 'utf8')
for (const match of source.matchAll(/export type (\w+) =([^;]*);/g)) {
// `Extract<ColorRole, 'a' | 'b'>` SELECTS from the derived union, so it
// tracks the const by construction. Only a fresh literal union
// re-declares the vocabulary, which is the shape that fell behind.
const rhs = match[2].replace(/Extract<[\s\S]*?>/g, '')
const spelled = [...rhs.matchAll(/'([a-z-]+)'/g)]
.map((literal) => literal[1])
.filter((literal) => roleNames.includes(literal))
if (spelled.length >= 2) {
violations.push(
`${file}: ${match[1]} re-spells [${spelled.join(', ')}] — derive from COLOR_ROLES`
)
}
}
}
expect(violations).toEqual([])
})
it('pairs a dynamic `data-color` stamp with `data-color-custom` (runtime openness)', () => {
// The type guard above proves the *type* stays open, but is blind to the
// RUNTIME: a wrapper that stamps `data-color={value}` without also stamping

@ -34,7 +34,7 @@ que navega».)
| Prop | Type | Default | Description |
| ------------------ | --------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------- |
| `intent` | `'neutral' \| 'affirm' \| 'fulfill' \| 'risk' \| 'threat' \| 'loss'` | `'neutral'` | Visual evaluative tint (chip + `data-color`). Does NOT load the sema event — see below. |
| `color` | `'primary' \| 'secondary' \| 'tertiary' \| 'neutral' \| (string & {})` | `'primary'` visual | Hierarchical accent; only applies under `intent='neutral'`. Any donor palette scale allowed (THEMING §25.5). |
| `color` | `'primary' \| 'secondary' \| 'tertiary' \| 'neutral' \| (string & {})` | `'primary'` visual | Hierarchical accent; only applies under `intent='neutral'`. Any donor palette scale allowed (THEMING §25). |
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | Native button type (default avoids implicit form submit). **Dropped in the `child` form** — the element is the consumer's, so `type` travels with it. |
| `disabled` | `boolean` | `false` | OR-merged with `Field.Provider`. Click flow gated. |
| `loading` | `boolean` | `false` | Gates the click flow (no `contact-activate`), sets `aria-busy`, renders the Spinner. |

@ -72,7 +72,7 @@ export type ButtonProps = WithChild<
* - `'secondary'` / `'tertiary'` — hierarchy promotion levels
* - `'neutral'` — chromeless / least promoted
* - any donor palette scale (`'teal'`, `'plum'`, …) — the Radix
* `color="grass"` per-instance override (THEMING §25.5)
* `color="grass"` per-instance override (THEMING §25)
*
* Headless writes the value verbatim to `data-color`; the eidos recipe
* decides what each value renders. Typed loosely (`string & {}`) so soma

Loading…
Cancel
Save

Powered by TurnKey Linux.