fold-ng 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,347 @@ All notable changes to **fold-ng** are documented here. The format follows
8
8
 
9
9
  _Nothing yet._
10
10
 
11
+ ## [0.8.1] - 2026-08-02
12
+
13
+ ### Changed
14
+
15
+ - **The visual-snapshot tier is no longer a release gate.** Pixel screenshots
16
+ against a live dev server drift for reasons that are not a regression — a
17
+ scrollbar toggling or a font hint moves the frame ~20px — and the baselines
18
+ are per-platform, so gating on them turned the release red on Linux CI _and_
19
+ intermittently on the Mac it was cut from. The shots are now tagged `@visual`
20
+ and run only via `pnpm test:e2e:visual` (a local eyeball aid); the gate
21
+ (`test:e2e`, used by CI and `pnpm release`) excludes them. The geometry that
22
+ matters stays gated, and stably, by measurement in `calendar.spec.ts`. `0.8.0`
23
+ was tagged but never published because of this — `0.8.1` is the first published
24
+ cut of the 0.8 line, and carries the whole calendar family.
25
+
26
+ ## [0.8.0] - 2026-08-02
27
+
28
+ ### Added
29
+
30
+ - **A calendar family, starting with `fold-calendar-month` and the plain-date
31
+ model it stands on.** The grid is a date axis: seven columns of whole days,
32
+ and over them a layer of **bands**, each stretching from the column its event
33
+ starts on to the column it ends on. A span crossing a week is drawn once per
34
+ week with an open edge on the side that continues, so a three-week holiday
35
+ reads as one thing rather than twenty-one chips. Bands pack into lanes
36
+ (earliest start, then longest) under a `maxLanes` budget, and whatever will
37
+ not fit becomes an overflow chip **in the column of each day that lost
38
+ something** — a hidden span counts against every day it would have covered,
39
+ so the chip says _which_ day to open rather than only that the week is
40
+ crowded, and `overflowClick` emits that day. Events sharing a `groupId`
41
+ collapse into one chip spanning the union of their ranges and carrying the
42
+ count; a half-day edge is kept only on the segment holding the event's real
43
+ edge. Inputs cover `weekStartsOn` (any anchor, not just Monday), `fixedWeeks`,
44
+ `locale` and `labels`; `month` is a two-way `model`, so keyboard paging writes
45
+ back. Project an `<ng-template foldCalendarEvent>` to replace the built-in
46
+ chip. Generic over `T`, so an event's `data` survives the round trip to
47
+ `eventClick` without a cast.
48
+ - **`fold-calendar-agenda` — a rail of what is still ahead, grouped by day.**
49
+ The counterpart to the grids: they answer "what does this month look like",
50
+ it answers "what do I do next". Its `todo` slice keeps only the events asking
51
+ for attention — the `warning` and `alert` tones, the **same scale the chips
52
+ paint with**, so the rail needs no second notion of urgency — and carries the
53
+ count as a badge. An event already running is filed under the boundary rather
54
+ than its real start, so a three-week absence that began last week sits at the
55
+ top of what's next instead of in a past day the rail never shows. Days inside
56
+ the next week are named relatively ("Today", "Tomorrow", then the weekday),
57
+ which reads faster than a date at that distance. `mode` and `collapsed` are
58
+ two-way `model`s — persist the collapse if you want it to stick; the package
59
+ stores nothing. Pure `foldBuildAgenda` / `foldCountActionable` behind it.
60
+ - **`fold-calendar-source-filter` — chips that switch each feed of a merged
61
+ calendar on and off.** A calendar usually merges several feeds (a programme,
62
+ staff leave, contracts); an event names its own with `sourceKey`, a
63
+ `FoldCalendarSource` declares the label and dot, and the chips count what each
64
+ contributes. They own the **selection only** — the caller runs the pure
65
+ `foldFilterBySource()` over its own events, so the chips never learn how
66
+ anything is fetched. An event with no `sourceKey` belongs to no feed and no
67
+ chip can hide it. Each chip is a real toggle (`aria-pressed` in both states)
68
+ whose accessible name says which feed and whether it is showing, because the
69
+ tick and the dot are colour and colour cannot be the only carrier.
70
+ - **`fold-calendar-toolbar` — the chrome that makes the views one calendar.**
71
+ Jump to today, page back and forward, the period's name, and the view switch.
72
+ It owns no data: both pieces of state are two-way `model`s, so a page binds
73
+ the same `date` and `view` it hands the view on screen and paging works with
74
+ no output handler. The step matches the reading — a month under the month
75
+ view, a week under the week view, a day under the day view. The title carries
76
+ `aria-live="polite"`, so paging announces where a keyboard user landed instead
77
+ of changing silently. Backed by pure `foldShiftDate`, `foldRangeForView` (the
78
+ window a caller fetches — the whole painted grid for a month, not just its
79
+ days) and `foldViewTitle`.
80
+ - **The three column views that complete the drill-down: `fold-calendar-week`,
81
+ `fold-calendar-day` and `fold-calendar-list`.** Where the month grid packs
82
+ spans into lanes, these simply list what covers each day — so nothing is
83
+ clipped and no lane budget can hide anything. `week` is seven day columns;
84
+ `day` is one day in full, with a tag-qualified `button[empty]` slot for a "new request" action
85
+ when nothing sits on it; `list` is the flat chronological reading, each row
86
+ led by its span (formatted with `Intl`'s own range formatter, which collapses
87
+ a single day to one date and factors out a shared month). All three take the
88
+ same `foldCalendarEvent` template, labels and `locale` as the month view.
89
+ Their a11y is **simpler than the month's on purpose**: because a chip nests
90
+ inside the day it belongs to rather than spanning columns, every control is a
91
+ real child in the natural tab order — no roving tabindex, no `aria-hidden`,
92
+ every event reachable by keyboard. New pure builders `foldBuildWeek` and
93
+ `foldBuildDay` back them.
94
+ - **`FoldCalendarDate` — the package's date primitive is a plain
95
+ `YYYY-MM-DD` string, not a `Date`.** A calendar of all-day spans deals in
96
+ dates, not instants: `new Date("2026-05-18")` is UTC midnight, i.e. the 17th
97
+ anywhere west of Greenwich, and that off-by-one is the most common calendar
98
+ bug there is. Strings remove the class by construction, compare
99
+ lexicographically (`a <= b` _is_ "on or before"), are `===`-equal when they
100
+ mean the same day, and are already the wire format. Arithmetic runs through
101
+ `Date.UTC`, so no DST boundary can repeat or skip a day. Ships with
102
+ `foldToday`, `foldAddDays`, `foldAddMonths`, `foldStartOfWeek`,
103
+ `foldStartOfMonth`, `foldEndOfMonth`, `foldDaysBetween`, `foldWeekdayIndex`,
104
+ `foldWeekdayOf`, `foldIsWeekend`, `foldIsCalendarDate`, `foldToNativeDate` and
105
+ `foldFromNativeDate` (the inbound bridge — the conversion every consumer would
106
+ otherwise write as `toISOString().slice(0, 10)`, which is the very timezone bug
107
+ this design exists to remove), plus the layout entry points
108
+ `foldBuildMonthGrid`, `foldBuildWeek`, `foldBuildDay`, `foldEventsOnDay`,
109
+ `foldEventsInRange` and `foldFilterBySource`. Supported range is
110
+ `0001-01-01`–`9999-12-31`: the four-digit year is what makes the lexicographic
111
+ guarantee hold, so it is enforced rather than assumed.
112
+ `fold-timeline` keeps a native `Date` on purpose — it plots dated _instants_,
113
+ which is the other domain.
114
+ - **Month and weekday names come from `Intl`, not from a label token.** The
115
+ `locale` input drives `Intl.DateTimeFormat`, so every locale works without
116
+ hand-translating twelve month names; `FoldCalendarLabels` (with
117
+ `provideFoldCalendarLabels`) covers only what `Intl` cannot supply — the today
118
+ marker, the overflow chip and the event count.
119
+
120
+ - **Everything the calendar draws _around_ an event is a projectable template.**
121
+ The chip was replaceable from day one; the containers were not, which had it
122
+ backwards — an app could restyle the smallest unit and not the cell behind it.
123
+ Four more `<ng-template>` seams, each with a typed context and an
124
+ `ngTemplateContextGuard` (so `let-` variables are real types under
125
+ `strictTemplates`, not `any`): **`foldCalendarDay`** replaces the inside of a
126
+ month cell — the hook for a public holiday, a closure, "3/8 staffed";
127
+ **`foldCalendarHeading`** replaces the agenda's day heading;
128
+ **`foldCalendarTitle`** replaces the toolbar's `<h2>`, which also lets a page
129
+ give the title the heading level its outline needs; **`foldCalendarOverflow`**
130
+ replaces the `+N` chip. Alongside them, `dayModifiers: (day) => string[]`
131
+ emits an app's own names as one `data-fold-day-modifiers` attribute, matchable
132
+ with `[data-fold-day-modifiers~="holiday"]` — so nobody has to write CSS
133
+ against an internal class name. `foldCalendarEvent` is now generic too, so
134
+ `event.data` comes back as the app's own record.
135
+ - **`fold-calendar-timegrid` — the reading the other four cannot give: when
136
+ _inside_ a day.** Hour columns for a week or a single day (`dayCount`), with
137
+ the all-day strip on top. A meeting is a block whose height is its duration
138
+ and whose width is shared with whatever it collides with; an absence is a band
139
+ across the strip, spanning days through **the same packer the month grid
140
+ uses** — extracted rather than copied, so a three-day leave request reads
141
+ identically in both.
142
+
143
+ Time is modelled as wall-clock `HH:mm` (`FoldCalendarTime`), **not an
144
+ instant** — the same decision as the date, for the same reason: 09:00 is the
145
+ hour on the wall, and an instant re-derives that from a zone every render,
146
+ which is one wrong default away from drawing the wrong hour. The app converts
147
+ once at its own boundary.
148
+
149
+ Two details that are the difference between a real time grid and a demo: the
150
+ overlap test is **exclusive** at the boundary, so back-to-back meetings keep
151
+ the full width instead of each taking half; and events are grouped into
152
+ **clusters**, so one triple-booked morning does not narrow an unrelated
153
+ afternoon. A span crossing midnight becomes one block per day, each with the
154
+ right open edge, rather than one impossible block running off the bottom.
155
+
156
+ `now` is an input, never a clock the package reads — `today` already works
157
+ that way, and a server render that invented one would hydrate to a different
158
+ position. Positions are **fractions of the visible window**, so `dayStart` /
159
+ `dayEnd` and the CSS height stay independent. Backed by pure
160
+ `foldBuildTimeGrid` and `foldLayOutOverlaps`.
161
+
162
+ - **`foldFromTemporal` — the family is Temporal-native without depending on
163
+ it.** `Temporal.PlainDate.toString()` **is** `YYYY-MM-DD`: the primitive
164
+ chosen for correctness turns out to be exactly Temporal-shaped, which no
165
+ `Date`-based calendar can say. The bridge accepts a `PlainDate`,
166
+ `PlainDateTime` or `ZonedDateTime` — typed structurally, so it compiles and
167
+ runs on an engine that has none of them — and drops the time, which is what a
168
+ whole-day calendar wants **and** keeps the day the value itself means rather
169
+ than the one UTC would have picked. The other direction needs no helper:
170
+ `Temporal.PlainDate.from(foldDate)` already accepts one of ours.
171
+ - **The week now comes from the locale, not from a guess.** `Intl.Locale`
172
+ knows which day a locale's week opens on and which days it rests — most
173
+ calendars stop at the month names and hard-code Monday, which is right in
174
+ Paris and wrong in Chicago, Cairo and Malé. `weekStartsOn` and `weekendDays`
175
+ default to `foldLocaleWeekInfo(locale)` and stay overridable, so the common
176
+ case needs no input at all. Both `getWeekInfo()` and the older `weekInfo`
177
+ getter are probed, and a runtime with neither falls back to Monday + Sat/Sun.
178
+ - **ISO week numbers, as an optional leading column.** `showWeekNumbers` on
179
+ `fold-calendar-month`, backed by `foldIsoWeek` / `foldIsoWeekYear`. ISO weeks
180
+ start on Monday and belong to the year holding their **Thursday**, so they are
181
+ deliberately _not_ derived from the calendar's own anchor — 1 January is
182
+ sometimes week 53 of the year before, which is the whole point of the rule and
183
+ the reason European B2B reporting asks for it. The column is a real grid
184
+ track, so every placed element — cells, bands, overflow chips — shifts with it.
185
+ - **A `formats` input beside `labels`.** Labels let a locale translate; this
186
+ lets it **reformat**. Every `Intl` option bag the family uses lives in one
187
+ table (`FOLD_CALENDAR_FORMATS`, now exported); `formats` merges over it per
188
+ instance — a narrow weekday header, a numeric month, a four-digit year.
189
+ - **A print stylesheet for the month.** Browsers drop backgrounds but keep text
190
+ colour, so a dark theme printed as-is is light-on-white — unreadable. The
191
+ print block re-expresses everything in system colours (`Canvas`/`CanvasText`/
192
+ `GrayText`), which are neither theme tokens nor hard-coded values, and asks
193
+ for ink on exactly one thing: the tone bar, the last cue telling two bands
194
+ apart on paper.
195
+ - **The month layout is ~5× faster, and the benchmark ships with it.** The cost
196
+ was never the algorithm — it was the representation: every comparison in the
197
+ candidate sort called `foldDaysBetween`, which re-parsed two strings and built
198
+ two `Date`s, and the whole feed was re-filtered once per week row. Spans now
199
+ carry epoch-day bounds computed once, rows are bucketed in a single pass
200
+ (`O(rows × N)` → `O(N)`), and clipping is integer arithmetic. The public model
201
+ is unchanged — this is internal only. Measured on a month layout, mean of 20:
202
+ 1 000 events 4.8 ms → **0.99 ms**, 5 000 28.9 → **5.0**, 20 000 124 → **22**.
203
+ `pnpm bench:calendar` re-runs it against a committed budget and exits non-zero
204
+ when a size blows it.
205
+ - **`foldCalendarNextFocus` is public — the geometry tier is now complete.**
206
+ The pure builders (`foldBuildMonthGrid`, `foldBuildWeek`, `foldBuildDay`,
207
+ `foldBuildAgenda`) and the period functions (`foldShiftDate`,
208
+ `foldRangeForView`, `foldViewTitle`) let an app lay a calendar out and draw it
209
+ its own way; the arrow-key map was the one piece missing, and a hand-rolled
210
+ date grid owes its users the same keyboard as the built-in one. Documented as
211
+ a **tier** in the README rather than left as an accident of what happened to
212
+ be exported. `foldFocusDayCell` deliberately stays internal: it reads a
213
+ `data-fold-day` attribute this package writes, and exporting it would freeze
214
+ that attribute into the public contract.
215
+ - **The view switch is open.** `FoldCalendarView` keeps the four built-ins as
216
+ autocompleting literals but accepts any string, and `views` takes
217
+ `{ value, label }` — so an app's own reading (a resource grid, a timeline) can
218
+ sit in the same toolbar without the library knowing about it. Paging and
219
+ titling an unrecognised view fall back to month semantics, which always lands
220
+ on a real date.
221
+ - **A `FoldCalendarDay` now carries its own `eventCount` and `hiddenCount`.**
222
+ Both were previously recomputed per cell per change-detection cycle (an O(N)
223
+ filter, with an allocation, 35 times a tick) or exposed as a positional
224
+ `hiddenByDay` array only readable when crossed with the row's dates. Counting
225
+ once while the grid is built is both cheaper and self-describing, and it is
226
+ what lets a day cell announce "5 events, 2 not shown".
227
+
228
+ ### Changed
229
+
230
+ - **A standalone `fold-view-nav` now separates itself from the content it heads
231
+ by the same gap a `fold-nav-layout` applies.** A horizontal bar used on its own
232
+ (outside a layout) gained a `margin-block-end` equal to `--fold-nav-layout-gap`
233
+ (default `--fold-space-lg`, 16px) — the value is now shared through a
234
+ `nav-content-gap` mixin (`layout/_nav-gap.scss`) that both the layout and the
235
+ bar `@use`, so the two can't drift and consumers stop hand-rolling a margin.
236
+ Applied only to a **standalone horizontal** bar: inside a `fold-nav-layout` the
237
+ layout still owns the gap (no double space), and a vertical bar is a side rail,
238
+ not a header. `fold-tabs` is unaffected (an in-place widget, not a bar that
239
+ introduces following content). Purely additive spacing — the layout's own
240
+ rendering is unchanged.
241
+
242
+ - **The weekend is its own input, because it is its own fact.**
243
+ `foldIsWeekEnd(date, weekStartsOn)` defined the weekend as "the last two
244
+ columns", which shades **Friday and Saturday** on a Sunday-first calendar and
245
+ calls Sunday a working day. Replaced by `foldIsWeekend(date, weekendDays)`
246
+ with a `weekendDays` input (default `['sat', 'sun']`) on the month and week
247
+ views — the anchor moves the columns, it does not move the days people rest
248
+ on, and a Saturday-first calendar resting Fri+Sat is now expressible.
249
+ - **`foldRangeForView('list')` returns the month, not the painted grid.** It
250
+ shared the month branch, so a May list showed late-April events: a month view
251
+ paints padding days and needs their events, a list has none.
252
+ - **A projected template replaces a list row's whole inside**, as it already did
253
+ in the other four views. The bar and the date used to render outside the
254
+ branch, so the same template rendered differently depending on which view
255
+ hosted it, and a one-element template landed in a four-column grid.
256
+ - **The month day cell is a `gridcell`, not a `<button>` wearing the role.**
257
+ `role="gridcell"` on a button replaces its native role, so activation is now
258
+ wired explicitly (`Enter`/`Space`) and the cell is the focusable widget the
259
+ ARIA grid pattern asks for. `role="grid"` and its accessible name moved onto
260
+ the host, which also removes the second root the keyboard helper had to find.
261
+ - **`fold-calendar-week`'s `date` is a plain input.** It was a
262
+ `model.required` that the component never wrote to — a two-way binding
263
+ promising something it does not do. Pair it with the toolbar, which does own
264
+ the paging.
265
+ - **The five views share one host directive for their chrome.** Labels, locale
266
+ and the projected chip template were eight identical lines copied five times,
267
+ and the `Intl.DateTimeFormat` option bags were duplicated with small
268
+ divergences — which is how the day view came to ask for
269
+ `{ weekday: 'long', month: 'long' }` and print "Saturday May", a phrase in no
270
+ locale. There is now one table of formats (`FOLD_CALENDAR_FORMATS`) and one
271
+ cache keyed by locale.
272
+ - **Tones are written once.** The four roles were reimplemented in three places
273
+ (the month's bands, the shared chip, the list's rows, which had drifted into
274
+ its own copy); they are now one mixin parameterised by selector.
275
+ - **RTL decorations follow the reading direction.** Continuation edges, the
276
+ chevrons, the cell separators and the chip padding were physical properties,
277
+ so a right-to-left calendar squared off the wrong side. All logical now.
278
+ - **The label token drops `dateRange`** (declared, translated by consumers,
279
+ used nowhere — `Intl.formatRange` already orders a span per locale) and gains
280
+ `hiddenCount`, `agendaModes` and `agendaMore`.
281
+
282
+ ### Fixed
283
+
284
+ - **Two events sharing an `id` no longer merge into one.** The layout keyed its
285
+ identity map on the id alone, so a duplicate was indistinguishable from an
286
+ explicit `groupId`: the second event was **never rendered**, both bands showed
287
+ the first one's label, a bogus group counter appeared, and the four column
288
+ views threw `NG0955` on their `track event.id`. Duplicates now stay separate,
289
+ with a dev-mode warning naming the id.
290
+ - **`maxLanes` is clamped in one place, and coerced from an attribute.** The
291
+ invariant lived in two and only one enforced it: `maxLanes="2"` as a string
292
+ built an overflow row of `"22"`, a negative value made `repeat(-5, …)` and the
293
+ CSS parser dropped the whole `grid-template-rows` rule, and `NaN` **disabled
294
+ the lane budget entirely** (`lane >= NaN` is never true) so nothing was ever
295
+ counted as hidden.
296
+ - **A reversed range (`end < start`) is put back in order.** Left alone it
297
+ rendered _twice, wrongly_: `grid-column: 5 / 2`, which CSS Grid silently
298
+ swaps, so a band covered four days that every other view — asking
299
+ `start <= day && end >= day` — reported as empty. Same input, two
300
+ contradictory renders, no error.
301
+ - **A month that is not a date yields no grid, and says so.** `foldIsCalendarDate`
302
+ existed, was tested, and was called by nothing: `month="not-a-date"` produced
303
+ a `role="grid"` with headers and zero rows, and `month="2026-13-45"` produced
304
+ a December calendar displayed with total confidence.
305
+ - **A collapsed group shows its most severe member.** It kept the first event in
306
+ document order and merged only the dates, so a cancelled first member greyed
307
+ out a whole group containing an alert, and an open-ended member inside a
308
+ closed group had its open edge dropped — a contract with no end drawn as
309
+ finished. Open edges are now the OR of the members, and the representative is
310
+ the most severe by tone (ties to document order), so tone, icon, label and
311
+ source all come from one event that really exists.
312
+ - **The agenda's `limit` can no longer empty the rail.** `limit: 0` printed
313
+ "Nothing to handle — all up to date." directly under a badge saying otherwise,
314
+ and `NaN` did the same (`slice(0, NaN)` returns nothing). It now clamps to at
315
+ least one day and reports what it cut off, so "there is more" stops rendering
316
+ as "there is nothing".
317
+ - **Paging with the keyboard twice in a row keeps the focus.** The deferred
318
+ focus target was a signal that was never reset, so repeating the _same_
319
+ transition wrote the same value, the effect did not re-run, and focus fell
320
+ onto `<body>`. The request is now consumed, and applied in an
321
+ `afterNextRender` rather than an `effect` — the ordering of an effect against
322
+ the DOM it wants to touch is not contracted, and has already changed between
323
+ Angular versions.
324
+ - **The day view's `[empty]` slot no longer swallows a child.** `empty` is also
325
+ an input on `fold-data-table` and `fold-field`; an unqualified selector
326
+ captured either of them, and with no default slot the child simply vanished
327
+ (rule 4.8). Tag-qualified, with a default slot behind it and a projection test.
328
+ - **Years below 100 no longer jump 1900 years.** `Date.UTC(99, …)` means 1999,
329
+ so `foldAddDays("0099-12-31", 1)` returned `"2000-01-01"`. Years are also
330
+ zero-padded to four digits, without which they sort before every other date.
331
+ - **`foldFilterBySource` accepts `null`** — the initial value of the very model
332
+ it exists to consume. Every caller was writing the same ternary; the gallery
333
+ did too.
334
+ - **The band icon takes the tone's colour**, and the collapsed agenda spine
335
+ names the slice it will open into rather than always saying "To handle". The
336
+ agenda's slice switch has its own accessible name instead of repeating the
337
+ rail's, and its badge honours a caller's `isActionable`, so it can no longer
338
+ disagree with the list under it.
339
+ - **The calendar is now honestly Gregorian in every locale.** Names were
340
+ formatted in the locale's _own_ calendar system, so `locale="ar-SA"` printed
341
+ Hijri month names over the Gregorian day numbers the grid counts, on rows that
342
+ break on Gregorian months — a calendar contradicting itself. The `Intl`
343
+ formatters are pinned to `calendar: 'gregory'`, so a locale now localises the
344
+ _language_ of the names, not the calendar behind them. A true non-Gregorian
345
+ layout is a separate widget this family does not pretend to be.
346
+ - **`foldIsCalendarTime` rejects an impossible minute field.** `"10:75"` and
347
+ `"23:60"` are `HH:mm`-shaped and under 1440 minutes, so a range check on the
348
+ total waved them through; the hour and minute fields are now validated
349
+ separately (`00:00`–`23:59`, plus `24:00`), the same rigour the date guard
350
+ applies to `2026-02-30`.
351
+
11
352
  ## [0.7.0] - 2026-07-29
12
353
 
13
354
  ### Added
@@ -715,7 +1056,9 @@ design-token stylesheet.
715
1056
  `currentColor`; `prefers-reduced-motion` + `forced-colors` are respected;
716
1057
  strings localise via inputs / providers (`provideFoldPanelLabels`).
717
1058
 
718
- [unreleased]: https://github.com/hugoheynard/fold-ng/compare/v0.7.0...HEAD
1059
+ [unreleased]: https://github.com/hugoheynard/fold-ng/compare/v0.8.1...HEAD
1060
+ [0.8.1]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.8.1
1061
+ [0.8.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.8.0
719
1062
  [0.7.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.7.0
720
1063
  [0.6.1]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.6.1
721
1064
  [0.6.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.6.0
package/README.md CHANGED
@@ -341,6 +341,56 @@ the package root.
341
341
  | `FoldPopoverComponent` | `fold-popover` | Anchored floating layer — projected content in the native top layer (escapes `overflow`/`z-index`), positioned by a dependency-free **flip → size → shift** engine (`computePlacement`): a tall panel gets a `max-height` and scrolls inside the viewport. `[(open)]`; `autoUpdate` (ResizeObserver); optional `arrow`; native CSS enter/exit (`@starting-style` + `allow-discrete`); outside-click + `Escape` dismissal, focus-return, auto-wired `aria-haspopup`/`expanded`/`controls`. |
342
342
  | `FoldDropdownComponent` | `fold-dropdown` | Actions menu on `fold-popover` — `role="menu"` with `<fold-dropdown-item>`s, ↑/↓ roving, `Home`/`End`, type-ahead; opens onto the first enabled item, closes returning focus to the trigger. Give the trigger `foldPopoverTrigger="menu"`. |
343
343
 
344
+ | `FoldCalendarMonthComponent` | `fold-calendar-month` | Month grid where events **span** the days they cover: a date-axis `role="grid"` with one roving tab stop, and over it a lane-packed layer of bands (one per week crossed, with open edges). `+N` chips sit on the crowded day, not at the end of the row. |
345
+ | `FoldCalendarWeekComponent` | `fold-calendar-week` | Seven day columns of stacked chips — nothing spans, so nothing is clipped and every chip is a real button in the tab order. Container-queried: labels and icons drop out before they truncate. |
346
+ | `FoldCalendarDayComponent` | `fold-calendar-day` | One day in full, with room for the subline — where a `dayClick` drill-down lands. Empty state takes a projected action (`button[empty]`). |
347
+ | `FoldCalendarListComponent` | `fold-calendar-list` | The flat reading of the same feed, in date order, each row leading with the span `Intl` formats. Nothing is ever hidden behind a lane budget. |
348
+ | `FoldCalendarTimegridComponent` | `fold-calendar-timegrid` | Hour columns for a week or a day, with the all-day strip on top. Timed events are placed on the clock and share their width only with what they actually collide with (exclusive boundaries, per-cluster widening); a span crossing midnight becomes one block per day. Times are wall-clock `HH:mm`, never instants; `now` is an input, not a clock. |
349
+ | `FoldCalendarAgendaComponent` | `fold-calendar-agenda` | "What's next" rail grouped by day, with a **to-handle** slice (the `warning`/`alert` tones — the same scale the chips paint with) and a live badge. Collapses to a spine; `mode`/`collapsed` are models, so the app owns persistence. |
350
+ | `FoldCalendarToolbarComponent` | `fold-calendar-toolbar` | Today / prev / next / period title / view switch. Owns no data — `date` and `view` are two-way, and the step matches the reading. `views` takes `{ value, label }` for an app's own view. |
351
+ | `FoldCalendarSourceFilterComponent` | `fold-calendar-source-filter` | Chips switching each feed of a merged calendar on and off. Owns the **selection** only; the caller runs the pure `foldFilterBySource()`. |
352
+
353
+ **Two tiers, on purpose.** The components are the first; under them, the same
354
+ geometry is exported as pure functions — `foldBuildMonthGrid` (week rows with
355
+ their events already packed into lanes), `foldBuildWeek` / `foldBuildDay`,
356
+ `foldBuildAgenda`, plus `foldShiftDate` / `foldRangeForView` / `foldViewTitle`
357
+ for paging and naming a period, and `foldCalendarNextFocus` for the arrow keys.
358
+ Lay a calendar out without rendering it with these components, and the hard part
359
+ — packing spans into lanes, clipping them at week boundaries, keeping an open
360
+ edge on the side that continues — is already solved and already tested. The
361
+ filters (`foldEventsOnDay`, `foldEventsInRange`, `foldFilterBySource`) are the
362
+ tier every page uses whichever rendering it picks.
363
+
364
+ **Measured, and kept measured.** Laying out a month (mean of 20 runs,
365
+ `pnpm bench:calendar`, committed with a budget so it cannot regress quietly):
366
+
367
+ | events in the window | 50 | 200 | 1 000 | 5 000 | 20 000 |
368
+ | -------------------- | ---- | ---- | ----- | ----- | ------ |
369
+ | ms per layout | 0.14 | 0.41 | 0.99 | 5.0 | 22 |
370
+
371
+ Calendar dates are plain `YYYY-MM-DD` **strings**, never `Date`. That is also
372
+ exactly what `Temporal.PlainDate.toString()` returns, so the family is
373
+ **Temporal-native without depending on it**: `foldFromTemporal()` accepts a
374
+ `PlainDate`, `PlainDateTime` or `ZonedDateTime` (structurally typed, so it
375
+ compiles on a runtime that has none of them), and going the other way needs no
376
+ helper — `Temporal.PlainDate.from(foldDate)` already takes one of ours. See
377
+ `foldToday` / `foldFromNativeDate` / `foldAddDays` and the reasoning in
378
+ [`/calendar-dates`](https://hugoheynard.github.io/fold-ng/calendar-dates). The
379
+ family plots on the **Gregorian** calendar and has no resource (staff × day)
380
+ view. Whole-day spans are the month, week, day, list and agenda views; the
381
+ **time grid** adds the hours, with time as wall-clock `HH:mm` rather than an
382
+ instant — the app converts at its own boundary, and no zone can move a meeting
383
+ after that.
384
+
385
+ It is a **pure display**, by decision and not by omission: it knows nothing
386
+ about what an event means or which layer produced it, so it never originates
387
+ one — no drag-to-create, no range-select. Tone is the caller's to compute,
388
+ which is what lets the same component paint a leave request and an account
389
+ sitting open between registration and activation, its band crossing into
390
+ `warning` then `alert` on a threshold the app owns. Everything drawn around an event is a projectable template:
391
+ `foldCalendarEvent`, `foldCalendarDay`, `foldCalendarHeading`,
392
+ `foldCalendarTitle`, `foldCalendarOverflow`.
393
+
344
394
  Directives worth knowing: **`foldSurface`** (`page`·`chrome` — the seam a mixed
345
395
  theme re-colours across), **`foldElevated`** (raise any bg-owning element into
346
396
  an inset, rounded, shadowed card — the per-surface "floating" mechanism, driven