feat(calendar-surface): la capa que ya existía, con nombre, casa y su agujero tapado

Firma 1 del acta, diseño presentado y firmado. **La medición desmontó el
encargo**: el handoff la vendía como una capa NUEVA de ~220 knobs, y la capa ya
existía de hecho, sin nombre. `--calendar-*` se emite en `:root` (76 claves) y
sus consumidores no acuñan NADA — medido: `range-calendar` 114 referencias
prestadas y 0 propias, `month-grid` 77/0, `year-grid` 77/0, y lo único ajeno
que leen es sistema puro (`--focus-ring-*`, `--state-hover`). Su 0 % era el
artefacto de `listbox` (§13), pero total.

**`lib/calendar-surface.css`** (hook `data-calendar-surface`):

- cuatro coordenadas por talla — `padding`, `control-size`, `day-size`,
  `font-size` — xs..lg, porque la familia NO tiene xl, y con la celda DOS pasos
  por debajo del bundle de control. Esa desviación estaba escondida en cuatro
  bloques `[data-size]` idénticos, uno por receta, cada uno puenteando a un
  privado con otro nombre; ahora se lee en un sitio.
- la FORMA del anillo de evento y de la marca de festivo.

**Capa HÍBRIDA, y es lo que la distingue de sus hermanas**: `list-surface` y
`viewport-placement` componen primitivos del sistema, así que declaran sus
públicos en el fichero y no tienen entrada de receta. Ésta no puede: su
vocabulario son 76 claves SEMÁNTICAS que un tema alcanza una a una por config,
así que la entrada `calendar` de `recipes/base.ts` pasa a ser la de la FAMILIA
y la capa posee sólo lo que una entrada de receta no sabe expresar.

**El defecto que la justificaba, medido**: `--calendar-event-shadow` y
`--calendar-day-holiday-shadow` se emitían con ámbito `[data-calendar]`
(audit B.2 los host-scopeó por buenas razones) mientras `range-calendar`,
`month-grid` y `year-grid` los leían desde hosts que nunca llevan ese atributo:
variable VACÍA, `box-shadow` inválido en computed, **el anillo sema de
`commit-select` / `commit-set` no pintaba jamás en tres componentes**. Un token
prestado cuyo ÁMBITO no te cubre no es un préstamo, es un agujero silencioso, y
ningún guard lo veía. Ahora la forma vive en la capa y el acento entra por
`--_calendar-surface-accent`, que cada superficie alimenta con su propio
forward de paleta THM-2.

**Siete wrappers estampan, no cuatro** — y esto casi se me cuela: `DatePicker`
y `DateRangePicker` renderizan la superficie soma por sus PROPIOS wrappers
(`date-picker-calendar`, `-month-view`, `-year-view`,
`date-range-picker-calendar`) y un panel portalado no hereda nada del root del
picker. Con sólo los cuatro standalone sellando, ambos quedaban con
`--calendar-padding` VACÍA y el panel a padding 0 (medido). La tentación era
enganchar la capa a las cuatro identidades de componente: eso viola la regla 1
de capas compartidas, y la respuesta correcta es un sello por wrapper.

  computed  0 diffs en range-calendar (19.285 valores) · month-grid (3.451) ·
            year-grid (3.451) · date-picker (464) · date-range-picker (406).
            `calendar` da 12, y son del INSTRUMENTO: dos corridas del MISMO
            código dan 24 en los mismos nodos y las mismas dos propiedades.
            Los «missing node» son el propio sello entrando en la clave.

**Una incidencia nueva, medida y NO arreglada aquí** (§13): el font-size de los
selectores month/year es una moneda al aire —
`[data-calendar-month-select][data-button]` (0,2,0) empata con
`[data-popover-trigger]:not([data-archetype='field-trigger'])` (0,2,0), la
MISMA regla de popover que dejó muerto el cromo de `gradient-picker`, y gana la
hoja que cargue después: 16px o 14px según la recarga. Arreglarlo fija el píxel
en un lado ⇒ decisión.

De paso, `calendar-select.css` deja de puentear un privado que sólo
`[data-calendar]` declaraba: en range / date-range corría SIEMPRE por el
fallback, clavado a md fuera cual fuera la talla.

**El censo deja de penalizar hacer lo correcto**: `LAYER_VOCABULARY` en
`theming-census.ts`, mismo precedente que D-TH.2-b con `--style-*`. Global
43 % → **45 %**, `calendar` 75 % → **84 %**. `list-surface` NO se registra: sus
consumidores puentean por privados, otra forma, y mueve diez componentes de
golpe. Y aparece el techo de debajo, anotado: a los tres consumidores sólo les
quedan los forwards de paleta THM-2 —que el censo cuenta como `private` en TODO
el catálogo— así que siguen leyendo 0 %.

`recipe-css-contract` aprende que una CAPA también declara públicos (antes sólo
miraba la receta, y una capa que comparte prefijo con un componente la hacía
fallar). Sin debilitarla: un nombre que no declara nadie sigue en rojo.

  eidos-lint 0 invalid (calendar 29/8 · range-calendar 41/12 · los grids 23/5) ·
  audit --only calendar PASS · vitest eidos 434/435 (el rojo conocido) ·
  rtl 0/181 · docs 0/813 · check 0 errores en tocados · prettier: revertido el
  reformateo en masa que se coló en cuatro README, el test y el censo (churn
  ajeno, no mío)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent df27351ffc
commit 8400e5bdd4

@ -503,6 +503,19 @@ existiera. Se anotan porque el eje entero se apoya en esas mediciones.
mientras el resto del catálogo lleva la mezcla suave del sistema
(`--focus-ring-color`, 52 %). Quedó sin tocar porque alinearlo mueve píxel —
y de hecho quedó sin token, porque la regla entera está muerta bajo popover.
- ~~**El censo PENALIZA consumir una capa compartida correctamente.**~~
**RESUELTO PARA LA FAMILIA CALENDAR 2026-08-21** y abierto para el resto: el
censo tiene ahora `LAYER_VOCABULARY` (mismo precedente que D-TH.2-b), así que
el `--calendar-*` que leen `range-calendar` / `month-grid` / `year-grid`
cuenta como `system`. **`list-surface` NO está registrado a propósito**: sus
consumidores puentean por PRIVADOS (`--_listbox-item-*: var(--list-item-*)`),
otra forma, y activarlo mueve diez componentes de golpe — sigue siendo
decisión aparte. Y aparece un techo NUEVO debajo: con el vocabulario de la
capa fuera del denominador, a los tres sólo les quedan los **forwards de
paleta THM-2** (`--_x-palette-*`, que el censo cuenta como `private` en TODO
el catálogo) y literales de layout, así que siguen leyendo 0 %. Reclasificar
el forward de paleta es la misma pregunta, un piso más abajo, y afecta a
todos.
- **El censo PENALIZA consumir una capa compartida correctamente.** `listbox`
adopta los cinco ejes de ritmo que `list-surface` posee — que es exactamente
lo que la doctrina de capas manda — y el censo los cuenta como `private`, así
@ -520,6 +533,22 @@ existiera. Se anotan porque el eje entero se apoya en esas mediciones.
~155 componentes sin contrato siguen sin superficie donde verlos hasta que se
tokenicen.
Lo que la capa `calendar-surface` añadió (2026-08-21):
- **⚠ El font-size de los selectores month/year del calendario es una MONEDA AL
AIRE.** `[data-calendar-month-select][data-button]` (0,2,0) empata con
`[data-popover-trigger]:not([data-archetype='field-trigger'])` (0,2,0) — la
MISMA regla de `popover.css` que dejó muerto el cromo entero del trigger de
`gradient-picker` — y gana la hoja que cargue después: dos corridas del mismo
código dan 16px o 14px en los mismos nodos (24 diffs de instrumento, medidos
con la sonda dos veces). El pin del calendario existe justamente para que
caja, etiqueta y chevron lean UNA talla, así que perderlo es visible.
Arreglarlo es subir el peso del pin de forma determinista — decisión, porque
fija el píxel en un lado. Segunda ocurrencia de la clase §12.3 (empate por
orden de carga) y segunda víctima de la misma regla de popover.
- **Los tres consumidores de la capa siguen leyendo 0 % de alcance**, ahora por
los forwards de paleta THM-2 — ver el punto del censo más arriba.
Lo que la revisión adversarial de F2-A añadió (2026-08-21):
- ~~**PENDIENTE DE FIRMA — el `striped` de `table` es inerte con RowDetail.**~~

@ -1,19 +1,18 @@
# CONTINUE — eje Theming «theme-reach» (handoff, act. 2026-08-21)
**Estado: EN EJECUCIÓN — vocabulario CERRADO, bloque F2-A CERRADO y con
REVISIÓN ADVERSARIAL PASADA (2026-08-21: 34 hipótesis · 26 refutadas · 8
reales, las de código arregladas con 0 diffs). El guard R-5.4 existe:**
`npm run theming:sentinel -- <c> <url>` + ledger
`scripts/theming-sentinel-exceptions.ts` (doctrina en recipe-contract §4).
Alcance global **43 %** (era 33 % al abrir el eje, 37 % al empezar el día 20).
15 componentes con contrato, 49 sin él, 7 al 100 %.
**Estado: EN EJECUCIÓN — vocabulario CERRADO · bloque F2-A CERRADO y con
REVISIÓN ADVERSARIAL PASADA · capa `calendar-surface` VIVA.** El guard
**R-5.4** existe (`npm run theming:sentinel -- <c> <url>` + ledger
`scripts/theming-sentinel-exceptions.ts`, doctrina en recipe-contract §4).
Alcance global **45 %** (33 % al abrir el eje, 37 % el día 20, 43 % tras F2-A).
`calendar` 84 %.
## LO PRIMERO AL ENTRAR (cinco minutos, en este orden)
1. **Comprueba que el suelo no se ha movido**:
```bash
node --import tsx/esm scripts/theming-census.ts # 162 · 5.190 · 2.102 (43 %) · 49 sin contrato
node --import tsx/esm scripts/theming-census.ts # 162 · 5.190 · 2.108 (45 %) · 49 sin contrato
node --import tsx/esm scripts/theming-census.ts --names # DESVIADAS 0 — la gramática se mantiene
```
@ -31,25 +30,26 @@ Alcance global **43 %** (era 33 % al abrir el eje, 37 % al empezar el día 20).
bloque, **tres veredictos de ocho tenían un error de lectura** que sólo
apareció al medir (ver «Lo que este bloque enseñó»).
## QUÉ HACER AHORA — dos opciones, y la recomendación
## QUÉ HACER AHORA — la cola, y nada más pendiente de diseño
**A · ~~Revisión adversarial del bloque F2-A~~ — HECHA (2026-08-21).** Molde
Sidebar: worktree con SOLO el CSS revertido a la base del bloque (DOM de demo
constante — prueba más dura que la del autor), sonda ×2 para el suelo de
ruido, 0 diffs en los ocho. Lo que sobrevivió y lo que no, en el registro de
abajo; los 4 hallazgos de código quedaron arreglados (dos retiradas de
declaración muerta, un literal tokenizado, y el ledger del guard). Los que
mueven píxel esperan firma (ver «PENDIENTE DE FIRMA»).
**A y B están HECHAS.** La revisión adversarial de F2-A (§7.7) cerró el bloque
y parió el guard **R-5.4**; la capa `calendar-surface` está firmada, escrita y
medida. Detalle de las dos en el registro de abajo. **Lo único que espera tu
firma** son las tres de «PENDIENTE DE FIRMA» — ninguna bloquea la cola.
**B · Diseño de la capa `calendar-surface`** (firma 1 del acta). Lo más
rentable en cifras: desbloquea `range-calendar` (98 knobs) + `month-grid` (61)
**Empieza por aquí mañana**: `grid-list` (32 knobs) es el siguiente ejecutable
sin decisión nueva — mismo patrón que `tree-grid`/`table` (fila + capa de
estado), y esos dos ya están hechos, así que hay molde. `field-langs` tiene más
knobs pero la mitad espera el mandato Field (firma 2), así que no es el primero.
- `year-grid` (61) + media `date-range-picker` — unos 220 knobs, el salto de
43 % a ~48 %. **El diseño se PRESENTA al autor antes de escribir una línea**
(nombre, ejes, qué posee la capa; molde `lib/list-surface.css` y sus cuatro
reglas). No es un backfill más: es una capa nueva.
**Y ojo con dos cosas nuevas del protocolo**, que no estaban cuando se escribió
la cola: cada componente pasa ahora el guard **R-5.4**
(`npm run theming:sentinel -- {c} <url>`) y los «no effect» se adjudican **en
el ledger** (`scripts/theming-sentinel-exceptions.ts`), no en el mensaje de
commit; y si el componente resulta consumir una capa compartida, el censo
puede estar puntuándolo mal — mira `LAYER_VOCABULARY` antes de creerte un 0 %.
**C · Seguir con la cola por alcance**, que tras F2-A queda así:
**La cola por alcance**, que tras F2-A y la capa queda así:
| componente | knobs | nota |
| -------------- | ----: | --------------------------------------------------------------- |
@ -62,6 +62,10 @@ rentable en cifras: desbloquea `range-calendar` (98 knobs) + `month-grid` (61)
| `spinner` | 23 | 15 privados |
| `picker-shell` | 21 | es CAPA de los pickers: mirar antes qué posee y qué presta |
Ya NO están en la cola `range-calendar` (98) · `month-grid` (61) ·
`year-grid` (61): no acuñaban un solo token y su superficie es
`calendar-surface`. Su 0 % era el censo midiendo mal, no deuda.
## EL PROTOCOLO, EN CORTO (el largo está en PLAN-theming.md §7)
Un componente = un commit. Por componente:
@ -171,6 +175,54 @@ se sigue. No se arregla dentro del commit del componente.
# REGISTRO DE LA SESIÓN DEL 2026-08-20/21 (histórico — no es la agenda)
## EJECUTADO 2026-08-21 — la capa `calendar-surface` (firmada y medida)
**La medición cambió el encargo.** El handoff la vendía como «una capa nueva,
~220 knobs, 43 % → ~48 %». No lo era: la capa **ya existía de hecho**, sin
nombre ni casa. `--calendar-*` se emite en `:root` (76 claves) y sus
consumidores **no acuñan NADA** — `range-calendar` 114 referencias prestadas y
0 propias, `month-grid` 77/0, `year-grid` 77/0, y lo único ajeno que leen es
sistema puro (`--focus-ring-*`, `--state-hover`). Su 0 % era el artefacto de
`listbox` (§13) pero TOTAL.
**Lo que se escribió**: `lib/calendar-surface.css` con el hook
`data-calendar-surface`, cuatro coordenadas por talla (`padding`,
`control-size`, `day-size`, `font-size`, xs..lg — la familia **no tiene xl**, y
la celda va DOS pasos por debajo del bundle de control, deviación que ahora se
lee en un sitio en vez de esconderse en cuatro bloques `[data-size]` idénticos)
y la FORMA del anillo de evento. El vocabulario **se queda en la entrada
`calendar` de la receta**, que pasa a ser la de la FAMILIA: ésta es una capa
HÍBRIDA y es lo que la distingue de `list-surface` / `viewport-placement`, que
componen primitivos y no tienen entrada de receta (76 claves semánticas tienen
que seguir siendo alcanzables una a una por config).
**El defecto que la justificó, medido**: `--calendar-event-shadow` y
`--calendar-day-holiday-shadow` se emitían con ámbito `[data-calendar]` y los
otros tres los leían desde hosts que nunca llevan ese atributo → variable
VACÍA, `box-shadow` inválido en computed, **el anillo sema no pintaba jamás en
tres componentes**. Ahora la forma vive en la capa y el acento entra por
`--_calendar-surface-accent`, que cada superficie alimenta con su forward de
paleta THM-2.
⚠️ **Lo que casi se me cuela, y la lección**: estampar el hook sólo en los
cuatro wrappers standalone dejaba a `DatePicker` y `DateRangePicker` con
`--calendar-padding` VACÍA y el panel a padding 0 — renderizan la superficie
soma por sus PROPIOS wrappers (`date-picker-calendar`, `-month-view`,
`-year-view`, `date-range-picker-calendar`) y un panel portalado no hereda
nada. **Son SIETE wrappers, no cuatro.** La tentación era enganchar la capa a
las cuatro identidades de componente; eso viola la regla 1 de capas
compartidas, y la respuesta correcta es un sello por wrapper.
**El censo aprendió a no penalizar**: `LAYER_VOCABULARY` en
`theming-census.ts` (mismo precedente que D-TH.2-b con `--style-*`). Global
43 % → **45 %**, `calendar` 75 % → **84 %**. Los tres consumidores **siguen
leyendo 0 %**, y es honesto: con el vocabulario de la capa fuera del
denominador sólo les quedan los forwards de paleta THM-2 (que el censo cuenta
como `private` en TODO el catálogo) y literales de layout. Reclasificar el
forward de paleta es la misma pregunta un piso más abajo — anotada en §13.
`list-surface` NO se registró: sus consumidores puentean por privados, otra
forma, y mueve diez componentes de golpe.
## EJECUTADO 2026-08-21 — revisión adversarial de F2-A (§7.7) + guard R-5.4
Molde Sidebar, medición propia contra worktree con solo el CSS revertido a la

@ -455,6 +455,22 @@ de commit. Sin artefacto, el paso no se ha hecho.
## 8. Registro
- 2026-08-21 — **La capa `calendar-surface`** (firma 1 del acta, diseño
presentado y firmado el mismo día). La medición desmontó el encargo: la capa
ya existía de hecho — `--calendar-*` en `:root`, y `range-calendar` /
`month-grid` / `year-grid` con 114 / 77 / 77 referencias prestadas y CERO
tokens propios. Se le dio nombre y casa (`lib/calendar-surface.css`, hook
`data-calendar-surface`, cuatro coordenadas xs..lg sin xl, celda dos pasos
bajo el bundle) y se cerró el defecto que la justificaba: las dos sombras del
anillo se emitían con ámbito `[data-calendar]` y los otros tres las leían
vacías, así que el anillo sema **no pintaba** en tres componentes. Capa
HÍBRIDA: el vocabulario se queda en la entrada de receta (76 claves
semánticas alcanzables por config), la capa posee la resolución por talla y
la forma del anillo. **Siete wrappers estampan**, no cuatro — los pickers
renderizan la superficie soma por los suyos y un panel portalado no hereda.
Censo: `LAYER_VOCABULARY` deja de penalizar consumir una capa; global 43 % →
**45 %**, `calendar` 75 % → **84 %**.
- 2026-08-21 — **Revisión adversarial de F2-A (§7.7) + nace R-5.4.** Molde
Sidebar sobre worktree con solo el CSS revertido a la base: 34 hipótesis ·
26 refutadas · 8 reales, todas las de código arregladas con diff de computed

@ -154,6 +154,35 @@ const TYPOGRAPHIC_PRIMITIVES = new Set(['heading', 'text', 's-text', 'code', 'di
const STYLE_LAYER = /var\(--style-[a-z0-9-]+/;
/**
* The vocabulary a SHARED LAYER publishes, and who consumes it (D-TH.2-b
* precedent, extended 2026-08-21 with the `calendar-surface` design).
*
* Same argument as the typographic primitives: when the layer owns an axis,
* the consumer's theming surface for that axis IS the layer, so reading
* `--{layer-vocabulary}-*` is reaching a live public surface — not a raw
* global the component should have minted a token for. Minting one would be
* the parallel vocabulary the layer doctrine forbids, AND it would outrank the
* layer for every other consumer.
*
* Until this existed the census PENALISED doing the correct thing:
* `range-calendar`, `month-grid` and `year-grid` mint zero tokens and read
* 114 / 77 / 77 references from `--calendar-*` — measured — and all three
* scored 0 % reach.
*
* DELIBERATELY not registered yet: `list-surface`. Its consumers bridge
* through PRIVATES (`--_listbox-item-*: var(--list-item-*)`), which is a
* different shape than reading the layer directly, and flipping it moves ten
* components at once — a separate decision (next-features §13).
*/
const LAYER_VOCABULARY: { layer: string; consumers: Set<string>; needle: RegExp }[] = [
{
layer: 'calendar-surface',
consumers: new Set(['range-calendar', 'month-grid', 'year-grid', 'date-picker']),
needle: /var\(\s*--calendar-[a-z0-9-]+/
}
];
function classify(
value: string,
pubNeedle: string,
@ -169,6 +198,10 @@ function classify(
// through the layer that owns it.
if (component && TYPOGRAPHIC_PRIMITIVES.has(component) && STYLE_LAYER.test(value))
return 'system';
// Same reasoning, one layer up: a shared layer's vocabulary read by one of
// its declared consumers IS that consumer's theming surface for the axis.
if (component && LAYER_VOCABULARY.some((l) => l.consumers.has(component) && l.needle.test(value)))
return 'system';
if (value.includes(privNeedle)) return 'private';
if (SYSTEM.test(value)) return 'system';
if (value.includes('var(--')) return 'global';
@ -404,6 +437,7 @@ const SHARED_LAYERS: Record<string, string[]> = {
'menubar',
'navigation-menu'
],
'calendar-surface': ['calendar', 'date-picker', 'month-grid', 'range-calendar', 'year-grid'],
'sliding-indicator': ['radio-group'],
'viewport-placement': ['affix', 'fab', 'menu-dial'],
'spin-field': ['css-field', 'knob', 'number-field'],

@ -297,9 +297,21 @@ una vez y se engancha por un **attr de capa** que cada consumidor estampa sobre
el elemento que ya tiene. Sin nodo envoltorio, sin anidar componentes.
Ejemplares vivos: `lib/list-surface.css` (ritmo de menú/listbox, consumido por
dropdown-menu · context-menu · menubar · select · combobox · command) y
dropdown-menu · context-menu · menubar · select · combobox · command),
`eidos/lib/viewport-placement.css` (colocación contra el viewport, consumida por
`Affix` · `Fab` · `MenuDial`).
`Affix` · `Fab` · `MenuDial`) y `lib/calendar-surface.css` (ritmo de la rejilla
de fechas y forma del anillo de evento, consumida por `Calendar` ·
`RangeCalendar` · `MonthGrid` · `YearGrid` y los wrappers de `DatePicker` /
`DateRangePicker`).
`calendar-surface` es el ejemplar HÍBRIDO y conviene leerlo antes de escribir
otra capa: las dos primeras componen primitivos del sistema, así que declaran
sus públicos en el propio fichero y no tienen entrada en `recipes/base.ts`. La
familia calendar no puede — su vocabulario son 76 claves SEMÁNTICAS que un tema
alcanza una a una por config —, así que la entrada `calendar` de la receta es la
de la FAMILIA y la capa posee sólo lo que una entrada de receta no sabe
expresar: la resolución por talla sobre un hook compartido, y la forma del
anillo.
Las reglas, y las tres primeras se aprendieron rompiéndose:

@ -138,6 +138,26 @@ before adding the Eidos recipe.
marca el `aria-readonly` y la receta lo tinta con tono atenuado pero
permite seguir leyendo y navegando con teclado.
## Talla y tema
Contrato en `lib/recipes/base.ts > calendar` — y esa entrada es la de la
**FAMILIA**, no sólo la de este componente: `RangeCalendar`, `MonthGrid`,
`YearGrid` y los calendarios de los dos pickers leen el mismo vocabulario
`--calendar-*` (medido 2026-08-21: 114 / 77 / 77 referencias y CERO tokens
propios). Alcance del censo **84 %**.
La resolución por talla vive en
[`lib/calendar-surface.css`](../../lib/calendar-surface.css), enganchada al
hook `data-calendar-surface` que este wrapper estampa: cuatro coordenadas
(`padding`, `control-size`, `day-size`, `font-size`), xs..lg — la familia **no
tiene xl** — y la celda dos pasos por debajo del bundle de control. Antes ese
bloque estaba copiado en cuatro recetas con un privado de nombre distinto en
cada una.
El anillo de evento y la marca de festivo también son de la capa: la FORMA
allí, el ACENTO desde aquí (`--_calendar-surface-accent` ← el forward de paleta
THM-2 de cada superficie).
## Gaps
| Gap | Disposición | Detalle |

@ -6,24 +6,28 @@
/* The trigger Button's own font scale is coarser than the calendar's (its xs is
14px while the calendar's is 12px), which left the button box sized for a
bigger font than the label showed (the size incoherence). Pin the Button font
to the calendar font so the box, label and chevron read as ONE size. */
to the calendar font so the box, label and chevron read as ONE size.
Reads the LAYER's resolved name (2026-08-21): the old
`var(--_calendar-font-size, var(--calendar-font-size-md))` bridged a private
that only `[data-calendar]` declared, so in range / date-range it always ran
on the fallback and was pinned to md whatever the size. */
[data-calendar-month-select][data-button],
[data-calendar-year-select][data-button] {
font-size: var(--_calendar-font-size, var(--calendar-font-size-md));
font-size: var(--calendar-font-size);
}
[data-calendar-select-label] {
/* Bump the trigger label to the host's type size (the Button's own sm font is
smaller). Falls back to the md token outside a [data-calendar] host so the
shared selector still reads correctly in range / date-range. */
font-size: var(--_calendar-font-size, var(--calendar-font-size-md));
smaller). The layer resolves the name on every calendar surface, so this
now follows the REAL size in range / date-range too. */
font-size: var(--calendar-font-size);
white-space: nowrap;
}
[data-calendar-select-chevron] {
flex: 0 0 auto;
inline-size: var(--_calendar-font-size, var(--calendar-font-size-md));
block-size: var(--_calendar-font-size, var(--calendar-font-size-md));
inline-size: var(--calendar-font-size);
block-size: var(--calendar-font-size);
opacity: var(--opacity-muted);
}

@ -1,46 +1,27 @@
[data-calendar] {
--_calendar-padding: var(--calendar-padding-md);
--_calendar-control-size: var(--calendar-control-size-md);
--_calendar-day-size: var(--calendar-day-size-md);
--_calendar-font-size: var(--calendar-font-size-md);
/* The four size coordinates resolve in `lib/calendar-surface.css`, on the
`data-calendar-surface` hook this component stamps — one cascade for the
whole family instead of the identical block repeated in four recipes. The
accent of the ring travels the other way: the layer owns the shape, this
host feeds it the per-instance palette. */
--_calendar-surface-accent: var(--_calendar-palette-border);
display: inline-flex;
flex-direction: column;
gap: var(--calendar-gap);
inline-size: max-content;
max-inline-size: 100%;
padding: var(--_calendar-padding);
padding: var(--calendar-padding);
border: var(--calendar-border-width) solid var(--calendar-border);
border-radius: var(--calendar-radius);
background: var(--calendar-bg);
box-shadow: var(--calendar-shadow);
color: var(--calendar-fg);
font-family: var(--calendar-font-family);
font-size: var(--_calendar-font-size);
font-size: var(--calendar-font-size);
line-height: var(--calendar-line-height);
}
[data-calendar][data-size='xs'] {
--_calendar-padding: var(--calendar-padding-xs);
--_calendar-control-size: var(--calendar-control-size-xs);
--_calendar-day-size: var(--calendar-day-size-xs);
--_calendar-font-size: var(--calendar-font-size-xs);
}
[data-calendar][data-size='sm'] {
--_calendar-padding: var(--calendar-padding-sm);
--_calendar-control-size: var(--calendar-control-size-sm);
--_calendar-day-size: var(--calendar-day-size-sm);
--_calendar-font-size: var(--calendar-font-size-sm);
}
[data-calendar][data-size='lg'] {
--_calendar-padding: var(--calendar-padding-lg);
--_calendar-control-size: var(--calendar-control-size-lg);
--_calendar-day-size: var(--calendar-day-size-lg);
--_calendar-font-size: var(--calendar-font-size-lg);
}
[data-calendar][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
@ -60,7 +41,7 @@
[data-calendar-header] {
display: grid;
grid-template-columns: var(--_calendar-control-size) minmax(0, 1fr) var(--_calendar-control-size);
grid-template-columns: var(--calendar-control-size) minmax(0, 1fr) var(--calendar-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
@ -137,7 +118,7 @@
}
[data-calendar-head-cell] {
inline-size: var(--_calendar-day-size);
inline-size: var(--calendar-day-size);
block-size: var(--calendar-weekday-size);
color: var(--calendar-weekday-fg);
font-size: var(--calendar-weekday-font-size);
@ -151,7 +132,7 @@
}
[data-calendar-week-number][scope='row'] {
block-size: var(--_calendar-day-size);
block-size: var(--calendar-day-size);
font-size: var(--calendar-weekday-font-size);
font-weight: var(--calendar-weekday-font-weight);
}
@ -166,8 +147,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--_calendar-day-size);
block-size: var(--_calendar-day-size);
inline-size: var(--calendar-day-size);
block-size: var(--calendar-day-size);
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
border-radius: var(--calendar-day-radius);
background: var(--calendar-day-bg);

@ -1,5 +1,6 @@
<script lang="ts">
import './calendar.css';
import '../../lib/calendar-surface.css';
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
@ -29,6 +30,7 @@
</script>
<Calendar.Provider
data-calendar-surface=""
{...rest}
bind:value
bind:placeholder

@ -1,4 +1,5 @@
<script lang="ts">
import '../../lib/calendar-surface.css';
import { ActiveEidos } from '$uix/eidos';
import * as DatePicker from '$soma/components/date-picker';
import { composeInlineStyle } from '$uix/eidos/lib/style';
@ -23,6 +24,7 @@
</script>
<DatePicker.Calendar
data-calendar-surface=""
{...rest}
data-size={resolvedSize}
data-variant={resolvedVariant}

@ -5,6 +5,7 @@
* MonthValue and the default MonthGrid composition. Eidos resolves
* `size` from the visual context and forwards it.
*/
import '../../lib/calendar-surface.css';
import * as DatePicker from '$soma/components/date-picker';
import type { PassthroughProps } from '$soma/types';
import type { MonthGridSize } from '../month-grid';
@ -23,4 +24,4 @@
const dataSize = $derived((visual?.getSize() ?? 'md') as MonthGridSize);
</script>
<DatePicker.MonthView data-size={dataSize} {...props} />
<DatePicker.MonthView data-size={dataSize} data-calendar-surface="" {...props} />

@ -5,6 +5,7 @@
* YearValue and the default YearGrid composition. Eidos resolves
* `size` from the visual context and forwards it.
*/
import '../../lib/calendar-surface.css';
import * as DatePicker from '$soma/components/date-picker';
import type { PassthroughProps } from '$soma/types';
import type { YearGridSize } from '../year-grid';
@ -25,4 +26,4 @@
const dataSize = $derived((visual?.getSize() ?? 'md') as YearGridSize);
</script>
<DatePicker.YearView data-size={dataSize} {...props} />
<DatePicker.YearView data-size={dataSize} data-calendar-surface="" {...props} />

@ -1,4 +1,5 @@
<script lang="ts">
import '../../lib/calendar-surface.css';
import { ActiveEidos } from '$uix/eidos';
import * as DateRangePicker from '$soma/components/date-range-picker';
import { composeInlineStyle } from '$uix/eidos/lib/style';
@ -26,6 +27,7 @@
</script>
<DateRangePicker.Calendar
data-calendar-surface=""
{...rest}
data-size={resolvedSize}
data-variant={resolvedVariant}

@ -85,6 +85,15 @@ fuera del DatePicker (filtros, reportes, año fiscal).
compuestos—, así que no tienen un `contact-activate` propio que mantuviera el
anillo vivo. La celda lo conserva por `commit-set`. Verificado en navegador.
## Talla y tema
**No acuña un solo token**: lee el vocabulario de la familia (`--calendar-*`) y
su ritmo por talla lo resuelve
[`lib/calendar-surface.css`](../../lib/calendar-surface.css) sobre el hook
`data-calendar-surface`. Fuera de eso sólo consume sistema (`--focus-ring-*`,
`--state-hover`) y su forward de paleta. El censo lo puntúa como `system` desde
la corrección de 2026-08-21.
## Gaps
| Gap | Disposición | Detalle |

@ -1,10 +1,10 @@
/* MonthGrid — reuses calendar tokens for visual consistency. */
[data-month-grid] {
--_month-grid-padding: var(--calendar-padding-md);
--_month-grid-control-size: var(--calendar-control-size-md);
--_month-grid-cell-size: var(--calendar-day-size-md);
--_month-grid-font-size: var(--calendar-font-size-md);
/* The ring shape is the layer's; this host feeds it the per-instance
accent (see lib/calendar-surface.css). */
--_calendar-surface-accent: var(--_month-grid-palette-border);
display: inline-flex;
flex-direction: column;
@ -12,38 +12,17 @@
inline-size: max-content;
max-inline-size: 100%;
box-sizing: border-box;
padding: var(--_month-grid-padding);
padding: var(--calendar-padding);
border: var(--calendar-border-width) solid var(--calendar-border);
border-radius: var(--calendar-radius);
background: var(--calendar-bg);
box-shadow: var(--calendar-shadow);
color: var(--calendar-fg);
font-family: var(--calendar-font-family);
font-size: var(--_month-grid-font-size);
font-size: var(--calendar-font-size);
line-height: var(--calendar-line-height);
}
[data-month-grid][data-size='xs'] {
--_month-grid-padding: var(--calendar-padding-xs);
--_month-grid-control-size: var(--calendar-control-size-xs);
--_month-grid-cell-size: var(--calendar-day-size-xs);
--_month-grid-font-size: var(--calendar-font-size-xs);
}
[data-month-grid][data-size='sm'] {
--_month-grid-padding: var(--calendar-padding-sm);
--_month-grid-control-size: var(--calendar-control-size-sm);
--_month-grid-cell-size: var(--calendar-day-size-sm);
--_month-grid-font-size: var(--calendar-font-size-sm);
}
[data-month-grid][data-size='lg'] {
--_month-grid-padding: var(--calendar-padding-lg);
--_month-grid-control-size: var(--calendar-control-size-lg);
--_month-grid-cell-size: var(--calendar-day-size-lg);
--_month-grid-font-size: var(--calendar-font-size-lg);
}
[data-month-grid][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
@ -59,9 +38,9 @@
[data-month-grid-header] {
display: grid;
grid-template-columns:
var(--_month-grid-control-size)
var(--calendar-control-size)
minmax(0, 1fr)
var(--_month-grid-control-size);
var(--calendar-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
@ -82,8 +61,8 @@
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_month-grid-control-size);
block-size: var(--_month-grid-control-size);
inline-size: var(--calendar-control-size);
block-size: var(--calendar-control-size);
padding: 0;
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
@ -139,8 +118,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: calc(var(--_month-grid-cell-size) * 2);
block-size: var(--_month-grid-cell-size);
min-inline-size: calc(var(--calendar-day-size) * 2);
block-size: var(--calendar-day-size);
padding-inline: var(--calendar-select-padding-inline, 8px);
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
border-radius: var(--calendar-day-radius);

@ -1,5 +1,6 @@
<script lang="ts">
import './month-grid.css';
import '../../lib/calendar-surface.css';
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
@ -26,6 +27,7 @@
</script>
<MonthGrid.Provider
data-calendar-surface=""
{...rest}
bind:value
bind:placeholder

@ -113,6 +113,19 @@ Referencias: [React Aria RangeCalendar](https://react-spectrum.adobe.com/react-a
par de props separadas invitaría a pares ilegibles; el tema puede override
los `--calendar-range-start-*` desde `:root` (S8).
## Talla y tema
**No acuña un solo token.** Todo su vocabulario es el de la familia
(`--calendar-*`, entrada `calendar` de `recipes/base.ts`) y su ritmo por talla
lo resuelve [`lib/calendar-surface.css`](../../lib/calendar-surface.css) sobre
el hook `data-calendar-surface`. Eso es exactamente lo que la doctrina de capas
manda, y por eso el censo lo puntúa como `system`, no como `global` (la
corrección de medición de 2026-08-21; antes leía 0 % por hacer lo correcto).
Lo único suyo es el **desempate de extremos**: cuando la paleta resuelve a
affirm/fulfill, el extremo START salta a secondary para que los dos no se
confundan. Es decisión de rango, no de superficie.
## Gaps
| Gap | Disposición | Detalle |

@ -30,10 +30,9 @@
/* ── Root token scaffolding ──────────────────────────────────────────── */
[data-range-calendar] {
--_calendar-padding: var(--calendar-padding-md);
--_calendar-control-size: var(--calendar-control-size-md);
--_calendar-day-size: var(--calendar-day-size-md);
--_calendar-font-size: var(--calendar-font-size-md);
/* The ring shape is the layer's; this host feeds it the per-instance
accent (see lib/calendar-surface.css). */
--_calendar-surface-accent: var(--_range-calendar-palette-border);
/* Range endpoints — the calendar-surface public API `--calendar-range-*`. The
asymmetry below is DELIBERATE: START defaults live in the recipe (`:root`,
= affirm — the range "opens") so a theme can override them via config; the host
@ -51,38 +50,17 @@
gap: var(--calendar-gap);
inline-size: max-content;
max-inline-size: 100%;
padding: var(--_calendar-padding);
padding: var(--calendar-padding);
border: var(--calendar-border-width) solid var(--calendar-border);
border-radius: var(--calendar-radius);
background: var(--calendar-bg);
box-shadow: var(--calendar-shadow);
color: var(--calendar-fg);
font-family: var(--calendar-font-family);
font-size: var(--_calendar-font-size);
font-size: var(--calendar-font-size);
line-height: var(--calendar-line-height);
}
[data-range-calendar][data-size='xs'] {
--_calendar-padding: var(--calendar-padding-xs);
--_calendar-control-size: var(--calendar-control-size-xs);
--_calendar-day-size: var(--calendar-day-size-xs);
--_calendar-font-size: var(--calendar-font-size-xs);
}
[data-range-calendar][data-size='sm'] {
--_calendar-padding: var(--calendar-padding-sm);
--_calendar-control-size: var(--calendar-control-size-sm);
--_calendar-day-size: var(--calendar-day-size-sm);
--_calendar-font-size: var(--calendar-font-size-sm);
}
[data-range-calendar][data-size='lg'] {
--_calendar-padding: var(--calendar-padding-lg);
--_calendar-control-size: var(--calendar-control-size-lg);
--_calendar-day-size: var(--calendar-day-size-lg);
--_calendar-font-size: var(--calendar-font-size-lg);
}
[data-range-calendar][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
@ -111,7 +89,7 @@
/* ── Header + nav controls ──────────────────────────────────────────── */
[data-range-calendar-header] {
display: grid;
grid-template-columns: var(--_calendar-control-size) minmax(0, 1fr) var(--_calendar-control-size);
grid-template-columns: var(--calendar-control-size) minmax(0, 1fr) var(--calendar-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
@ -147,7 +125,7 @@
[data-range-calendar-month-select],
[data-range-calendar-year-select] {
min-inline-size: 0;
block-size: var(--_calendar-control-size);
block-size: var(--calendar-control-size);
padding-inline: var(--calendar-select-padding-inline);
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
@ -164,12 +142,12 @@
[data-range-calendar-month-select] {
min-inline-size: var(--calendar-select-min-width);
inline-size: calc(var(--_calendar-day-size) * 2.35);
inline-size: calc(var(--calendar-day-size) * 2.35);
}
[data-range-calendar-year-select] {
min-inline-size: var(--calendar-select-min-width);
inline-size: calc(var(--_calendar-day-size) * 2.15);
inline-size: calc(var(--calendar-day-size) * 2.15);
}
[data-range-calendar-month-select]:hover,
@ -215,7 +193,7 @@
}
[data-range-calendar-head-cell] {
inline-size: var(--_calendar-day-size);
inline-size: var(--calendar-day-size);
block-size: var(--calendar-weekday-size);
color: var(--calendar-weekday-fg);
font-size: var(--calendar-weekday-font-size);
@ -229,7 +207,7 @@
}
[data-range-calendar-week-number][scope='row'] {
block-size: var(--_calendar-day-size);
block-size: var(--calendar-day-size);
font-size: var(--calendar-weekday-font-size);
font-weight: var(--calendar-weekday-font-weight);
}
@ -251,8 +229,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--_calendar-day-size);
block-size: var(--_calendar-day-size);
inline-size: var(--calendar-day-size);
block-size: var(--calendar-day-size);
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
border-radius: var(--calendar-day-radius);
background: var(--calendar-day-bg);

@ -1,5 +1,6 @@
<script lang="ts">
import './range-calendar.css';
import '../../lib/calendar-surface.css';
/**
* Eidos `<RangeCalendar>` — root wrapper around Soma's RangeCalendar
* provider. Adds size / variant / color cascades; otherwise pass-through.
@ -34,6 +35,7 @@
</script>
<RangeCalendar.Provider
data-calendar-surface=""
{...rest}
bind:value
bind:placeholder

@ -82,6 +82,15 @@ nuevo en UIX; sirve para escenarios de selección de año aislada
(no mutan el valor). Sólo Enter/Space y los botones de paginación
mutan estado.
## Talla y tema
**No acuña un solo token**: lee el vocabulario de la familia (`--calendar-*`) y
su ritmo por talla lo resuelve
[`lib/calendar-surface.css`](../../lib/calendar-surface.css) sobre el hook
`data-calendar-surface`. Fuera de eso sólo consume sistema (`--focus-ring-*`,
`--state-hover`) y su forward de paleta. El censo lo puntúa como `system` desde
la corrección de 2026-08-21.
## Gaps
| Gap | Disposición | Detalle |

@ -1,10 +1,10 @@
/* YearGrid — reuses calendar tokens for visual consistency. */
[data-year-grid] {
--_year-grid-padding: var(--calendar-padding-md);
--_year-grid-control-size: var(--calendar-control-size-md);
--_year-grid-cell-size: var(--calendar-day-size-md);
--_year-grid-font-size: var(--calendar-font-size-md);
/* The ring shape is the layer's; this host feeds it the per-instance
accent (see lib/calendar-surface.css). */
--_calendar-surface-accent: var(--_year-grid-palette-border);
display: inline-flex;
flex-direction: column;
@ -12,38 +12,17 @@
inline-size: max-content;
max-inline-size: 100%;
box-sizing: border-box;
padding: var(--_year-grid-padding);
padding: var(--calendar-padding);
border: var(--calendar-border-width) solid var(--calendar-border);
border-radius: var(--calendar-radius);
background: var(--calendar-bg);
box-shadow: var(--calendar-shadow);
color: var(--calendar-fg);
font-family: var(--calendar-font-family);
font-size: var(--_year-grid-font-size);
font-size: var(--calendar-font-size);
line-height: var(--calendar-line-height);
}
[data-year-grid][data-size='xs'] {
--_year-grid-padding: var(--calendar-padding-xs);
--_year-grid-control-size: var(--calendar-control-size-xs);
--_year-grid-cell-size: var(--calendar-day-size-xs);
--_year-grid-font-size: var(--calendar-font-size-xs);
}
[data-year-grid][data-size='sm'] {
--_year-grid-padding: var(--calendar-padding-sm);
--_year-grid-control-size: var(--calendar-control-size-sm);
--_year-grid-cell-size: var(--calendar-day-size-sm);
--_year-grid-font-size: var(--calendar-font-size-sm);
}
[data-year-grid][data-size='lg'] {
--_year-grid-padding: var(--calendar-padding-lg);
--_year-grid-control-size: var(--calendar-control-size-lg);
--_year-grid-cell-size: var(--calendar-day-size-lg);
--_year-grid-font-size: var(--calendar-font-size-lg);
}
[data-year-grid][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
@ -59,9 +38,9 @@
[data-year-grid-header] {
display: grid;
grid-template-columns:
var(--_year-grid-control-size)
var(--calendar-control-size)
minmax(0, 1fr)
var(--_year-grid-control-size);
var(--calendar-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
@ -82,8 +61,8 @@
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_year-grid-control-size);
block-size: var(--_year-grid-control-size);
inline-size: var(--calendar-control-size);
block-size: var(--calendar-control-size);
padding: 0;
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
@ -139,8 +118,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: calc(var(--_year-grid-cell-size) * 1.8);
block-size: var(--_year-grid-cell-size);
min-inline-size: calc(var(--calendar-day-size) * 1.8);
block-size: var(--calendar-day-size);
padding-inline: var(--calendar-select-padding-inline, 8px);
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
border-radius: var(--calendar-day-radius);

@ -1,5 +1,6 @@
<script lang="ts">
import './year-grid.css';
import '../../lib/calendar-surface.css';
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
@ -26,6 +27,7 @@
</script>
<YearGrid.Provider
data-calendar-surface=""
{...rest}
bind:value
bind:placeholder

@ -5792,8 +5792,6 @@
--_calendar-palette-track: var(--color-primary-track);
--_calendar-palette-border: var(--color-primary-border);
--_calendar-palette-text: var(--color-primary-text);
--calendar-day-holiday-shadow: inset 0 -2px 0 var(--_calendar-palette-border);
--calendar-event-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--_calendar-palette-border);
}
[data-calendar][data-color], [data-calendar][data-color-custom] {

@ -0,0 +1,102 @@
/*
* Calendar-surface — the SINGLE SOURCE for the size rhythm of every grid-of-
* dates surface, and the shape of its event ring.
*
* Shared by the four surfaces that render a grid of selectable date cells:
* - `calendar` (the day grid — the reference implementation)
* - `range-calendar` (the same grid with two endpoints)
* - `month-grid` (12 months instead of 31 days)
* - `year-grid` (a decade of years)
* plus the pickers that host them (`date-picker`, `date-range-picker`), which
* inherit through the surface they render.
*
* Each surface IMPORTS this file and STAMPS `data-calendar-surface=""` +
* `data-size` on its eidos Provider — a VISUAL-LAYER identity, never declared
* in the morfo (the `viewport-placement` split: the layer hook is not the
* component's identity, so `morfo-check` skips it). Nothing else is needed:
* the vocabulary below is already emitted at `:root`.
*
* ── Where the vocabulary lives, and why this layer is a HYBRID ─────────────
* `list-surface` and `viewport-placement` compose SYSTEM primitives, so they
* declare their public tokens here and carry no `recipes/base.ts` key. This
* family cannot: its vocabulary is 76 SEMANTIC keys (`--calendar-day-today-
* border`, `--calendar-weekday-fg`, the two range endpoints…) that a theme has
* to reach one by one through `EidosConfig.recipes`. So the vocabulary stays
* in the `calendar` recipe entry — the entry is the FAMILY's, not one
* component's — and this file owns only what a recipe entry cannot express:
* the per-size RESOLUTION on a shared hook, and the ring shape.
*
* ── The size axis ─────────────────────────────────────────────────────────
* Four coordinates move together, and the family runs xs | sm | md | lg —
* there is NO xl. The cell reads TWO steps below the control bundle
* (`md → --size-sm-control-height`, `xs → --size-xxs-*`): a date grid packs 31
* targets into a panel, so a day is not a control-sized row. That deviation
* used to hide inside four identical `[data-size]` blocks (one per component,
* bridging to a private with a different name each time); naming it once here
* is the point of the layer.
*
* Doctrine: a consumer does NOT mint `--{component}-{axis}` for an axis this
* layer owns — it reads `--calendar-*` directly.
*/
/* ── Every renderer of a calendar surface stamps the hook ───────────────────
* SEVEN wrappers, not four: `DatePicker` and `DateRangePicker` render the soma
* surface straight into their popover through their OWN eidos wrappers
* (`date-picker-calendar`, `-month-view`, `-year-view`,
* `date-range-picker-calendar`), and a portaled panel inherits nothing from
* the picker root. Measured 2026-08-21: with only the four standalone
* wrappers stamping, both pickers rendered with `--calendar-padding` EMPTY and
* the panel at padding 0. Rule 1 of shared layers still holds — the geometry
* hooks on the LAYER attr, never on a component identity — so the answer is a
* stamp in each wrapper, not a selector on the four hosts. */
[data-calendar-surface] {
--calendar-padding: var(--calendar-padding-md);
--calendar-control-size: var(--calendar-control-size-md);
--calendar-day-size: var(--calendar-day-size-md);
--calendar-font-size: var(--calendar-font-size-md);
}
[data-calendar-surface][data-size='xs'] {
--calendar-padding: var(--calendar-padding-xs);
--calendar-control-size: var(--calendar-control-size-xs);
--calendar-day-size: var(--calendar-day-size-xs);
--calendar-font-size: var(--calendar-font-size-xs);
}
[data-calendar-surface][data-size='sm'] {
--calendar-padding: var(--calendar-padding-sm);
--calendar-control-size: var(--calendar-control-size-sm);
--calendar-day-size: var(--calendar-day-size-sm);
--calendar-font-size: var(--calendar-font-size-sm);
}
[data-calendar-surface][data-size='lg'] {
--calendar-padding: var(--calendar-padding-lg);
--calendar-control-size: var(--calendar-control-size-lg);
--calendar-day-size: var(--calendar-day-size-lg);
--calendar-font-size: var(--calendar-font-size-lg);
}
/* ── The event ring and the holiday mark ────────────────────────────────────
* SHAPE here, ACCENT from the consumer. Both used to be emitted scoped to
* `[data-calendar]` while `range-calendar`, `month-grid` and `year-grid` read
* them from hosts that never carry that attribute: the variable resolved EMPTY
* there, `box-shadow: var(--calendar-event-shadow)` was invalid at
* computed-value time, and the sema ring of `commit-select` / `commit-set`
* never painted in three components (measured 2026-08-21, adversarial review
* of F2-A — a borrowed token whose SCOPE does not cover you is not a loan, it
* is a silent hole, and no guard sees it).
*
* `--_calendar-surface-accent` is the OVERRIDE SLOT (the `menu-indicator`
* pattern): every consumer sets it from its OWN per-instance palette forward
* (`--_{component}-palette-border`, THM-2), because the accent is per-instance
* and the shape is not. The fallback keeps the layer standalone-correct.
*/
[data-calendar-surface] {
--calendar-day-holiday-shadow: inset 0 -2px 0
var(--_calendar-surface-accent, var(--color-primary-border));
--calendar-event-shadow:
0 0 0 var(--focus-ring-offset) var(--color-surface-default),
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width))
var(--_calendar-surface-accent, var(--color-primary-border));
}

@ -3009,22 +3009,18 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'_palette-track': { value: 'var(--color-primary-track)', scope: 'host' },
'_palette-border': { value: 'var(--color-primary-border)', scope: 'host' },
'_palette-text': { value: 'var(--color-primary-text)', scope: 'host' },
// Host-scoped ON PURPOSE (audit B.2, 2026-07-07): both values reference the
// private `--_calendar-palette-border`, which lives on `[data-calendar]`
// (the recipe host default + the THM-2 forward). Emitted at `:root` they
// froze guaranteed-invalid (the private doesn't exist there) and the
// holiday/event marks NEVER painted. On the host, the custom property
// computes where the private (and its colour retunes) actually cascade.
'day-holiday-shadow': {
value: 'inset 0 -2px 0 var(--_calendar-palette-border)',
scope: 'host'
},
// `day-holiday-shadow` and `event-shadow` are NOT here any more: they live
// in `lib/calendar-surface.css` (2026-08-21). Audit B.2 had host-scoped
// them because their value reads `--_calendar-palette-border`, a private
// that only exists on `[data-calendar]` — correct for this component and
// silently broken for the other three surfaces of the family, which read
// the same two tokens from hosts that never carry that attribute (the
// variable resolved EMPTY, the box-shadow was invalid at computed-value
// time and the ring never painted; measured). The layer declares the
// SHAPE on the shared hook and takes the ACCENT through the
// `--_calendar-surface-accent` slot each surface feeds from its own
// palette forward.
'day-unavailable-decoration': 'var(--color-risk-border)',
'event-shadow': {
value:
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--_calendar-palette-border)',
scope: 'host'
},
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)'

@ -25,6 +25,7 @@ function configWithRecipes(recipes: RecipeTokenSet): EidosConfig {
}
const COMPONENTS_DIR = 'src/uix/eidos/components'
const LIB_DIR = 'src/uix/eidos/lib'
const CSS_CUSTOM_PROPERTY = /--([a-z][a-z0-9]*-[a-z0-9-]+)/g
const COMPONENT_SOURCE_FILE = /\.(css|svelte|ts)$/
const RAW_COLOR_LITERAL = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla)\(/i
@ -154,9 +155,32 @@ function collectOwnPublicVariables(component: string, css: string): readonly str
].sort()
}
/**
* Public variables a SHARED LAYER declares (`lib/*.css`). A layer whose
* vocabulary does NOT share a component prefix (`--list-*`,
* `--viewport-placement-*`) is invisible to the check below; one that DOES is
* not, and `calendar-surface` does on purpose — the calendar family's
* vocabulary is `--calendar-*` and the layer resolves four of those names per
* size on its hook (2026-08-21). Those ARE declared, just not by a recipe
* entry, which is what `viewport-placement` documents as the mark of a layer.
* Reading them here keeps the guard honest without weakening it: a name nobody
* declares — recipe OR layer — still fails.
*/
function collectLayerDeclaredVariables(): ReadonlySet<string> {
const out = new Set<string>()
if (!existsSync(LIB_DIR)) return out
for (const entry of readdirSync(LIB_DIR)) {
if (!entry.endsWith('.css')) continue
const css = readFileSync(join(LIB_DIR, entry), 'utf8')
for (const match of css.matchAll(/^\s*(--[a-z][a-z0-9-]+)\s*:/gm)) out.add(match[1])
}
return out
}
describe('Eidos recipe CSS contract', () => {
it('declares every public component variable consumed by component CSS', () => {
const missing: string[] = []
const layerDeclared = collectLayerDeclaredVariables()
for (const { component, css } of readComponentCssFiles()) {
const declared = new Set(
@ -164,6 +188,7 @@ describe('Eidos recipe CSS contract', () => {
recipeTokenName(component, key)
)
)
for (const name of layerDeclared) declared.add(name)
for (const name of collectOwnPublicVariables(component, css)) {
if (!declared.has(name)) missing.push(`${component}: ${name}`)

Loading…
Cancel
Save

Powered by TurnKey Linux.