feat(chronos): event-calendar component (month/week/day/agenda)

Scheduler built across morfo/soma/sema/eidos, composing framework
components (DragDrop/Dialog/Field/Select/ToggleGroup/pickers). Dates via
$libs/days.

- Views: month grid (overlap layout + per-cell "+N more" cap), week/day
  time-grid, agenda. Week-spanning events: arrow tail + notched head.
- Drag-drop move (month cells + week/day hour slots); drag preview = styled
  chip sized to the full event span.
- Month "+N more" -> resizable day-peek popover (day-view format).
- Day cells: hover (+) to create (no click-to-create).
- Event dialog with view (readonly) / edit modes + permission getters;
  3-mode time model (all-day/single/split bands) + draft validation.
- Customizable editor + tooltip via snippets (ChronosEditorContext).

Hand-off + pending: src/uix/eidos/components/chronos/CONTINUE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 3 months ago
parent cb1d02a479
commit 4c5e33a098

@ -0,0 +1,121 @@
# CONTINUE — Chronos (hand-off 2026-06-27 → next session)
Branch `active-uix`. Chronos is the event-calendar (scheduler) component built
across the 4 layers. This is its **first commit** (everything was untracked).
Resume from the **immediate next task** below.
Reading order: this file → `SPEC.md` (scope/roadmap) → `README.md` →
`src/uix/soma/components/chronos/` (engine + provider).
## File map
```
src/uix/morfo/components/chronos.ts contract (parts, events)
src/uix/sema/components/chronos.ts perception pack
src/uix/soma/components/chronos/
chronos-provider.svelte.ts state + editing session + permissions
types.ts ChronosEvent / Editing / EditorContext / Tooltip
engine/ pure: state.svelte.ts, layout.ts, time-layout.ts, …
src/uix/eidos/components/chronos/
chronos.svelte / chronos-view.svelte wrapper + month/week/day/agenda renderer + dialog
chronos-when-editor.svelte the "when" controls (mode + dates + times/bands)
chronos-day-peek.svelte month "+N more" → day-view popover (resizable)
chronos.css recipe + all chrome
web/routes/uix/components/chronos/+page.svelte testbed demo
```
## Hard rules (from the user — still active)
- Only modify chronos-own files + the 3 registration files he lifted:
`eidos/lib/recipes/base.ts`, `sema/components/index.ts`, `eidos/index.css`.
- Never stage/commit/edit `palabras/**` or `words/**`. Stage explicit chronos paths.
- Event colors come from the consumer `CalendarSchema` (hex/CSS + `predefinedColors`),
NEVER the intent channel. Canonical "Other" type id = `otros`.
- Compose framework components (DragDrop/Dialog/Field/Select/ToggleGroup/pickers/
Button/IconButton), never reinvent. All values from tokens — no magic numbers/raw colors.
- Castilian replies. Verify in the browser with measurements, not by eye.
## IMMEDIATE NEXT TASK — native `readonly` for Select + ToggleGroup
The event dialog has a **view (readonly) mode** and an **edit mode** (Pencil
toggle in the header). View mode should render every control readonly (legible,
non-editable). Of the dialog's controls:
| Control | native `readonly`? |
| --- | --- |
| Field.Input, DateRangePicker, TimeRangePicker | ✅ yes — uses `readonly={!editable}` |
| **Select** (Type), **ToggleGroup** (mode) | ❌ **no** — only `disabled` |
For the two without `readonly` I shipped a **workaround** in `chronos.css`
(scoped to `.chronos-editor-fields[data-view]` / `.chronos-when[data-view]`):
keep `disabled` (AT-correct) but strip the dimming (`opacity:1` + restore the
Select trigger's normal `--select-trigger-bg/border/color`).
**Do it properly:** add a canonical `readonly` prop to **Select** and
**ToggleGroup** (morfo → soma → eidos), same shape as Field/pickers. Then:
1. Switch chronos to `readonly={!editable}` on both.
2. Delete the workaround CSS blocks in `chronos.css` (the `[data-view]` un-gray
rules for select + toggle-group).
3. Keep the date/time **segment flatten** rule (`[data-…-field-segment][data-readonly]`
→ `background: transparent; text-decoration: none`) — that's a deliberate
chronos choice (the field's readonly affordance paints a per-segment bg + dotted
underline; too noisy in an all-readonly dialog).
⚠️ This touches `select` + `toggle-group` (NOT in the allowed file list) — the
user must OK that scope first (he flagged it; answer pending).
## Done this session (in this commit)
- **Month "+N more" → day-peek popover** (`chronos-day-peek.svelte`): clicking the
overflow link opens a Popover showing the day in **day-view format** (all-day
row + windowed hour grid). `dayTimeLayout(day)` computes per-day layout on demand
(`timeLayoutFor` is empty in month view — keyed by `visibleDays`).
- **Day-peek polish**: "+N more" is `<Button variant="plain">`; **resizable width**
via a visible right-edge handle (`.chronos-peek-resize`, sets inline width; scroll
moved to `.chronos-peek-scroll` so the handle stays pinned); z-index pinned just
below the dialog overlay; header = day number + weekday/month-year; all-day padding.
- **Day cells**: removed click-to-create; a **(+) button appears on hover** (top-right
of each cell) → `openCreate`.
- **Event dialog view/edit modes**: `ChronosEditing.editable` + provider getters
`canEditDraft` / `draftEditable` / `showEditAction` + `enableEditing()`. Existing
events open in view (Pencil action, footer = Close); create opens editable.
`openEdit` now ALWAYS opens (in view) even when readonly/non-editable. Pencil is
absolutely positioned at `Dialog.Content` top-right, inset `--dialog-content-padding-md`
(same as the footer buttons).
- **Editor validation** (`draftError`) blocks Save + shows an inline alert.
- **Week-spanning events**: arrow tail + notched head via clip-paths; per-cell "+N more"
lane cap; single-day right padding; drag preview = styled chip sized to the full span.
## Other pending (not requested yet — confirm before starting)
- **Tooltip on event bands** (hover → time/description/type; consumer-customizable
via `eventTooltip` snippet + `ChronosTooltipContext`). Requested earlier, interrupted,
never built. Types already declared.
- **Zero-hardcodes / langs**: `components.chronos.label` missing (uses "Calendar"
fallback); aria-labels + UI strings ("New event", "Edit event", "Resize width",
"Add band", weekday/month formatting in the peek) are hardcoded English — route
through `langs`.
- Event **resize handles** (drag edge to change duration); Week/Day **finer snap**
(currently hour); keyboard roving nav; picker providers don't fire `close` events
(inert). Year/Timeline/RRULE/virtualization = roadmap (`SPEC.md`).
## Gotchas (cost me time — don't relearn)
- `timeLayoutFor(day)` is **empty in Month view** (cached by `visibleDays`) → use
`dayTimeLayout(day)`.
- Composing `<Button>` as a `Popover.Trigger` inherits the popover **surface
envelope** (`[data-popover-trigger]:not([data-archetype='field-trigger'])`, 0-2-0)
— overrides the Button's `plain`. Fix = 0-3-0 chronos rule re-pointing to `--_button-*`.
- Popover layer sits **above** the dialog by default (popover content-z 75 > dialog 70/71)
→ pin the peek wrapper z below `--dialog-overlay-z`.
- Native `resize` only exposes the **invisible bottom-right corner grip** → custom
right-edge handle for discoverable horizontal resize.
- Portaled pickers inside the Dialog lose `ActiveEidos` → bridge with
`ActiveEidos.set(ActiveEidos.require())` in `chronos-when-editor.svelte`.
- Readonly date/time **segments** ship a per-segment bg + dotted underline affordance.
## Verify at resume
`npm run check` is clean for chronos. Demo at `/uix/components/chronos`. The
testbed has Size/Accent/week-start/max-per-cell/editable/readonly/custom-editor
controls + a busy day (Jun 22) and a week-spanning Trip for arrows.

@ -0,0 +1,107 @@
# Chronos (eidos)
Event calendar / scheduler. **v1 = Month view.** Design, competitive matrix and
roadmap: [`SPEC.md`](./SPEC.md).
```svelte
<script lang="ts">
import Chronos, { type ChronosEvent } from '$uix/eidos/components/chronos';
import { CalendarDate } from '$libs/days';
let events: ChronosEvent[] = [
{
id: '1',
title: 'Standup',
start: new CalendarDate(2026, 6, 2),
end: new CalendarDate(2026, 6, 2),
allDay: true
}
];
</script>
<Chronos {events} onEventCreate={(e) => /* persist */ undefined} />
```
## Layers
- **morfo** `morfo/components/chronos.ts` — grid contract (parts, events, ARIA, keyboard).
- **soma** `soma/components/chronos/` — provider + `engine/` (pure: commands, history,
layout, grid, event-store; reactive `state.svelte.ts`). The engine owns the event set
post-mount so undo/redo history stays intact.
- **sema** `sema/components/chronos.ts` — sound/haptic cascade (not yet registered).
- **eidos** here — wrapper + `chronos.css` + `chronos-when-editor.svelte`. **Every chrome
control is a framework component** (no native/reinvented buttons): the toolbar uses
**IconButton** (prev / next / search) + **Button** (today / "+N more" / Add event) + **Select**
(view); the editor uses **Dialog** + **Field** + **Select** (details) and the "when" editor
uses **ToggleGroup** (mode) + **DateRangePicker** (date span) + **TimePicker** (times) +
**IconButton** (add / remove band). Only the calendar-specific surfaces (grid, day cells,
event chips/bars, overflow, time-grid) are net-new.
## Props
`date` (initial month) · `events` (initial set) · `schema` (event types + predefined colors) ·
`otherTypeLabel` · `view` (initial) · `weekStartsOn` · `fixedWeeks` · `locale` ·
`maxEventsPerCell` · `editable` · `readonly` · `disabled` · `size` (sm/md/lg) ·
`color` (ColorRole). Callbacks: `onViewChange` / `onEventCreate` / `onEventUpdate` /
`onEventDelete` / `onRangeChange` / `onEventClick`.
- **`editor`** — optional snippet `Snippet<[ChronosEditorContext]>`. Replaces the editor
dialog's **detail** fields (the event's "what") with app-defined ones; Chronos always keeps
the header, the "when" controls (date span + time mode + bands) and the footer. The context
exposes `draft` + `save` / `remove` / `cancel` / `setAllDay`, all wired to the engine.
## Decisiones
- **Engine en soma** (`engine/`), no en lib aparte — patrón `words/engine` + `table`.
- **`$libs/days`** para todo el modelo de fechas (tz + DST), nunca Temporal/`@internationalized`.
- **Provider dueño de los eventos** post-montaje (historia íntegra); cambios vía callbacks,
sin two-way binding de `events`.
- **Rejilla auto-contenida** (`engine/grid.ts`) sobre `$libs/days`, sin acoplar a `calendar/helpers`.
- **Barras multi-día** en un overlay grid alineado a 7 columnas (los huecos quedan
click-through a la celda → create-on-click).
- **Modelo de tiempo de 3 modos** (derivado de la forma del evento, nunca almacenado —
`eventTimeMode`): `all-day` (`allDay`), `single` (bloque continuo; `start`/`end` con hora),
`split` (`parts: ChronosTimeBand[]` — franjas que se repiten en cada día del rango). El
editor SIEMPRE muestra el "cuándo" (fecha + modo + horas/franjas) y el footer; el snippet
`editor` solo personaliza el "qué".
- **Cero controles nativos**: todo el chrome se compone de componentes del framework
(IconButton/Button/Select/ToggleGroup/DateRangePicker/TimePicker/Dialog/Field). Los
`data-chronos-*-button` del morfo se conservan como passthrough sobre los Buttons.
- **Drag & drop con el componente `DragDrop`**: mes (chips→celdas, conserva `role=gridcell`
vía snippet `child`), semana/día (chips temporales→slots por hora; all-day→columnas;
`accept` separa `timed`/`allday`). Las franjas `split` se renderizan como bloques pero
NO son arrastrables (una franja suelta no tiene ancla de movimiento única).
## Vistas
Selector en el header (Select): **Month · Week · Day · Agenda**, vía `view` prop +
`onViewChange`.
- **Month** — rejilla 6×7 con barras multi-día + overlap + "+N more".
- **Week / Day** — time-grid (rail de 24h + columnas de día), eventos posicionados por hora,
solape resuelto en columnas (`engine/time-layout.ts`), fila all-day, indicador "ahora",
hoy resaltado.
- **Agenda** — lista cronológica del mes agrupada por día.
## Pending (v1+ / follow-ups)
- **Resize** de eventos por arrastre (morfo declara `handle-resize` + `commit-move`; falta
el cableado). Drag&drop de movimiento YA está (mes + semana/día).
- **Drag de franjas `split`** y **snap más fino** (hoy semana/día snap a la hora).
- **Navegación por teclado** del grid (roving focus) — hoy los elementos interactivos
(botones, chips) son alcanzables, pero falta el roving de celdas.
- **Formato vía `Intl` directo** en provider/vista en vez del motor `$format`/`uix.langs`
(deuda heredada; re-rutear es un refactor transversal, correctness-neutral).
- **Swatches de color** y **filas de agenda** son superficies nativas bespoke (no hay
primitiva de framework equivalente); revisar si emergen primitivas.
- **Day-view sidebar** (mini-calendario + panel de detalle del evento) de la referencia —
requiere datos de evento (invitados, recordatorios) fuera del modelo actual.
- **Vistas Año/Timeline-recursos, RRULE, virtualización, tz per-event** — roadmap (SPEC §12).
## Theming
Tokens en el recipe central `lib/recipes/base.ts` (bloque `chronos:`), emitidos a
`generated/base.css` vía `npm run generate:eidos-css`. `chronos.css` solo referencia esos
`--chronos-*` + primitivas del tema; el focus usa `--chronos-focus-shadow` (patrón del
sistema). El componente es **code-split** (importa su CSS desde `chronos.svelte`), así que
NO se añade a `eidos/index.css`. Sema pack registrado en `sema/components/index.ts`.

@ -0,0 +1,277 @@
# Chronos Calendar — Especificación técnica y de alcance
> Calendario de eventos para Svelte 5 (runes). Núcleo *headless* + componentes con estilo.
> Pensado para ser mejor que FullCalendar, Schedule-X, react-big-calendar y SVAR en los puntos
> donde ellos flaquean. *(Chronos = "el momento oportuno"; nombre en clave, renombrable.)*
---
## 1. Posicionamiento: qué significa "mejor"
No vamos a competir por tener "más features". Vamos a ganar en los puntos concretos donde los líderes tienen huecos reales. Estos son los ejes de diferenciación, con el competidor al que atacamos en cada uno:
| Eje | Estado del arte | Qué hace Chronos diferente |
|---|---|---|
| **Reactividad** | FullCalendar = motor imperativo envuelto en un wrapper; re-renderiza vistas enteras | Reactividad de grano fino con runes ($state/$derived). Solo cambia el evento que cambió. |
| **Rendimiento con miles de eventos** | Punto débil declarado de FullCalendar | Virtualización por celda/columna + índice espacial de eventos (interval tree) |
| **Editor de eventos** | FullCalendar **no trae** editor; SVAR sí | Editor accesible incluido, *opt-in* y reemplazable |
| **Undo/Redo** | FullCalendar **no lo tiene** | Pila de comandos integrada (command pattern) |
| **Fechas/zonas horarias** | Dependen de moment/luxon/dayjs | **Temporal API** nativa (con polyfill). Cero librerías de fechas pesadas. |
| **Personalización** | Theming por CSS, límites en estructura | **Headless de verdad**: el core no pinta nada; tú controlas el markup vía snippets |
| **Accesibilidad** | Irregular entre librerías | ARIA grid + navegación por teclado completa desde el día 1 |
| **DX (SvelteKit)** | Wrappers de comunidad, fricción en SSR | Componente nativo, SSR-safe, tipado estricto, snippets de Svelte 5 |
| **Tamaño** | Bundles grandes y monolíticos | Modular por vista; importas solo lo que usas (tree-shakeable) |
**Tesis central:** Svelte 5 + runes nos da una ventaja estructural que un wrapper de React/Vue sobre un motor imperativo no puede igualar: la reactividad de grano fino es *gratis* y va al DOM directamente.
---
## 2. Decisiones técnicas fundacionales
Estas decisiones condicionan todo lo demás. Conviene cerrarlas antes de codear.
1. **Svelte 5 con runes en modo universal.** `$state`, `$derived`, `$effect`. El estado del calendario vive en clases reactivas (runes en `.svelte.ts`), no en stores legacy.
2. **Temporal API** como modelo de fecha/hora (`Temporal.ZonedDateTime`, `PlainDate`, `Duration`). Polyfill `temporal-polyfill` hasta soporte nativo. Esto resuelve zonas horarias y DST de raíz, que es donde todas las demás sufren.
3. **Arquitectura headless-first.** El núcleo calcula *layout* y *estado*; el render lo provee el consumidor mediante `snippets`. Encima publicamos un paquete `@Chronos/themed` con UI lista para usar.
4. **Monorepo** (pnpm workspaces). Core agnóstico + adaptador Svelte + temas + plugins.
5. **TypeScript estricto**, tipos públicos como contrato de primera clase.
6. **Plugin system** para vistas y para funcionalidad (drag&drop, recurrencia, etc.). El core no conoce las vistas; las vistas se registran.
7. **SSR-safe**: nada toca `window`/`document` en init; hidratación limpia en SvelteKit.
---
## 3. Alcance (scope)
### 3.1. Vistas — qué entra
| Vista | MVP | Descripción |
|---|---|---|
| **Mes** (month grid) | ✅ | Rejilla 6×7, eventos all-day y "+N más" |
| **Semana** (time grid) | ✅ | Columnas por día con rejilla horaria, eventos solapados |
| **Día** (time grid) | ✅ | Una columna, alta densidad |
| **Agenda / Lista** | ✅ | Lista cronológica scrolleable |
| **Año** (multi-month) | 🔶 v2 | 12 meses, "heatmap" de ocupación, hover con eventos del día |
| **Timeline / Recursos** | 🔶 v3 | Filas por recurso (salas, personas), eje temporal horizontal |
### 3.2. Funcionalidad — qué entra
**MVP (v1):**
- Navegación (anterior/siguiente/hoy, salto de fecha)
- Render de eventos: puntuales, all-day, multi-día, solapados (algoritmo de layout)
- Selección de rango (click-drag para crear)
- Drag & drop de eventos + resize
- Editor de evento (crear/editar/borbar), reemplazable
- Fuentes de datos: array, función async, feed JSON
- i18n + localización (primer día de semana, formatos, idiomas)
- Zonas horarias (data tz vs display tz)
- Accesibilidad (ARIA grid, teclado)
- Theming por CSS custom properties + modo oscuro
- SSR
**v2+:**
- Recurrencia (RRULE / iCalendar)
- Undo/redo
- Vista año y timeline/recursos
- Integraciones (Google Calendar, .ics)
- Virtualización avanzada para datasets enormes
- "Background events" / disponibilidad / horas de trabajo
### 3.3. Fuera de alcance (explícito)
- Backend / persistencia (damos hooks, no servidor)
- Sincronización bidireccional con proveedores (se deja a plugins/usuario)
- Gantt / planificación de proyectos (es otro producto)
- Soporte para frameworks que no sean Svelte en v1 (el core es agnóstico y permitiría adaptadores después)
---
## 4. Arquitectura por capas
```
┌─────────────────────────────────────────────────────────┐
│ @Chronos/themed (UI lista: componentes con estilo) │ ← opcional
├─────────────────────────────────────────────────────────┤
│ @Chronos/svelte (componentes headless + snippets) │ ← capa Svelte
│ <Calendar> <MonthView> <WeekView> <DayView> ... │
├─────────────────────────────────────────────────────────┤
│ @Chronos/core (motor agnóstico, runes en .svelte.ts) │ ← cerebro
│ ┌───────────┬────────────┬───────────┬──────────────┐ │
│ │ Estado │ Layout │ Comandos │ Plugins │ │
│ │ (runes) │ engine │ (undo) │ registry │ │
│ └───────────┴────────────┴───────────┴──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ @Chronos/temporal (utilidades de fecha sobre Temporal) │ ← base
├─────────────────────────────────────────────────────────┤
│ @Chronos/plugins (interaction, recurrence, ical, gcal) │ ← extensiones
└─────────────────────────────────────────────────────────┘
```
**Principio clave:** el flujo de datos es unidireccional. El estado reactivo (runes) es la única fuente de verdad; las vistas son `$derived` de ese estado; las interacciones disparan *comandos* que mutan el estado; el DOM se actualiza solo.
---
## 5. Inventario de partes (módulos)
### 5.1. `@Chronos/temporal` — base de fechas
- Wrappers ergonómicos sobre `Temporal` (rangos, iteración de días/semanas, "está dentro de").
- Conversión data-tz ↔ display-tz.
- Cálculo de slots horarios y duraciones.
- Formateadores localizados (`Intl.DateTimeFormat`).
### 5.2. `@Chronos/core` — el motor
- **CalendarState** (`.svelte.ts`): clase reactiva con `$state` para fecha actual, vista activa, eventos, selección.
- **EventStore**: índice de eventos con *interval tree* para consultas "qué hay en [t1, t2]" en O(log n + k).
- **LayoutEngine**: algoritmos puros de colocación:
- *Month*: empaquetado de eventos multi-día en "lanes" (carriles) + overflow "+N".
- *Time grid*: resolución de solapamientos en columnas (algoritmo de grafos de intervalos).
- **CommandStack**: patrón comando para mover/redimensionar/crear/borrar → habilita undo/redo.
- **PluginRegistry**: registro de vistas y de comportamientos; ciclo de vida con hooks.
- **Tipos públicos**: `CalendarEvent`, `CalendarOptions`, `ViewDef`, `EventSource`...
### 5.3. `@Chronos/svelte` — capa de componentes (headless)
- `<Calendar>` — orquestador raíz; provee contexto reactivo.
- `<Toolbar>` — navegación (slot/snippet, totalmente reemplazable).
- `<MonthView>` / `<WeekView>` / `<DayView>` / `<AgendaView>`.
- Primitivas: `<TimeGrid>`, `<DayColumn>`, `<EventChip>`, `<NowIndicator>`, `<AllDayRow>`.
- `<EventEditor>` (opt-in).
- Acciones Svelte (`use:`) para drag, resize, drag-to-create.
- Todo el render expuesto como **snippets** para override total.
### 5.4. `@Chronos/plugins`
- `interaction` — drag&drop, resize, select.
- `recurrence` — expansión de RRULE a ocurrencias (con caché).
- `ical` / `google-calendar` — fuentes externas.
- `resources` — vista timeline por recurso.
### 5.5. `@Chronos/themed`
- Componentes con CSS por defecto (light/dark), tokens de diseño, look "Google Calendar-like".
---
## 6. Modelo de datos (contrato público)
```ts
interface CalendarEvent {
id: string;
title: string;
start: Temporal.ZonedDateTime | Temporal.PlainDate; // PlainDate = all-day
end: Temporal.ZonedDateTime | Temporal.PlainDate;
allDay?: boolean;
resourceId?: string; // para vista de recursos
rrule?: string; // recurrencia (plugin)
color?: string;
editable?: boolean;
extendedProps?: Record<string, unknown>;
}
interface CalendarOptions {
initialView: 'month' | 'week' | 'day' | 'agenda' | string;
initialDate?: Temporal.PlainDate;
events: EventSource; // array | () => Promise | feed
timeZone?: string; // display tz
locale?: string;
firstDayOfWeek?: 0 | 1; // domingo | lunes
slotDuration?: Temporal.Duration;
editable?: boolean;
plugins?: Plugin[];
}
```
---
## 7. Reactividad con runes (el corazón)
Esto es lo que nos hace estructuralmente superiores. Esquema del estado:
```ts
// calendar-state.svelte.ts
export class CalendarState {
current = $state<Temporal.PlainDate>(Temporal.Now.plainDateISO());
view = $state<string>('month');
events = $state<CalendarEvent[]>([]);
selection = $state<{ start; end } | null>(null);
// El rango visible se deriva de (vista + fecha actual). Cero recálculo manual.
visibleRange = $derived(computeRange(this.view, this.current));
// Solo los eventos del rango visible, ya resueltos en layout.
// Se recalcula únicamente cuando cambian events o visibleRange.
laidOutEvents = $derived.by(() =>
layoutFor(this.view, this.events, this.visibleRange)
);
}
```
La diferencia con FullCalendar: cuando arrastras un evento, en FullCalendar el motor recalcula y repinta la vista; aquí solo `laidOutEvents` se recomputa y Svelte parchea el DOM de los chips afectados. Con virtualización, además, solo existen en el DOM las celdas visibles.
---
## 8. API de uso (cómo se siente para el desarrollador)
```svelte
<script lang="ts">
import { Calendar } from '@Chronos/svelte';
import { interaction } from '@Chronos/plugins';
let events = $state([
{ id: '1', title: 'Standup', start: /* ZDT */, end: /* ZDT */ }
]);
</script>
<Calendar
initialView="week"
bind:events
plugins={[interaction()]}
oneventdrop={(e) => updateBackend(e)}
>
{#snippet event(ev)}
<div class="my-chip">{ev.title}</div> <!-- override total opcional -->
{/snippet}
</Calendar>
```
Headless por defecto + snippets = personalización sin pelear con CSS ajeno (el problema #1 que mencionan todas las reviews).
---
## 9. Accesibilidad e internacionalización
- **A11y:** la rejilla de mes implementa el patrón ARIA *grid*; navegación con flechas, Home/End, PageUp/Down; foco gestionado; anuncios `aria-live` al navegar; eventos como botones accesibles.
- **i18n:** todo formato vía `Intl`; primer día de semana, traducciones de UI inyectables, RTL.
---
## 10. Estructura del monorepo
```
Chronos/
├── packages/
│ ├── temporal/ # @Chronos/temporal
│ ├── core/ # @Chronos/core (runes, layout, comandos)
│ ├── svelte/ # @Chronos/svelte (componentes headless)
│ ├── plugins/ # @Chronos/plugins
│ └── themed/ # @Chronos/themed
├── apps/
│ └── playground/ # SvelteKit demo + banco de pruebas visual
├── docs/ # documentación
└── pnpm-workspace.yaml
```
---
## 11. Roadmap por fases
| Fase | Entregable | Contenido |
|---|---|---|
| **F0** | Andamiaje | Monorepo, tooling, tipos base, `@Chronos/temporal` |
| **F1** | Núcleo + Mes | CalendarState (runes), LayoutEngine de mes, `<MonthView>`, toolbar, navegación |
| **F2** | Time grids | `<WeekView>`/`<DayView>`, algoritmo de solapamiento, NowIndicator |
| **F3** | Interacción | Plugin interaction: drag, resize, drag-to-create, editor |
| **F4** | Agenda + i18n + a11y | Vista agenda, localización, accesibilidad completa |
| **F5** | Avanzado | Recurrencia, undo/redo, vista año, virtualización |
| **F6** | Recursos + integraciones | Timeline, Google/.ics, paquete themed pulido |
---
## 12. Próximo paso propuesto
Empezar por **F0 + F1**: montar el andamiaje del monorepo, `@Chronos/temporal`, el `CalendarState` con runes y una **vista de Mes funcional** (navegable, con eventos renderizados y layout multi-día) como prueba de concepto verticalmente completa. Es el "esqueleto que anda" sobre el que se cuelga todo lo demás.

@ -0,0 +1,556 @@
---
title: Chronos — Calendario de eventos (especificación adaptada a activeUIX)
type: spec
audience: human + agent
status: draft
supersedes: SPEC-CHRONOS-INICIAL.md
---
# Chronos — Calendario de eventos para activeUIX
> Scheduler / event-calendar headless para el ecosistema **activeUIX** (Svelte 5 runes).
> Esta es la reescritura del borrador `SPEC-CHRONOS-INICIAL.md` **alineada con la
> arquitectura real del repo**: las 4 capas `morfo → soma → sema → eidos`, la lib de
> fechas canónica `$libs/days`, y reuso máximo de los componentes ya existentes.
> El borrador inicial (monorepo `@Chronos/*`, Temporal API, plugin-system propio) se
> conserva como referencia histórica; **este documento manda**.
>
> *Chronos = "el momento oportuno"; nombre en clave, renombrable.*
---
## 0. Cómo leer este documento
- **§1–§2** justifican el proyecto: por qué tiene sentido y cómo se posiciona frente al
estado del arte (matriz competitiva verificada). Es la garantía de "no salir por debajo
del estándar".
- **§3–§4** fijan la arquitectura y el alcance: cómo encaja en las 4 capas y qué entra en v1.
- **§5–§10** son el diseño técnico: datos, morfo, soma+engine, sema, eidos, a11y/i18n.
- **§11–§13** son ejecución: inventario reuso/net-new, roadmap y lista de archivos.
Alcance de esta tanda: **solo este documento**. No hay código todavía. La implementación
de la vista de Mes es una tanda posterior, sujeta a aprobación de scope.
---
## 1. Posicionamiento — qué significa "mejor" aquí
No competimos por *feature-count*. La investigación de los 7 schedulers líderes
(FullCalendar, Toast UI, Schedule-X, react-big-calendar, SVAR Svelte, Mobiscroll,
DayPilot — §2) muestra que **todos flaquean a la vez en los mismos cinco ejes**. Ahí
ganamos, y ganamos por **arquitectura**, no por acumular opciones:
1. **Accesibilidad real.** Todas las librerías tienen a11y débil o sin documentar.
FullCalendar es la mejor y aún así su cobertura de lectores de pantalla reales es
floja; el resto no documenta ARIA. En activeUIX la a11y **no es un esfuerzo aparte**:
el `morfo` declara el patrón ARIA grid + el teclado **como contrato**, y soma lo
transcribe. La accesibilidad es estructural, no un parche. **Diferenciador #1.**
2. **Reactividad de grano fino.** Mobiscroll y DayPilot son motores imperativos con
wrappers de framework; FullCalendar envuelve un VDOM de Preact; react-big-calendar son
class-components que laguean a 40+ eventos. Solo SVAR es runes-nativo. Chronos usa
runes (`$state`/`$derived`): cuando se mueve un evento, **solo se recomputa ese evento**,
y Svelte parchea el DOM del chip afectado. Ventaja estructural que un wrapper no iguala.
3. **Headless de verdad.** Ninguna es headless real: todas vienen estilizadas o, como mucho,
"semi-headless" con slots. activeUIX separa `morfo`/`soma` (comportamiento headless) de
`eidos` (CSS opcional): puedes consumir el provider headless y pintar tú vía snippets, o
usar el wrapper eidos. Sin pelear con CSS ajeno (la queja #1 de las reviews).
4. **Zonas horarias de raíz.** SVAR, DayPilot y react-big-calendar **no tienen** tz;
Schedule-X solo per-calendar. `$libs/days` trae `ZonedDateTime` con DST correcto
(disambiguation `compatible`/`earlier`/`later`/`reject`) y, en roadmap, tz por evento.
5. **Sin paywall.** Drag&drop, resize, editor y recurrencia están **de pago** en
Schedule-X (€479/yr), SVAR PRO ($599), Mobiscroll ($995) y FullCalendar (resource/
timeline premium). En Chronos son parte del framework, sin coste ni tier.
> **Tesis.** *Chronos no compite por cantidad de features; compite donde todas las
> referencias fallan simultáneamente — a11y real (declarada en morfo), reactividad runes,
> headless de verdad, tz+DST de raíz, y sin paywall. Eso es lo que un wrapper de
> React/Vue sobre un motor imperativo no puede replicar.*
Lo demás (vistas, recurrencia, virtualización…) es **price-to-play**: hay que tenerlo
para no quedar por debajo, y el roadmap (§4, §12) lo cubre con trazabilidad a qué
competidor iguala o supera cada pieza.
---
## 2. Comparativa con referencia (verificada)
Matriz feature × librería. Leyenda: ✅ sí · ⚠️ parcial/limitado · ❌ no · 💲 de pago ·
🔜 roadmap Chronos. Fuentes al pie.
### 2.1. Vistas
| Vista | FullCalendar | Toast UI | Schedule-X | react-big-cal | SVAR | Mobiscroll | DayPilot | **Chronos** |
|---|---|---|---|---|---|---|---|---|
| Mes | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ **v1** |
| Semana | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🔜 |
| Día | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🔜 |
| Agenda/Lista | ✅ | ❌ | ✅ | ✅ | 💲 | ✅ | ⚠️ | 🔜 |
| Año | ✅ | ❌ | ❌ | ❌ | 💲 | ✅ | ⚠️ | 🔜 |
| Timeline/Recursos | 💲 | ❌ | 💲 | ❌ | 💲 | ✅ | ✅ | 🔜 |
### 2.2. Eventos y layout
| Capacidad | FC | Toast | SchedX | RBC | SVAR | Mobi | DayPilot | **Chronos** |
|---|---|---|---|---|---|---|---|---|
| All-day / multi-día / timed | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ **v1** |
| Overlap layout (lanes/columnas) | ✅ | ⚠️ | ✅ | ⚠️ bug | ✅ | ✅ | ✅ | ✅ **v1** (mes) |
| "+N más" overflow | ✅ | ✅ | ❌ | ❌ | ⚠️ | ✅ | ⚠️ | ✅ **v1** |
| Background events / disponibilidad | ✅ | ⚠️ | ❌ | ❌ | ❌ | ✅ | ✅ | 🔜 |
### 2.3. Interacción y edición
| Capacidad | FC | Toast | SchedX | RBC | SVAR | Mobi | DayPilot | **Chronos** |
|---|---|---|---|---|---|---|---|---|
| Drag-move | ✅ | ✅ | 💲 | ✅ | ✅ | ✅ | ✅ | ✅ **v1** |
| Resize | ✅ | ✅ | 💲 | ❌ | ✅ | ✅ | ✅ | ✅ **v1** |
| Drag-to-create | ✅ | ⚠️ | 💲 | ⚠️ | ✅ | ✅ | ⚠️ | ✅ **v1** |
| Editor integrado | ❌ BYO | ✅ | 💲 | ❌ BYO | ✅ | ✅ | ✅ | ✅ **v1** (Dialog, reemplazable) |
| Undo / Redo | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ **v1** (¡nadie lo trae!) |
### 2.4. Datos, tiempo y rendimiento
| Capacidad | FC | Toast | SchedX | RBC | SVAR | Mobi | DayPilot | **Chronos** |
|---|---|---|---|---|---|---|---|---|
| Recurrencia RRULE | ✅ | ⚠️ | ⚠️ | ❌ | 💲 | ✅ | ⚠️ propio | 🔜 (RRULE iCal estándar) |
| Excepciones / single-vs-series | ✅ | ❌ | ⚠️ | ❌ | ⚠️ | ✅ | ✅ | 🔜 |
| Timezone + DST | ✅ | ⚠️ | ⚠️ per-cal | ❌ | ❌ | ✅ per-event | ❌ | ✅ tz+DST **v1**, per-event 🔜 |
| Data sources (array/fn/feed) | ✅ | ⚠️ | ✅ | ⚠️ | ⚠️ | ✅ | ✅ | array **v1**, fn/feed 🔜 |
| Lazy-load por rango visible | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | 🔜 |
| .ics import/export | ✅ | ❌ | ✅ | ❌ | ✅ | ⚠️ | ⚠️ | 🔜 |
| Virtualización (datasets grandes) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | 🔜 (`virtual-list/grid`) |
### 2.5. Plataforma
| Eje | FC | Toast | SchedX | RBC | SVAR | Mobi | DayPilot | **Chronos** |
|---|---|---|---|---|---|---|---|---|
| A11y ARIA+teclado documentada | ⚠️ | ❌ | ❌ | ❌ | ⚠️ | ⚠️ | ❌ | ✅ **morfo** |
| Reactividad runes nativa | ❌ vdom | ❌ | ❌ | ❌ class | ✅ | ❌ wrapper | ❌ wrapper | ✅ |
| Headless real | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ❌ | ❌ | ✅ |
| i18n + first-day-of-week | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (`$langs`/`$format`) |
| RTL | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ (`$libs/direction`) |
| Now-indicator | ✅ | ❌ | ✅ | ⚠️ | ✅ | ✅ | ✅ | 🔜 (semana/día) |
| Business hours | ✅ | ❌ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ | 🔜 |
| Svelte nativo | ❌ WC | ❌ | ✅ adapter | ❌ | ✅ | ❌ | ❌ | ✅ |
| Licencia | MIT + 💲 | MIT | freemium 💲 | MIT | GPL/💲 | 💲 | Apache/💲 | interno, sin tier |
**Lectura.** En la superficie de **v1 (Mes)** Chronos ya iguala o supera a todos en su
columna: trae overlap layout correcto, "+N más", drag/resize/create, editor reemplazable
y **undo/redo (que ninguno tiene)**, con a11y declarada y tz+DST. El roadmap (§12) cierra
la paridad total (semana/día/agenda/año/timeline, RRULE, virtualización) trazando cada
fase a la referencia que iguala o bate.
> **Fuentes (web, verificadas):** fullcalendar.io/docs · github.com/nhn/tui.calendar ·
> schedule-x.dev/docs · github.com/jquense/react-big-calendar · svar.dev/svelte/calendar ·
> mobiscroll.com/docs · doc.daypilot.org. (Precios y limitaciones a fecha de la
> investigación; verificar antes de citar públicamente.)
---
## 3. Encaje en las 4 capas
Chronos es **un componente UIX más**. Nada de monorepo ni paquetes `@Chronos/*`: la
separación de responsabilidades del borrador (temporal / core / svelte / themed) se
re-expresa en las capas del repo.
```
morfo declara → soma transcribe → sema proyecta → eidos pinta
(el contrato: (comportamiento + el (sound/haptic + (CSS reaccionando
parts, data-*, engine puro; estado data-event-* en a los data-*:
ARIA, eventos, runes; runtime.trigger) el hold) chips, celdas,
teclado) overlap, temas)
```
Mapa borrador → capas:
| Borrador `@Chronos/*` | En activeUIX |
|---|---|
| `@Chronos/temporal` | **`$libs/days` + `$format`** (existentes; §5). No se crea nada. |
| `@Chronos/core` (CalendarState, LayoutEngine, CommandStack, PluginRegistry) | **`soma/components/chronos/engine/`** (puro, runes en `state.svelte.ts`; §7). |
| `@Chronos/svelte` (componentes headless + snippets) | **`morfo` + `soma`** (§6, §7). |
| `@Chronos/themed` | **`eidos/components/chronos/`** (recipe + wrapper; §9). |
| `@Chronos/plugins` (interaction, recurrence, ical) | reuso de `drag-drop` + helpers del engine; recurrencia/ical 🔜. |
### Cadena causal de ejemplo — mover un evento por drag (v1)
1. **Usuario** arrastra un `EventChip` sobre otra `DayCell`. El componente `drag-drop`
(reuso) emite su `onDrop({ value: eventId, target })`.
2. **Soma** traduce el drop a un **comando** `{ type: 'moveEvent', id, newStart, newEnd }`
y lo pasa al `engine` → `applyChronosCommand(state, cmd)` muta el estado runes y empuja
el snapshot anterior a `history.past` (habilita undo).
3. **Soma** dispara `runtime.trigger('commit-update', { semantic })`.
4. **Sema** estampa `data-event-*` en el chip durante el *hold* y emite sound/haptic
(`commit` + intent `affirm`).
5. **Eidos** reacciona: el `$derived` del layout recoloca solo ese chip; el CSS lo anima.
Ningún `setTimeout` crudo, ningún `document.querySelector`, ningún reach cross-layer: el
DOM va por `$adom`, los timers por `uix.timers`, los selectores sema por `semaSelector`.
---
## 4. Alcance — v1 vs roadmap
### v1 — Vista de **Mes** con paridad competitiva
Una sola vista, pero **completa** (no un esqueleto). Incluye todo lo que la columna "Mes"
de los competidores exige más lo que ninguno trae:
- **Eventos**: all-day, multi-día (barras que cruzan semanas), timed (chip con hora).
- **Layout**: empaquetado en *lanes* por celda + overflow **"+N más"** (abre overlay con
el día completo). Algoritmo correcto (sin el bug de solapamiento de react-big-calendar).
- **Interacción**: drag-move entre días, **resize** (alargar multi-día arrastrando el
borde), **drag-to-create** (arrastrar sobre celdas vacías para crear).
- **Editor**: `Dialog` con formulario (título, fechas, all-day, color, descripción),
**reemplazable** por snippet del consumidor. Crear / editar / borrar.
- **Undo/redo**: pila de comandos (nadie más lo trae).
- **Navegación**: mes anterior/siguiente/hoy, salto de fecha; teclado completo (ARIA grid).
- **i18n**: locale + `firstDayOfWeek` vía `$langs`/`$format`; **RTL** vía `$libs/direction`.
- **A11y**: patrón ARIA grid heredado del `calendarMorfo`; `aria-live` al navegar.
- **tz/DST**: el modelo de datos es tz-aware desde el día 1 (`ZonedDateTime`).
### v2+ — Roadmap (cada ítem trazado a qué competidor iguala/supera; ver §12)
Semana/Día (time-grid + now-indicator) · Agenda (VirtualList) · Año (multi-mes) ·
Timeline/Recursos · RRULE iCal + excepciones + single-vs-series · tz **por evento** ·
virtualización para datasets grandes · business-hours/disponibilidad · data sources
async + lazy-load por rango visible · .ics import/export.
---
## 5. Modelo de datos (contrato público)
Sustituye el contrato Temporal del borrador por tipos de **`$libs/days`** (regla dura:
nunca `@internationalized/date`/Temporal externo). Tipos verificados en
`src/libs/days/` (`CalendarDate`, `CalendarDateTime`, `ZonedDateTime`, `Time`,
`DateValue`, `DateDuration`, `WeekStartsOn`).
```ts
import type { DateValue, ZonedDateTime, WeekStartsOn } from '$libs/days'
import type { ColorRole } from '$uix/eidos/lib/types'
/** Un evento del calendario. `start`/`end` son DateValue tz-aware. */
export interface ChronosEvent {
id: string
title: string
/** all-day → CalendarDate ; timed → ZonedDateTime (lleva su zona + DST). */
start: DateValue
end: DateValue
allDay?: boolean
/** Rol de color del eidos (preferido) o string CSS libre. */
color?: ColorRole | (string & {})
category?: string
/** Si false, no es arrastrable/redimensionable/borrable. */
editable?: boolean
extendedProps?: Record<string, unknown>
// — roadmap —
rrule?: string // recurrencia iCal (v2+)
resourceId?: string // vista de recursos (v3)
}
/** Origen de eventos. v1 = array; función/feed = roadmap. */
export type ChronosEventSource =
| ChronosEvent[]
| (() => ChronosEvent[]) // (roadmap: () => Promise<ChronosEvent[]> con lazy por rango)
export interface ChronosOptions {
/** v1: solo 'month'. El union crece con el roadmap. */
view?: 'month'
/** Fecha de anclaje (placeholder) de la vista. Default: hoy. */
date?: DateValue
events: ChronosEventSource
/** Zona de display. Default: zona local (`getLocalTimeZone()`). */
timeZone?: string
locale?: string
firstDayOfWeek?: WeekStartsOn
/** Rejilla de 6 filas constante (estabiliza la altura). Default: true. */
fixedWeeks?: boolean
editable?: boolean
/** Tope de chips por celda antes de "+N más". Default: 3–4. */
maxEventsPerCell?: number
// callbacks (el consumidor persiste; Chronos no toca backend)
onEventCreate?: (e: ChronosEvent) => void
onEventUpdate?: (e: ChronosEvent) => void
onEventDelete?: (id: string) => void
onRangeChange?: (range: { start: DateValue; end: DateValue }) => void
}
```
**Decisiones del modelo:**
- `start`/`end` son `DateValue` (no `Date`) para conservar zona + evitar el lío de los
`Date` "naive" que sufren SVAR/DayPilot/react-big-calendar.
- `end` **inclusivo** internamente; documentaremos claramente la semántica (react-big-calendar
usa exclusivo y genera confusión constante en sus issues).
- Persistencia fuera de alcance: Chronos da callbacks, no servidor (igual que el borrador §3.3).
---
## 6. Morfo — `morfo/components/chronos.ts`
Modelado sobre `calendarMorfo` (`src/uix/morfo/components/calendar.ts`, patrón ARIA
*grid* ya probado). Helpers `v.*`: `v.partRef`, `v.stateRef`, `v.propRef`, `v.literal`,
`v.commonRef`. Familias canónicas (8): `contact·commit·signal·handle·emerge·shift·
sustain·delegate`. Intents (6): `neutral·affirm·fulfill·risk·threat·loss`.
### 6.1. Partes (propuesta)
| Part (kebab) | archetype | role | data-* clave | Notas |
|---|---|---|---|---|
| `provider` | provider | `application` | `data-view`, `data-readonly`, `data-disabled` | raíz; contexto + live region |
| `toolbar` | header | — | — | barra de navegación (opcional, reemplazable) |
| `prev-button` / `next-button` / `today-button` | trigger | `button` | `data-disabled` | navegación |
| `heading` | header | — | — | "Junio 2026" (etiqueta del grid) |
| `grid` | — | `grid` | `aria-labelledby`=heading | la rejilla del mes |
| `grid-head` / `head-cell` | header | — | — | nombres de día de la semana |
| `grid-body` / `grid-row` | item | `row` | — | semanas |
| `day-cell` | item | `gridcell` | `data-date`, `data-today`, `data-weekend`, `data-outside-month`, `data-selected`, `data-has-events`, `data-drop-target` | celda de día |
| `day-number` | label | — | `data-value` | número del día |
| `event-chip` | item | `button` | `data-event-id`, `data-all-day`, `data-multi-day`, `data-continues-before`, `data-continues-after`, `data-dragging`, `data-color` | el evento renderizado |
| `event-resize-handle` | trigger | — | `data-edge` (`start`/`end`) | net-new; no hay primitiva |
| `more-link` | trigger | `button` | `data-count` | "+N más" → overlay del día |
| `editor` | — | — | (ref a `Dialog`) | el morfo del editor lo aporta Dialog |
`now-indicator` y partes de time-grid quedan para semana/día (roadmap).
### 6.2. Eventos (propuesta — sujeta a tu revisión doctrinal)
> Nota: los nombres/familias siguen la doctrina del libro, pero **tu diseño manda** sobre
> el canon (feedback "User design overrides canon vocabulary"). Esto es una propuesta de
> partida, no una imposición.
| Evento | family · verb · intent | target | Cuándo |
|---|---|---|---|
| `shift-navigate` | `shift · navigate` | `provider` | prev/next/today/salto de mes |
| `commit-create` | `commit · create · affirm` | `day-cell` | drag-to-create / nuevo evento confirmado |
| `commit-update` | `commit · set · affirm` | `event-chip` | mover/redimensionar confirmado |
| `commit-delete` | `commit · delete · loss` | `event-chip` | borrar evento |
| `handle-drag` | `handle · move` | `event-chip` | progreso del arrastre (haptic, sin sonido por frame) |
| `handle-resize` | `handle · move` | `event-resize-handle` | progreso del resize |
| `emerge-open` | `emerge · open` | `editor` | abrir el editor (Dialog) |
| `emerge-close` | `emerge · close` (allowedFamilies: emerge/commit/signal) | `editor` | cerrar editor (polymorphic, como Dialog/Drawer) |
| `signal-warn-overflow` | `signal · inform · risk` | `day-cell` | (opcional) demasiados eventos / conflicto |
**Undo/redo**: propuesta `shift-restore` (undo) + variante para redo, o un par de eventos
propios. A decidir contigo — es el caso donde más conviene tu criterio sobre el verbo.
Las **reglas de cascade sema** se construirán con `semaSelector(chronosMorfo, part,
matchers)` (typed builder; nunca strings a mano).
---
## 7. Soma + engine — `soma/components/chronos/`
El "core" del borrador vive **dentro del soma component**, en `engine/` (precedente
directo: `words/engine/` con `commands.ts` + `history.ts`; y `table` que consume
`createTable` de `$libs/datagrid`). Avala la regla "Table belongs in soma": un state
manager + layout + command stack es **comportamiento justificado**, no data logic.
```
src/uix/soma/components/chronos/
├── chronos-provider.svelte.ts # ChronosProvider: runtime(morfo) + cablea el engine
├── engine/
│ ├── state.svelte.ts # CalendarState (runes): current, view, events, selection, drag
│ ├── event-store.ts # índice de eventos por rango (puro) — consultas [t1,t2]
│ ├── layout.ts # empaquetado en lanes + overlap del mes (puro, testeable)
│ ├── commands.ts # union discriminada + applyChronosCommand (puro)
│ ├── history.ts # past/present/future snapshot + undo/redo (puro)
│ └── types.ts # ChronosCommand, CalendarSnapshot, Lane, ...
├── components/
│ ├── chronos.svelte # root wrapper (mount Provider, @bindable)
│ ├── chronos-toolbar.svelte
│ ├── chronos-grid.svelte
│ ├── chronos-day-cell.svelte
│ ├── chronos-event-chip.svelte
│ └── chronos-more-link.svelte
├── types.ts # props públicos + callbacks
├── langs.ts # idlangref de strings imperativos
├── exports.ts · internals.ts · index.ts
└── chronos-provider.svelte.test.ts
```
### 7.1. El engine (puro salvo `state.svelte.ts`)
- **`state.svelte.ts`** — `CalendarState` con runes:
```ts
current = $state<DateValue>(today(getLocalTimeZone()))
events = $state<ChronosEvent[]>([])
selection = $state<{ start: DateValue; end: DateValue } | null>(null)
drag = $state<DragState | null>(null)
// Rejilla visible derivada de (vista + fecha). Reusa createMonths() del calendar.
visibleMonth = $derived.by(() => createMonths(this.current, { ... }))
// Eventos del rango visible, ya colocados en lanes. Solo recomputa si cambian
// events o visibleMonth.
laidOut = $derived.by(() => layoutMonth(this.events, this.visibleMonth, this.store))
```
- **`event-store.ts`** — índice por rango para "qué eventos tocan [t1,t2]". v1: barrido
O(n) (suficiente para el mes; es lo que hacen todos). Interval-tree → roadmap si hace falta.
- **`layout.ts`** — puro: dado los eventos + la rejilla, devuelve lanes por celda +
segmentos multi-día + recuento de overflow. Aquí vive el algoritmo correcto de solapamiento.
- **`commands.ts`** — `type ChronosCommand = { type:'createEvent'; event } | { type:'moveEvent'; id; newStart; newEnd } | { type:'resizeEvent'; id; newEnd } | { type:'deleteEvent'; id }` + `applyChronosCommand(snapshot, cmd) → { snapshot, changed }`.
- **`history.ts`** — `past[] / present / future[]` con tope (≈200, como `WORDS_HISTORY_LIMIT`); `undo` / `redo` / `canUndo` / `canRedo`. Patrón snapshot de `words/engine/operations/history.ts`.
### 7.2. El provider
`ChronosProvider.create(opts)` → `Soma.require().runtime(chronosMorfo, { states, props })`
→ `runtime.part('provider', …)`. Cablea: navegación (muta `state.current` + `runtime.trigger('shift-navigate')`), CRUD (despacha comandos al engine + `runtime.trigger('commit-*')`), drag/resize (traduce el `drag-drop` reuso a comandos + `handle-*`), editor (abre/cierra Dialog + `emerge-*`). Toda actividad DOM por `$adom`; timers por `uix.timers`.
---
## 8. Sema — `sema/components/chronos.ts`
Pack opcional con `cascade` construido vía `semaSelector(chronosMorfo, part, matchers)`.
Sin canales nuevos (sound + haptic son los únicos canónicos). Reusa *tunings* existentes:
- `commit-create` / `commit-update` → `soundTuning('form.commit.soft')` + `haptic: tap`.
- `commit-delete` → tuning de `loss` (descendente) + `haptic: error` suave.
- `shift-navigate` → `form.commit.subtle` ascendente/descendente según dirección.
- `handle-drag` / `handle-resize` → solo `haptic` (la familia `handle` activa haptic; **no
emitir sonido por frame** — misma doctrina que `virtual-*` `handle-scroll`).
- `emerge-open` / `emerge-close` → `emerge.soft` / `emerge.dismiss.passive`.
Las reglas **no** sobrescriben las primitivas `pitch/gain/contour` del intent (capa 2);
solo añaden carácter (kind/pattern/easing).
---
## 9. Eidos — `eidos/components/chronos/`
Wrapper compound + recipe. Tokens públicos `--chronos-*`, internos `--_chronos-*` (nunca
`--eidos-*`). Variantes/colores desde `EIDOS_VARIANTS` + `ColorRole` (canon del eidos, no
del theme). Reuso de las recetas de `calendar` (rejilla) y `dialog` (editor) importándolas
antes de `chronos.css` para poder afinar.
```
eidos/components/chronos/
├── chronos.svelte # root: mount soma Provider, resolve size/variant/color, data-*
├── chronos-toolbar.svelte · chronos-grid.svelte · chronos-day-cell.svelte
├── chronos-event-chip.svelte · chronos-more-link.svelte
├── chronos.css # recipe: layout de rejilla, chips, lanes, overflow, temas
├── context.svelte.ts # propaga size/variant a las sub-partes
├── types.ts # ChronosProps, ChronosSize, ChronosVariant, ChronosColor
├── index.ts # namespace compound
└── README.md
```
Tokens (muestra): `--chronos-cell-min-block-size`, `--chronos-cell-gap`,
`--chronos-event-radius`, `--chronos-event-bg`, `--chronos-today-ring`,
`--chronos-outside-month-fg`, `--chronos-weekend-bg`. Colores de evento via
`data-color` → mapa de roles (patrón TSC, como avatar/toggle-group).
### Registro (cuando se implemente)
`eidos/index.css` (+`@import`) y `eidos/lib/recipes/base.ts` (+entrada `chronos`) y
`sema/components/index.ts` (+`export { chronosSema }`). **Estos barrels están FUERA del
componente propio**: su modificación se hará en la tanda de implementación, listada aquí
sólo para trazabilidad (§13). En esta tanda no se tocan.
---
## 10. A11y e i18n
- **A11y**: patrón ARIA *grid* heredado de `calendarMorfo` — `role=grid/row/gridcell`,
roving focus, navegación con flechas + Home/End + PageUp/Down, Enter/Espacio para
abrir/seleccionar. Eventos como botones accesibles (`role=button`, `aria-label` con
fecha+título). `aria-live` (polite) al navegar de mes vía `uix.announce`. Reduced-motion
vía `ActiveDom.prefersReducedMotion`.
- **i18n**: formato vía `$format/dates`; `firstDayOfWeek` + nombres de día/mes localizados
vía `$langs`/`$format`. **RTL** vía `$libs/direction` (la rejilla se espeja). Strings
imperativos en `langs.ts` con `idlangref`.
---
## 11. Inventario — reuso vs net-new (rutas verificadas)
**REUSE (existe, ruta exacta):**
| Pieza | Ruta |
|---|---|
| Rejilla de mes 6×7 + week numbers | `src/uix/soma/components/calendar/helpers.ts` (`createMonths`, `getIsoWeekNumber`) |
| Modelo y aritmética de fechas | `src/libs/days/` |
| Formato localizado | `src/arts/format/` (`$format/dates`) |
| Editor (overlay + form) | `src/uix/soma/components/{dialog,field,button,select,switch,textarea,time-field,color-field}/` |
| Drag&drop de eventos | `src/uix/soma/components/drag-drop/` (`Draggable`/`Droppable`/`Preview`) |
| Patrón undo/redo | `src/uix/soma/components/words/engine/operations/history.ts` |
| Patrón command dispatcher | `src/uix/soma/components/words/engine/operations/commands.ts` |
| Virtualización (agenda, roadmap) | `src/uix/soma/components/{virtual-list,virtual-grid}/` |
| DOM / timers / direction | `$adom`, `uix.timers`, `$libs/direction` |
| Patrón de componente end-to-end | `morfo/components/calendar.ts`, `soma|eidos/components/date-picker/` |
**NET-NEW (construir, dentro de `chronos/`):**
| Pieza | Dónde | Aprox. |
|---|---|---|
| `EventChip` + segmentación multi-día | eidos/soma chronos | media |
| Layout de lanes + overflow "+N más" | `engine/layout.ts` | media |
| Resize handle (no hay primitiva) | soma/eidos chronos | baja |
| `CommandStack` + `History` de Chronos | `engine/{commands,history}.ts` | baja (patrón words) |
| `EventStore` (índice por rango) | `engine/event-store.ts` | baja |
| `CalendarState` runes | `engine/state.svelte.ts` | media |
Fuera de v1 (roadmap): time-slot grid (semana/día), now-indicator, RRULE, tz per-event,
virtualización cableada, .ics, data sources async.
---
## 12. Roadmap por fases (estilo repo)
Cada fase = `morfo + soma + sema + eidos + demo + checklist` (COMPONENT_GUIDE +
COMPONENT_COMPLETION_CHECKLIST). Columna "iguala/supera" = referencia que la fase alcanza
o bate.
| Fase | Entregable | Iguala / supera |
|---|---|---|
| **F1 (v1)** | **Vista de Mes** completa: eventos all-day/multi-día/timed, overlap+"+N más", drag/resize/create, editor Dialog, undo/redo, i18n/RTL, a11y grid, tz+DST | Mes de FC/SchedX/SVAR **+ undo/redo (nadie lo trae)** + a11y declarada |
| **F2** | **Semana/Día** (time-grid, slots, now-indicator, overlap en columnas) | FC/SchedX/Mobiscroll en time-grid |
| **F3** | **Agenda** (VirtualList) + data sources async + lazy-load por rango | FC/SchedX/Mobiscroll; supera a Toast/RBC (no la tienen bien) |
| **F4** | **Recurrencia** RRULE iCal + excepciones + single-vs-series + .ics | FC (referencia en RRULE); supera a SVAR/DayPilot (formato propio o de pago) |
| **F5** | **Año** + **tz por evento** + business-hours/disponibilidad | Mobiscroll; supera al resto |
| **F6** | **Timeline/Recursos** + **virtualización** datasets grandes | DayPilot/Mobiscroll (su especialidad), con a11y+runes que ellos no tienen |
---
## 13. Lista de archivos a crear/tocar (para la tanda de implementación)
> Sólo referencia. En **esta** tanda no se crea nada de esto — el único archivo es este SPEC.
**Crear (dentro del componente propio):**
```
src/uix/morfo/components/chronos.ts
src/uix/sema/components/chronos.ts
src/uix/soma/components/chronos/** (provider, engine/, components/, barrels, test)
src/uix/eidos/components/chronos/** (wrapper, css, types, index, context, README)
web/routes/uix/components/chronos/+page.svelte (demo)
```
**Modificar (barrels FUERA del componente — requieren confirmación explícita por la
prohibición de tocar fuera del componente propio):**
```
src/uix/sema/components/index.ts (+ export { chronosSema })
src/uix/eidos/index.css (+ @import chronos.css)
src/uix/eidos/lib/recipes/base.ts (+ entrada chronos)
```
> ⚠️ Estos tres barrels son necesarios para que el componente sea visible, pero quedan
> **fuera de `chronos/`**. Antes de tocarlos en la fase de implementación, se pedirá tu
> visto bueno explícito (acorde con la prohibición "no modificar nada fuera del componente
> propio"). Alternativa a evaluar entonces: mecanismo de registro que no requiera editar
> barrels compartidos.
---
## Apéndice — divergencias respecto a `SPEC-CHRONOS-INICIAL.md`
| Borrador inicial | Adaptación | Motivo |
|---|---|---|
| Monorepo pnpm `@Chronos/*` | Un componente en las 4 capas del repo | El repo es proyecto único; convención del ecosistema |
| Temporal API + polyfill | `$libs/days` + `$format` | Regla dura; cero libs de fechas extra; ya cubre tz/DST |
| Core agnóstico + adaptador Svelte | `soma/.../engine/` (runes) | Precedente `words`/`table`; "el motor vive en soma" |
| Plugin registry propio | Reuso (`drag-drop`) + helpers de engine | "No premature abstraction"; sin plugin-system hasta tener 2+ consumidores |
| `@Chronos/themed` | `eidos/components/chronos` | El theming es responsabilidad del eidos |
| MVP = Mes+Semana+Día+Agenda | **v1 = solo Mes** (rica) | Decisión de alcance; el resto es roadmap trazado |
| Tabla comparativa breve | **Matriz de 7 referencias verificada (§2)** | Exigencia: no salir por debajo del estándar |

@ -0,0 +1,219 @@
<script lang="ts">
/**
* Day-peek popover — opens from a month cell's "+N more" link and shows ALL of
* that day's events in the day-view's format: an all-day row plus a vertical
* hour grid with proportionally-positioned blocks. Clicking an event opens its
* editor. The grid is WINDOWED to the hours that actually hold events, so it
* stays compact inside the popover instead of spanning a full 24h column.
*
* Reuses the time-grid chip styling (`[data-chronos-event-chip][data-timed]`)
* and the now-indicator (`.chronos-tg-now`); only the peek's own layout chrome
* is bespoke. The eidos context is bridged across the portal boundary so the
* portaled content resolves tokens / locale like the rest of the calendar.
*/
import type { DateValue } from '$libs/days';
import { getLocalTimeZone, isToday } from '$libs/days';
import { Popover } from '$uix/eidos/components/popover';
import { Button } from '$uix/eidos/components/button';
import { ActiveEidos } from '$uix/eidos';
import { ChronosProvider, type ChronosEvent } from '$soma/components/chronos';
import { eventColorVars } from '$soma/components/chronos/engine/schema';
import type { TimedBlock } from '$soma/components/chronos/engine/time-layout';
let {
date,
count,
column,
row
}: { date: DateValue; count: number; column: number; row: number } = $props();
const provider = ChronosProvider.require();
const tz = getLocalTimeZone();
// Bridge the eidos context across the popover's portal boundary.
ActiveEidos.set(ActiveEidos.require());
// Header: a prominent day number + the weekday and month/year stacked beside
// it (the day-view's date identity, cleaned up).
const weekdayLong = $derived(
new Intl.DateTimeFormat(provider.effectiveLocale, { weekday: 'long' }).format(date.toDate(tz))
);
const monthYear = $derived(
new Intl.DateTimeFormat(provider.effectiveLocale, { month: 'long', year: 'numeric' }).format(
date.toDate(tz)
)
);
const layout = $derived(provider.dayTimeLayout(date));
const allDay = $derived(layout.allDay);
const timed = $derived(layout.timed);
// Window = the whole hours that bracket every timed block (≥1h), so the grid
// shows just the relevant slice instead of a full midnight-to-midnight column.
const win = $derived.by(() => {
if (timed.length === 0) return null;
let lo = Infinity;
let hi = -Infinity;
for (const b of timed) {
lo = Math.min(lo, b.startMin);
hi = Math.max(hi, b.endMin);
}
const startMin = Math.floor(lo / 60) * 60;
const endMin = Math.ceil(hi / 60) * 60;
return { startMin, endMin, span: Math.max(60, endMin - startMin) };
});
const windowHours = $derived.by(() => {
if (!win) return [];
const out: number[] = [];
for (let h = win.startMin / 60; h < win.endMin / 60; h++) out.push(h);
return out;
});
const showNow = $derived(
!!win &&
isToday(date, tz) &&
provider.nowMinutes >= win.startMin &&
provider.nowMinutes <= win.endMin
);
const nowTop = $derived(win ? ((provider.nowMinutes - win.startMin) / win.span) * 100 : 0);
const fmtMin = (min: number): string =>
new Intl.DateTimeFormat(provider.effectiveLocale, {
hour: 'numeric',
minute: '2-digit'
}).format(new Date(2020, 0, 1, Math.floor(min / 60), min % 60));
// Absolute position within the windowed grid (percent of the window span).
function blockStyle(b: TimedBlock): string {
const w = win;
if (!w) return '';
const top = ((b.startMin - w.startMin) / w.span) * 100;
const height = ((b.endMin - b.startMin) / w.span) * 100;
const left = (b.col / b.cols) * 100;
const width = (1 / b.cols) * 100;
return `top:${top}%; height:${height}%; inset-inline-start:calc(${left}% + 1px); inline-size:calc(${width}% - 2px); ${eventColorVars(provider.resolveColor(b.event))}`;
}
// Open the editor for the clicked event. The peek stays open — the editor
// dialog floats above it (its own dismissal layer), so closing the dialog
// leaves the peek where it was and the user can pick another event.
function edit(event: ChronosEvent): void {
provider.openEdit(event);
}
// Horizontal resize via a visible right-edge handle (the native corner grip is
// invisible + only the very corner works). Sets the panel's inline width
// imperatively — like the native grip, so it beats the popover recipe's capped
// inline-size; CSS min/max-inline-size still bound it.
let resize: { panel: HTMLElement; startX: number; startW: number } | null = null;
function resizeStart(e: PointerEvent): void {
const handle = e.currentTarget as HTMLElement;
const panel = handle.closest('[data-chronos-peek-panel]') as HTMLElement | null;
if (!panel) return;
resize = { panel, startX: e.clientX, startW: panel.getBoundingClientRect().width };
handle.setPointerCapture(e.pointerId);
e.preventDefault();
}
function resizeMove(e: PointerEvent): void {
if (!resize) return;
resize.panel.style.width = `${resize.startW + (e.clientX - resize.startX)}px`;
}
function resizeEnd(e: PointerEvent): void {
if (!resize) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
resize = null;
}
</script>
<Popover>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="plain"
size="xs"
data-chronos-more-link
style="grid-column: {column}; grid-row: {row};"
>
{count} more…
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content size="lg" side="bottom" align="center" data-chronos-peek-panel>
<div class="chronos-peek-scroll">
<div class="chronos-peek" data-chronos-day-peek>
<header class="chronos-peek-head">
<span class="chronos-peek-daynum" data-today={isToday(date, tz) ? '' : undefined}>
{date.day}
</span>
<span class="chronos-peek-headmeta">
<span class="chronos-peek-weekday">{weekdayLong}</span>
<span class="chronos-peek-fulldate">{monthYear}</span>
</span>
</header>
{#if allDay.length > 0}
<div class="chronos-peek-allday">
{#each allDay as ev (ev.id)}
<button
type="button"
data-chronos-event-chip
data-all-day
style={eventColorVars(provider.resolveColor(ev))}
aria-label={provider.eventAriaLabel(ev)}
onclick={() => edit(ev)}
>
<span class="chronos-chip-title">{ev.title || '(untitled)'}</span>
</button>
{/each}
</div>
{/if}
{#if win}
<div class="chronos-peek-body">
<div class="chronos-peek-rail">
{#each windowHours as h (h)}
<div class="chronos-peek-hour"><span>{provider.hourLabels[h]}</span></div>
{/each}
</div>
<div class="chronos-peek-col">
{#each windowHours as h (h)}
<div class="chronos-peek-slot"></div>
{/each}
{#each timed as b (b.key)}
<button
type="button"
data-chronos-event-chip
data-timed
style={blockStyle(b)}
aria-label={provider.eventAriaLabel(b.event)}
onclick={() => edit(b.event)}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</button>
{/each}
{#if showNow}
<div class="chronos-tg-now" style="top:{nowTop}%"></div>
{/if}
</div>
</div>
{:else}
<p class="chronos-peek-empty">No timed events</p>
{/if}
</div>
</div>
<div
class="chronos-peek-resize"
role="separator"
aria-orientation="vertical"
aria-label="Resize width"
title="Drag to resize"
onpointerdown={resizeStart}
onpointermove={resizeMove}
onpointerup={resizeEnd}
onpointercancel={resizeEnd}
></div>
</Popover.Content>
</Popover.Portal>
</Popover>

@ -0,0 +1,675 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import {
ChronosProvider,
type ChronosEvent,
type ChronosView,
type ChronosEditorContext
} from '$soma/components/chronos';
import {
isToday,
isWeekend,
getLocalTimeZone,
today,
parseDate,
toCalendarDate
} from '$libs/days';
import { eventColorVars } from '$soma/components/chronos/engine/schema';
import { MINUTES_PER_DAY, type TimedBlock } from '$soma/components/chronos/engine/time-layout';
// Reuse framework components — never reinvent.
import { DragDrop, type DropEvent } from '$uix/eidos/components/drag-drop';
import { Dialog } from '$uix/eidos/components/dialog';
import { Field } from '$uix/eidos/components/field';
import { Select } from '$uix/eidos/components/select';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import {
ChevronLeft,
ChevronRight,
Search,
Plus,
Pencil,
TriangleAlert
} from '$uix/eidos/components/icon';
import ChronosWhenEditor from './chronos-when-editor.svelte';
import ChronosDayPeek from './chronos-day-peek.svelte';
let { editor }: { editor?: Snippet<[ChronosEditorContext]> } = $props();
const provider = ChronosProvider.require();
const tz = getLocalTimeZone();
// Editor context exposed to a consumer-provided `editor` snippet. Actions are
// wired to the engine so a custom dialog drives create/update/delete exactly
// like the built-in form.
const editorCtx = $derived.by<ChronosEditorContext | null>(() => {
const e = provider.editing;
if (!e) return null;
return {
mode: e.mode,
editable: provider.draftEditable,
enableEditing: () => provider.enableEditing(),
draft: e.draft,
schema: provider.schema,
eventTypes: provider.eventTypes,
setAllDay: (on: boolean) => provider.setDraftAllDay(on),
save: () => provider.saveEditor(e.draft),
remove: () => provider.deleteEvent(e.draft.id),
cancel: () => provider.closeEditor()
};
});
const weeks = $derived(provider.calendar.weeks);
const layout = $derived(provider.calendar.layout);
const maxLanes = $derived(provider.calendar.maxLanes);
const anchor = $derived(provider.calendar.anchor);
const anchorMonth = $derived(anchor.month);
const locale = $derived(provider.effectiveLocale);
const todayDate = $derived(today(tz));
const overflowByDate = $derived(new Map(layout.overflow.map((o) => [o.date, o.hiddenCount])));
function segsForWeek(w: number) {
return layout.segments.filter((s) => s.weekIndex === w);
}
// ── Header derived strings ────────────────────────────────────────────────
const badgeMonth = $derived(
new Intl.DateTimeFormat(locale, { month: 'short' }).format(todayDate.toDate(tz)).toUpperCase()
);
const weekOfMonth = $derived(Math.ceil(todayDate.day / 7));
const VIEW_LABELS: Record<ChronosView, string> = {
month: 'Month view',
week: 'Week view',
day: 'Day view',
agenda: 'Agenda'
};
const hours = Array.from({ length: 24 }, (_, i) => i);
function eventTime(ev: ChronosEvent): string {
// Empty for all-day AND split (date-only start → no single time).
if (ev.allDay || !('hour' in ev.start)) return '';
return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(
ev.start.toDate(tz)
);
}
const fmtMin = (min: number): string =>
new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(
new Date(2020, 0, 1, Math.floor(min / 60), min % 60)
);
// Full day-span of an event (≥1). A multi-day event is rendered as one chip
// per week, so the dragged fragment is narrower than the event — the preview
// must reflect the whole span (capped to one week, the widest a row can be).
function eventDaySpan(ev: ChronosEvent): number {
const s = toCalendarDate(ev.start).toDate(tz).getTime();
const e = toCalendarDate(ev.end).toDate(tz).getTime();
return Math.max(1, Math.round((e - s) / 86_400_000) + 1);
}
// Absolute position + color for a time-grid block (single block or split band).
function blockStyle(b: TimedBlock): string {
const top = (b.startMin / MINUTES_PER_DAY) * 100;
const height = ((b.endMin - b.startMin) / MINUTES_PER_DAY) * 100;
const left = (b.col / b.cols) * 100;
const width = (1 / b.cols) * 100;
return `top:${top}%; height:${height}%; inset-inline-start:calc(${left}% + 1px); inline-size:calc(${width}% - 2px); ${eventColorVars(provider.resolveColor(b.event))}`;
}
function swatchStyle(color: string): string {
return `background: ${color}`;
}
function save(e: MouseEvent): void {
if (provider.editing)
provider.saveEditor(provider.editing.draft, e.currentTarget as HTMLElement);
}
function removeEvent(e: MouseEvent): void {
if (provider.editing)
provider.deleteEvent(provider.editing.draft.id, e.currentTarget as HTMLElement);
}
// Drag-drop (framework DragDrop): drop an event chip on a day cell → move it.
function handleDrop(e: DropEvent): void {
const cell = (e.target as HTMLElement).closest('[data-date]') as HTMLElement | null;
const dateStr = cell?.dataset.date;
if (!dateStr) return;
provider.moveEventToDay(e.value, parseDate(dateStr), cell ?? undefined);
}
// Week/Day time-grid drag-drop. Timed chips land on hour slots (day + minute);
// all-day chips land on all-day columns (date only). `accept` predicates keep
// each kind in its own zone, so the branch here is just reading attrs.
function handleTimeDrop(e: DropEvent): void {
const el = e.target as HTMLElement;
const dateEl = el.closest('[data-date]') as HTMLElement | null;
const dateStr = dateEl?.dataset.date;
if (!dateStr) return;
const date = parseDate(dateStr);
const minuteAttr = (el.closest('[data-minute]') as HTMLElement | null)?.dataset.minute;
if (minuteAttr != null) {
provider.moveEventToDayTime(e.value, date, Number(minuteAttr), dateEl ?? undefined);
} else {
provider.moveEventToDay(e.value, date, dateEl ?? undefined);
}
}
</script>
<!-- Toolbar — replica of the reference header -->
<div data-chronos-toolbar>
<div class="chronos-date-badge" aria-hidden="true">
<span class="chronos-date-badge-month">{badgeMonth}</span>
<span class="chronos-date-badge-day">{todayDate.day}</span>
</div>
<div class="chronos-title-block">
<div class="chronos-title-row">
<span data-chronos-heading>{provider.headingValue}</span>
<span class="chronos-week-badge">Week {weekOfMonth}</span>
</div>
<span class="chronos-range">{provider.subtitle}</span>
</div>
<span class="chronos-toolbar-spacer"></span>
<IconButton aria-label="Search" variant="surface" data-chronos-search-button>
<Search size="sm" />
</IconButton>
<div class="chronos-nav-group">
<IconButton
aria-label="Previous"
variant="ghost"
data-chronos-prev-button
onclick={(e: MouseEvent) => provider.prev(e.currentTarget as HTMLElement)}
>
<ChevronLeft size="sm" />
</IconButton>
<Button
variant="ghost"
data-chronos-today-button
onclick={(e: MouseEvent) => provider.goToday(e.currentTarget as HTMLElement)}
>
Today
</Button>
<IconButton
aria-label="Next"
variant="ghost"
data-chronos-next-button
onclick={(e: MouseEvent) => provider.next(e.currentTarget as HTMLElement)}
>
<ChevronRight size="sm" />
</IconButton>
</div>
<div class="chronos-view-select">
<Select
type="single"
value={[provider.view]}
onValueChange={(v) => v[0] && provider.setView(v[0] as ChronosView)}
>
<Select.Trigger>
<Select.Value placeholder={VIEW_LABELS[provider.view]} />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content sideOffset={6}>
<Select.Viewport>
<Select.Item value="month" label={VIEW_LABELS.month}>{VIEW_LABELS.month}</Select.Item>
<Select.Item value="week" label={VIEW_LABELS.week}>{VIEW_LABELS.week}</Select.Item>
<Select.Item value="day" label={VIEW_LABELS.day}>{VIEW_LABELS.day}</Select.Item>
<Select.Item value="agenda" label={VIEW_LABELS.agenda}>{VIEW_LABELS.agenda}</Select.Item
>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</div>
<Button variant="solid" color="primary" onclick={() => provider.openCreate(todayDate)}>
<Plus size="sm" /> Add event
</Button>
</div>
<!-- Drag preview: the floating element mirrors the dragged event's chip — same
color, title AND size. A multi-day event is rendered as one chip per week, so
the dragged source is only that week's fragment; size to the FULL event span
(× day-cell width, capped to one week) instead. Single-day / timed chips
have no overlay parent and fall back to the source rect. -->
{#snippet dragPreview(active: { value: string; label: string; source: HTMLElement })}
{@const ev = provider.calendar.events.find((e) => e.id === active.value)}
{@const rect = active.source.getBoundingClientRect()}
{@const overlay = active.source.closest('.chronos-overlay')}
{@const width =
overlay && ev
? Math.min(eventDaySpan(ev), 7) * (overlay.getBoundingClientRect().width / 7)
: rect.width}
<div
class="chronos-drag-preview"
data-chronos-event-chip
style="inline-size: {Math.round(width)}px; block-size: {Math.round(rect.height)}px; {ev
? eventColorVars(provider.resolveColor(ev))
: ''}"
>
<span class="chronos-chip-title">{ev?.title || active.label}</span>
</div>
{/snippet}
{#if provider.view === 'month'}
<!-- Month grid — chips are Draggable, day cells are Droppable (framework DragDrop). -->
<DragDrop onDrop={handleDrop}>
{#snippet children({ active })}
<div data-chronos-grid role="grid" aria-label={provider.headingValue}>
<div data-chronos-grid-head role="row">
{#each provider.weekdays as wd (wd)}
<div data-chronos-head-cell role="columnheader">{wd}</div>
{/each}
</div>
<div data-chronos-grid-body role="rowgroup">
{#each weeks as week, w (w)}
<div data-chronos-grid-row role="row">
{#each week as date (date.toString())}
<!-- child snippet keeps role=gridcell (default Droppable forces role=region). -->
<DragDrop.Droppable textValue={date.toString()}>
{#snippet child({ over, props })}
<div
{...props}
data-chronos-day-cell
role="gridcell"
tabindex={-1}
data-date={date.toString()}
data-today={isToday(date, tz) ? '' : undefined}
data-weekend={isWeekend(date, locale) ? '' : undefined}
data-outside-month={date.month !== anchorMonth ? '' : undefined}
data-dragover={over ? '' : undefined}
aria-label={provider.dayAriaLabel(date)}
>
<div class="chronos-day-cell-head">
<div data-chronos-day-number data-value={date.day}>
{provider.formatDayNumber(date)}
</div>
<!-- Create affordance — only on hover/focus; the cell itself no
longer captures clicks (drag/drop owns the cell surface). -->
<IconButton
data-chronos-day-add
aria-label="New event"
variant="ghost"
size="xs"
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openCreate(date);
}}
>
<Plus size="sm" />
</IconButton>
</div>
</div>
{/snippet}
</DragDrop.Droppable>
{/each}
<div class="chronos-overlay">
{#each segsForWeek(w) as seg (seg.event.id)}
<DragDrop.Draggable
value={seg.event.id}
data={{ eventId: seg.event.id }}
textValue={seg.event.title || 'event'}
data-chronos-event-chip
role="button"
tabindex={0}
data-all-day={seg.event.allDay ? '' : undefined}
data-continues-before={seg.continuesBefore ? '' : undefined}
data-continues-after={seg.continuesAfter ? '' : undefined}
data-single-day={seg.span === 1 && !seg.continuesBefore && !seg.continuesAfter
? ''
: undefined}
aria-label={provider.eventAriaLabel(seg.event)}
style="grid-column: {seg.startCol + 1} / span {seg.span}; grid-row: {seg.lane +
1}; {eventColorVars(provider.resolveColor(seg.event))}"
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(seg.event);
}}
>
<span class="chronos-chip-title">{seg.event.title || '(untitled)'}</span>
{#if !seg.event.allDay}
<span class="chronos-chip-time">{eventTime(seg.event)}</span>
{/if}
</DragDrop.Draggable>
{/each}
{#each week as date, col (date.toString())}
{@const hidden = overflowByDate.get(date.toString())}
{#if hidden}
<!-- "+N more" → day-peek popover with the full day's events. -->
<ChronosDayPeek {date} count={hidden} column={col + 1} row={maxLanes} />
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
{#if active}
<DragDrop.Preview>{@render dragPreview(active)}</DragDrop.Preview>
{/if}
{/snippet}
</DragDrop>
{:else if provider.view === 'week' || provider.view === 'day'}
<!-- Time-grid (Week / Day) — timed chips drag onto hour slots, all-day chips
onto all-day columns (framework DragDrop). -->
<DragDrop onDrop={handleTimeDrop}>
{#snippet children({ active })}
<div data-chronos-timegrid data-view={provider.view}>
<div class="chronos-tg-sticky">
<div class="chronos-tg-head">
<div class="chronos-tg-corner"></div>
{#each provider.visibleDays as day (day.toString())}
{@const dl = provider.dayColumnLabel(day)}
<div class="chronos-tg-dayhead" data-today={isToday(day, tz) ? '' : undefined}>
<span class="chronos-tg-weekday">{dl.weekday}</span>
<span class="chronos-tg-daynum">{dl.day}</span>
</div>
{/each}
</div>
<div class="chronos-tg-allday">
<div class="chronos-tg-railcell">All day</div>
{#each provider.visibleDays as day (day.toString())}
<DragDrop.Droppable
textValue={provider.dayAriaLabel(day)}
accept={(d) => d.kind === 'allday'}
>
{#snippet child({ over, props })}
<div
{...props}
class="chronos-tg-alldaycol"
data-date={day.toString()}
data-allday-col
data-dragover={over ? '' : undefined}
>
{#each provider.timeLayoutFor(day)?.allDay ?? [] as ev (ev.id)}
<DragDrop.Draggable
value={ev.id}
data={{ eventId: ev.id, kind: 'allday' }}
textValue={ev.title || 'event'}
data-chronos-event-chip
role="button"
tabindex={0}
aria-label={provider.eventAriaLabel(ev)}
style={eventColorVars(provider.resolveColor(ev))}
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(ev);
}}
>
<span class="chronos-chip-title">{ev.title || '(untitled)'}</span>
</DragDrop.Draggable>
{/each}
</div>
{/snippet}
</DragDrop.Droppable>
{/each}
</div>
</div>
<div class="chronos-tg-body">
<div class="chronos-tg-rail">
{#each provider.hourLabels as label, h (h)}
<div class="chronos-tg-hour"><span>{label}</span></div>
{/each}
</div>
<div class="chronos-tg-cols">
{#each provider.visibleDays as day (day.toString())}
{@const dl = provider.timeLayoutFor(day)}
<div class="chronos-tg-col" role="presentation">
{#each hours as h (h)}
<DragDrop.Droppable
textValue={`${provider.dayAriaLabel(day)}, ${provider.hourLabels[h]}`}
accept={(d) => d.kind === 'timed'}
>
{#snippet child({ over, props })}
<div
{...props}
class="chronos-tg-slot"
data-date={day.toString()}
data-minute={h * 60}
data-dragover={over ? '' : undefined}
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openCreate(day, h * 60);
}}
></div>
{/snippet}
</DragDrop.Droppable>
{/each}
{#each dl?.timed ?? [] as b (b.key)}
{#if b.key.includes('#')}
<!-- Split band: not draggable (a band has no single movable anchor). -->
<button
data-chronos-event-chip
data-timed
type="button"
aria-label={provider.eventAriaLabel(b.event)}
style={blockStyle(b)}
onclick={(e) => {
e.stopPropagation();
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</button>
{:else}
<DragDrop.Draggable
value={b.event.id}
data={{ eventId: b.event.id, kind: 'timed' }}
textValue={b.event.title || 'event'}
data-chronos-event-chip
data-timed
role="button"
tabindex={0}
aria-label={provider.eventAriaLabel(b.event)}
style={blockStyle(b)}
onclick={(e: MouseEvent) => {
e.stopPropagation();
provider.openEdit(b.event);
}}
>
<span class="chronos-chip-title">{b.event.title || '(untitled)'}</span>
<span class="chronos-chip-time">{fmtMin(b.startMin)}</span>
</DragDrop.Draggable>
{/if}
{/each}
{#if isToday(day, tz)}
<div
class="chronos-tg-now"
style="top:{(provider.nowMinutes / MINUTES_PER_DAY) * 100}%"
></div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{#if active}
<DragDrop.Preview>{@render dragPreview(active)}</DragDrop.Preview>
{/if}
{/snippet}
</DragDrop>
{:else}
<!-- Agenda -->
<div class="chronos-agenda">
{#if provider.agendaGroups.length === 0}
<p class="chronos-agenda-empty">No events this month.</p>
{:else}
{#each provider.agendaGroups as g (g.day.toString())}
<div class="chronos-agenda-group">
<div class="chronos-agenda-date">{provider.dayAriaLabel(g.day)}</div>
<div class="chronos-agenda-list">
{#each g.events as ev (ev.id)}
<button
class="chronos-agenda-item"
type="button"
style={eventColorVars(provider.resolveColor(ev))}
onclick={() => provider.openEdit(ev)}
>
<span class="chronos-agenda-dot"></span>
<span class="chronos-agenda-title">{ev.title || '(untitled)'}</span>
<span class="chronos-agenda-time"
>{ev.allDay ? 'All day' : provider.eventTimeLabel(ev)}</span
>
</button>
{/each}
</div>
</div>
{/each}
{/if}
</div>
{/if}
<!-- Legend from the schema -->
{#if provider.schema.eventTypes.length > 0}
<div class="chronos-legend" aria-label="Event types">
{#each provider.eventTypes as t (t.id)}
<span class="chronos-legend-item">
<span class="chronos-legend-swatch" style={swatchStyle(t.color)}></span>
{t.label}
</span>
{/each}
</div>
{/if}
<!-- Editor — framework Dialog; composites carry their own focus (no Field.Control wrap). -->
<Dialog
open={provider.editing != null}
onOpenChange={(o) => {
if (!o) provider.closeEditor();
}}
>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
{#if provider.editing}
{@const editing = provider.editing}
{@const editable = provider.draftEditable}
<Dialog.Header>
<Dialog.Title>
{editing.mode === 'create' ? 'New event' : editable ? 'Edit event' : 'Event'}
</Dialog.Title>
</Dialog.Header>
<!-- View → edit toggle, pinned to the top-right corner at the same inset as
the footer buttons. Shown only for an existing event in view mode when
editing is permitted (perms / readonly / per-event). -->
{#if provider.showEditAction}
<IconButton
data-chronos-edit-action
aria-label="Edit event"
variant="ghost"
size="sm"
onclick={() => provider.enableEditing()}
>
<Pencil size="sm" />
</IconButton>
{/if}
<div class="chronos-editor-fields" data-view={!editable ? '' : undefined}>
<!-- Details — the event's "what". Customizable: a host `editor` snippet
replaces these fields; the "when" controls + footer always stay.
In view mode controls are readonly/disabled. -->
{#if editor && editorCtx}
{@render editor(editorCtx)}
{:else}
<Field>
<Field.Label>Title</Field.Label>
<Field.Control>
<Field.Input
type="text"
bind:value={editing.draft.title}
placeholder="Event title"
readonly={!editable}
/>
</Field.Control>
</Field>
<div class="chronos-field">
<span class="chronos-field-label">Type</span>
<Select
type="single"
disabled={!editable}
value={editing.draft.typeId ? [editing.draft.typeId] : []}
onValueChange={(v) => (editing.draft.typeId = v[0])}
>
<Select.Trigger>
<Select.Value placeholder="Type" />
<Select.Indicator />
</Select.Trigger>
<Select.Portal>
<Select.Content sideOffset={6}>
<Select.Viewport>
{#each provider.eventTypes as t (t.id)}
<Select.Item value={t.id} label={t.label}>
<span class="chronos-type-option">
<span class="chronos-type-swatch" style={swatchStyle(t.color)}></span>
{t.label}
</span>
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select>
</div>
{#if editable && editing.draft.typeId === 'otros' && provider.schema.predefinedColors.length > 0}
<div class="chronos-field">
<span class="chronos-field-label">Color</span>
<div class="chronos-swatches">
{#each provider.schema.predefinedColors as c (c)}
<button
type="button"
class="chronos-swatch"
data-selected={editing.draft.color === c ? '' : undefined}
style={swatchStyle(c)}
aria-label={c}
onclick={() => (editing.draft.color = c)}
></button>
{/each}
</div>
</div>
{/if}
{/if}
<!-- When — ALWAYS rendered by Chronos. Date/time is intrinsic to an
event, so it is never dropped, even behind a custom `editor`. -->
<ChronosWhenEditor />
{#if editable && provider.draftError}
<p class="chronos-editor-error" role="alert">
<TriangleAlert size="sm" />
{provider.draftError}
</p>
{/if}
</div>
<Dialog.Footer>
{#if editable && editing.mode === 'edit'}
<Button variant="ghost" intent="loss" onclick={removeEvent}>Delete</Button>
{/if}
<span class="chronos-editor-spacer"></span>
{#if editable}
<Dialog.Close>Cancel</Dialog.Close>
<Button variant="solid" color="primary" disabled={!!provider.draftError} onclick={save}>
Save
</Button>
{:else}
<Dialog.Close>Close</Dialog.Close>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog>

@ -0,0 +1,242 @@
<script lang="ts">
// The event editor's "when" section — ALWAYS rendered by Chronos (date/time is
// intrinsic to an event). Grouped in a recessed panel: a full-width mode
// ToggleGroup drives the controls below — a date-span DateRangePicker plus
// time controls that depend on the mode:
// - all-day → none
// - single → one TimeRangePicker (the continuous start–end block)
// - split → an editable list of bands, each a TimeRangePicker (start–end),
// repeated on every day of the span; each band clamps to the
// previous band's end.
// Every control is a framework component (ToggleGroup / DateRangePicker /
// TimeRangePicker / IconButton / Button) — no native form controls, no
// reinvented buttons, no two-fields-where-a-range-picker-exists.
import { ChronosProvider, type ChronosTimeMode } from '$soma/components/chronos';
import { ActiveEidos } from '$uix/eidos';
import { Time } from '$libs/days';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { DateRangePicker } from '$uix/eidos/components/date-range-picker';
import { TimeRangePicker } from '$uix/eidos/components/time-range-picker';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { IconButton } from '$uix/eidos/components/icon-button';
import { Button } from '$uix/eidos/components/button';
import { Plus, Trash2, ChevronLeft, ChevronRight } from '$uix/eidos/components/icon';
const provider = ChronosProvider.require();
// View mode → every control readonly/disabled (the dialog owns the view/edit flip).
const editable = $derived(provider.draftEditable);
// Context bridge: the Dialog injects ActiveEidos via `mount(..., { context })`,
// which `getAllContexts()` (used by the pickers' Portal) does NOT enumerate —
// so the portaled date/time popovers, nested inside the dialog's portal, lose
// it and throw "No ActiveEidos context". Re-providing it here as a tree
// `setContext` makes `getAllContexts()` capture it, so the portaled popovers
// resolve the (same) instance and float cleanly above the dialog.
ActiveEidos.set(ActiveEidos.require());
const MODES: { value: ChronosTimeMode; label: string }[] = [
{ value: 'all-day', label: 'All day' },
{ value: 'single', label: 'Single' },
{ value: 'split', label: 'Split' }
];
const asTime = (t: { hour: number; minute: number }) => new Time(t.hour, t.minute);
</script>
<!-- Reusable start–end TimeRangePicker (used by single mode + each split band). -->
{#snippet timeRange(
start: Time,
end: Time,
onStart: (t: Time) => void,
onEnd: (t: Time) => void,
labelPrefix: string,
min: Time | undefined
)}
<TimeRangePicker
value={{ start, end }}
minValue={min}
readonly={!editable}
onStartValueChange={(t) => t && onStart(asTime(t))}
onEndValueChange={(t) => t && onEnd(asTime(t))}
>
<TimeRangePicker.Input type="start" aria-label={`${labelPrefix} start`}>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${labelPrefix}-s-${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
{/each}
{/snippet}
</TimeRangePicker.Input>
<span class="chronos-pair-sep" aria-hidden="true">-></span>
<TimeRangePicker.Input type="end" aria-label={`${labelPrefix} end`}>
{#snippet children({ segments })}
{#each segments as { part, value }, i (`${labelPrefix}-e-${part}-${i}`)}
<TimeRangePicker.Segment {part}>{value}</TimeRangePicker.Segment>
{/each}
<TimeRangePicker.Trigger />
{/snippet}
</TimeRangePicker.Input>
<TimeRangePicker.Portal>
<TimeRangePicker.Content>
<PickerShell.Body>
<TimeRangePicker.Clock>
<div class="chronos-clock-endpoint">
<span class="chronos-when-label">Start</span>
<TimeRangePicker.HourSlider type="start" />
<TimeRangePicker.MinuteSlider type="start" />
</div>
<div class="chronos-clock-endpoint">
<span class="chronos-when-label">End</span>
<TimeRangePicker.HourSlider type="end" />
<TimeRangePicker.MinuteSlider type="end" />
</div>
</TimeRangePicker.Clock>
</PickerShell.Body>
</TimeRangePicker.Content>
</TimeRangePicker.Portal>
</TimeRangePicker>
{/snippet}
<div class="chronos-when" data-view={!editable ? '' : undefined}>
<!-- Mode switch — primary control, full width. No `readonly` prop on ToggleGroup,
so it stays `disabled`; the [data-view] CSS removes the dimming so it reads. -->
<ToggleGroup
type="single"
deselectable={false}
attached
block
color="primary"
disabled={!editable}
aria-label="Time mode"
value={provider.draftMode ? [provider.draftMode] : []}
onValueChange={(v) => v[0] && provider.setDraftTimeMode(v[0] as ChronosTimeMode)}
>
{#each MODES as m (m.value)}
<ToggleGroup.Item value={m.value}>{m.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
<!-- Date span (date-only picker; start + end compact, on one line) -->
<div class="chronos-when-field">
<span class="chronos-when-label">Dates</span>
<DateRangePicker
numberOfMonths={1}
readonly={!editable}
value={provider.draftDateRange ?? undefined}
onStartValueChange={(d) => d && provider.setDraftDateRange({ start: d })}
onEndValueChange={(d) => d && provider.setDraftDateRange({ end: d })}
calendarLabel="Event dates"
>
<div class="chronos-pair">
<DateRangePicker.Input type="start" aria-label="Start date">
{#snippet children({ segments })}
{#each segments as { part, value }, i (`s-${part}-${i}`)}
<DateRangePicker.Segment {part}>{value}</DateRangePicker.Segment>
{/each}
{/snippet}
</DateRangePicker.Input>
<span class="chronos-pair-sep" aria-hidden="true">-></span>
<DateRangePicker.Input type="end" aria-label="End date">
{#snippet children({ segments })}
{#each segments as { part, value }, i (`e-${part}-${i}`)}
<DateRangePicker.Segment {part}>{value}</DateRangePicker.Segment>
{/each}
<DateRangePicker.Trigger />
{/snippet}
</DateRangePicker.Input>
</div>
<DateRangePicker.Portal>
<DateRangePicker.Content align="start" sideOffset={6}>
<DateRangePicker.Calendar>
{#snippet children({ months, weekdays })}
<DateRangePicker.Header>
<DateRangePicker.PrevButton aria-label="Previous month">
<ChevronLeft size="sm" />
</DateRangePicker.PrevButton>
<DateRangePicker.Heading />
<DateRangePicker.NextButton aria-label="Next month">
<ChevronRight size="sm" />
</DateRangePicker.NextButton>
</DateRangePicker.Header>
{#each months as month (month.value.toString())}
<DateRangePicker.Grid>
<DateRangePicker.GridHead>
<DateRangePicker.GridRow>
{#each weekdays as d (d)}
<DateRangePicker.HeadCell>{d}</DateRangePicker.HeadCell>
{/each}
</DateRangePicker.GridRow>
</DateRangePicker.GridHead>
<DateRangePicker.GridBody>
{#each month.weeks as week, w (w)}
<DateRangePicker.GridRow {week}>
{#snippet children()}
{#each week as date (date.toString())}
<DateRangePicker.Cell {date} month={month.value}>
<DateRangePicker.Day />
</DateRangePicker.Cell>
{/each}
{/snippet}
</DateRangePicker.GridRow>
{/each}
</DateRangePicker.GridBody>
</DateRangePicker.Grid>
{/each}
{/snippet}
</DateRangePicker.Calendar>
</DateRangePicker.Content>
</DateRangePicker.Portal>
</DateRangePicker>
</div>
<!-- Time controls — depend on the mode -->
{#if provider.draftMode === 'single'}
<div class="chronos-when-field">
<span class="chronos-when-label">Hours</span>
{@render timeRange(
provider.draftStartTime,
provider.draftEndTime,
(t) => provider.setDraftStartTime(t),
(t) => provider.setDraftEndTime(t),
'Event time',
undefined
)}
</div>
{:else if provider.draftMode === 'split'}
<div class="chronos-when-field">
<span class="chronos-when-label">Time bands</span>
<div class="chronos-bands">
{#each provider.draftBands as band, i (i)}
<div class="chronos-band-row">
{@render timeRange(
band.start,
band.end,
(t) => provider.setDraftBandStart(i, t),
(t) => provider.setDraftBandEnd(i, t),
`Band ${i + 1}`,
i > 0 ? provider.draftBands[i - 1].end : undefined
)}
{#if editable}
<IconButton
aria-label={`Remove band ${i + 1}`}
variant="ghost"
size="sm"
disabled={provider.draftBands.length <= 1}
onclick={() => provider.removeDraftBand(i)}
>
<Trash2 size="sm" />
</IconButton>
{/if}
</div>
{/each}
{#if editable}
<Button variant="ghost" onclick={() => provider.addDraftBand()}>
<Plus /> Add band
</Button>
{/if}
</div>
{#if editable}
<span class="chronos-when-hint">Bands repeat on each day of the date range.</span>
{/if}
</div>
{/if}
</div>

@ -0,0 +1,937 @@
/**
* Chronos — Month view recipe CSS.
*
* Spacing, typography and colors come straight from the eidos foundation
* primitives (`--space-*`, `--font-size-*`, `--font-weight-*`, `--color-*`,
* `--radius-*`, `--border-width`, `--control-height-*`). The only `--chronos-*`
* recipe tokens are the component-semantic dimensions (cell height, lane height,
* focus ring). No magic numbers, no raw colors, focus via the system token.
*
* Event chip colors are NOT in CSS: they are driven per-chip by inline custom
* properties (`--_chronos-event-bg/accent/fg`) computed from the consumer's
* CalendarSchema (palette scales), never from the intent channel.
*/
[data-chronos] {
--_chronos-cell-min-block: var(--chronos-cell-min-block-md);
--_chronos-lane-height: var(--chronos-lane-height-md);
/* Drop-target highlight tint (single source; state tokens are pending). */
--_chronos-dragover-bg: color-mix(in oklab, var(--color-primary-solid) 14%, transparent);
/* Depth of the multi-day chip continuation arrow / notch. */
--_chronos-chip-arrow: var(--space-1-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: 100%;
color: var(--color-content-primary);
font-family: var(--font-ui);
font-size: var(--font-size-sm);
line-height: var(--leading-ui);
}
[data-chronos][data-size='sm'] {
--_chronos-cell-min-block: var(--chronos-cell-min-block-sm);
--_chronos-lane-height: var(--chronos-lane-height-sm);
font-size: var(--font-size-xs);
}
[data-chronos][data-size='lg'] {
--_chronos-cell-min-block: var(--chronos-cell-min-block-lg);
--_chronos-lane-height: var(--chronos-lane-height-lg);
font-size: var(--font-size-md);
}
[data-chronos][data-disabled] {
opacity: var(--chronos-disabled-opacity);
pointer-events: none;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
[data-chronos-toolbar] {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.chronos-toolbar-spacer {
flex: 1;
}
.chronos-date-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-inline-size: var(--space-12);
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
line-height: 1.1;
}
.chronos-date-badge-month {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
color: var(--color-content-muted);
}
.chronos-date-badge-day {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-content-primary);
}
.chronos-title-block {
display: flex;
flex-direction: column;
gap: var(--space-0-5);
}
.chronos-title-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
[data-chronos-heading] {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-content-primary);
}
.chronos-week-badge {
padding: 0 var(--space-1-5);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.chronos-range {
font-size: var(--font-size-sm);
color: var(--color-content-muted);
}
/* Nav group — wraps framework Buttons (prev / today / next) as a segmented
control. The border + dividers come from the group; the Buttons are `ghost`. */
.chronos-nav-group {
display: inline-flex;
align-items: center;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.chronos-nav-group > :not(:first-child) {
border-inline-start: var(--border-width) solid var(--color-border-subtle);
}
/* ── Grid ────────────────────────────────────────────────────────────────── */
[data-chronos-grid] {
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface-raised);
}
[data-chronos-grid-head] {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
border-block-end: var(--border-width) solid var(--color-border-subtle);
}
[data-chronos-head-cell] {
padding-block: var(--space-2);
text-align: center;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-content-muted);
text-transform: capitalize;
}
[data-chronos-grid-body] {
display: flex;
flex-direction: column;
}
[data-chronos-grid-row] {
position: relative;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
min-block-size: var(--_chronos-cell-min-block);
}
[data-chronos-grid-row]:not(:last-child) {
border-block-end: var(--border-width) solid var(--color-border-subtle);
}
[data-chronos-day-cell] {
min-inline-size: 0;
padding: var(--space-1) var(--space-1-5);
border-inline-end: var(--border-width) solid var(--color-border-subtle);
transition: background var(--duration-fast) var(--ease-default);
}
[data-chronos-day-cell]:last-child {
border-inline-end: none;
}
[data-chronos-day-cell][data-weekend],
[data-chronos-day-cell][data-outside-month] {
background: var(--color-surface-default);
}
[data-chronos-day-cell][data-outside-month] [data-chronos-day-number] {
color: var(--color-content-muted);
opacity: 0.6;
}
[data-chronos-day-cell]:hover {
background: var(--color-surface-overlay);
}
[data-chronos][data-readonly] [data-chronos-day-cell],
[data-chronos][data-disabled] [data-chronos-day-cell] {
cursor: default;
}
[data-chronos][data-readonly] [data-chronos-day-cell]:hover,
[data-chronos][data-disabled] [data-chronos-day-cell]:hover {
background: transparent;
}
[data-chronos-day-cell]:focus-visible {
outline: none;
box-shadow: var(--chronos-focus-shadow);
}
[data-chronos-day-cell][data-dragover] {
background: var(--_chronos-dragover-bg);
box-shadow: inset 0 0 0 var(--focus-ring-width) var(--color-primary-border);
}
.chronos-day-cell-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-1);
}
/* Per-day create button — hidden until the cell is hovered or the button is
focused (keyboard). The cell no longer creates on click. */
[data-chronos-day-add] {
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-default);
}
[data-chronos-day-cell]:hover [data-chronos-day-add],
[data-chronos-day-add]:focus-visible {
opacity: 1;
}
[data-chronos][data-readonly] [data-chronos-day-add],
[data-chronos][data-disabled] [data-chronos-day-add] {
display: none;
}
[data-chronos-day-number] {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: var(--space-6);
block-size: var(--space-6);
font-size: var(--font-size-sm);
color: var(--color-content-primary);
}
[data-chronos-day-cell][data-today] [data-chronos-day-number] {
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
border-radius: var(--radius-full);
font-weight: var(--font-weight-semibold);
}
/* ── Event overlay (left-accent chips, colored from the schema) ──────────── */
.chronos-overlay {
position: absolute;
inset-block-start: var(--chronos-num-row);
inset-block-end: var(--space-1);
inset-inline: var(--space-1);
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: var(--_chronos-lane-height);
gap: var(--space-0-5);
pointer-events: none;
}
[data-chronos-event-chip] {
pointer-events: auto;
display: flex;
align-items: center;
gap: var(--space-1-5);
min-inline-size: 0;
padding-inline: var(--space-1-5);
border-radius: var(--radius-sm);
border-inline-start: var(--space-0-5) solid var(--_chronos-event-accent);
background: var(--_chronos-event-bg);
color: var(--_chronos-event-fg);
font-size: var(--font-size-xs);
cursor: pointer;
text-align: start;
transition: filter var(--duration-fast) var(--ease-default);
}
[data-chronos-event-chip]:hover {
filter: brightness(0.97);
}
[data-chronos-event-chip]:focus-visible {
outline: none;
box-shadow: var(--chronos-focus-shadow);
}
/* Continuation shapes: a bar that runs past the week boundary ends in an arrow
point (continues-after) and the next week resumes with a matching notch
(continues-before), so the two read as one event split across rows. */
[data-chronos-event-chip][data-continues-before] {
border-inline-start: none;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--_chronos-chip-arrow) 50%);
padding-inline-start: calc(var(--space-1-5) + var(--_chronos-chip-arrow));
}
[data-chronos-event-chip][data-continues-after] {
clip-path: polygon(
0 0,
calc(100% - var(--_chronos-chip-arrow)) 0,
100% 50%,
calc(100% - var(--_chronos-chip-arrow)) 100%,
0 100%
);
padding-inline-end: calc(var(--space-1-5) + var(--_chronos-chip-arrow));
}
[data-chronos-event-chip][data-continues-before][data-continues-after] {
clip-path: polygon(
0 0,
calc(100% - var(--_chronos-chip-arrow)) 0,
100% 50%,
calc(100% - var(--_chronos-chip-arrow)) 100%,
0 100%,
var(--_chronos-chip-arrow) 50%
);
}
/* Single-day bars leave a small gap at the cell's right edge; multi-day bars
run to the column edges so they connect across days. */
[data-chronos-event-chip][data-single-day] {
margin-inline-end: var(--space-1);
}
.chronos-chip-title {
flex: 1 1 auto;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-medium);
}
.chronos-chip-time {
flex: 0 0 auto;
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
/* Drag preview — the event's chip, lifted off the surface while dragging.
Size comes inline from the source chip, so keep border-box to match exactly. */
.chronos-drag-preview {
box-sizing: border-box;
box-shadow: var(--shadow-overlay);
cursor: grabbing;
}
/* The "+N more" Button lives inside the pointer-events:none overlay grid. */
[data-chronos-more-link] {
pointer-events: auto;
align-self: start;
justify-self: start;
}
/* It's a composed <Button variant="plain">, but as a popover trigger it also
inherits the popover's default surface envelope (bg + border + fixed control
height, specificity 0-2-0). Hand styling back to the Button so `plain` reads
plain — same private vars the Button recipe consumes, no literals. */
[data-popover-trigger][data-chronos-more-link]:not([data-archetype='field-trigger']) {
height: auto;
padding-inline: var(--_button-padding-inline);
border: var(--button-border-width) solid var(--_button-border);
border-radius: var(--_button-radius);
background: var(--_button-bg);
}
/* ── Legend ──────────────────────────────────────────────────────────────── */
.chronos-legend {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding-block-start: var(--space-1);
}
.chronos-legend-item {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.chronos-legend-swatch {
inline-size: var(--space-2-5);
block-size: var(--space-2-5);
border-radius: var(--radius-xs);
}
/* ── Editor field layout (controls are framework Dialog/Field/DateField/…) ─── */
/* View→edit toggle, pinned to the dialog's top-right corner at the same inset as
the footer buttons (Dialog.Content is the positioned ancestor). */
[data-chronos-edit-action] {
position: absolute;
inset-block-start: var(--dialog-content-padding-md);
inset-inline-end: var(--dialog-content-padding-md);
}
/* View mode = readonly. Field/date/time pickers use their `readonly` prop (values
stay legible). Select + ToggleGroup have no `readonly`, so they're `disabled`;
strip the disabled dimming here so they read like the rest, while staying
non-interactive (and still announced as disabled to AT). */
.chronos-editor-fields[data-view] [data-select][data-disabled],
.chronos-editor-fields[data-view] [data-select-trigger][data-disabled] {
opacity: 1;
}
.chronos-editor-fields[data-view] [data-select-trigger][data-disabled] {
border-color: var(--select-trigger-border);
background: var(--select-trigger-bg);
color: var(--select-trigger-color);
cursor: default;
}
.chronos-when[data-view] [data-toggle-group][data-disabled],
.chronos-when[data-view] [data-toggle][data-disabled] {
opacity: 1;
cursor: default;
}
/* The date/time field's readonly mode paints each segment with a per-segment bg
affordance (`--date-field-segment-readonly-bg`). In an all-readonly view dialog
that reads as noisy chips — flatten to plain text. (Only matches in view mode;
editable segments have no `data-readonly`.) */
.chronos-editor-fields [data-date-range-picker] [data-date-field-segment][data-readonly],
.chronos-editor-fields [data-time-range-picker] [data-time-field-segment][data-readonly] {
background: transparent;
text-decoration: none;
}
.chronos-editor-fields {
display: flex;
flex-direction: column;
gap: var(--space-3);
inline-size: 100%;
min-inline-size: 0;
}
.chronos-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
inline-size: 100%;
min-inline-size: 0;
}
/* Force the framework controls to fill the dialog and shrink — they default to
intrinsic/inline widths that overflow Dialog.Content. Bare descendant
selectors (not :where) so the specificity beats each component's own width. */
.chronos-editor-fields [data-field],
.chronos-editor-fields [data-field-control],
.chronos-editor-fields [data-date-field],
.chronos-editor-fields [data-select] {
inline-size: 100%;
min-inline-size: 0;
max-inline-size: 100%;
box-sizing: border-box;
}
.chronos-editor-fields [data-date-field-input],
.chronos-editor-fields [data-select-trigger],
.chronos-editor-fields [data-field-input] {
inline-size: 100%;
min-inline-size: 0;
box-sizing: border-box;
}
.chronos-editor-fields [data-date-range-picker] {
inline-size: 100%;
min-inline-size: 0;
max-inline-size: 100%;
box-sizing: border-box;
}
/* ── "When" editor — recessed scheduling panel ───────────────────────────── */
.chronos-when {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
background: var(--color-surface-subtle);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-lg);
}
.chronos-when-field {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
min-inline-size: 0;
}
.chronos-when-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-content-secondary);
}
.chronos-when-hint {
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
/* start–end pairs (dates, single hours) + band rows share alignment */
.chronos-pair,
.chronos-band-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-inline-size: 0;
}
.chronos-pair-sep {
flex: 0 0 auto;
color: var(--color-content-muted);
}
/* Date/time fields default to filling their container; keep them compact
(content width) so the controls sit left-aligned and never stretch. */
.chronos-pair > [data-date-range-field-input],
.chronos-when-field > [data-time-range-picker],
.chronos-band-row > [data-time-range-picker] {
flex: 0 0 auto;
/* max-content = one-line natural width (start–end never wraps to two rows);
capped so it never overflows the dialog. */
inline-size: max-content;
max-inline-size: 100%;
}
/* The time-range field stacks its start/end inputs in a column by default; lay
them horizontally on one line to match the date range above. The gap keeps the
two time boxes visually distinct. */
.chronos-when [data-time-range-picker] {
flex-direction: row;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* Compact the start/end time inputs so they sit on one line — they default to
filling, which (in a row) forces a wrap to two rows. */
.chronos-when [data-time-range-field-input] {
flex: 0 0 auto;
inline-size: max-content;
}
.chronos-bands {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-inline-size: 0;
}
/* Per-endpoint column inside the time-range popover clock (Start / End stacks). */
.chronos-clock-endpoint {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
}
/* Remove sits at the row's right edge → a clean aligned column across bands. */
.chronos-band-row > [data-button] {
flex: 0 0 auto;
margin-inline-start: auto;
}
.chronos-pair > [data-date-range-field-input][data-date-field-input] {
width: auto;
}
/* The single "+ Add band" affordance below the list, left-aligned. */
.chronos-bands > [data-button] {
align-self: flex-start;
}
.chronos-field-inline {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.chronos-field-label {
font-family: var(--font-ui);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
color: var(--color-content-secondary);
}
.chronos-editor-spacer {
flex: 1;
}
/* Validation message (blocks Save) — threat intent. */
.chronos-editor-error {
display: flex;
align-items: center;
gap: var(--space-1-5);
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-threat-text);
}
.chronos-swatches {
display: flex;
flex-wrap: wrap;
gap: var(--space-1-5);
}
.chronos-swatch {
inline-size: var(--space-5);
block-size: var(--space-5);
border-radius: var(--radius-full);
border: var(--border-width) solid var(--color-border-subtle);
cursor: pointer;
}
.chronos-swatch[data-selected] {
box-shadow:
0 0 0 var(--border-width) var(--color-surface-raised),
0 0 0 calc(var(--border-width) * 3) var(--color-content-primary);
}
.chronos-type-option {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
}
.chronos-type-swatch {
flex: 0 0 auto;
inline-size: var(--space-2);
block-size: var(--space-2);
border-radius: var(--radius-full);
}
/* ── Time-grid (Week / Day) ─────────────────────────────────────────────── */
[data-chronos-timegrid] {
--_chronos-hour-height: var(--space-12);
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-lg);
/* Single scroll container: the scrollbar narrows head + all-day + body
equally, so their column lines stay aligned. Header rows are sticky. */
overflow: auto;
background: var(--color-surface-raised);
block-size: var(--chronos-timegrid-block-size);
}
.chronos-tg-sticky {
position: sticky;
inset-block-start: 0;
z-index: 3;
background: var(--color-surface-raised);
}
.chronos-tg-head,
.chronos-tg-allday {
display: grid;
grid-template-columns: var(--space-12) 1fr;
border-block-end: var(--border-width) solid var(--color-border-subtle);
}
[data-chronos-timegrid][data-view='week'] .chronos-tg-head,
[data-chronos-timegrid][data-view='week'] .chronos-tg-allday {
grid-template-columns: var(--space-12) repeat(7, minmax(0, 1fr));
}
.chronos-tg-dayhead {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1-5);
padding-block: var(--space-2);
border-inline-start: var(--border-width) solid var(--color-border-subtle);
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
}
.chronos-tg-weekday {
text-transform: capitalize;
}
.chronos-tg-dayhead[data-today] .chronos-tg-daynum {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: var(--space-6);
block-size: var(--space-6);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
border-radius: var(--radius-full);
font-weight: var(--font-weight-semibold);
}
.chronos-tg-railcell {
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
.chronos-tg-alldaycol {
display: flex;
flex-direction: column;
gap: var(--space-0-5);
padding: var(--space-0-5);
border-inline-start: var(--border-width) solid var(--color-border-subtle);
min-inline-size: 0;
}
.chronos-tg-body {
display: grid;
grid-template-columns: var(--space-12) 1fr;
}
.chronos-tg-rail {
display: flex;
flex-direction: column;
}
.chronos-tg-hour {
position: relative;
block-size: var(--_chronos-hour-height);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
.chronos-tg-hour > span {
position: absolute;
inset-block-start: calc(var(--space-1) * -1);
inset-inline-end: var(--space-1);
}
.chronos-tg-cols {
display: grid;
grid-template-columns: 1fr;
}
[data-chronos-timegrid][data-view='week'] .chronos-tg-cols {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.chronos-tg-col {
position: relative;
border-inline-start: var(--border-width) solid var(--color-border-subtle);
min-inline-size: 0;
cursor: pointer;
}
.chronos-tg-slot {
block-size: var(--_chronos-hour-height);
border-block-end: var(--border-width) solid var(--color-border-subtle);
cursor: pointer;
}
.chronos-tg-slot[data-dragover],
.chronos-tg-alldaycol[data-dragover] {
background: var(--_chronos-dragover-bg);
box-shadow: inset 0 0 0 var(--focus-ring-width) var(--color-primary-border);
}
/* During an active drag, event chips must not intercept the pointer — the drop
hit-test (elementFromPoint) has to reach the slots / cells beneath them. */
[data-drag-drop][data-dragging] [data-chronos-event-chip] {
pointer-events: none;
}
[data-chronos-event-chip][data-timed] {
position: absolute;
flex-direction: column;
align-items: flex-start;
gap: 0;
padding-block: var(--space-0-5);
overflow: hidden;
z-index: 1;
}
.chronos-tg-now {
position: absolute;
inset-inline: 0;
block-size: 0;
border-block-start: var(--border-width) solid var(--color-primary-solid);
z-index: 2;
pointer-events: none;
}
.chronos-tg-now::before {
content: '';
position: absolute;
inset-block-start: calc(var(--space-1) * -1);
inset-inline-start: calc(var(--space-1) * -1);
inline-size: var(--space-2);
block-size: var(--space-2);
background: var(--color-primary-solid);
border-radius: var(--radius-full);
}
/* ── Day peek (month "+N more" popover) ─────────────────────────────────── */
/* Resizable floating panel: the user can drag the corner to grow/shrink it.
Initial inline-size comes from the popover `size` recipe; bounds are token /
viewport based (no magic constants). */
[data-chronos-peek-panel] {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
min-inline-size: var(--popover-min-width-sm);
/* Free horizontal resize via the right-edge handle — bounded only by the
viewport, not the popover's default max-width (which caps at 32rem). */
max-inline-size: calc(100vw - var(--space-4));
min-block-size: var(--space-16);
max-block-size: calc(100dvh - var(--space-8));
}
/* Vertical scroll lives on an inner wrapper so the resize handle (a non-scrolling
sibling on the panel) stays pinned to the right edge. */
.chronos-peek-scroll {
flex: 1 1 auto;
min-block-size: 0;
inline-size: 100%;
overflow-y: auto;
}
/* Visible width-resize handle pinned to the panel's right edge. The native corner
grip is invisible and only the corner works — this is the discoverable, full-
height horizontal handle the popover needs. */
.chronos-peek-resize {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
display: flex;
align-items: center;
justify-content: center;
inline-size: var(--space-2);
cursor: ew-resize;
touch-action: none;
}
.chronos-peek-resize::after {
content: '';
inline-size: var(--border-width);
block-size: var(--space-6);
border-radius: var(--radius-full);
background: var(--color-border-default);
transition:
background var(--duration-fast) var(--ease-default),
block-size var(--duration-fast) var(--ease-default);
}
.chronos-peek-resize:hover::after,
.chronos-peek-resize:active::after {
background: var(--color-primary-solid);
block-size: var(--space-8);
}
/* Editing an event opens the dialog FROM the peek; the dialog must float above
it (and its modal overlay dim it). The popover layer sits above the dialog by
default, so pin the peek's floating wrapper just below the dialog overlay. */
[data-floating-wrapper]:has(> [data-chronos-peek-panel]) {
z-index: calc(var(--dialog-overlay-z) - 1);
}
[data-chronos-day-peek] {
--_chronos-peek-hour: var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-2);
min-inline-size: 0;
}
.chronos-peek-head {
display: flex;
align-items: center;
gap: var(--space-2-5);
padding-block-end: var(--space-1-5);
border-block-end: var(--border-width) solid var(--color-border-subtle);
}
.chronos-peek-daynum {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
line-height: var(--leading-none);
color: var(--color-content-primary);
}
.chronos-peek-daynum[data-today] {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: var(--space-8);
block-size: var(--space-8);
background: var(--color-primary-solid);
color: var(--color-content-on-solid);
border-radius: var(--radius-full);
}
.chronos-peek-headmeta {
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.chronos-peek-weekday {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-content-primary);
}
.chronos-peek-fulldate {
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
.chronos-peek-allday {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.chronos-peek-allday [data-chronos-event-chip] {
padding-block: var(--space-1-5);
}
.chronos-peek-body {
display: grid;
grid-template-columns: var(--space-10) 1fr;
border-block-start: var(--border-width) solid var(--color-border-subtle);
padding-block-start: var(--space-1);
}
.chronos-peek-rail {
display: flex;
flex-direction: column;
}
.chronos-peek-hour {
position: relative;
block-size: var(--_chronos-peek-hour);
font-size: var(--font-size-xs);
color: var(--color-content-muted);
}
.chronos-peek-hour > span {
position: absolute;
inset-block-start: calc(var(--space-1) * -1);
inset-inline-end: var(--space-1);
}
.chronos-peek-col {
position: relative;
border-inline-start: var(--border-width) solid var(--color-border-subtle);
min-inline-size: 0;
}
.chronos-peek-slot {
block-size: var(--_chronos-peek-hour);
border-block-end: var(--border-width) solid var(--color-border-subtle);
}
.chronos-peek-empty {
margin: 0;
padding-block: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-content-muted);
}
/* ── Agenda ─────────────────────────────────────────────────────────────── */
.chronos-agenda {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.chronos-agenda-empty {
margin: 0;
color: var(--color-content-muted);
}
.chronos-agenda-group {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
}
.chronos-agenda-date {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-content-secondary);
text-transform: capitalize;
}
.chronos-agenda-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.chronos-agenda-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1-5) var(--space-2);
border: var(--border-width) solid var(--color-border-subtle);
border-inline-start: var(--space-0-5) solid var(--_chronos-event-accent);
border-radius: var(--radius-sm);
background: var(--color-surface-default);
color: var(--color-content-primary);
font: inherit;
text-align: start;
cursor: pointer;
}
.chronos-agenda-item:hover {
background: var(--color-surface-overlay);
}
.chronos-agenda-dot {
flex: 0 0 auto;
inline-size: var(--space-2);
block-size: var(--space-2);
border-radius: var(--radius-full);
background: var(--_chronos-event-accent);
}
.chronos-agenda-title {
flex: 1 1 auto;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chronos-agenda-time {
flex: 0 0 auto;
color: var(--color-content-muted);
font-size: var(--font-size-sm);
}

@ -0,0 +1,13 @@
<script lang="ts">
import './chronos.css';
import * as Chronos from '$soma/components/chronos';
import ChronosView from './chronos-view.svelte';
import type { ChronosProps } from './types';
let { size = 'md', color = 'primary', editor, children, ...rest }: ChronosProps = $props();
</script>
<Chronos.Provider {...rest} data-size={size} data-color={color}>
<ChronosView {editor} />
{@render children?.()}
</Chronos.Provider>

@ -0,0 +1,22 @@
// Chronos — eidos compound API.
//
// import Chronos from '$uix/eidos/components/chronos';
// <Chronos events={events} onEventCreate={...} />
//
// Single-root component: the eidos wrapper renders the full Month view
// (toolbar + grid + inline editor) from the soma provider's reactive model.
import ChronosComponent from './chronos.svelte';
export const Chronos = ChronosComponent;
export default Chronos;
export type { ChronosProps, ChronosSize, ChronosColor } from './types';
export type {
ChronosEvent,
ChronosEventType,
ChronosEditorContext,
ChronosTooltipContext,
ChronosRange,
ChronosView,
CalendarSchema
} from '$soma/components/chronos';

@ -0,0 +1,23 @@
import type { Snippet } from 'svelte';
import type {
ChronosProps as SomaChronosProps,
ChronosEditorContext
} from '$soma/components/chronos';
import type { ColorRole, Size } from '$uix/eidos/lib/types';
/** Month-cell density. (Responsive `ResponsiveProp` support is a v1+ enhancement.) */
export type ChronosSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/** Accent color role (today marker + default event chip). */
export type ChronosColor = ColorRole;
export type ChronosProps = SomaChronosProps & {
size?: ChronosSize;
color?: ChronosColor;
/**
* Custom editor body. When provided, Chronos renders this snippet inside its
* Dialog instead of the built-in form — the consuming application decides what
* fields the dialog shows. The context's `save` / `remove` stay wired to the
* calendar engine (command/history + callbacks).
*/
editor?: Snippet<[ChronosEditorContext]>;
};

@ -0,0 +1,413 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* Chronos — event calendar (scheduler). v1 ships the **Month** view.
*
* Modelled on `calendarMorfo` (the ARIA `grid` pattern) but for an EVENT
* calendar, not a date picker: cells host event chips, chips are draggable /
* resizable, and the component owns an editor (a reused Dialog) plus an
* undo/redo command stack.
*
* Design + competitive rationale live in
* `src/uix/eidos/components/chronos/SPEC.md`. Scope decisions: v1 = Month with
* create/move/resize + editor + undo/redo; Week/Day/Agenda/Year/Timeline,
* recurrence and virtualization are roadmap.
*
* NOTE (verbs): undo/redo are expressed as `commit-restore` / `commit-apply`
* (both canonical commit verbs). This is provisional — pending the author's
* doctrinal call on the right verb for undo/redo (see SPEC §6.2).
*
* NOTE (editor): the create/edit editor is a REUSED Dialog. Its perceptual
* open/close (emerge) belongs to Dialog's own morfo; Chronos does not
* re-declare it. Chronos only orchestrates when the Dialog opens.
*/
export const chronosMorfo = {
name: 'Chronos',
kebab: 'chronos',
scope: ['soma', 'sema'],
expression: 'pack',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {
label: '#?components.chronos.label|Calendar',
prevMonth: '#?components.chronos.prev-month|Previous month',
nextMonth: '#?components.chronos.next-month|Next month',
today: '#?components.chronos.today|Today',
moreEvents: '#?components.chronos.more-events|Show more events'
},
events: [
// Month navigation (prev / next / today / jump).
{
name: 'shift-navigate',
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
sequence: 'post'
}
},
// An event was created OR its dates changed (move / resize) and persisted.
// Unified like Dialog's polymorphic close — the provider distinguishes via
// the engine command; the perceptual signal is "something landed".
{
name: 'commit-save',
semantic: {
family: 'commit',
verb: 'save',
target: v.partRef('event-chip'),
intent: 'affirm',
sequence: 'post'
}
},
// An event was moved by drag (committed). Distinct from a plain edit-save
// so the cascade can give drag-drop its own character if desired.
{
name: 'commit-move',
semantic: {
family: 'commit',
verb: 'move',
target: v.partRef('event-chip'),
intent: 'affirm',
sequence: 'post'
}
},
// An event was deleted (consequence consumed → loss).
{
name: 'commit-delete',
semantic: {
family: 'commit',
verb: 'delete',
target: v.partRef('event-chip'),
intent: 'loss',
sequence: 'post'
}
},
// Undo — restore the previous snapshot. (Provisional verb; see header.)
{
name: 'commit-restore',
semantic: {
family: 'commit',
verb: 'restore',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}
},
// Redo — re-apply the undone snapshot. (Provisional verb; see header.)
{
name: 'commit-apply',
semantic: {
family: 'commit',
verb: 'apply',
target: v.partRef('provider'),
intent: 'affirm',
sequence: 'post'
}
},
// Direct manipulation in flight — move. Haptic-only by doctrine (no sound
// per frame). Intent concentrates on the drop (commit-move), not the carry.
{
name: 'handle-drag',
semantic: {
family: 'handle',
verb: 'drag',
target: v.partRef('event-chip'),
sequence: 'coincident'
}
},
// Direct manipulation in flight — resize (drag the chip edge).
{
name: 'handle-resize',
semantic: {
family: 'handle',
verb: 'resize',
target: v.partRef('event-resize-handle'),
sequence: 'coincident'
}
}
],
parts: [
// ── Root ──────────────────────────────────────────────────────────────
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
role: 'application',
optional: false,
data: [
{ attr: 'data-view', values: ['month', 'week', 'day', 'agenda'], severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
],
keyboard: [
{ key: 'ArrowRight', action: 'next-day' },
{ key: 'ArrowLeft', action: 'prev-day' },
{ key: 'ArrowDown', action: 'next-week' },
{ key: 'ArrowUp', action: 'prev-week' },
{ key: 'PageUp', action: 'prev-month' },
{ key: 'PageDown', action: 'next-month' },
{ key: 'Home', action: 'first-day-of-week' },
{ key: 'End', action: 'last-day-of-week' },
{ key: 'Enter', action: 'create-or-open' },
{ key: ' ', action: 'create-or-open' }
]
},
// ── Toolbar (optional, fully replaceable) ──────────────────────────────
{
name: 'Toolbar',
kebab: 'toolbar',
archetype: 'header',
kind: 'public',
defaultElement: 'header',
optional: true,
data: [],
aria: []
},
{
name: 'PrevButton',
kebab: 'prev-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.translationRef('prevMonth', 'Previous month'), severity: 'recommended' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
]
},
{
name: 'NextButton',
kebab: 'next-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.translationRef('nextMonth', 'Next month'), severity: 'recommended' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
]
},
{
name: 'TodayButton',
kebab: 'today-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.translationRef('today', 'Today'), severity: 'recommended' }
]
},
{
name: 'UndoButton',
kebab: 'undo-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.commonRef('action.undo', 'Undo'), severity: 'recommended' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
]
},
{
name: 'RedoButton',
kebab: 'redo-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.commonRef('action.redo', 'Redo'), severity: 'recommended' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
]
},
{
name: 'Heading',
kebab: 'heading',
archetype: 'title',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
// ── Grid (required) ────────────────────────────────────────────────────
{
name: 'Grid',
kebab: 'grid',
kind: 'public',
defaultElement: 'div',
role: 'grid',
optional: false,
data: [],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('heading'),
condition: { when: 'part-present', part: 'heading' },
severity: 'recommended'
},
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional', ariaBoolean: true },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional', ariaBoolean: true }
]
},
{
name: 'GridHead',
kebab: 'grid-head',
archetype: 'header',
kind: 'public',
defaultElement: 'div',
role: 'row',
optional: false,
data: [],
aria: []
},
{
name: 'HeadCell',
kebab: 'head-cell',
archetype: 'header',
kind: 'public',
defaultElement: 'div',
role: 'columnheader',
optional: false,
data: [],
aria: []
},
{
name: 'GridBody',
kebab: 'grid-body',
kind: 'public',
defaultElement: 'div',
role: 'rowgroup',
optional: false,
data: [],
aria: []
},
{
name: 'GridRow',
kebab: 'grid-row',
archetype: 'item',
kind: 'public',
defaultElement: 'div',
role: 'row',
optional: false,
data: [],
aria: []
},
// ── Day cell + its children ────────────────────────────────────────────
{
name: 'DayCell',
kebab: 'day-cell',
archetype: 'item',
kind: 'public',
defaultElement: 'div',
role: 'gridcell',
optional: false,
data: [
{ attr: 'data-date', value: v.propRef('date'), emit: 'value' },
{ attr: 'data-today', severity: 'optional' },
{ attr: 'data-weekend', severity: 'optional' },
{ attr: 'data-outside-month', severity: 'optional' },
{ attr: 'data-selected', severity: 'optional' },
{ attr: 'data-has-events', severity: 'optional' },
{ attr: 'data-drop-target', severity: 'optional' },
{ attr: 'data-focused', severity: 'optional' }
],
aria: [
{ attr: 'aria-label', value: v.propRef('dateLabel'), severity: 'recommended' },
{ attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional', ariaBoolean: true }
]
},
{
name: 'DayNumber',
kebab: 'day-number',
archetype: 'label',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-value', value: v.propRef('value'), emit: 'value' }],
aria: []
},
// ── Event chip + resize handle ─────────────────────────────────────────
{
name: 'EventChip',
kebab: 'event-chip',
archetype: 'item',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: false,
data: [
{ attr: 'data-event-id', value: v.propRef('eventId'), emit: 'value' },
{ attr: 'data-color', value: v.propRef('color'), emit: 'value', severity: 'optional' },
{ attr: 'data-all-day', severity: 'optional' },
{ attr: 'data-multi-day', severity: 'optional' },
{ attr: 'data-continues-before', severity: 'optional' },
{ attr: 'data-continues-after', severity: 'optional' },
{ attr: 'data-dragging', severity: 'optional' },
{ attr: 'data-resizing', severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.propRef('eventLabel'), severity: 'recommended' }
]
},
{
name: 'EventResizeHandle',
kebab: 'event-resize-handle',
archetype: 'trigger',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [{ attr: 'data-edge', values: ['start', 'end'], value: v.propRef('edge') }],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
// ── "+N more" overflow link ────────────────────────────────────────────
{
name: 'MoreLink',
kebab: 'more-link',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-count', value: v.propRef('count'), emit: 'value' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.translationRef('moreEvents', 'Show more events'), severity: 'recommended' }
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,62 @@
import { semaSelector } from '$uix/morfo';
import { chronosMorfo } from '$uix/morfo/components/chronos';
import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* Chronos sema pack. NOTE: not yet registered in `sema/components/index.ts`
* (that barrel is outside the component and edits there need explicit sign-off
* — see SPEC §13). Sema is ornamental (sound/haptic default off), so the
* component is fully functional without registration; wiring the export is a
* follow-up.
*/
export const chronosSema: Sema = {
name: 'chronos',
cascade: [
// Event landed (create / edit-save / resize-save).
{
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-save' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
},
// Event moved by drag.
{
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-move' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
},
// Event deleted (loss).
{
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'commit-delete' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
},
// Month navigation.
{
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'shift-navigate' }),
sound: soundTuning('form.commit.subtle', {
gain: 0.18,
contour: 'ascending',
pitch: { op: 'add', value: -90 }
})
},
// Undo / redo.
{
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-restore' }),
sound: soundTuning('form.commit.subtle')
},
{
selector: semaSelector(chronosMorfo, 'provider', { eventName: 'commit-apply' }),
sound: soundTuning('form.commit.subtle')
},
// Direct manipulation in flight — haptic only (no sound per frame).
{
selector: semaSelector(chronosMorfo, 'event-chip', { eventName: 'handle-drag' }),
haptic: { kind: 'tap' }
},
{
selector: semaSelector(chronosMorfo, 'event-resize-handle', { eventName: 'handle-resize' }),
haptic: { kind: 'tap' }
}
]
};

@ -0,0 +1,697 @@
import { context, type WithRefOpts } from '../../provider';
import { type ActiveProps } from '$libs/reactive';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef } from '$adom';
import { Soma } from '../../core/soma.svelte';
import {
type DateValue,
type WeekStartsOn,
getLocalTimeZone,
startOfMonth,
endOfMonth,
toCalendarDate,
toCalendarDateTime,
Time
} from '$libs/days';
import { chronosMorfo } from '../../../morfo/components/chronos';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { ChronosCalendar, type ChronosView, type ChronosAgendaGroup } from './engine/state.svelte';
import type { DayTimeLayout } from './engine/time-layout';
import {
CHRONOS_OTHER_TYPE,
EMPTY_CALENDAR_SCHEMA,
listEventTypes,
resolveEventColor
} from './engine/schema';
import {
CHRONOS_OTHER_TYPE_ID,
eventTimeMode,
type CalendarSchema,
type ChronosEvent,
type ChronosEventType,
type ChronosTimeBand,
type ChronosTimeMode
} from './engine/types';
import type { ChronosRange } from './types';
const attrs = createAttrs(chronosMorfo);
/** Per-session unique id for newly created events (no Date.now / Math.random). */
let eventSeq = 0;
function nextEventId(): string {
return `chronos-evt-${++eventSeq}`;
}
/** Editor session: a draft event being created or edited. */
export interface ChronosEditing {
mode: 'create' | 'edit';
draft: ChronosEvent;
/**
* Interaction mode: `false` = view (readonly), `true` = edit. New events open
* editable; existing events open as view and flip to edit via the head action
* (gated by permissions — see `canEditDraft`).
*/
editable: boolean;
}
export interface ChronosOpts
extends
WithRefOpts,
ActiveProps<{
date: DateValue | undefined;
events: readonly ChronosEvent[];
weekStartsOn: WeekStartsOn | undefined;
fixedWeeks: boolean;
locale: string;
maxEventsPerCell: number;
editable: boolean;
readonly: boolean;
disabled: boolean;
ariaLabel: string | undefined;
schema: CalendarSchema | undefined;
otherTypeLabel: string | undefined;
view: ChronosView | undefined;
}> {
onViewChange?: (view: ChronosView) => void;
onEventCreate?: (event: ChronosEvent) => void;
onEventUpdate?: (event: ChronosEvent) => void;
onEventDelete?: (id: string) => void;
onRangeChange?: (range: ChronosRange) => void;
onEventClick?: (event: ChronosEvent) => void;
}
export class ChronosProvider {
readonly opts: ChronosOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly calendar: ChronosCalendar;
static readonly ctx = context<ChronosProvider>('Chronos');
static get(): ChronosProvider | undefined {
return this.ctx.getOr(undefined) as ChronosProvider | undefined;
}
static require(): ChronosProvider {
return this.ctx.get();
}
static create(opts: ChronosOpts) {
return new ChronosProvider(opts);
}
/** Active editor session, or null when closed. */
editing = $state<ChronosEditing | null>(null);
private constructor(opts: ChronosOpts) {
this.opts = opts;
this.soma = Soma.require();
this.calendar = new ChronosCalendar({
initialDate: opts.date.current,
view: opts.view.current,
events: opts.events.current ?? [],
weekStartsOn: opts.weekStartsOn.current,
fixedWeeks: opts.fixedWeeks.current,
locale: opts.locale.current || this.soma.langs.getLocale() || 'en',
maxLanes: opts.maxEventsPerCell.current
});
this.runtime = this.soma.runtime(chronosMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: ChronosProvider.ctx,
props: {
disabled: () => opts.disabled.current,
ariaLabel: () => this.effectiveAriaLabel
}
});
// One-way config sync: external reactive inputs → the reactive engine.
// Events are intentionally NOT synced here (the provider owns them after
// mount so the undo/redo history stays intact — see types.ts).
$effect(() => {
this.calendar.locale = this.effectiveLocale;
this.calendar.weekStartsOn = this.opts.weekStartsOn.current;
this.calendar.fixedWeeks = this.opts.fixedWeeks.current;
this.calendar.maxLanes = this.opts.maxEventsPerCell.current;
});
}
// ── Derived display model ──────────────────────────────────────────────────
readonly effectiveLocale = $derived.by(
() => this.opts.locale.current || this.soma.langs.getLocale() || 'en'
);
/** The consumer's color/category contract (empty when none supplied). */
readonly schema = $derived.by(() => this.opts.schema.current ?? EMPTY_CALENDAR_SCHEMA);
/** Selectable event types: schema categories + the canonical "Other". */
readonly eventTypes: ChronosEventType[] = $derived.by(() =>
listEventTypes(this.schema, this.opts.otherTypeLabel.current ?? CHRONOS_OTHER_TYPE.label)
);
/** Resolve an event's color (scale name or CSS color) from the schema. */
resolveColor(event: ChronosEvent): string {
return resolveEventColor(event, this.schema);
}
readonly headingValue = $derived.by(() =>
new Intl.DateTimeFormat(this.effectiveLocale, { month: 'long', year: 'numeric' }).format(
this.calendar.anchor.toDate(getLocalTimeZone())
)
);
readonly effectiveAriaLabel = $derived.by(() => {
const base =
this.opts.ariaLabel.current ?? this.soma.langs.ts(chronosMorfo.texts!.label) ?? 'Calendar';
return `${base}, ${this.headingValue}`;
});
/** Weekday header labels, respecting the resolved first-day-of-week. */
readonly weekdays = $derived.by(() => {
const row = this.calendar.weeks[0] ?? [];
const fmt = new Intl.DateTimeFormat(this.effectiveLocale, { weekday: 'short' });
return row.map((d) => fmt.format(d.toDate(getLocalTimeZone())));
});
readonly visibleRange = $derived.by<ChronosRange>(() => {
const weeks = this.calendar.weeks;
const first = weeks[0]?.[0];
const lastRow = weeks[weeks.length - 1];
const last = lastRow?.[lastRow.length - 1];
return { start: first ?? this.calendar.anchor, end: last ?? this.calendar.anchor };
});
formatDayNumber(date: DateValue): string {
return new Intl.DateTimeFormat(this.effectiveLocale, { day: 'numeric' }).format(
date.toDate(getLocalTimeZone())
);
}
dayAriaLabel(date: DateValue): string {
return new Intl.DateTimeFormat(this.effectiveLocale, { dateStyle: 'full' }).format(
date.toDate(getLocalTimeZone())
);
}
eventAriaLabel(event: ChronosEvent): string {
const when = new Intl.DateTimeFormat(this.effectiveLocale, { dateStyle: 'medium' }).format(
event.start.toDate(getLocalTimeZone())
);
return `${event.title || '(untitled)'}, ${when}`;
}
get isReadonly(): boolean {
return this.opts.readonly.current || this.opts.disabled.current || !this.opts.editable.current;
}
// ── Views ───────────────────────────────────────────────────────────────────
get view(): ChronosView {
return this.calendar.view;
}
readonly visibleDays = $derived.by(() => this.calendar.visibleDays);
readonly agendaGroups = $derived.by<ChronosAgendaGroup[]>(() => this.calendar.agendaGroups);
timeLayoutFor(day: DateValue): DayTimeLayout | undefined {
return this.calendar.timeLayoutFor(day);
}
/** On-demand timed + all-day layout for any day (Month "+N more" peek). */
dayTimeLayout(day: DateValue): DayTimeLayout {
return this.calendar.dayTimeLayout(day);
}
setView(view: ChronosView, target?: HTMLElement): void {
this.calendar.setView(view);
this.opts.onViewChange?.(view);
this.emitRange(target);
}
/** Localized hour labels 0..23 for the time-grid rail. */
readonly hourLabels = $derived.by(() =>
Array.from({ length: 24 }, (_, h) =>
new Intl.DateTimeFormat(this.effectiveLocale, { hour: 'numeric' }).format(
new Date(2020, 0, 1, h, 0)
)
)
);
/** Header for a time-grid day column: weekday + day number. */
dayColumnLabel(day: DateValue): { weekday: string; day: number } {
return {
weekday: new Intl.DateTimeFormat(this.effectiveLocale, { weekday: 'short' }).format(
day.toDate(getLocalTimeZone())
),
day: day.day
};
}
eventTimeLabel(event: ChronosEvent): string {
// Empty for all-day AND split (date-only start carries no single time).
if (event.allDay || !('hour' in event.start)) return '';
return new Intl.DateTimeFormat(this.effectiveLocale, {
hour: 'numeric',
minute: '2-digit'
}).format(event.start.toDate(getLocalTimeZone()));
}
/** Resolved type label for an event (schema category or canonical "Other"). */
eventTypeLabel(event: ChronosEvent): string {
const id = event.typeId ?? CHRONOS_OTHER_TYPE_ID;
return this.eventTypes.find((t) => t.id === id)?.label ?? '';
}
/** Time label for tooltips: all-day, a single start–end range, or the bands. */
eventRangeLabel(event: ChronosEvent): string {
if (event.allDay) return 'All day';
const f = (h: number, m: number) =>
new Intl.DateTimeFormat(this.effectiveLocale, {
hour: 'numeric',
minute: '2-digit'
}).format(new Date(2020, 0, 1, h, m));
if (eventTimeMode(event) === 'split') {
return (event.parts ?? [])
.map((b) => `${f(b.start.hour, b.start.minute)}–${f(b.end.hour, b.end.minute)}`)
.join(', ');
}
const tz = getLocalTimeZone();
const fmt = new Intl.DateTimeFormat(this.effectiveLocale, {
hour: 'numeric',
minute: '2-digit'
});
return `${fmt.format(event.start.toDate(tz))} – ${fmt.format(event.end.toDate(tz))}`;
}
/** Now-indicator position in minutes-from-midnight. */
get nowMinutes(): number {
const n = new Date();
return n.getHours() * 60 + n.getMinutes();
}
/** Header subtitle (view-dependent: weekday / week range / month range). */
readonly subtitle = $derived.by(() => {
const tz = getLocalTimeZone();
const f = (d: DateValue, o: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(this.effectiveLocale, o).format(d.toDate(tz));
if (this.view === 'day') return f(this.calendar.anchor, { weekday: 'long' });
const range: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' };
if (this.view === 'week') {
const days = this.calendar.weekDays;
return `${f(days[0], range)} – ${f(days[days.length - 1], range)}`;
}
return `${f(startOfMonth(this.calendar.anchor), range)} – ${f(endOfMonth(this.calendar.anchor), range)}`;
});
// ── Navigation ──────────────────────────────────────────────────────────────
private emitRange(target?: HTMLElement): void {
this.opts.onRangeChange?.(this.visibleRange);
void this.runtime.trigger('shift-navigate', target ? { fallbackTarget: target } : undefined);
}
prev(target?: HTMLElement): void {
this.calendar.prev();
this.emitRange(target);
}
next(target?: HTMLElement): void {
this.calendar.next();
this.emitRange(target);
}
goToday(target?: HTMLElement): void {
this.calendar.goToToday();
this.emitRange(target);
}
// ── Undo / redo ──────────────────────────────────────────────────────────────
get canUndo(): boolean {
return this.calendar.canUndo;
}
get canRedo(): boolean {
return this.calendar.canRedo;
}
undo(target?: HTMLElement): void {
if (this.calendar.undo()) {
void this.runtime.trigger('commit-restore', target ? { fallbackTarget: target } : undefined);
}
}
redo(target?: HTMLElement): void {
if (this.calendar.redo()) {
void this.runtime.trigger('commit-apply', target ? { fallbackTarget: target } : undefined);
}
}
// ── Editor ────────────────────────────────────────────────────────────────────
/**
* Open the editor for a new event. With `startMinute` (from a time-grid slot
* click) the draft is TIMED at that minute, default 1h long; without it the
* draft is all-day.
*/
openCreate(date: DateValue, startMinute?: number): void {
if (this.isReadonly) return;
const defaultType = this.schema.eventTypes[0]?.id ?? CHRONOS_OTHER_TYPE_ID;
if (startMinute != null) {
const start = toCalendarDateTime(
toCalendarDate(date),
new Time(Math.floor(startMinute / 60), startMinute % 60)
);
this.editing = {
mode: 'create',
editable: true,
draft: {
id: nextEventId(),
title: '',
start,
end: start.add({ hours: 1 }),
allDay: false,
typeId: defaultType
}
};
return;
}
this.editing = {
mode: 'create',
editable: true,
draft: {
id: nextEventId(),
title: '',
start: date,
end: date,
allDay: true,
typeId: defaultType
}
};
}
/** Toggle the editing draft between all-day (CalendarDate) and timed
* (CalendarDateTime), so the date field shows/hides its time segments. */
setDraftAllDay(on: boolean): void {
this.setDraftTimeMode(on ? 'all-day' : 'single');
}
// ── Draft time model ───────────────────────────────────────────────────────────
//
// The editor's "when" controls. A draft is one of three time modes (see
// `eventTimeMode`): `all-day`, `single` (one continuous block) or `split`
// (time bands repeated across the date span). The date span (start/end dates)
// is always editable; the time controls depend on the mode.
/** Current draft time mode, or null when the editor is closed. */
get draftMode(): ChronosTimeMode | null {
return this.editing ? eventTimeMode(this.editing.draft) : null;
}
/** Switch the draft between all-day / single / split, converting its shape. */
setDraftTimeMode(mode: ChronosTimeMode): void {
const e = this.editing;
if (!e) return;
const d = e.draft;
const startDate = toCalendarDate(d.start);
const endDate = toCalendarDate(d.end);
if (mode === 'all-day') {
d.start = startDate;
d.end = endDate;
d.allDay = true;
d.parts = undefined;
} else if (mode === 'single') {
d.start = 'hour' in d.start ? d.start : toCalendarDateTime(startDate, new Time(9, 0));
d.end = 'hour' in d.end ? d.end : toCalendarDateTime(endDate, new Time(10, 0));
d.allDay = false;
d.parts = undefined;
} else {
// split — bands live in `parts`; dates become date-only.
d.start = startDate;
d.end = endDate;
d.allDay = false;
if (!d.parts || d.parts.length === 0) {
d.parts = [{ start: new Time(9, 0), end: new Time(10, 0) }];
}
}
}
/** Draft date span as date-only values (for the date-range picker). */
get draftDateRange(): { start: DateValue; end: DateValue } | null {
const e = this.editing;
if (!e) return null;
return { start: toCalendarDate(e.draft.start), end: toCalendarDate(e.draft.end) };
}
/** Update the draft date span, preserving each endpoint's time (single mode). */
setDraftDateRange(range: { start?: DateValue | undefined; end?: DateValue | undefined }): void {
const e = this.editing;
if (!e) return;
const d = e.draft;
if (range.start) {
d.start =
'hour' in d.start
? toCalendarDateTime(toCalendarDate(range.start), new Time(d.start.hour, d.start.minute))
: toCalendarDate(range.start);
}
if (range.end) {
d.end =
'hour' in d.end
? toCalendarDateTime(toCalendarDate(range.end), new Time(d.end.hour, d.end.minute))
: toCalendarDate(range.end);
}
}
/** Single-mode start/end times (fallbacks when the draft is date-only). */
get draftStartTime(): Time {
const s = this.editing?.draft.start;
return s && 'hour' in s ? new Time(s.hour, s.minute) : new Time(9, 0);
}
get draftEndTime(): Time {
const en = this.editing?.draft.end;
return en && 'hour' in en ? new Time(en.hour, en.minute) : new Time(10, 0);
}
setDraftStartTime(t: Time): void {
const e = this.editing;
if (!e) return;
const cur = e.draft.start;
// Idempotent: a controlled range picker echoes its value every render; skip
// the write when nothing changed so it never triggers an update loop.
if ('hour' in cur && cur.hour === t.hour && cur.minute === t.minute) return;
e.draft.start = toCalendarDateTime(toCalendarDate(cur), t);
}
setDraftEndTime(t: Time): void {
const e = this.editing;
if (!e) return;
const cur = e.draft.end;
if ('hour' in cur && cur.hour === t.hour && cur.minute === t.minute) return;
e.draft.end = toCalendarDateTime(toCalendarDate(cur), t);
}
// ── Split bands ─────────────────────────────────────────────────────────────────
/** The draft's time bands (split mode). Empty otherwise. */
get draftBands(): readonly ChronosTimeBand[] {
return this.editing?.draft.parts ?? [];
}
/** Append a band starting at the previous band's end (default 1h long). */
addDraftBand(): void {
const e = this.editing;
if (!e) return;
const bands = e.draft.parts ?? [];
const start = bands.length ? bands[bands.length - 1].end : new Time(9, 0);
e.draft.parts = [...bands, { start, end: start.add({ hours: 1 }) }];
}
/** Remove band `i` (keeps at least one band so the event stays "split"). */
removeDraftBand(i: number): void {
const e = this.editing;
if (!e) return;
const bands = e.draft.parts ?? [];
if (bands.length <= 1) return;
e.draft.parts = bands.filter((_, idx) => idx !== i);
}
/** Set band `i` start, clamped to ≥ the previous band's end; keeps end > start. */
setDraftBandStart(i: number, t: Time): void {
const e = this.editing;
if (!e) return;
const bands = [...(e.draft.parts ?? [])];
const band = bands[i];
if (!band) return;
const min = i > 0 ? bands[i - 1].end : null;
const start = min && t.compare(min) < 0 ? min : t;
const end = band.end.compare(start) <= 0 ? start.add({ minutes: 30 }) : band.end;
// Idempotent (see setDraftStartTime): skip if the band is unchanged.
if (start.compare(band.start) === 0 && end.compare(band.end) === 0) return;
bands[i] = { start, end };
e.draft.parts = bands;
}
/** Set band `i` end, kept strictly after its start. */
setDraftBandEnd(i: number, t: Time): void {
const e = this.editing;
if (!e) return;
const bands = [...(e.draft.parts ?? [])];
const band = bands[i];
if (!band) return;
const end = t.compare(band.start) <= 0 ? band.start.add({ minutes: 30 }) : t;
if (end.compare(band.end) === 0) return;
bands[i] = { start: band.start, end };
e.draft.parts = bands;
}
/**
* Validation message for the current draft, or null when valid. Checks the
* date span, the single-mode block and the split bands (positive duration +
* sequential, non-overlapping). The editor shows this and blocks Save.
*/
get draftError(): string | null {
const e = this.editing;
if (!e) return null;
const d = e.draft;
if (toCalendarDate(d.end).compare(toCalendarDate(d.start)) < 0) {
return 'End date is before the start date.';
}
const mode = eventTimeMode(d);
if (mode === 'single') {
if (d.end.compare(d.start) <= 0) return 'End time must be after the start time.';
} else if (mode === 'split') {
const bands = d.parts ?? [];
if (bands.length === 0) return 'Add at least one time band.';
for (let i = 0; i < bands.length; i++) {
if (bands[i].end.compare(bands[i].start) <= 0) {
return `Band ${i + 1}: end must be after start.`;
}
if (i > 0 && bands[i].start.compare(bands[i - 1].end) < 0) {
return `Band ${i + 1} overlaps the previous band.`;
}
}
}
return null;
}
/**
* Open an existing event. Always opens — in VIEW (readonly) mode — even when
* the calendar is readonly or the event is non-editable; those only suppress
* the head "Edit" action. The dialog flips to edit via `enableEditing()`.
*/
openEdit(event: ChronosEvent): void {
this.opts.onEventClick?.(event);
this.editing = { mode: 'edit', draft: { ...event }, editable: false };
}
/** Whether editing the current draft is permitted (perms / readonly / per-event). */
get canEditDraft(): boolean {
const e = this.editing;
if (!e) return false;
if (e.mode === 'create') return true;
return !this.isReadonly && e.draft.editable !== false;
}
/** Whether the dialog's controls are currently editable (mode + permission). */
get draftEditable(): boolean {
return (this.editing?.editable ?? false) && this.canEditDraft;
}
/** Show the head "Edit" action: an existing event, in view mode, edit allowed. */
get showEditAction(): boolean {
const e = this.editing;
return !!e && e.mode === 'edit' && !e.editable && this.canEditDraft;
}
/** Flip the current view-mode dialog into edit mode (if permitted). */
enableEditing(): void {
const e = this.editing;
if (e && this.canEditDraft) e.editable = true;
}
closeEditor(): void {
this.editing = null;
}
saveEditor(draft: ChronosEvent, target?: HTMLElement): void {
// Guard: never commit from view mode, nor an invalid draft (the editor
// also hides Save in view mode / disables it while invalid).
if (!this.draftEditable || this.draftError) return;
const mode = this.editing?.mode ?? 'create';
this.calendar.dispatch({ type: 'create', event: draft });
if (mode === 'create') this.opts.onEventCreate?.(draft);
else this.opts.onEventUpdate?.(draft);
void this.runtime.trigger('commit-save', target ? { fallbackTarget: target } : undefined);
this.closeEditor();
}
deleteEvent(id: string, target?: HTMLElement): void {
if (this.calendar.dispatch({ type: 'delete', id })) {
this.opts.onEventDelete?.(id);
void this.runtime.trigger('commit-delete', target ? { fallbackTarget: target } : undefined);
}
if (this.editing?.draft.id === id) this.closeEditor();
}
// ── Drag / resize (committed mutations; pointer wiring is in the view) ──────────
moveEvent(id: string, start: DateValue, end: DateValue, target?: HTMLElement): void {
if (this.calendar.dispatch({ type: 'move', id, start, end })) {
const moved = this.calendar.events.find((e) => e.id === id);
if (moved) this.opts.onEventUpdate?.(moved);
void this.runtime.trigger('commit-move', target ? { fallbackTarget: target } : undefined);
}
}
/** Move an event to a different day (drag-drop), preserving time + duration. */
moveEventToDay(id: string, targetDate: DateValue, target?: HTMLElement): void {
const ev = this.calendar.events.find((e) => e.id === id);
if (!ev || ev.editable === false) return;
const tz = getLocalTimeZone();
const startDate = toCalendarDate(ev.start);
const deltaDays = Math.round(
(targetDate.toDate(tz).getTime() - startDate.toDate(tz).getTime()) / 86_400_000
);
if (deltaDays === 0) return;
this.moveEvent(id, ev.start.add({ days: deltaDays }), ev.end.add({ days: deltaDays }), target);
}
/**
* Move a TIMED event to a specific day + start minute (week/day time-grid
* drag-drop), preserving its duration. The event becomes timed if it wasn't.
*/
moveEventToDayTime(
id: string,
targetDate: DateValue,
startMinute: number,
target?: HTMLElement
): void {
const ev = this.calendar.events.find((e) => e.id === id);
if (!ev || ev.editable === false) return;
const tz = getLocalTimeZone();
const durationMin = Math.max(
0,
Math.round((ev.end.toDate(tz).getTime() - ev.start.toDate(tz).getTime()) / 60_000)
);
const newStart = toCalendarDateTime(
toCalendarDate(targetDate),
new Time(Math.floor(startMinute / 60), startMinute % 60)
);
const newEnd = newStart.add({ minutes: durationMin || 60 });
this.moveEvent(id, newStart, newEnd, target);
}
resizeEvent(id: string, end: DateValue, target?: HTMLElement): void {
if (this.calendar.dispatch({ type: 'resize', id, end })) {
const resized = this.calendar.events.find((e) => e.id === id);
if (resized) this.opts.onEventUpdate?.(resized);
void this.runtime.trigger('commit-save', target ? { fallbackTarget: target } : undefined);
}
}
// ── Render props ───────────────────────────────────────────────────────────────
readonly props = $derived.by(() =>
this.runtimePart.assert({
// role + aria-label + aria-disabled are morfo-declared (renderProps).
...this.runtimePart.renderProps(),
'data-view': this.view,
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const)
);
}
export { attrs as chronosAttrs };

@ -0,0 +1,75 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { ChronosProvider } from '../chronos-provider.svelte';
import type { ChronosProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'chronos'),
date,
events = [],
schema,
otherTypeLabel,
view,
onViewChange,
weekStartsOn,
fixedWeeks = true,
locale,
maxEventsPerCell = 3,
editable = true,
readonly = false,
disabled = false,
ariaLabel,
onEventCreate,
onEventUpdate,
onEventDelete,
onRangeChange,
onEventClick,
children,
child,
...restProps
}: ChronosProps = $props();
const provider = ChronosProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
date: readableActive(() => date),
events: readableActive(() => events),
schema: readableActive(() => schema),
otherTypeLabel: readableActive(() => otherTypeLabel),
view: readableActive(() => view),
weekStartsOn: readableActive(() => weekStartsOn),
fixedWeeks: readableActive(() => fixedWeeks),
locale: readableActive(() => locale ?? ''),
maxEventsPerCell: readableActive(() => maxEventsPerCell),
editable: readableActive(() => editable),
readonly: readableActive(() => readonly),
disabled: readableActive(() => disabled),
ariaLabel: readableActive(() => ariaLabel),
// Wrap in closures so the provider always calls the current prop value
// (avoids `state_referenced_locally` — destructured props are reactive).
onViewChange: (v) => onViewChange?.(v),
onEventCreate: (e) => onEventCreate?.(e),
onEventUpdate: (e) => onEventUpdate?.(e),
onEventDelete: (id) => onEventDelete?.(id),
onRangeChange: (r) => onRangeChange?.(r),
onEventClick: (e) => onEventClick?.(e)
});
const mergedProps = $derived(mergeProps(restProps, provider.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,53 @@
/**
* Command dispatcher — pure. Each command maps a snapshot to a new snapshot
* plus a `changed` flag. Precedent: `words/engine/operations/commands.ts`.
*
* No-op commands (move an unknown id, edit a non-editable event, delete a
* missing id) return the SAME snapshot reference with `changed: false` so the
* caller can skip pushing a useless history entry.
*/
import type { ChronosCommand, ChronosCommandResult, ChronosEvent, ChronosSnapshot } from './types';
function mapEvent(
snapshot: ChronosSnapshot,
id: string,
fn: (e: ChronosEvent) => ChronosEvent
): ChronosCommandResult {
let changed = false;
const events = snapshot.events.map((e) => {
if (e.id !== id) return e;
if (e.editable === false) return e;
const next = fn(e);
if (next !== e) changed = true;
return next;
});
return changed ? { snapshot: { events }, changed } : { snapshot, changed: false };
}
export function applyChronosCommand(
snapshot: ChronosSnapshot,
cmd: ChronosCommand
): ChronosCommandResult {
switch (cmd.type) {
case 'create': {
// Replacing an existing id is treated as an update-by-replace.
const exists = snapshot.events.some((e) => e.id === cmd.event.id);
const events = exists
? snapshot.events.map((e) => (e.id === cmd.event.id ? cmd.event : e))
: [...snapshot.events, cmd.event];
return { snapshot: { events }, changed: true };
}
case 'move':
return mapEvent(snapshot, cmd.id, (e) => ({ ...e, start: cmd.start, end: cmd.end }));
case 'resize':
return mapEvent(snapshot, cmd.id, (e) => ({ ...e, end: cmd.end }));
case 'update':
return mapEvent(snapshot, cmd.id, (e) => ({ ...e, ...cmd.patch }));
case 'delete': {
const events = snapshot.events.filter((e) => e.id !== cmd.id);
const changed = events.length !== snapshot.events.length;
return changed ? { snapshot: { events }, changed } : { snapshot, changed: false };
}
}
}

@ -0,0 +1,187 @@
import { describe, it, expect } from 'vitest';
import { CalendarDate, type DateValue } from '$libs/days';
import { applyChronosCommand } from './commands';
import {
createHistory,
commitHistory,
undoHistory,
redoHistory,
canUndo,
canRedo
} from './history';
import { eventsOnDay, eventsInRange, compareForLayout } from './event-store';
import { layoutMonth } from './layout';
import type { ChronosEvent, ChronosSnapshot } from './types';
function ev(id: string, start: DateValue, end: DateValue, extra: Partial<ChronosEvent> = {}): ChronosEvent {
return { id, title: id, start, end, ...extra };
}
const d = (m: number, day: number) => new CalendarDate(2026, m, day);
function buildWeeks(start: CalendarDate, numWeeks: number): DateValue[][] {
const weeks: DateValue[][] = [];
let cur: DateValue = start;
for (let w = 0; w < numWeeks; w++) {
const row: DateValue[] = [];
for (let i = 0; i < 7; i++) {
row.push(cur);
cur = cur.add({ days: 1 });
}
weeks.push(row);
}
return weeks;
}
describe('commands', () => {
const base: ChronosSnapshot = { events: [ev('a', d(6, 3), d(6, 3), { allDay: true })] };
it('create appends', () => {
const r = applyChronosCommand(base, { type: 'create', event: ev('b', d(6, 5), d(6, 5)) });
expect(r.changed).toBe(true);
expect(r.snapshot.events).toHaveLength(2);
});
it('create with existing id replaces', () => {
const r = applyChronosCommand(base, {
type: 'create',
event: ev('a', d(6, 4), d(6, 4), { title: 'A2' })
});
expect(r.snapshot.events).toHaveLength(1);
expect(r.snapshot.events[0].title).toBe('A2');
});
it('move updates start/end', () => {
const r = applyChronosCommand(base, { type: 'move', id: 'a', start: d(6, 10), end: d(6, 10) });
expect(r.changed).toBe(true);
expect(r.snapshot.events[0].start.day).toBe(10);
});
it('resize updates end only', () => {
const r = applyChronosCommand(base, { type: 'resize', id: 'a', end: d(6, 6) });
expect(r.snapshot.events[0].end.day).toBe(6);
expect(r.snapshot.events[0].start.day).toBe(3);
});
it('delete removes', () => {
const r = applyChronosCommand(base, { type: 'delete', id: 'a' });
expect(r.snapshot.events).toHaveLength(0);
});
it('non-editable event resists mutation', () => {
const locked: ChronosSnapshot = { events: [ev('x', d(6, 3), d(6, 3), { editable: false })] };
const r = applyChronosCommand(locked, { type: 'move', id: 'x', start: d(6, 9), end: d(6, 9) });
expect(r.changed).toBe(false);
expect(r.snapshot).toBe(locked);
});
it('missing id is a no-op (same reference)', () => {
const r = applyChronosCommand(base, { type: 'delete', id: 'nope' });
expect(r.changed).toBe(false);
expect(r.snapshot).toBe(base);
});
});
describe('history', () => {
const s0: ChronosSnapshot = { events: [] };
const s1: ChronosSnapshot = { events: [ev('a', d(6, 1), d(6, 1))] };
const s2: ChronosSnapshot = { events: [ev('a', d(6, 2), d(6, 2))] };
it('commit / undo / redo round-trips', () => {
let h = createHistory(s0);
h = commitHistory(h, s1);
h = commitHistory(h, s2);
expect(h.present).toBe(s2);
expect(canUndo(h)).toBe(true);
h = undoHistory(h);
expect(h.present).toBe(s1);
h = undoHistory(h);
expect(h.present).toBe(s0);
expect(canUndo(h)).toBe(false);
h = redoHistory(h);
expect(h.present).toBe(s1);
expect(canRedo(h)).toBe(true);
});
it('a fresh commit clears the redo branch', () => {
let h = createHistory(s0);
h = commitHistory(h, s1);
h = undoHistory(h);
expect(canRedo(h)).toBe(true);
h = commitHistory(h, s2);
expect(canRedo(h)).toBe(false);
expect(h.present).toBe(s2);
});
it('caps the past at the limit', () => {
let h = createHistory(s0, 2);
h = commitHistory(h, s1);
h = commitHistory(h, s2);
h = commitHistory(h, s1);
expect(h.past).toHaveLength(2);
});
});
describe('event-store', () => {
const events = [
ev('a', d(6, 3), d(6, 3), { allDay: true }),
ev('b', d(6, 5), d(6, 9), { allDay: true })
];
it('eventsOnDay covers multi-day spans', () => {
expect(eventsOnDay(events, d(6, 3)).map((e) => e.id)).toEqual(['a']);
expect(eventsOnDay(events, d(6, 7)).map((e) => e.id)).toEqual(['b']);
expect(eventsOnDay(events, d(6, 20))).toHaveLength(0);
});
it('eventsInRange overlaps inclusively', () => {
expect(eventsInRange(events, d(6, 1), d(6, 4)).map((e) => e.id)).toEqual(['a']);
expect(eventsInRange(events, d(6, 1), d(6, 30)).map((e) => e.id)).toEqual(['a', 'b']);
});
it('compareForLayout: all-day before timed', () => {
const allDay = ev('z', d(6, 1), d(6, 1), { allDay: true });
const timed = ev('y', d(6, 1), d(6, 1));
expect(compareForLayout(allDay, timed)).toBeLessThan(0);
});
});
describe('layout', () => {
// Week 0 = Jun 1..7, Week 1 = Jun 8..14 (2026).
const weeks = buildWeeks(d(6, 1), 2);
it('places a single-day event in one cell', () => {
const out = layoutMonth([ev('a', d(6, 3), d(6, 3), { allDay: true })], weeks, { maxLanes: 5 });
expect(out.segments).toHaveLength(1);
expect(out.segments[0]).toMatchObject({ weekIndex: 0, startCol: 2, span: 1, lane: 0 });
});
it('splits a multi-day event across the week boundary', () => {
const out = layoutMonth([ev('b', d(6, 5), d(6, 9), { allDay: true })], weeks, { maxLanes: 5 });
expect(out.segments).toHaveLength(2);
const [first, second] = out.segments;
expect(first).toMatchObject({ weekIndex: 0, startCol: 4, span: 3, continuesAfter: true });
expect(second).toMatchObject({ weekIndex: 1, startCol: 0, span: 2, continuesBefore: true });
});
it('assigns different lanes to overlapping events', () => {
const out = layoutMonth(
[ev('a', d(6, 3), d(6, 3), { allDay: true }), ev('c', d(6, 3), d(6, 3), { allDay: true })],
weeks,
{ maxLanes: 5 }
);
const lanes = out.segments.map((s) => s.lane).sort();
expect(lanes).toEqual([0, 1]);
expect(out.usedLanes).toBe(2);
});
it('collapses overflow into a +N more tally', () => {
const out = layoutMonth(
[ev('a', d(6, 3), d(6, 3), { allDay: true }), ev('c', d(6, 3), d(6, 3), { allDay: true })],
weeks,
{ maxLanes: 1 }
);
expect(out.segments).toHaveLength(1);
expect(out.overflow).toEqual([{ date: '2026-06-03', hiddenCount: 1 }]);
});
});

@ -0,0 +1,58 @@
/**
* Event queries — pure. Day-granular overlap for the Month view.
*
* v1 uses O(n) scans, which is what every reference scheduler does for a
* month's worth of events. An interval-tree is roadmap (only justified once a
* single view holds thousands of events).
*/
import type { DateValue } from '$libs/days';
import type { ChronosEvent } from './types';
/**
* Comparable day ordinal: `YYYYMMDD` as a number. Reduces any `DateValue`
* (CalendarDate / CalendarDateTime / ZonedDateTime) to day granularity so
* all-day and timed events compare uniformly in the Month grid.
*/
export function dayOrdinal(d: DateValue): number {
return d.year * 10000 + d.month * 100 + d.day;
}
/** True when an event's [start, end] (inclusive, day-granular) overlaps [from, to]. */
export function eventOverlapsRange(event: ChronosEvent, from: DateValue, to: DateValue): boolean {
const evStart = dayOrdinal(event.start);
const evEnd = dayOrdinal(event.end);
return evStart <= dayOrdinal(to) && evEnd >= dayOrdinal(from);
}
/** Events overlapping the inclusive day range [from, to]. */
export function eventsInRange(
events: readonly ChronosEvent[],
from: DateValue,
to: DateValue
): ChronosEvent[] {
return events.filter((e) => eventOverlapsRange(e, from, to));
}
/** Events that cover a single day. */
export function eventsOnDay(events: readonly ChronosEvent[], day: DateValue): ChronosEvent[] {
const key = dayOrdinal(day);
return events.filter((e) => dayOrdinal(e.start) <= key && dayOrdinal(e.end) >= key);
}
/**
* Stable ordering for events sharing a cell/row: all-day before timed, then by
* start, then by (longer) duration, then by id for determinism.
*/
export function compareForLayout(a: ChronosEvent, b: ChronosEvent): number {
const aAll = a.allDay ? 0 : 1;
const bAll = b.allDay ? 0 : 1;
if (aAll !== bAll) return aAll - bAll;
const aStart = dayOrdinal(a.start);
const bStart = dayOrdinal(b.start);
if (aStart !== bStart) return aStart - bStart;
const aLen = dayOrdinal(a.end) - aStart;
const bLen = dayOrdinal(b.end) - bStart;
if (aLen !== bLen) return bLen - aLen; // longer first
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { CalendarDate, type DateValue } from '$libs/days';
import { buildMonthGrid } from './grid';
const anchor = new CalendarDate(2026, 6, 15); // mid-June 2026
function flat(weeks: DateValue[][]): DateValue[] {
return weeks.flat();
}
describe('buildMonthGrid', () => {
it('produces rows of exactly 7 days', () => {
const weeks = buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: false, locale: 'en-US' });
for (const row of weeks) expect(row).toHaveLength(7);
});
it('days are strictly consecutive across the whole grid', () => {
const days = flat(buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: true, locale: 'en-US' }));
for (let i = 1; i < days.length; i++) {
expect(days[i].compare(days[i - 1].add({ days: 1 }))).toBe(0);
}
});
it('contains every day of the target month', () => {
const keys = new Set(
flat(buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: false, locale: 'en-US' })).map((d) =>
d.toString()
)
);
for (let day = 1; day <= 30; day++) {
expect(keys.has(new CalendarDate(2026, 6, day).toString())).toBe(true);
}
});
it('starts on the week-start on/before the 1st', () => {
const weeks = buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: false, locale: 'en-US' });
const first = weeks[0][0];
// First cell is on or before June 1, and within the prior week.
expect(first.compare(new CalendarDate(2026, 6, 1))).toBeLessThanOrEqual(0);
expect(new CalendarDate(2026, 6, 1).compare(first.add({ days: 7 }))).toBeLessThan(0);
});
it('fixedWeeks pads to a constant 6 rows (42 cells)', () => {
const weeks = buildMonthGrid(anchor, { weekStartsOn: 0, fixedWeeks: true, locale: 'en-US' });
expect(weeks).toHaveLength(6);
expect(flat(weeks)).toHaveLength(42);
});
});

@ -0,0 +1,76 @@
/**
* Month-grid generation — pure. Produces the 6×7 (or 5×7) matrix of
* `DateValue`s the Month view renders.
*
* Self-contained on `$libs/days` primitives (the same ones `calendar/helpers`
* uses) rather than importing Calendar's internal helper — Chronos stays a
* clean island and the prohibition on touching other components is respected.
*/
import {
startOfMonth,
endOfMonth,
getLastFirstDayOfWeek,
getNextLastDayOfWeek,
type DateValue,
type WeekStartsOn
} from '$libs/days';
/** The 7 days of the week containing `anchor`, respecting first-day-of-week. */
export function weekOf(anchor: DateValue, weekStartsOn: WeekStartsOn | undefined, locale: string): DateValue[] {
const start =
weekStartsOn === undefined
? getLastFirstDayOfWeek(anchor, 0, locale)
: getLastFirstDayOfWeek(anchor, weekStartsOn, 'en-US');
const days: DateValue[] = [];
let cursor: DateValue = start;
for (let i = 0; i < 7; i++) {
days.push(cursor);
cursor = cursor.add({ days: 1 });
}
return days;
}
export interface MonthGridOpts {
/** First day of week. `undefined` → derive from `locale`. */
weekStartsOn: WeekStartsOn | undefined;
/** Pad to a constant 6 rows so layout height never jumps. */
fixedWeeks: boolean;
locale: string;
}
/**
* Walk from the week-start on/before the 1st through the week-end on/after the
* last day, chunked into rows of 7. With `fixedWeeks` the grid is padded to 42
* cells (6 rows).
*/
export function buildMonthGrid(anchor: DateValue, opts: MonthGridOpts): DateValue[][] {
const monthStart = startOfMonth(anchor);
const monthEnd = endOfMonth(monthStart);
const firstCell =
opts.weekStartsOn === undefined
? getLastFirstDayOfWeek(monthStart, 0, opts.locale)
: getLastFirstDayOfWeek(monthStart, opts.weekStartsOn, 'en-US');
const lastCell =
opts.weekStartsOn === undefined
? getNextLastDayOfWeek(monthEnd, 0, opts.locale)
: getNextLastDayOfWeek(monthEnd, opts.weekStartsOn, 'en-US');
const dates: DateValue[] = [];
let cursor: DateValue = firstCell;
while (cursor.compare(lastCell) <= 0) {
dates.push(cursor);
cursor = cursor.add({ days: 1 });
}
if (opts.fixedWeeks) {
while (dates.length < 42) {
dates.push(dates[dates.length - 1].add({ days: 1 }));
}
}
const weeks: DateValue[][] = [];
for (let i = 0; i < dates.length; i += 7) weeks.push(dates.slice(i, i + 7));
return weeks;
}

@ -0,0 +1,69 @@
/**
* Undo/redo — snapshot stack. Pure functions over an immutable
* `ChronosHistory`. Precedent: `words/engine/operations/history.ts`
* (`WORDS_HISTORY_LIMIT = 200`).
*
* `commit` pushes the current present onto `past`, sets the new present, and
* clears `future` (a fresh edit invalidates the redo branch). `past` is capped
* at `limit` — the oldest entries fall off the bottom.
*/
import type { ChronosSnapshot } from './types';
export const CHRONOS_HISTORY_LIMIT = 200;
export interface ChronosHistory {
readonly past: readonly ChronosSnapshot[];
readonly present: ChronosSnapshot;
readonly future: readonly ChronosSnapshot[];
readonly limit: number;
}
export function createHistory(
present: ChronosSnapshot,
limit: number = CHRONOS_HISTORY_LIMIT
): ChronosHistory {
return { past: [], present, future: [], limit };
}
/** Record a new present, capping `past` and discarding the redo branch. */
export function commitHistory(h: ChronosHistory, next: ChronosSnapshot): ChronosHistory {
const past = [...h.past, h.present];
const overflow = past.length - h.limit;
return {
past: overflow > 0 ? past.slice(overflow) : past,
present: next,
future: [],
limit: h.limit
};
}
export function canUndo(h: ChronosHistory): boolean {
return h.past.length > 0;
}
export function canRedo(h: ChronosHistory): boolean {
return h.future.length > 0;
}
export function undoHistory(h: ChronosHistory): ChronosHistory {
if (h.past.length === 0) return h;
const previous = h.past[h.past.length - 1];
return {
past: h.past.slice(0, -1),
present: previous,
future: [h.present, ...h.future],
limit: h.limit
};
}
export function redoHistory(h: ChronosHistory): ChronosHistory {
if (h.future.length === 0) return h;
const [next, ...rest] = h.future;
return {
past: [...h.past, h.present],
present: next,
future: rest,
limit: h.limit
};
}

@ -0,0 +1,136 @@
/**
* Month layout — pure. Given the month's week rows and the events, produce the
* render-ready segments (one per event per week it touches) with consistent
* lane assignment, plus the per-cell "+N more" overflow tally.
*
* This is the correct overlap algorithm Chronos ships that several references
* get wrong (react-big-calendar's "no-overlap" still overlaps short events).
*
* Lane assignment per week row (greedy): segments are ordered (all-day first,
* earlier start, longer span), then each takes the lowest lane index free
* across every column it spans. Multi-day bars therefore keep ONE lane across
* the whole row. Segments whose lane exceeds `maxLanes` are not emitted; each
* cell they cover gets a "+N more" increment instead.
*/
import type { DateValue } from '$libs/days';
import type {
ChronosCellOverflow,
ChronosEvent,
ChronosLayoutOpts,
ChronosMonthLayout,
ChronosSegment
} from './types';
import { compareForLayout, dayOrdinal } from './event-store';
interface DraftSegment extends Omit<ChronosSegment, 'lane'> {
lane: number;
}
export function layoutMonth(
events: readonly ChronosEvent[],
weeks: readonly (readonly DateValue[])[],
opts: ChronosLayoutOpts
): ChronosMonthLayout {
const segments: ChronosSegment[] = [];
const hidden = new Map<string, number>();
let usedLanes = 0;
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
const week = weeks[weekIndex];
if (week.length === 0) continue;
const wStart = dayOrdinal(week[0]);
const wEnd = dayOrdinal(week[week.length - 1]);
// 1. Build this row's segments (clamped to the visible week).
const drafts: DraftSegment[] = [];
for (const event of events) {
const evStart = dayOrdinal(event.start);
const evEnd = dayOrdinal(event.end);
if (evEnd < wStart || evStart > wEnd) continue;
let startCol = 0;
while (startCol < week.length && dayOrdinal(week[startCol]) < evStart) startCol++;
if (startCol >= week.length) startCol = week.length - 1;
let endCol = week.length - 1;
while (endCol > 0 && dayOrdinal(week[endCol]) > evEnd) endCol--;
drafts.push({
event,
weekIndex,
startCol,
span: Math.max(1, endCol - startCol + 1),
lane: -1,
continuesBefore: evStart < wStart,
continuesAfter: evEnd > wEnd
});
}
// 2. Order then greedily assign the lowest free lane across the span.
drafts.sort((a, b) => {
if (a.startCol !== b.startCol) return a.startCol - b.startCol;
if (a.span !== b.span) return b.span - a.span;
return compareForLayout(a.event, b.event);
});
const laneCols: boolean[][] = []; // laneCols[lane][col] = occupied
for (const seg of drafts) {
let lane = 0;
for (;;) {
if (!laneCols[lane]) laneCols[lane] = new Array(week.length).fill(false);
let free = true;
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
if (laneCols[lane][c]) {
free = false;
break;
}
}
if (free) {
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) laneCols[lane][c] = true;
seg.lane = lane;
break;
}
lane++;
}
}
// 3. Per-cell visible cap. A cell with MORE than `maxLanes` segments
// reserves its last lane for the "+N more" link, so it shows
// (maxLanes - 1) bars + the link and never exceeds `maxLanes` rows.
// Cells at/under the cap show everything.
const cap = opts.maxLanes;
const cellTotal = new Map<string, number>();
for (const seg of drafts) {
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
const key = week[c].toString();
cellTotal.set(key, (cellTotal.get(key) ?? 0) + 1);
}
}
const visibleCap = (key: string) => ((cellTotal.get(key) ?? 0) > cap ? cap - 1 : cap);
for (const seg of drafts) {
// A multi-day bar uses the strictest cap across the cells it covers.
let segCap = cap;
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
segCap = Math.min(segCap, visibleCap(week[c].toString()));
}
if (seg.lane < segCap) {
usedLanes = Math.max(usedLanes, seg.lane + 1);
segments.push(seg);
} else {
for (let c = seg.startCol; c < seg.startCol + seg.span; c++) {
const key = week[c].toString();
hidden.set(key, (hidden.get(key) ?? 0) + 1);
}
}
}
}
const overflow: ChronosCellOverflow[] = [...hidden.entries()].map(([date, hiddenCount]) => ({
date,
hiddenCount
}));
return { segments, overflow, usedLanes };
}

@ -0,0 +1,54 @@
/**
* Calendar color schema — pure resolution. Event colors come from the
* consumer's `CalendarSchema` (categories → palette scale), NOT from the intent
* channel. The canonical "Other" type is always available and carries a custom
* color chosen per-event.
*/
import { CHRONOS_OTHER_TYPE_ID, type CalendarSchema, type ChronosEvent, type ChronosEventType } from './types';
/** The single built-in event type. Its color is per-event (custom picker). */
export const CHRONOS_OTHER_TYPE: ChronosEventType = {
id: CHRONOS_OTHER_TYPE_ID,
label: 'Other',
color: '#6b7280'
};
export const EMPTY_CALENDAR_SCHEMA: CalendarSchema = { eventTypes: [], predefinedColors: [] };
/** Selectable types: the schema's categories plus the canonical "Other". */
export function listEventTypes(
schema: CalendarSchema,
otherLabel: string = CHRONOS_OTHER_TYPE.label
): ChronosEventType[] {
return [...schema.eventTypes, { ...CHRONOS_OTHER_TYPE, label: otherLabel }];
}
/** Resolve an event's color (eidos scale name or CSS color string). */
export function resolveEventColor(event: ChronosEvent, schema: CalendarSchema): string {
if (event.typeId && event.typeId !== CHRONOS_OTHER_TYPE_ID) {
const type = schema.eventTypes.find((t) => t.id === event.typeId);
if (type) return type.color;
}
return event.color || CHRONOS_OTHER_TYPE.color;
}
/**
* Build the per-chip CSS custom properties for a resolved color. `color` is a
* CSS color VALUE (hex / oklch / rgb) — NOT an eidos scale name: the raw
* `--scale-*` palette is not shipped at runtime (the foundation ships role
* scales only), so we composite the chip's bg / accent / text from the value
* with `color-mix`, which is theme-adaptive and dependency-free.
*/
export function eventColorVars(color: string): string {
return (
`--_chronos-event-accent: ${color};` +
`--_chronos-event-bg: color-mix(in oklab, ${color} 18%, var(--color-surface-raised));` +
`--_chronos-event-fg: color-mix(in oklab, ${color} 64%, var(--color-content-primary));`
);
}
/** A swatch fill (legend / picker) for a color value. */
export function swatchColor(color: string): string {
return color;
}

@ -0,0 +1,191 @@
/**
* ChronosCalendar — the reactive brain. The ONLY file in `engine/` that uses
* runes. Wraps the pure engine (commands / history / grid / layout) in
* `$state` / `$derived` so the Month grid and laid-out events recompute with
* fine-grained reactivity: moving one event recomputes only the layout, and
* Svelte patches the affected chip. This is the structural advantage over the
* imperative-core wrappers (FullCalendar/Mobiscroll/DayPilot) — see SPEC §1.
*/
import {
today,
startOfMonth,
endOfMonth,
getLocalTimeZone,
type DateValue,
type WeekStartsOn
} from '$libs/days';
import { applyChronosCommand } from './commands';
import {
canRedo,
canUndo,
commitHistory,
createHistory,
redoHistory,
undoHistory,
type ChronosHistory
} from './history';
import { buildMonthGrid, weekOf } from './grid';
import { layoutMonth } from './layout';
import { layoutDayTimed, type DayTimeLayout } from './time-layout';
import { eventsOnDay } from './event-store';
import type { ChronosCommand, ChronosEvent } from './types';
/** The available calendar views. */
export type ChronosView = 'month' | 'week' | 'day' | 'agenda';
/** A day bucket for the Agenda view. */
export interface ChronosAgendaGroup {
day: DateValue;
events: ChronosEvent[];
}
export interface ChronosCalendarInit {
initialDate?: DateValue;
view?: ChronosView;
events?: readonly ChronosEvent[];
weekStartsOn?: WeekStartsOn;
fixedWeeks?: boolean;
locale?: string;
maxLanes?: number;
historyLimit?: number;
}
export class ChronosCalendar {
/** Snapshot stack — source of truth for the event set (enables undo/redo). */
history = $state<ChronosHistory>(createHistory({ events: [] }));
/** The month being displayed (any day within it). */
anchor = $state<DateValue>(today(getLocalTimeZone()));
/** Active view. */
view = $state<ChronosView>('month');
// Reactive layout inputs (the provider keeps these in sync with props).
weekStartsOn = $state<WeekStartsOn | undefined>(undefined);
fixedWeeks = $state(true);
locale = $state('en');
maxLanes = $state(3);
constructor(init: ChronosCalendarInit = {}) {
if (init.initialDate) this.anchor = init.initialDate;
if (init.view) this.view = init.view;
this.history = createHistory({ events: init.events ?? [] }, init.historyLimit);
if (init.weekStartsOn !== undefined) this.weekStartsOn = init.weekStartsOn;
if (init.fixedWeeks !== undefined) this.fixedWeeks = init.fixedWeeks;
if (init.locale) this.locale = init.locale;
if (init.maxLanes !== undefined) this.maxLanes = init.maxLanes;
}
get events(): readonly ChronosEvent[] {
return this.history.present.events;
}
get canUndo(): boolean {
return canUndo(this.history);
}
get canRedo(): boolean {
return canRedo(this.history);
}
/** The 6×7 (or 5×7) grid for the current anchor month. */
readonly weeks = $derived.by(() =>
buildMonthGrid(this.anchor, {
weekStartsOn: this.weekStartsOn,
fixedWeeks: this.fixedWeeks,
locale: this.locale
})
);
/** Render-ready event segments + per-cell overflow for the current grid. */
readonly layout = $derived.by(() =>
layoutMonth(this.events, this.weeks, { maxLanes: this.maxLanes })
);
/** The 7 days of the week containing the anchor (Week view). */
readonly weekDays = $derived.by(() => weekOf(this.anchor, this.weekStartsOn, this.locale));
/** Days shown in the time-grid: 1 (Day) or 7 (Week); empty otherwise. */
readonly visibleDays = $derived.by(() =>
this.view === 'day' ? [this.anchor] : this.view === 'week' ? this.weekDays : []
);
/** Per-day timed-event layout for the time-grid views (keyed by ISO date). */
readonly timeLayout = $derived.by(
() => new Map(this.visibleDays.map((d) => [d.toString(), layoutDayTimed(this.events, d)]))
);
/** Day buckets with events for the Agenda view (the anchor month). */
readonly agendaGroups = $derived.by<ChronosAgendaGroup[]>(() => {
const groups: ChronosAgendaGroup[] = [];
let cursor: DateValue = startOfMonth(this.anchor);
const end = endOfMonth(this.anchor);
while (cursor.compare(end) <= 0) {
const dayEvents = eventsOnDay(this.events, cursor);
if (dayEvents.length > 0) groups.push({ day: cursor, events: dayEvents });
cursor = cursor.add({ days: 1 });
}
return groups;
});
eventsForDay(day: DateValue): ChronosEvent[] {
return eventsOnDay(this.events, day);
}
timeLayoutFor(day: DateValue): DayTimeLayout | undefined {
return this.timeLayout.get(day.toString());
}
/**
* Timed + all-day layout for ANY day, computed on demand. Unlike
* `timeLayoutFor` (cached by the time-grid's `visibleDays`, empty in Month
* view), this works for any date — used by the Month "+N more" day-peek.
*/
dayTimeLayout(day: DateValue): DayTimeLayout {
return layoutDayTimed(this.events, day);
}
// ── Mutations (each commits a history snapshot when it changes state) ──────
/** Apply a command; returns true when the state actually changed. */
dispatch(cmd: ChronosCommand): boolean {
const result = applyChronosCommand(this.history.present, cmd);
if (!result.changed) return false;
this.history = commitHistory(this.history, result.snapshot);
return true;
}
undo(): boolean {
if (!canUndo(this.history)) return false;
this.history = undoHistory(this.history);
return true;
}
redo(): boolean {
if (!canRedo(this.history)) return false;
this.history = redoHistory(this.history);
return true;
}
/** Re-seed the working set from an external `events` prop (resets history). */
setEvents(events: readonly ChronosEvent[]): void {
this.history = createHistory({ events }, this.history.limit);
}
// ── Navigation ────────────────────────────────────────────────────────────
setView(view: ChronosView): void {
this.view = view;
}
/** Step back by the active view's unit (month / week / day). */
prev(): void {
if (this.view === 'week') this.anchor = this.anchor.subtract({ weeks: 1 });
else if (this.view === 'day') this.anchor = this.anchor.subtract({ days: 1 });
else this.anchor = startOfMonth(this.anchor).subtract({ months: 1 });
}
/** Step forward by the active view's unit (month / week / day). */
next(): void {
if (this.view === 'week') this.anchor = this.anchor.add({ weeks: 1 });
else if (this.view === 'day') this.anchor = this.anchor.add({ days: 1 });
else this.anchor = startOfMonth(this.anchor).add({ months: 1 });
}
goToToday(): void {
this.anchor = today(getLocalTimeZone());
}
goTo(date: DateValue): void {
this.anchor = date;
}
}

@ -0,0 +1,101 @@
/**
* Time-grid layout — pure. Positions timed events within a single day's
* vertical hour axis (Week / Day views) and resolves overlaps into side-by-side
* columns (the correct algorithm that several references get wrong).
*
* All-day / multi-day events are returned separately so the view can render
* them in the all-day row above the grid.
*/
import type { DateValue } from '$libs/days';
import type { ChronosEvent } from './types';
import { eventTimeMode } from './types';
import { dayOrdinal } from './event-store';
export const MINUTES_PER_DAY = 1440;
const MIN_BLOCK_MINUTES = 30;
/** Minutes from midnight for a DateValue (0 for date-only values). */
function minutesOf(d: DateValue): number {
return 'hour' in d ? d.hour * 60 + d.minute : 0;
}
/** A positioned timed event within a day's grid. */
export interface TimedBlock {
event: ChronosEvent;
/** Stable per-block key — `id` for single events, `id#band` for split bands. */
key: string;
/** Minutes from midnight (clamped to the day). */
startMin: number;
endMin: number;
/** Column index + total columns in the overlap cluster (for side-by-side). */
col: number;
cols: number;
}
export interface DayTimeLayout {
timed: TimedBlock[];
allDay: ChronosEvent[];
}
/** Layout the timed events of a single day, plus its all-day events. */
export function layoutDayTimed(events: readonly ChronosEvent[], day: DateValue): DayTimeLayout {
const key = dayOrdinal(day);
const onDay = events.filter((e) => dayOrdinal(e.start) <= key && dayOrdinal(e.end) >= key);
const allDay = onDay.filter((e) => e.allDay);
const blocks: TimedBlock[] = [];
for (const e of onDay) {
if (e.allDay) continue;
if (eventTimeMode(e) === 'split') {
// Split: the same bands repeat on every day of the span.
(e.parts ?? []).forEach((band, i) => {
const startMin = band.start.hour * 60 + band.start.minute;
let endMin = band.end.hour * 60 + band.end.minute;
if (endMin <= startMin) endMin = Math.min(MINUTES_PER_DAY, startMin + MIN_BLOCK_MINUTES);
blocks.push({ event: e, key: `${e.id}#${i}`, startMin, endMin, col: 0, cols: 1 });
});
} else {
// Single: one continuous block, clipped to this day.
const startMin = dayOrdinal(e.start) < key ? 0 : minutesOf(e.start);
let endMin = dayOrdinal(e.end) > key ? MINUTES_PER_DAY : minutesOf(e.end);
if (endMin <= startMin) endMin = Math.min(MINUTES_PER_DAY, startMin + MIN_BLOCK_MINUTES);
blocks.push({ event: e, key: e.id, startMin, endMin, col: 0, cols: 1 });
}
}
blocks.sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);
assignColumns(blocks);
return { timed: blocks, allDay };
}
/**
* Greedy interval-graph column assignment. Walks blocks in start order,
* grouping transitively-overlapping blocks into clusters; within a cluster each
* block takes the lowest free column, and every block in the cluster shares the
* cluster's column count (so widths line up).
*/
function assignColumns(blocks: TimedBlock[]): void {
let cluster: TimedBlock[] = [];
let clusterEnd = -1;
const flush = () => {
const cols = Math.max(1, ...cluster.map((b) => b.col + 1));
for (const b of cluster) b.cols = cols;
cluster = [];
clusterEnd = -1;
};
for (const block of blocks) {
if (cluster.length > 0 && block.startMin >= clusterEnd) flush();
// Lowest column whose last block ends on/before this block's start.
const colEnds: number[] = [];
for (const b of cluster) colEnds[b.col] = Math.max(colEnds[b.col] ?? -1, b.endMin);
let col = 0;
while (colEnds[col] !== undefined && colEnds[col] > block.startMin) col++;
block.col = col;
cluster.push(block);
clusterEnd = Math.max(clusterEnd, block.endMin);
}
if (cluster.length > 0) flush();
}

@ -0,0 +1,160 @@
/**
* Chronos engine — pure data model + command/layout types.
*
* No DOM, no runes, no Svelte. Everything here is plain data + pure functions
* so the calendar's brain is trivially unit-testable (precedent:
* `words/engine/operations`, `$libs/datagrid`). The reactive `CalendarState`
* (state.svelte.ts) is the only file in `engine/` that touches runes.
*/
import type { DateValue, Time } from '$libs/days';
/**
* Canonical, always-available event type. Every other type comes from the
* consumer's `CalendarSchema`; this one is built in so a user can always file
* an event as "Other" and pick a custom color for it.
*/
export const CHRONOS_OTHER_TYPE_ID = 'otros';
/**
* How an event occupies time. Derived from the event shape, never stored:
* - `all-day` → `allDay: true` (covers whole days across the date span).
* - `single` → one continuous timed block (`start`/`end` carry the time).
* - `split` → multiple time bands repeated on each day of the date span
* (`parts` holds them; `start`/`end` are date-only).
*/
export type ChronosTimeMode = 'all-day' | 'single' | 'split';
/** One time band of a `split` event — a start→end slice within a day. */
export interface ChronosTimeBand {
start: Time;
end: Time;
}
/** Resolve an event's time mode from its shape (single source of truth). */
export function eventTimeMode(event: ChronosEvent): ChronosTimeMode {
if (event.allDay) return 'all-day';
if (event.parts && event.parts.length > 0) return 'split';
return 'single';
}
/**
* One event category in a `CalendarSchema`. `color` is an eidos palette SCALE
* name (`blue`, `grass`, `tomato`, `amber`, `violet`, …) — NEVER an intent role
* (affirm/risk/…). Intent is an evaluative channel; event categories are not
* evaluative, so they use the neutral palette scales.
*/
export interface ChronosEventType {
id: string;
label: string;
/** CSS color value (hex / oklch / rgb). Composited per-chip via `color-mix`. */
color: string;
}
/**
* The color/category contract a consumer passes to Chronos. Event types are
* consumer-defined; `predefinedColors` is the palette offered by the editor's
* color picker when an event is filed under the canonical "Other" type.
*/
export interface CalendarSchema {
eventTypes: readonly ChronosEventType[];
/** CSS color values offered in the editor's picker for "Other" events. */
predefinedColors: readonly string[];
}
/**
* A calendar event. `start`/`end` are `$libs/days` values (never raw `Date`)
* so zone + DST survive. `end` is INCLUSIVE of the last covered instant
* (documented; react-big-calendar's exclusive end is a perennial confusion).
*/
export interface ChronosEvent {
id: string;
title: string;
/** all-day → `CalendarDate`; timed → `ZonedDateTime`. */
start: DateValue;
end: DateValue;
allDay?: boolean;
/** References a `CalendarSchema` event type id, or `CHRONOS_OTHER_TYPE_ID`. */
typeId?: string;
/** Custom color (scale name or CSS color). Only used for the "Other" type. */
color?: string;
/**
* `split` mode: time bands repeated on every day of `[start, end]`. When
* present and non-empty the event is "split"; `start`/`end` are date-only and
* the times live here. Empty / absent → `all-day` or `single` (see
* {@link eventTimeMode}). Bands are kept ordered + non-overlapping.
*/
parts?: ChronosTimeBand[];
/** When false, the event cannot be dragged / resized / deleted. */
editable?: boolean;
extendedProps?: Record<string, unknown>;
// — roadmap (declared for forward-compat; ignored by v1 engine) —
rrule?: string;
resourceId?: string;
}
/** Immutable snapshot of the mutable calendar state for the history stack. */
export interface ChronosSnapshot {
readonly events: readonly ChronosEvent[];
}
/**
* Command pattern (precedent: `words/engine/operations/commands.ts`). Each
* command is plain JSON → `applyCommand` returns a new snapshot + a `changed`
* flag. This is what makes undo/redo a thin snapshot stack.
*/
export type ChronosCommand =
| { type: 'create'; event: ChronosEvent }
| { type: 'move'; id: string; start: DateValue; end: DateValue }
| { type: 'resize'; id: string; end: DateValue }
| { type: 'update'; id: string; patch: Partial<Omit<ChronosEvent, 'id'>> }
| { type: 'delete'; id: string };
/** Result of applying a command: the next snapshot + whether anything changed. */
export interface ChronosCommandResult {
snapshot: ChronosSnapshot;
changed: boolean;
}
/**
* A single contiguous segment of an event inside ONE week row. A multi-day
* event spanning a week boundary produces multiple segments (one per row).
* This is the unit the Month view renders as a bar/chip.
*/
export interface ChronosSegment {
event: ChronosEvent;
/** Index of the week row in the month grid (0-based). */
weekIndex: number;
/** Column where the segment starts within the row (0 = first weekday). */
startCol: number;
/** Number of columns the segment spans (1..7). */
span: number;
/** The event's lane (vertical track) within the cells it covers. */
lane: number;
/** True when the event began before this row's first visible day. */
continuesBefore: boolean;
/** True when the event continues past this row's last visible day. */
continuesAfter: boolean;
}
/** Per-cell overflow info: how many lanes were hidden behind "+N more". */
export interface ChronosCellOverflow {
/** ISO date string of the cell. */
date: string;
/** Number of events not shown because they exceed `maxLanes`. */
hiddenCount: number;
}
/** The laid-out Month: segments to render + overflow tallies per cell. */
export interface ChronosMonthLayout {
segments: readonly ChronosSegment[];
overflow: readonly ChronosCellOverflow[];
/** Max lane index actually used (for sizing). */
usedLanes: number;
}
export interface ChronosLayoutOpts {
/** Max lanes rendered per cell before collapsing into "+N more". */
maxLanes: number;
}

@ -0,0 +1,17 @@
export { default as Provider } from './components/chronos.svelte';
export { ChronosProvider, chronosAttrs } from './internals';
export type { ChronosOpts, ChronosEditing } from './internals';
export type {
ChronosProps,
ChronosEditorContext,
ChronosTooltipContext,
ChronosEvent,
ChronosEventType,
ChronosRange,
ChronosView,
ChronosAgendaGroup,
ChronosTimeBand,
ChronosTimeMode,
CalendarSchema
} from './types';
export { eventTimeMode } from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,7 @@
// Public barrel must not re-export the provider implementation by filename.
export {
ChronosProvider,
chronosAttrs,
type ChronosOpts,
type ChronosEditing
} from './chronos-provider.svelte';

@ -0,0 +1,122 @@
import type { Snippet } from 'svelte';
import type { DateValue, WeekStartsOn } from '$libs/days';
import type { CalendarSchema, ChronosEvent, ChronosEventType } from './engine/types';
import type { ChronosView } from './engine/state.svelte';
export type {
ChronosEvent,
ChronosEventType,
CalendarSchema,
ChronosTimeBand,
ChronosTimeMode
} from './engine/types';
export { eventTimeMode } from './engine/types';
export type { ChronosView, ChronosAgendaGroup } from './engine/state.svelte';
/** Visible date range emitted on navigation. */
export interface ChronosRange {
start: DateValue;
end: DateValue;
}
/**
* Context handed to a consumer-provided editor snippet. Lets an application
* render its OWN dialog body (whatever fields it needs) while the actions stay
* wired to the calendar engine — `save`/`remove` dispatch through the
* command/history stack and fire the public callbacks, exactly like the
* built-in editor. This is how Chronos stays customizable: the host app decides
* what information lives in the dialog; Chronos owns the Dialog mechanics +
* state + engine.
*/
export interface ChronosEditorContext {
/** Whether the draft is a brand-new event or an existing one being edited. */
mode: 'create' | 'edit';
/**
* Interaction mode: `false` = view (readonly), `true` = edit. A host editor
* should render its fields readonly/disabled when this is `false`.
*/
editable: boolean;
/** Flip a view-mode dialog into edit mode (no-op if editing isn't permitted). */
enableEditing: () => void;
/** The reactive draft. Bind form fields to its properties. */
draft: ChronosEvent;
/** The consumer's color/category contract (read-only). */
schema: CalendarSchema;
/** Selectable types: schema categories + the canonical "Other". */
eventTypes: readonly ChronosEventType[];
/** Toggle all-day ↔ timed (converts the draft's start/end accordingly). */
setAllDay: (on: boolean) => void;
/** Commit the draft through the calendar engine, then close. */
save: () => void;
/** Delete the event (edit mode) through the engine, then close. */
remove: () => void;
/** Close the editor without saving. */
cancel: () => void;
}
/**
* Context handed to a consumer-provided `eventTooltip` snippet — the data shown
* when hovering an event band. The application decides the markup; Chronos
* supplies the resolved bits. Without a custom snippet, Chronos renders a
* default body (title + time + type).
*/
export interface ChronosTooltipContext {
/** The hovered event. Read `extendedProps` for app-specific fields. */
event: ChronosEvent;
/** Resolved type label (schema category or the canonical "Other"). */
typeLabel: string;
/** Localized time label — a start–end range, or "All day". */
timeLabel: string;
/** Resolved event color (CSS value). */
color: string;
}
/**
* Public props for the Chronos root. v1 ships the Month view. The provider
* OWNS the event set after mount (its history stack enables undo/redo); the
* consumer observes mutations through the callbacks rather than two-way
* binding `events` (which would reset history on every echo).
*/
export interface ChronosProps {
ref?: HTMLElement | null;
id?: string;
/** Initial month to display (any day within it). Default: today. */
date?: DateValue;
/** Initial event set. Read once at mount; subsequent state lives in the provider. */
events?: readonly ChronosEvent[];
/**
* Color/category contract: event types + their palette scales, plus the
* predefined colors offered by the editor's picker for the canonical "Other"
* type. Event colors come from here — never from the intent channel.
*/
schema?: CalendarSchema;
/** Localized label for the canonical "Other" event type. */
otherTypeLabel?: string;
/** Initial view. Default: 'month'. */
view?: ChronosView;
onViewChange?: (view: ChronosView) => void;
/** First day of week. `undefined` → derive from locale. */
weekStartsOn?: WeekStartsOn;
/** Pad to a constant 6 rows. Default: true. */
fixedWeeks?: boolean;
locale?: string;
/** Max event chips per cell before "+N more". Default: 3. */
maxEventsPerCell?: number;
/** Allow create / move / resize / delete. Default: true. */
editable?: boolean;
readonly?: boolean;
disabled?: boolean;
ariaLabel?: string;
onEventCreate?: (event: ChronosEvent) => void;
onEventUpdate?: (event: ChronosEvent) => void;
onEventDelete?: (id: string) => void;
onRangeChange?: (range: ChronosRange) => void;
onEventClick?: (event: ChronosEvent) => void;
children?: Snippet;
child?: Snippet<[{ props: Record<string, unknown> }]>;
/** Passthrough for `data-*` attrs (e.g. the eidos wrapper's `data-size`/`data-color`). */
[dataAttr: `data-${string}`]: string | number | boolean | null | undefined;
}

@ -0,0 +1,213 @@
<script lang="ts">
import Chronos, {
type ChronosEvent,
type ChronosRange,
type CalendarSchema,
type ChronosEditorContext
} from '$uix/eidos/components/chronos';
import type { ChronosColor, ChronosSize } from '$uix/eidos/components/chronos';
import { CalendarDate, CalendarDateTime, type WeekStartsOn } from '$libs/days';
// Framework component reused INSIDE the consumer-defined custom editor details.
import { Field } from '$uix/eidos/components/field';
const M = (d: number) => new CalendarDate(2026, 6, d);
const DT = (d: number, h: number, min = 0) => new CalendarDateTime(2026, 6, d, h, min);
const schema: CalendarSchema = {
eventTypes: [
{ id: 'work', label: 'Work', color: '#3b82f6' },
{ id: 'meeting', label: 'Meeting', color: '#8b5cf6' },
{ id: 'personal', label: 'Personal', color: '#22c55e' },
{ id: 'travel', label: 'Travel', color: '#f59e0b' }
],
predefinedColors: [
'#3b82f6',
'#8b5cf6',
'#22c55e',
'#f59e0b',
'#ef4444',
'#a855f7',
'#14b8a6',
'#6b7280'
]
};
const initialEvents: ChronosEvent[] = [
{ id: 'e1', title: 'Standup', start: M(2), end: M(2), allDay: true, typeId: 'work' },
{ id: 'e2', title: 'Design review', start: M(8), end: M(8), allDay: true, typeId: 'meeting' },
{ id: 'e3', title: 'Conference', start: M(8), end: M(11), allDay: true, typeId: 'travel' },
{ id: 'e4', title: 'Launch', start: M(15), end: M(15), allDay: true, typeId: 'work' },
{ id: 'e5', title: 'Retro', start: M(18), end: M(18), allDay: true, typeId: 'meeting' },
// A busy day to exercise "+N more".
{ id: 'e6', title: 'Sync A', start: M(22), end: M(22), allDay: true, typeId: 'work' },
{ id: 'e7', title: 'Sync B', start: M(22), end: M(22), allDay: true, typeId: 'meeting' },
{ id: 'e8', title: 'Sync C', start: M(22), end: M(22), allDay: true, typeId: 'personal' },
{ id: 'e9', title: '1:1', start: M(22), end: M(22), allDay: true, typeId: 'meeting' },
{ id: 'e10', title: 'Trip', start: M(24), end: M(30), allDay: true, typeId: 'travel' },
// Timed events so Week / Day views (and their drag-drop) have content.
{ id: 't1', title: 'Standup', start: DT(2, 9, 0), end: DT(2, 9, 30), typeId: 'work' },
{
id: 't2',
title: 'Design review',
start: DT(2, 11, 0),
end: DT(2, 12, 30),
typeId: 'meeting'
},
{ id: 't3', title: 'Lunch', start: DT(3, 13, 0), end: DT(3, 14, 0), typeId: 'personal' },
{ id: 't4', title: 'Flight', start: DT(4, 16, 0), end: DT(4, 18, 0), typeId: 'travel' },
// Timed events on the busy day (22) so the "+N more" day-peek shows a grid.
{ id: 't5', title: 'Planning', start: DT(22, 10, 0), end: DT(22, 11, 0), typeId: 'work' },
{ id: 't6', title: 'Review', start: DT(22, 11, 30), end: DT(22, 12, 30), typeId: 'meeting' },
{ id: 't7', title: 'Demo call', start: DT(22, 15, 0), end: DT(22, 16, 0), typeId: 'personal' }
];
let size = $state<ChronosSize>('md');
let color = $state<ChronosColor>('primary');
let weekStart = $state<'locale' | '0' | '1'>('locale');
let fixedWeeks = $state(true);
let maxEventsPerCell = $state(3);
let editable = $state(true);
let readonly = $state(false);
let customEditor = $state(false);
let trace = $state<string[]>([]);
const weekStartsOn = $derived(
weekStart === 'locale' ? undefined : (Number(weekStart) as WeekStartsOn)
);
function log(msg: string): void {
trace = [msg, ...trace].slice(0, 10);
}
</script>
<div
data-uix-canvas-inner
style="display:flex; flex-direction:column; gap:1.5rem; padding:1.5rem; max-width:1100px;"
>
<header>
<h1 style="margin:0 0 0.25rem;">Chronos</h1>
<p style="margin:0; opacity:0.7;">
Event calendar — Month view. Hover a day and click <strong>+</strong> to create, click an event
to edit, undo/redo in the toolbar.
</p>
</header>
<section style="display:flex; gap:1rem; flex-wrap:wrap; align-items:flex-end;">
<label style="display:flex; flex-direction:column; gap:0.25rem;">
Size
<select bind:value={size}>
<option value="sm">sm</option>
<option value="md">md</option>
<option value="lg">lg</option>
</select>
</label>
<label style="display:flex; flex-direction:column; gap:0.25rem;">
Accent
<select bind:value={color}>
<option value="primary">primary</option>
<option value="secondary">secondary</option>
<option value="neutral">neutral</option>
</select>
</label>
<label style="display:flex; flex-direction:column; gap:0.25rem;">
Week start
<select bind:value={weekStart}>
<option value="locale">locale</option>
<option value="0">Sunday</option>
<option value="1">Monday</option>
</select>
</label>
<label style="display:flex; flex-direction:column; gap:0.25rem;">
Max / cell
<input type="number" min="1" max="6" bind:value={maxEventsPerCell} style="width:4rem;" />
</label>
<label style="display:flex; align-items:center; gap:0.4rem;">
<input type="checkbox" bind:checked={fixedWeeks} /> Fixed weeks
</label>
<label style="display:flex; align-items:center; gap:0.4rem;">
<input type="checkbox" bind:checked={editable} /> Editable
</label>
<label style="display:flex; align-items:center; gap:0.4rem;">
<input type="checkbox" bind:checked={readonly} /> Readonly
</label>
<label style="display:flex; align-items:center; gap:0.4rem;">
<input type="checkbox" bind:checked={customEditor} /> Custom editor
</label>
</section>
<section>
<Chronos
date={M(1)}
events={initialEvents}
{schema}
{size}
{color}
{weekStartsOn}
{fixedWeeks}
{maxEventsPerCell}
{editable}
{readonly}
onEventCreate={(e: ChronosEvent) => log(`create: ${e.title || '(untitled)'}`)}
onEventUpdate={(e: ChronosEvent) => log(`update: ${e.title || '(untitled)'}`)}
onEventDelete={(id: string) => log(`delete: ${id}`)}
onRangeChange={(r: ChronosRange) => log(`range → ${r.start.toString()}`)}
onEventClick={(e: ChronosEvent) => log(`click: ${e.title || '(untitled)'}`)}
editor={customEditor ? customEventEditor : undefined}
/>
</section>
<section>
<h2 style="font-size:1rem; margin:0 0 0.5rem;">Trace</h2>
<p style="opacity:0.6; margin:0 0 0.5rem; font-size:0.8rem;">
Drag a chip to another day (Month) or another time slot (Week / Day). Toggle
<strong>Custom editor</strong> to replace the dialog body with an app-defined form.
</p>
{#if trace.length === 0}
<p style="opacity:0.6; margin:0;">Interact with the calendar to see callbacks.</p>
{:else}
<ul style="margin:0; padding-left:1.2rem; font-family:monospace; font-size:0.85rem;">
{#each trace as line, i (i)}
<li>{line}</li>
{/each}
</ul>
{/if}
</section>
</div>
<!--
Consumer-defined editor DETAILS. Chronos owns the dialog header, the "when"
controls (date span + time mode + bands) and the footer (save/cancel/delete);
the host app only decides the detail fields shown here. `ctx.draft` is the live
draft; mutations flow straight into the calendar engine on save.
-->
{#snippet customEventEditor(ctx: ChronosEditorContext)}
<p style="margin:0 0 0.25rem; font-size:0.8rem; opacity:0.7;">
These detail fields are defined by the host application, not by Chronos.
</p>
<Field>
<Field.Label>Title</Field.Label>
<Field.Control>
<Field.Input
type="text"
bind:value={ctx.draft.title}
placeholder="Event title"
readonly={!ctx.editable}
/>
</Field.Control>
</Field>
<Field>
<Field.Label>Notes (app-specific field)</Field.Label>
<Field.Control>
<Field.Input
type="text"
value={(ctx.draft.extendedProps?.notes as string) ?? ''}
readonly={!ctx.editable}
oninput={(e) =>
((ctx.draft.extendedProps ??= {}).notes = (e.currentTarget as HTMLInputElement).value)}
placeholder="Anything the app needs"
/>
</Field.Control>
</Field>
{/snippet}
Loading…
Cancel
Save

Powered by TurnKey Linux.