feat(morfo,soma,eidos): el cruce de mes lo sella la vista paginada, no un mes suelto

Con `numberOfMonths=2` el calendario tenia un *shift invisible* a medias: al
pulsar «siguiente», junio cambiaba sus fechas quieto y julio cruzaba. Medido —
un solo sello, sobre el segundo grid.

La causa no es el runtime ni el llamador. `grid` es una parte REPETIDA, y un
emit sin ancla resuelve a la instancia viva mas reciente (`resolveEmitTarget`).
`shift-navigate` apuntaba a `grid` desde el 2026-08-11, cuando se le saco del
boton para que dejara de pisar su `contact-activate` (A-36). Era correcto para
un mes y falso para dos, y un destino que solo vale para un valor del parametro
no es un destino.

La ley que este mismo eje escribio ya traia la respuesta
(`morfo.md` §Where the stamp lands): *if it isn't the subject, the morfo is
wrong*. El sujeto es la VISTA PAGINADA — lo que cruza como unidad bajo cualquier
numero de meses. No existia como parte, asi que se declara: `months`, arquetipo
`viewport`, con `targetFallback: [grid]` para que una composicion headless sin
ella degrade a lo de antes en vez de callarse.

Se descartaron tres alternativas, y por que
-------------------------------------------

- Emitir N veces ancladas: acuna N ocurrencias para UN gesto, N ids en el
  arbitro de dominancia y una repeticion en la memoria de frecuencia (C-2 solo
  exime a `handle`), y luego hay que silenciar N-1 desde el componente.
- Un emit con N sellos: `resolveEmitTarget` es UNA resolucion para TRES lectores
  «so they can never disagree» — y el movimiento de foco a11y necesita uno.
  Ademas declararia que cruzaron dos cosas; el cap. 27 dice que cruzo una.
- Volver al provider con CSS descendiente a mano: el provider no es el sujeto
  (la cabecera no se mueve), deshace una correccion medida y duplica el
  shorthand de animacion en cuatro recetas.

De paso, dos partes clandestinas legalizadas
--------------------------------------------

`data-calendar-month-panel` lo fabricaban a mano los cuatro demos multi-mes y
`calendar.css` lo estilizaba igualmente, sin que ningun morfo lo declarara; el
contenedor lo montaban con un `style` en linea que rederivaba el numero de meses
en cada pagina, o con clases locales (`.range-months`, `.month-stack`) que
apuntaban a un `--calendar-month-gap` que NO EXISTE — el fallback `36px`/`32px`
hacia todo el trabajo. Ahora son partes, la disposicion vive en la receta
(`grid-auto-flow: column`, sin contar meses) y el token es
`--calendar-months-gap`.

Correccion de lo que dije al proponerlo: `eidos-lint` NO cazaba esa deriva —
clasificaba el selector como `eidos-only`, no como invalido. Lo que gana el
cambio es mover 2 selectores de eidos-only a morfo-backed. Quien si lo caza es
`morfo:check`, contra el DOM real.

Medido
------

Navegador, `numberOfMonths=2`: el sello cae en `[data-calendar-months]` con
`data-event-direction=forward`, corre `shift-cross-forward 0.32s`, y los DOS
grids se desplazan 10 px a los 40 ms del cruce (antes: uno). En RTL,
`--motion-shift-sign` pasa a -1 y los dos van a -10 px. El boton conserva su
`contact-activate` en su propia ranura. Consola limpia en los cuatro demos; el
date-range-picker, que muestra dos meses por defecto, es donde mas mordia.

`morfo:check` contra el DOM: calendar y range-calendar PASS. `eidos-lint`:
invalid 0 en ambos, morfo-backed 27→29 y 39→41. `docs:check` 0/623.

Verificado sobre el ARBOL INDEXADO en un worktree aparte, no sobre el mio:
`check` 57 errores, identico a HEAD, cero nuevos; 2154/2154 en morfo + soma +
eidos + sema. Los 6 fallos de `contracts.test.ts` que se ven en mi arbol de
trabajo son de la otra sesion (waveform, media-player, audio-player, menubar,
aura, radio-group, tabs): en el arbol firmado pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 2c555e4be6
commit e5811e422c

@ -984,6 +984,16 @@ Two consequences worth naming:
When a rule doesn't fire, the diagnostic question is always: **is the stamped node the subject of the event?** If it is, the recipe descends. If it isn't, the morfo is wrong.
### A repeated part cannot be the subject of one occurrence
`resolveEmitTarget` resolves an unanchored emit to the declared target's **newest live instance**. That is exactly right when the occurrence has one subject among many — one row selected, one day, one item — and the framework has two ways to name it: an anchored emit (`runtimePart.trigger`, as `table` does for its row) or `targetOverride` (as `calendar` does for its day). Fifty of the catalogue's 252 partRef targets are repeatable parts, and every one of them works this way.
It breaks when one occurrence has **N subjects at once**. `calendar.shift-navigate` targeted `grid`; with `numberOfMonths=2` there are two grids and both cross, but only the newest was stamped — measured 2026-08-12: June changed its dates standing still while July crossed. Neither mechanism helps, because both pick one instance, and neither does emitting N times: that mints N occurrences for one gesture, N ids in the dominance arbiter, and a repeat in the frequency memory (C-2 exempts only `handle`).
The fix is not to administer the stamp. **It is to declare the part that is actually the subject** — here `months`, the paginated view, which crosses as a unit under every value of `numberOfMonths`. A target that is only correct for one parameter value is not a target.
So the diagnostic has a second half: **if the target is a repeated part, ask whether the occurrence has one subject or all of them.** One → anchor it. All → the subject is the container, and it needs to exist.
---
## Typed selector builder — `semaSelector`

@ -37,7 +37,7 @@ APG Date Picker Dialog/Grid.
| ----------------- | ---------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------- |
| `commit-select` | `commit.select` | `day` | `CalendarProvider.select(date, target)` after value changes | soft form commit + tap |
| `commit-unselect` | `commit.remove` | `day` | same, when a date is removed/cleared | subtle form commit + tap |
| `shift-navigate` | `shift.navigate` | `grid` | `prevPage`, `nextPage`, `setMonth`, `setYear`, keyboard month boundary | family default `slide` (arc, gain 0.06) + the directional `shift` motion firma |
| `shift-navigate` | `shift.navigate` | `months` (fallback `grid`) | `prevPage`, `nextPage`, `setMonth`, `setYear`, keyboard month boundary | family default `slide` (arc, gain 0.06) + the directional `shift` motion firma |
Calendar is not passive: a 0-event contract would hide committed selection and
visible-range navigation from the semantic layer. This migration closes that gap
@ -102,17 +102,31 @@ before adding the Eidos recipe.
(`forward`/`backward`), y lo pone quien da el paso — la paginación y el
teclado saben hacia dónde fueron; un salto desde el select no pasa
ninguno.
- **El sello de `shift-navigate` cae en el `grid`, no en el provider ni
en el botón**. El grid es el sujeto del cruce (es lo que cambia de mes;
la cabecera no se mueve y la flecha es sólo el instrumento) y es lo
único que puede LUCIR una firma de `shift`: un mes se desliza, un botón
no. Medido el 2026-08-11 sobre la flecha «anterior»: recibía su
`contact-activate` y arrancaba el `press-squeeze`, y 8,5 ms después
—media trama— `shift-navigate` pisaba la misma ranura y el squeeze
moría sin pintar. Una superficie, una ranura.
- **El sello de `shift-navigate` cae en `months`, no en el provider, ni
en el botón, ni en un `grid`**. `Months` es la vista paginada: el sujeto
del cruce, y lo único que puede LUCIR una firma de `shift` (una página se
desliza, un botón no). Llegó ahí en dos correcciones. La primera, el
2026-08-11, lo sacó del botón: medido sobre la flecha «anterior»,
recibía su `contact-activate`, arrancaba el `press-squeeze` y 8,5 ms
después —media trama— `shift-navigate` pisaba la misma ranura y el
squeeze moría sin pintar. Una superficie, una ranura. La segunda, el
2026-08-12, lo sacó del `grid`: **`grid` es una parte REPETIDA**, y un
emit sin ancla resuelve a la instancia viva más reciente, así que con
`numberOfMonths=2` sólo cruzaba el segundo mes y el primero cambiaba de
fechas sin moverse — el *shift invisible* (cap. 34 §14) sobre la mitad
del componente. Un destino que sólo es correcto para un valor del
parámetro no es un destino. Medido tras el cambio: los dos grids se
desplazan 10 px a los 40 ms del cruce, y −10 px en RTL.
- **El anillo de evento perdió el provider en la receta**
(`box-shadow: var(--calendar-event-shadow)`): con el sello en el grid,
nada apunta ya a `[data-calendar]` y la regla era una selección muerta.
(`box-shadow: var(--calendar-event-shadow)`): nada apunta ya a
`[data-calendar]` y la regla era una selección muerta. `months` tampoco
entra en la lista: su firma es el cruce, no un anillo.
- **`MonthPanel` es una parte, no un div del consumidor.** Los cuatro demos
multi-mes fabricaban a mano un `<div data-calendar-month-panel>` que
ningún morfo declaraba y que esta receta estilizaba igualmente; el
contenedor lo montaban con un `style` en línea que rederivaba el número
de meses en cada página. Ambos son partes desde el 2026-08-12, y la
disposición vive aquí (`grid-auto-flow: column`, sin contar meses).
Se quedan el día (lo alimenta `commit-select`) y las flechas y los dos
selects, que componen `<Button>` y conservan el anillo por su propio
`contact-activate`. Verificado en navegador.

@ -0,0 +1,10 @@
<script lang="ts">
import * as Calendar from '$soma/components/calendar';
import type { CalendarMonthPanelProps } from './types';
let { children: bodyContent, ...rest }: CalendarMonthPanelProps = $props();
</script>
<Calendar.MonthPanel {...rest}>
{@render bodyContent?.()}
</Calendar.MonthPanel>

@ -0,0 +1,10 @@
<script lang="ts">
import * as Calendar from '$soma/components/calendar';
import type { CalendarMonthsProps } from './types';
let { children: bodyContent, ...rest }: CalendarMonthsProps = $props();
</script>
<Calendar.Months {...rest}>
{@render bodyContent?.()}
</Calendar.Months>

@ -83,6 +83,22 @@
text-align: center;
}
/* The paginated view of months — the surface `shift-navigate` stamps, so the
whole page crosses as one however many months are on screen. `grid-auto-flow:
column` needs no month COUNT: the demos used to hand-roll this as an inline
`repeat(N, max-content)`, which meant every consumer re-derived N and the
layout lived outside the recipe. */
[data-calendar-months] {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: var(--calendar-months-gap);
align-items: start;
justify-content: safe center;
max-inline-size: 100%;
overflow: auto;
}
[data-calendar-month-panel] {
display: flex;
flex-direction: column;
@ -249,9 +265,11 @@
/* Event ring — any surface of this component that carries a stamp gets it for
the hold. The provider is NOT in the list since 2026-08-11: `shift-navigate`
used to be redirected onto whichever control caused the navigation and fell
back to the provider, so `[data-calendar]` could be stamped. It now targets
`grid` (the thing that actually navigated), and nothing else aims at the
provider — measured: no gesture stamps it. The controls below keep the ring
back to the provider, so `[data-calendar]` could be stamped. It targets
`months` (2026-08-12; `grid` in between, which was right for one month and
wrong for two — `grid` repeats), and nothing else aims at the provider —
measured: no gesture stamps it. `months` is deliberately out of this list
too: its firma is the crossing, not a ring. The controls below keep the ring
through their OWN `contact-activate` (they compose `<Button>`), which was
always their real feeder; the day keeps it through `commit-select`. */
[data-calendar-day][data-event],

@ -8,6 +8,8 @@ import MonthSelect from './calendar-month-select.svelte'
import YearSelect from './calendar-year-select.svelte'
import MonthView from './calendar-month-view.svelte'
import YearView from './calendar-year-view.svelte'
import Months from './calendar-months.svelte'
import MonthPanel from './calendar-month-panel.svelte'
import Grid from './calendar-grid.svelte'
import GridHead from './calendar-grid-head.svelte'
import GridBody from './calendar-grid-body.svelte'
@ -26,6 +28,8 @@ type CalendarNamespace = typeof CalendarComponent & {
YearSelect: typeof YearSelect
MonthView: typeof MonthView
YearView: typeof YearView
Months: typeof Months
MonthPanel: typeof MonthPanel
Grid: typeof Grid
GridHead: typeof GridHead
GridBody: typeof GridBody
@ -45,6 +49,8 @@ Calendar.MonthSelect = MonthSelect
Calendar.YearSelect = YearSelect
Calendar.MonthView = MonthView
Calendar.YearView = YearView
Calendar.Months = Months
Calendar.MonthPanel = MonthPanel
Calendar.Grid = Grid
Calendar.GridHead = GridHead
Calendar.GridBody = GridBody

@ -12,6 +12,8 @@ import type {
CalendarHeaderProps as SomaCalendarHeaderProps,
CalendarHeadingProps as SomaCalendarHeadingProps,
CalendarHeadingSnippetProps,
CalendarMonthPanelProps as SomaCalendarMonthPanelProps,
CalendarMonthsProps as SomaCalendarMonthsProps,
CalendarMonthSelectProps as SomaCalendarMonthSelectProps,
CalendarMonthSelectSnippetProps,
CalendarNextButtonProps as SomaCalendarNextButtonProps,
@ -43,6 +45,8 @@ export type CalendarPrevButtonProps = SomaCalendarPrevButtonProps
export type CalendarNextButtonProps = SomaCalendarNextButtonProps
export type CalendarMonthSelectProps = SomaCalendarMonthSelectProps
export type CalendarYearSelectProps = SomaCalendarYearSelectProps
export type CalendarMonthsProps = SomaCalendarMonthsProps
export type CalendarMonthPanelProps = SomaCalendarMonthPanelProps
export type CalendarGridProps = SomaCalendarGridProps
export type CalendarGridHeadProps = SomaCalendarGridHeadProps
export type CalendarGridBodyProps = SomaCalendarGridBodyProps

@ -26,6 +26,8 @@ import PrevButton from '../calendar/calendar-prev-button.svelte';
import NextButton from '../calendar/calendar-next-button.svelte';
import MonthSelect from '../calendar/calendar-month-select.svelte';
import YearSelect from '../calendar/calendar-year-select.svelte';
import Months from '../calendar/calendar-months.svelte';
import MonthPanel from '../calendar/calendar-month-panel.svelte';
import Grid from '../calendar/calendar-grid.svelte';
import GridHead from '../calendar/calendar-grid-head.svelte';
import GridBody from '../calendar/calendar-grid-body.svelte';
@ -60,6 +62,8 @@ type DatePickerNamespace = typeof DatePickerComponent & {
NextButton: typeof NextButton;
MonthSelect: typeof MonthSelect;
YearSelect: typeof YearSelect;
Months: typeof Months;
MonthPanel: typeof MonthPanel;
Grid: typeof Grid;
GridHead: typeof GridHead;
GridBody: typeof GridBody;
@ -94,6 +98,8 @@ DatePicker.PrevButton = PrevButton;
DatePicker.NextButton = NextButton;
DatePicker.MonthSelect = MonthSelect;
DatePicker.YearSelect = YearSelect;
DatePicker.Months = Months;
DatePicker.MonthPanel = MonthPanel;
DatePicker.Grid = Grid;
DatePicker.GridHead = GridHead;
DatePicker.GridBody = GridBody;

@ -0,0 +1,10 @@
<script lang="ts">
import * as DateRangePicker from '$soma/components/date-range-picker';
import type { DateRangePickerMonthPanelProps } from './types';
let { children, ...rest }: DateRangePickerMonthPanelProps = $props();
</script>
<DateRangePicker.MonthPanel {...rest}>
{@render children?.()}
</DateRangePicker.MonthPanel>

@ -0,0 +1,10 @@
<script lang="ts">
import * as DateRangePicker from '$soma/components/date-range-picker';
import type { DateRangePickerMonthsProps } from './types';
let { children, ...rest }: DateRangePickerMonthsProps = $props();
</script>
<DateRangePicker.Months {...rest}>
{@render children?.()}
</DateRangePicker.Months>

@ -25,6 +25,8 @@ import NextButton from './date-range-picker-next-button.svelte';
// active calendar context here, publishes it), instead of native selects.
import MonthSelect from '../calendar/calendar-month-select.svelte';
import YearSelect from '../calendar/calendar-year-select.svelte';
import Months from './date-range-picker-months.svelte';
import MonthPanel from './date-range-picker-month-panel.svelte';
import Grid from './date-range-picker-grid.svelte';
import GridHead from './date-range-picker-grid-head.svelte';
import GridBody from './date-range-picker-grid-body.svelte';
@ -58,6 +60,8 @@ type DateRangePickerNamespace = typeof DateRangePickerComponent & {
NextButton: typeof NextButton;
MonthSelect: typeof MonthSelect;
YearSelect: typeof YearSelect;
Months: typeof Months;
MonthPanel: typeof MonthPanel;
Grid: typeof Grid;
GridHead: typeof GridHead;
GridBody: typeof GridBody;
@ -91,6 +95,8 @@ DateRangePicker.PrevButton = PrevButton;
DateRangePicker.NextButton = NextButton;
DateRangePicker.MonthSelect = MonthSelect;
DateRangePicker.YearSelect = YearSelect;
DateRangePicker.Months = Months;
DateRangePicker.MonthPanel = MonthPanel;
DateRangePicker.Grid = Grid;
DateRangePicker.GridHead = GridHead;
DateRangePicker.GridBody = GridBody;

@ -4,6 +4,8 @@ import type {
CalendarSnippetProps as SomaDateRangePickerCalendarSnippetProps,
GridBodyProps as SomaDateRangePickerGridBodyProps,
GridHeadProps as SomaDateRangePickerGridHeadProps,
MonthPanelProps as SomaDateRangePickerMonthPanelProps,
MonthsProps as SomaDateRangePickerMonthsProps,
GridProps as SomaDateRangePickerGridProps,
HeadCellProps as SomaDateRangePickerHeadCellProps,
HeaderProps as SomaDateRangePickerHeaderProps,
@ -90,6 +92,8 @@ export type DateRangePickerPrevButtonProps = SomaDateRangePickerPrevButtonProps;
export type DateRangePickerNextButtonProps = SomaDateRangePickerNextButtonProps;
export type DateRangePickerMonthSelectProps = SomaDateRangePickerMonthSelectProps;
export type DateRangePickerYearSelectProps = SomaDateRangePickerYearSelectProps;
export type DateRangePickerMonthsProps = SomaDateRangePickerMonthsProps;
export type DateRangePickerMonthPanelProps = SomaDateRangePickerMonthPanelProps;
export type DateRangePickerGridProps = SomaDateRangePickerGridProps;
export type DateRangePickerGridHeadProps = SomaDateRangePickerGridHeadProps;
export type DateRangePickerGridBodyProps = SomaDateRangePickerGridBodyProps;

@ -83,12 +83,16 @@ Referencias: [React Aria RangeCalendar](https://react-spectrum.adobe.com/react-a
`commit-select-start` (**affirm** — el rango se abre, confirmación suave) →
`commit-select-range` (**fulfill** — el sellado del PAR es el momento de
cierre) → `commit-reset` (neutral) + `shift-navigate` (paginación de mes).
- **`shift-navigate` sella el `grid`, no el provider ni la flecha** (igual que
el donante, 2026-08-11): el grid es el sujeto del cruce y lo único capaz de
lucir una firma de `shift` — un mes se desliza, un botón no. Medido en el
donante: con el sello en la flecha, su propio `contact-activate` quedaba
pisado 8,5 ms después y el `press-squeeze` moría sin pintar. Una superficie,
una ranura.
- **`shift-navigate` sella `months`, no el provider, ni la flecha, ni un
`grid`** (igual que el donante). Salió de la flecha el 2026-08-11: con el
sello ahí, su propio `contact-activate` quedaba pisado 8,5 ms después y el
`press-squeeze` moría sin pintar. Una superficie, una ranura. Y salió del
`grid` el 2026-08-12, que es el defecto que ESTE componente exhibía peor:
un selector de rango muestra **dos meses por defecto**, `grid` es una parte
repetida, y un emit sin ancla resuelve a la instancia viva más reciente —
así que sólo cruzaba el segundo mes. `Months` es la vista paginada, y cruza
entera bajo cualquier número de meses. Medido: los dos grids se desplazan
10 px a los 40 ms.
- **El anillo de evento perdió el provider en el recipe**
(`box-shadow: var(--calendar-event-shadow)`): nada apunta ya a
`[data-range-calendar]`, así que la regla era selección muerta. Se quedan el

@ -33,6 +33,8 @@ import NextButton from './range-calendar-next-button.svelte';
// publishes, so RangeCalendar reuses them instead of its own native selects.
import MonthSelect from '../calendar/calendar-month-select.svelte';
import YearSelect from '../calendar/calendar-year-select.svelte';
import Months from './range-calendar-months.svelte'
import MonthPanel from './range-calendar-month-panel.svelte'
import Grid from './range-calendar-grid.svelte';
import GridHead from './range-calendar-grid-head.svelte';
import GridBody from './range-calendar-grid-body.svelte';
@ -48,6 +50,8 @@ type RangeCalendarNamespace = typeof RangeCalendarComponent & {
NextButton: typeof NextButton;
MonthSelect: typeof MonthSelect;
YearSelect: typeof YearSelect;
Months: typeof Months
MonthPanel: typeof MonthPanel
Grid: typeof Grid;
GridHead: typeof GridHead;
GridBody: typeof GridBody;
@ -64,6 +68,8 @@ RangeCalendar.PrevButton = PrevButton;
RangeCalendar.NextButton = NextButton;
RangeCalendar.MonthSelect = MonthSelect;
RangeCalendar.YearSelect = YearSelect;
RangeCalendar.Months = Months
RangeCalendar.MonthPanel = MonthPanel
RangeCalendar.Grid = Grid;
RangeCalendar.GridHead = GridHead;
RangeCalendar.GridBody = GridBody;

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarMonthPanelProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarMonthPanelProps = $props();
</script>
<RangeCalendar.MonthPanel {...rest}>
{@render bodyContent?.()}
</RangeCalendar.MonthPanel>

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarMonthsProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarMonthsProps = $props();
</script>
<RangeCalendar.Months {...rest}>
{@render bodyContent?.()}
</RangeCalendar.Months>

@ -179,6 +179,31 @@
color: var(--calendar-control-color-hover);
}
/* ── Months (the paginated view) ─────────────────────────────────────
The surface `shift-navigate` stamps, so both months of a range cross as one
page. `grid-auto-flow: column` needs no month COUNT — the demo used to carry
this as a local `.range-months` class plus a `data-month-count` attribute of
its own invention, and leaned on a `--calendar-month-gap` token that was
never defined (its `36px` fallback did the work). Both live here now. */
[data-range-calendar-months] {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: var(--calendar-months-gap);
align-items: start;
justify-content: safe center;
max-inline-size: 100%;
overflow: auto;
}
[data-range-calendar-month-panel] {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--calendar-grid-gap);
min-inline-size: max-content;
}
/* ── Grid ───────────────────────────────────────────────────────────── */
[data-range-calendar-grid] {
inline-size: max-content;
@ -367,8 +392,11 @@
}
/* Event ring — same shape as calendar.css, same 2026-08-11 prune: the provider
left the list when `shift-navigate` moved from the pressed control to `grid`.
Nothing aims at `[data-range-calendar]` any more (measured). The controls
left the list when `shift-navigate` moved off the pressed control. Its target
is `months` since 2026-08-12 (`grid` in between — a repeated part, so with
two months only the last one crossed). Nothing aims at
`[data-range-calendar]` any more (measured), and `months` stays out of this
list too: its firma is the crossing, not a ring. The controls
keep the ring through their own `contact-activate` — they share calendar's
`CalendarNavButton` — and the day through `commit-select-*`. */
[data-range-calendar-day][data-event],

@ -10,6 +10,8 @@ import type {
RangeCalendarGridRowSnippetProps,
RangeCalendarHeadCellProps as SomaRangeCalendarHeadCellProps,
RangeCalendarHeaderProps as SomaRangeCalendarHeaderProps,
RangeCalendarMonthPanelProps as SomaRangeCalendarMonthPanelProps,
RangeCalendarMonthsProps as SomaRangeCalendarMonthsProps,
RangeCalendarHeadingProps as SomaRangeCalendarHeadingProps,
RangeCalendarHeadingSnippetProps,
RangeCalendarMonthSelectProps as SomaRangeCalendarMonthSelectProps,
@ -64,6 +66,8 @@ export type RangeCalendarPrevButtonProps = SomaRangeCalendarPrevButtonProps;
export type RangeCalendarNextButtonProps = SomaRangeCalendarNextButtonProps;
export type RangeCalendarMonthSelectProps = SomaRangeCalendarMonthSelectProps;
export type RangeCalendarYearSelectProps = SomaRangeCalendarYearSelectProps;
export type RangeCalendarMonthsProps = SomaRangeCalendarMonthsProps;
export type RangeCalendarMonthPanelProps = SomaRangeCalendarMonthPanelProps;
export type RangeCalendarGridProps = SomaRangeCalendarGridProps;
export type RangeCalendarGridHeadProps = SomaRangeCalendarGridHeadProps;
export type RangeCalendarGridBodyProps = SomaRangeCalendarGridBodyProps;

@ -1692,6 +1692,7 @@
--calendar-control-color-hover: var(--color-content-primary);
--calendar-select-min-width: 8ch;
--calendar-select-padding-inline: var(--space-2);
--calendar-months-gap: var(--space-4);
--calendar-grid-gap: var(--space-0-5);
--calendar-weekday-size: var(--space-5);
--calendar-weekday-color: var(--color-content-muted);

@ -2107,6 +2107,9 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'control-color-hover': 'var(--color-content-primary)',
'select-min-width': '8ch',
'select-padding-inline': 'var(--space-2)',
// Between month COLUMNS in the paginated view — a full step, unlike the
// hairline `grid-gap` that separates day cells inside one month.
'months-gap': 'var(--space-4)',
'grid-gap': 'var(--space-0-5)',
'weekday-size': 'var(--space-5)',
'weekday-color': 'var(--color-content-muted)',

@ -36,18 +36,34 @@ export const calendarMorfo = {
semantic: {
family: 'shift',
verb: 'navigate',
// The GRID is what navigated — the header does not move, and the
// arrow is the instrument, not the subject. Until 2026-08-11 this
// targeted `provider` and the runtime redirected the stamp onto
// whichever surface CAUSED the navigation (arrow, select, spilled
// day). Measured on the prev-button: `contact-activate` stamped
// and started `press-squeeze`, then `shift-navigate` overwrote the
// same slot 8.5 ms later — half a frame — and the squeeze never
// painted. One surface, one slot (A-36): an instrument that owns
// its own event cannot also carry someone else's. Naming the grid
// is also what makes a `shift` firma expressible at all — a month
// can slide, a button cannot.
target: v.partRef('grid'),
// The subject is the PAGINATED VIEW, not one of the months in it.
//
// Two corrections got us here. Until 2026-08-11 this targeted
// `provider` and the runtime redirected the stamp onto whichever
// surface CAUSED the navigation (arrow, select, spilled day).
// Measured on the prev-button: `contact-activate` stamped and
// started `press-squeeze`, then `shift-navigate` overwrote the same
// slot 8.5 ms later — half a frame — and the squeeze never painted.
// One surface, one slot (A-36): an instrument that owns its own
// event cannot also carry someone else's. So it moved to `grid`.
//
// `grid` was right for one month and wrong for two. `grid` is a
// REPEATED part, and an unanchored emit resolves to the target's
// NEWEST live instance (`resolveEmitTarget`) — measured with
// `numberOfMonths=2`: only the second table was stamped, so June
// changed its dates without moving while July crossed. A frame that
// swaps with nothing moving is the *shift invisible* of the
// antipattern catalogue (ch. 34 §14, CANON §8 rule 5) — here, half
// of one.
//
// `months` is the surface that crosses as a unit under every value
// of `numberOfMonths`, so the target is true under all of them —
// a target correct for only one parameter value is not a target.
// It is also what keeps the firma expressible: a page can slide, an
// arrow cannot. The fallback keeps a headless composition that
// renders bare `Grid`s honest rather than silent.
target: v.partRef('months'),
targetFallback: [v.partRef('grid')],
sequence: 'post'
}
}
@ -210,6 +226,34 @@ export const calendarMorfo = {
}
]
},
{
name: 'Months',
kebab: 'months',
archetype: 'viewport',
kind: 'public',
defaultElement: 'div',
// Optional so a single-month headless composition can still render a
// bare `Grid`; `shift-navigate` falls back to it. Rendering it is what
// makes the crossing perceivable with more than one month on screen.
optional: true,
data: [],
aria: []
},
{
// One month's column — its heading plus its grid. Shipped since the
// multi-month demos existed, as a hand-made `<div
// data-calendar-month-panel>` that no morfo declared and that
// `calendar.css` styled anyway. Declaring it closes that drift: the
// recipe now selects an attr the contract promises.
name: 'MonthPanel',
kebab: 'month-panel',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Grid',
kebab: 'grid',

@ -51,15 +51,26 @@ export const rangeCalendarMorfo = {
semantic: {
family: 'shift',
verb: 'navigate',
// The GRID is the subject — the header stays put and the arrow is
// only the instrument. Until 2026-08-11 this declared `provider`
// while the provider redirected the stamp onto the arrow / select /
// spilled day: off-contract (no `allowedTargets`, so it warned in
// dev and shipped anyway), and it made the arrow carry two events
// in one slot — its own `contact-activate` press died half a frame
// later, unpainted (A-36). Naming the grid also makes a `shift`
// signature expressible: a month can slide, a button cannot.
target: v.partRef('grid'),
// The subject is the PAGINATED VIEW — the header stays put and the
// arrow is only the instrument. Until 2026-08-11 this declared
// `provider` while the provider redirected the stamp onto the arrow
// / select / spilled day: off-contract (no `allowedTargets`, so it
// warned in dev and shipped anyway), and it made the arrow carry two
// events in one slot — its own `contact-activate` press died half a
// frame later, unpainted (A-36). So it moved to `grid`.
//
// `grid` is a REPEATED part, and this component is the one that
// repeats it by default: a range picker shows two months. An
// unanchored emit resolves to the target's NEWEST live instance
// (`resolveEmitTarget`), so only the last month crossed and the
// first changed its dates standing still — the *shift invisible*
// (ch. 34 §14, CANON §8 rule 5) over half the component.
//
// `months` crosses as a unit under every month count, which is what
// makes the target true rather than true-for-one-value, and what
// keeps the firma expressible: a page can slide, an arrow cannot.
target: v.partRef('months'),
targetFallback: [v.partRef('grid')],
sequence: 'post'
}
}
@ -196,6 +207,33 @@ export const rangeCalendarMorfo = {
}
]
},
{
name: 'Months',
kebab: 'months',
archetype: 'viewport',
kind: 'public',
defaultElement: 'div',
// Optional so a bare headless composition still works; `shift-navigate`
// falls back to `grid`. Rendering it is what makes the crossing
// perceivable across the two months a range picker shows by default.
optional: true,
data: [],
aria: []
},
{
// One month's column — its heading plus its grid. Shipped since the
// multi-month demos existed, as a hand-made `<div
// data-calendar-month-panel>` that no morfo declared and that
// `calendar.css` styled anyway. Declaring it closes that drift.
name: 'MonthPanel',
kebab: 'month-panel',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Grid',
kebab: 'grid',

@ -7,12 +7,15 @@ import type { Sema } from '../sema-map';
*
* Almost nothing, and that is the point. `commit-select` / `commit-unselect`
* take the commit family's `tick` (and its intent variants, by name);
* `shift-navigate` takes the shift family's `slide` on the `grid` — the surface
* that actually navigated. It used to be redirected onto whichever control
* caused it (arrow, select, a day that spilled the month); that stopped on
* 2026-08-11, because the arrow already owns its own `contact-activate` and one
* surface holds one stamp. None of that is written here any more: the map's
* verb table says it once for the whole framework.
* `shift-navigate` takes the shift family's `slide` on `months` — the paginated
* view, which is what actually crossed. It used to be redirected onto whichever
* control caused it (arrow, select, a day that spilled the month); that stopped
* on 2026-08-11, because the arrow already owns its own `contact-activate` and
* one surface holds one stamp. It sat on `grid` until 2026-08-12, when two
* months on screen showed that a repeated part cannot be the subject of one
* occurrence. None of that is written here any more: the map's verb table says
* it once for the whole framework, and it says it by VERB, so moving the target
* changed no rule in this file.
*
* Until 2026-08-06 this file carried eight sound rules, one of which set
* `gain: 0.18` — the loudest value in the system, louder than an alert, for

@ -597,6 +597,63 @@ export class CalendarProvider implements CalendarNav {
// ── Header ───────────────────────────────────────────────────────────────────
/**
* The paginated view of months — the SUBJECT of `shift-navigate`.
*
* It exists so the crossing has a surface that stays true whatever
* `numberOfMonths` is. Stamping a `grid` was correct for one month and wrong
* for two: `grid` repeats, and an unanchored emit lands on the newest live
* instance, so only the last month crossed.
*/
export class CalendarMonthsProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart<typeof calendarMorfo, 'months'>;
static create(opts: WithRefOpts) {
return new CalendarMonthsProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = CalendarProvider.require();
this.runtimePart = provider.runtime.part('months', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── MonthPanel ───────────────────────────────────────────────────────────────
/** One month's column: its heading and its grid. */
export class CalendarMonthPanelProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart<typeof calendarMorfo, 'month-panel'>;
static create(opts: WithRefOpts) {
return new CalendarMonthPanelProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = CalendarProvider.require();
this.runtimePart = provider.runtime.part('month-panel', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Header ───────────────────────────────────────────────────────────────────
export class CalendarHeaderProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart<typeof calendarMorfo, 'header'>;

@ -0,0 +1,35 @@
<script lang="ts">
import { partOpts } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CalendarMonthPanelProvider } from '../calendar-provider.svelte';
import type { CalendarMonthPanelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-month-panel'),
children,
child,
...restProps
}: CalendarMonthPanelProps = $props();
const state = CalendarMonthPanelProvider.create(
partOpts(
() => id,
() => ref,
(v) => (ref = v)
)
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { partOpts } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { CalendarMonthsProvider } from '../calendar-provider.svelte';
import type { CalendarMonthsProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-months'),
children,
child,
...restProps
}: CalendarMonthsProps = $props();
const state = CalendarMonthsProvider.create(
partOpts(
() => id,
() => ref,
(v) => (ref = v)
)
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -5,6 +5,8 @@ export { default as PrevButton } from './components/calendar-prev-button.svelte'
export { default as NextButton } from './components/calendar-next-button.svelte';
export { default as MonthSelect } from './components/calendar-month-select.svelte';
export { default as YearSelect } from './components/calendar-year-select.svelte';
export { default as Months } from './components/calendar-months.svelte';
export { default as MonthPanel } from './components/calendar-month-panel.svelte';
export { default as Grid } from './components/calendar-grid.svelte';
export { default as GridHead } from './components/calendar-grid-head.svelte';
export { default as GridBody } from './components/calendar-grid-body.svelte';
@ -28,6 +30,8 @@ export type {
CalendarView,
CalendarProps,
CalendarHeaderProps,
CalendarMonthsProps,
CalendarMonthPanelProps,
CalendarHeadingProps,
CalendarPrevButtonProps,
CalendarNextButtonProps,

@ -236,6 +236,18 @@ export type CalendarProps = WithChild<
export type CalendarHeaderProps = WithChild<{ id?: string }> &
Without<PrimitiveHeaderAttributes, {}>;
/**
* The paginated view of months. Render one `MonthPanel` per visible month
* inside it; it is the surface `shift-navigate` stamps, so the whole page
* crosses as a unit however many months are on screen.
*/
export type CalendarMonthsProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
/** One month's column — its heading and its grid. */
export type CalendarMonthPanelProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type CalendarHeadingProps = WithChild<
{
id?: string;

@ -34,6 +34,8 @@ export {
NextButton,
MonthSelect,
YearSelect,
Months,
MonthPanel,
Grid,
GridHead,
GridBody,

@ -27,6 +27,8 @@ export {
NextButton,
MonthSelect,
YearSelect,
Months,
MonthPanel,
Grid,
GridHead,
GridBody,
@ -54,6 +56,8 @@ export type {
DateRangePickerNextButtonProps as NextButtonProps,
DateRangePickerMonthSelectProps as MonthSelectProps,
DateRangePickerYearSelectProps as YearSelectProps,
DateRangePickerMonthsProps as MonthsProps,
DateRangePickerMonthPanelProps as MonthPanelProps,
DateRangePickerGridProps as GridProps,
DateRangePickerGridHeadProps as GridHeadProps,
DateRangePickerGridBodyProps as GridBodyProps,

@ -183,6 +183,8 @@ export type {
RangeCalendarNextButtonProps as DateRangePickerNextButtonProps,
RangeCalendarMonthSelectProps as DateRangePickerMonthSelectProps,
RangeCalendarYearSelectProps as DateRangePickerYearSelectProps,
RangeCalendarMonthsProps as DateRangePickerMonthsProps,
RangeCalendarMonthPanelProps as DateRangePickerMonthPanelProps,
RangeCalendarGridProps as DateRangePickerGridProps,
RangeCalendarGridHeadProps as DateRangePickerGridHeadProps,
RangeCalendarGridBodyProps as DateRangePickerGridBodyProps,

@ -0,0 +1,35 @@
<script lang="ts">
import { partOpts } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RangeCalendarMonthPanelProvider } from '../range-calendar-provider.svelte';
import type { RangeCalendarMonthPanelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'range-calendar-month-panel'),
children,
child,
...restProps
}: RangeCalendarMonthPanelProps = $props();
const state = RangeCalendarMonthPanelProvider.create(
partOpts(
() => id,
() => ref,
(v) => (ref = v)
)
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { partOpts } from '../../../provider';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { RangeCalendarMonthsProvider } from '../range-calendar-provider.svelte';
import type { RangeCalendarMonthsProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'range-calendar-months'),
children,
child,
...restProps
}: RangeCalendarMonthsProps = $props();
const state = RangeCalendarMonthsProvider.create(
partOpts(
() => id,
() => ref,
(v) => (ref = v)
)
);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -5,6 +5,8 @@ export { default as PrevButton } from './components/range-calendar-prev-button.s
export { default as NextButton } from './components/range-calendar-next-button.svelte';
export { default as MonthSelect } from './components/range-calendar-month-select.svelte';
export { default as YearSelect } from './components/range-calendar-year-select.svelte';
export { default as Months } from './components/range-calendar-months.svelte';
export { default as MonthPanel } from './components/range-calendar-month-panel.svelte';
export { default as Grid } from './components/range-calendar-grid.svelte';
export { default as GridHead } from './components/range-calendar-grid-head.svelte';
export { default as GridBody } from './components/range-calendar-grid-body.svelte';
@ -16,6 +18,8 @@ export { default as Day } from './components/range-calendar-day.svelte';
export type {
RangeCalendarProps,
RangeCalendarHeaderProps,
RangeCalendarMonthsProps,
RangeCalendarMonthPanelProps,
RangeCalendarHeadingProps,
RangeCalendarPrevButtonProps,
RangeCalendarNextButtonProps,

@ -669,6 +669,52 @@ export class RangeCalendarProvider implements CalendarNav {
// ── Header / Heading / Buttons ──────────────────────────────────────────────
/**
* The paginated view of months — the SUBJECT of `shift-navigate`. A range
* picker shows two months by default, so this is the component where stamping
* one `grid` left the other month changing its dates standing still.
*/
export class RangeCalendarMonthsProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart<typeof rangeCalendarMorfo, 'months'>;
static create(opts: WithRefOpts) {
return new RangeCalendarMonthsProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = RangeCalendarProvider.require();
this.runtimePart = provider.runtime.part('months', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
/** One month's column: its heading and its grid. */
export class RangeCalendarMonthPanelProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart<typeof rangeCalendarMorfo, 'month-panel'>;
static create(opts: WithRefOpts) {
return new RangeCalendarMonthPanelProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = RangeCalendarProvider.require();
this.runtimePart = provider.runtime.part('month-panel', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
export class RangeCalendarHeaderProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart<typeof rangeCalendarMorfo, 'header'>;

@ -157,6 +157,18 @@ export type RangeCalendarProps = WithChild<
export type RangeCalendarHeaderProps = WithChild<{ id?: string }> &
Without<PrimitiveHeaderAttributes, {}>;
/**
* The paginated view of months. Render one `MonthPanel` per visible month
* inside it; it is the surface `shift-navigate` stamps, so both months of a
* range picker cross as one page.
*/
export type RangeCalendarMonthsProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
/** One month's column — its heading and its grid. */
export type RangeCalendarMonthPanelProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type RangeCalendarHeadingProps = WithChild<
{ id?: string },
RangeCalendarHeadingSnippetProps

@ -287,31 +287,35 @@
' </div>',
' <Calendar.NextButton>&gt;</Calendar.NextButton>',
' </Calendar.Header>',
' {#each months as month (month.value.toString())}',
' {#if numberOfMonths > 1}',
' <Calendar.Heading id={monthHeadingId(month.value)} value={month.value} />',
' {/if}',
' <Calendar.Grid labelledBy={numberOfMonths > 1 ? monthHeadingId(month.value) : undefined}>',
' <Calendar.GridHead>',
' <Calendar.GridRow>',
' {#each weekdays as day}',
' <Calendar.HeadCell>{day}</Calendar.HeadCell>',
' {/each}',
' </Calendar.GridRow>',
' </Calendar.GridHead>',
' <Calendar.GridBody>',
' {#each month.weeks as week}',
' <Calendar.GridRow>',
' {#each week as date}',
' <Calendar.Cell {date} month={month.value}>',
' <Calendar.Day />',
' </Calendar.Cell>',
' <Calendar.Months>',
' {#each months as month (month.value.toString())}',
' <Calendar.MonthPanel>',
' {#if numberOfMonths > 1}',
' <Calendar.Heading id={monthHeadingId(month.value)} value={month.value} />',
' {/if}',
' <Calendar.Grid labelledBy={numberOfMonths > 1 ? monthHeadingId(month.value) : undefined}>',
' <Calendar.GridHead>',
' <Calendar.GridRow>',
' {#each weekdays as day}',
' <Calendar.HeadCell>{day}</Calendar.HeadCell>',
' {/each}',
' </Calendar.GridRow>',
' </Calendar.GridHead>',
' <Calendar.GridBody>',
' {#each month.weeks as week}',
' <Calendar.GridRow>',
' {#each week as date}',
' <Calendar.Cell {date} month={month.value}>',
' <Calendar.Day />',
' </Calendar.Cell>',
' {/each}',
' </Calendar.GridRow>',
' {/each}',
' </Calendar.GridRow>',
' {/each}',
' </Calendar.GridBody>',
' </Calendar.Grid>',
' {/each}',
' </Calendar.GridBody>',
' </Calendar.Grid>',
' </Calendar.MonthPanel>',
' {/each}',
' </Calendar.Months>',
' {/snippet}',
'</Calendar.Provider>'
]
@ -376,12 +380,16 @@
' </div>',
' <Calendar.NextButton>&gt;</Calendar.NextButton>',
' </Calendar.Header>',
' {#each months as month (month.value.toString())}',
' {#if numberOfMonths > 1}',
' <Calendar.Heading id={monthHeadingId(month.value)} value={month.value} />',
' {/if}',
' <Calendar.Grid>...</Calendar.Grid>',
' {/each}',
' <Calendar.Months>',
' {#each months as month (month.value.toString())}',
' <Calendar.MonthPanel>',
' {#if numberOfMonths > 1}',
' <Calendar.Heading id={monthHeadingId(month.value)} value={month.value} />',
' {/if}',
' <Calendar.Grid>...</Calendar.Grid>',
' </Calendar.MonthPanel>',
' {/each}',
' </Calendar.Months>',
' {/snippet}',
'</Calendar>'
]
@ -399,11 +407,9 @@
</div>
<Calendar.NextButton aria-label="Next month" />
</Calendar.Header>
<div
style={`display:grid;grid-template-columns:repeat(${numberOfMonths},max-content);gap:var(--uix-space-4);align-items:start;justify-content:safe center;max-inline-size:100%;overflow:auto;`}
>
<Calendar.Months>
{#each months as month (month.value.toString())}
<div data-calendar-month-panel>
<Calendar.MonthPanel>
{#if numberOfMonths > 1}
<Calendar.Heading id={monthHeadingId(month.value)} value={month.value} />
{/if}
@ -437,9 +443,9 @@
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</div>
</Calendar.MonthPanel>
{/each}
</div>
</Calendar.Months>
{#if validationMessage}
<div
data-calendar-demo-error

@ -1164,9 +1164,9 @@
<p class="calendar-error" role="alert">{validationMessage}</p>
{/if}
<div class="month-stack" data-month-count={months.length}>
<DatePicker.Months>
{#each months as month (month.value.toString())}
<div data-calendar-month-panel>
<DatePicker.MonthPanel>
{#if months.length > 1}
<DatePicker.Heading id={monthHeadingId(month.value)} value={month.value} />
{/if}
@ -1200,9 +1200,9 @@
{/each}
</DatePicker.GridBody>
</DatePicker.Grid>
</div>
</DatePicker.MonthPanel>
{/each}
</div>
</DatePicker.Months>
{/snippet}
<style>
@ -1223,19 +1223,9 @@
}
.month-stack {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
justify-content: center;
gap: var(--calendar-month-gap, 32px);
max-inline-size: 100%;
overflow-x: auto;
}
.month-stack[data-month-count='1'] {
grid-auto-flow: row;
}
/* The `.month-stack` layout that lived here is now the calendar recipe's,
on `[data-calendar-months]`. It also leaned on a `--calendar-month-gap`
token that was never defined — the `32px` fallback did the work. */
.calendar-error {
margin: 0;

@ -1137,9 +1137,9 @@
<p class="calendar-error" role="alert">{validationMessage || boundsMessage || spanMessage}</p>
{/if}
<div class="range-months" data-month-count={months.length}>
<DateRangePicker.Months>
{#each months as month (month.value.toString())}
<div class="range-month-panel"><!-- per-month title now lives in the header above -->
<DateRangePicker.MonthPanel><!-- per-month title now lives in the header above -->
<DateRangePicker.Grid>
<DateRangePicker.GridHead>
<DateRangePicker.GridRow>
@ -1170,9 +1170,9 @@
{/each}
</DateRangePicker.GridBody>
</DateRangePicker.Grid>
</div>
</DateRangePicker.MonthPanel>
{/each}
</div>
</DateRangePicker.Months>
{/snippet}
{#snippet pickerFooter()}
@ -1217,27 +1217,9 @@
color: var(--color-threat-text);
}
.range-months {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
justify-content: center;
gap: var(--calendar-month-gap, 36px);
max-inline-size: 100%;
overflow-x: auto;
}
.range-months[data-month-count='1'] {
grid-auto-flow: row;
}
.range-month-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--calendar-grid-gap);
min-inline-size: max-content;
}
/* The multi-month layout that lived here is now the range-calendar
recipe's, on [data-range-calendar-months] / -month-panel. It also leaned
on a --calendar-month-gap token that was never defined. */
.range-header-titles {
display: grid;

@ -189,18 +189,24 @@
' <RangeCalendar.Heading />',
' <RangeCalendar.NextButton />',
' </RangeCalendar.Header>',
' <RangeCalendar.Grid>',
' <RangeCalendar.GridHead>',
' <RangeCalendar.GridRow>',
' {#snippet children({ weekdays })}',
' {#each weekdays as wd}<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>{/each}',
' {/snippet}',
' </RangeCalendar.GridRow>',
' </RangeCalendar.GridHead>',
' <RangeCalendar.GridBody>',
' <!-- rows iterate via the months snippet prop -->',
' </RangeCalendar.GridBody>',
' </RangeCalendar.Grid>',
' <RangeCalendar.Months>',
' {#each months as month (month.value.toString())}',
' <RangeCalendar.MonthPanel>',
' <RangeCalendar.Grid>',
' <RangeCalendar.GridHead>',
' <RangeCalendar.GridRow>',
' {#snippet children({ weekdays })}',
' {#each weekdays as wd}<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>{/each}',
' {/snippet}',
' </RangeCalendar.GridRow>',
' </RangeCalendar.GridHead>',
' <RangeCalendar.GridBody>',
' <!-- rows iterate via the months snippet prop -->',
' </RangeCalendar.GridBody>',
' </RangeCalendar.Grid>',
' </RangeCalendar.MonthPanel>',
' {/each}',
' </RangeCalendar.Months>',
'</RangeCalendar>'
]
.filter(Boolean)
@ -279,12 +285,12 @@
<RangeCalendar.Heading />
<RangeCalendar.NextButton aria-label="Next month" />
</RangeCalendar.Header>
<!-- Multi-month layout: grids sit side-by-side horizontally via
`grid-auto-flow: column` (mirrors the DateRangePicker demo).
When numberOfMonths=1 this collapses to a single grid track. -->
<div class="range-months" data-month-count={months.length}>
<!-- `Months` is the paginated view: the surface `shift-navigate`
stamps, so both months cross as one page. Its side-by-side
layout is the recipe's, not this page's. -->
<RangeCalendar.Months>
{#each months as month (month.value.toString())}
<div class="range-month-panel">
<RangeCalendar.MonthPanel>
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<RangeCalendar.GridRow>
@ -309,9 +315,9 @@
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
</div>
</RangeCalendar.MonthPanel>
{/each}
</div>
</RangeCalendar.Months>
{/snippet}
</RangeCalendar>
</div>
@ -770,30 +776,10 @@
<style>
/*
* Multi-month layout — mirrors the date-range-picker demo
* (web/routes/uix/components/date-range-picker/+page.svelte). Side-by-side
* via `grid-auto-flow: column` so the grids never stack vertically; the
* single-month case collapses to one row.
* The multi-month layout that used to live here (`.range-months` /
* `.range-month-panel`) is now the recipe's, on `[data-range-calendar-months]`
* and `[data-range-calendar-month-panel]`. It also leaned on a
* `--calendar-month-gap` token that was never defined — its `36px` fallback
* did all the work. The real token is `--calendar-months-gap`.
*/
.range-months {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
justify-content: center;
gap: var(--calendar-month-gap, 36px);
max-inline-size: 100%;
overflow-x: auto;
}
.range-months[data-month-count='1'] {
grid-auto-flow: row;
}
.range-month-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--calendar-grid-gap);
min-inline-size: max-content;
}
</style>

Loading…
Cancel
Save

Powered by TurnKey Linux.