fold-ng 0.7.0 → 0.9.0

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,554 @@ All notable changes to **fold-ng** are documented here. The format follows
8
8
 
9
9
  _Nothing yet._
10
10
 
11
+ ## [0.9.0] - 2026-08-04
12
+
13
+ ### Changed
14
+
15
+ - **BREAKING — the shell owns the content scroll by default; pages flow.** The
16
+ all-in-one scroll model (`docs/scroll.md`), slice A. `fold-app-shell`'s
17
+ `contentScroll="clip" | "auto"` input is **renamed and reshaped** to
18
+ `scroll="scroll" | "stage"`, and the **default flips**: the shell's content
19
+ region now owns the scroll (`scroll`, was `clip`/page-owns), so a
20
+ `fold-page-layout` inside it no longer double-scrolls. `fold-page-layout` gains
21
+ `scroll="flow" | "own"` and **defaults to `flow`** — it owns no scroll box and
22
+ flows inside the shell. Net effect for a normal page: identical, minus the P0
23
+ bug where a `footerBehavior="scroll"` footer sat below an unreachable
24
+ `overscroll-behavior: contain` boundary (this **deletes the LaFolieDouce B2B
25
+ `!important` workaround**). The scroll lives on an **inner** box, never the
26
+ content region itself, so a docked panel anchored to the region stays fixed
27
+ over the frame. Migration: a page that must scroll as a self-contained unit
28
+ (a split view whose shell must not move) sets `fold-app-shell scroll="stage"`
29
+ and/or `fold-page-layout scroll="own"`. A short page still pins a trailing
30
+ `scroll` footer to the bottom (the content grows to fill), replacing the old
31
+ `margin-top: auto` glue.
32
+
33
+ - **`@angular/router` is now a declared (optional) peer dependency.** `fold-view-nav`
34
+ imports `RouterLink`/`RouterLinkActive`, but the package only listed router as a
35
+ devDependency — an undeclared peer that happened to resolve because every Angular
36
+ app ships Router. It's now in `peerDependencies` with `peerDependenciesMeta:
37
+ { "@angular/router": { optional: true } }`, so apps that use the router-coupled
38
+ nav components (view-nav, and the new breadcrumb / back-link) get a correct
39
+ install signal, while apps that don't aren't forced to add it (those components
40
+ tree-shake out). No API change.
41
+
42
+ - **`FoldPanelHostService.open()` accepts an optional-data panel without a manual
43
+ type widen.** A panel whose `data` input is optional (`data = input<T>()` →
44
+ `InputSignal<T | undefined>`) used to force `open<T | undefined, R>()` to dodge
45
+ a `TS2345` (hit by the LaFolieDouce B2B PickupPanel). `FoldPanelContent<T>.data`
46
+ is now typed as the covariant **read** side (`Signal<T | undefined>`) instead of
47
+ the invariant `InputSignal<T>`, so both a required (`input.required<T>()`) and an
48
+ optional data input satisfy the contract — `open(Cmp, { data })` infers `T` from
49
+ the value with no widen, and the data value stays type-checked. Non-breaking for
50
+ existing panels (an `InputSignal<T>` still assigns to the contract).
51
+
52
+ ### Added
53
+
54
+ - **House scrollbar tokens + `overflow-anchor` — scroll-system Slice C.** The
55
+ shell content scroll box and every `[foldScrollRegion]` now paint one tokenised
56
+ scrollbar: `--fold-scrollbar-size` / `-radius` / `-thumb` / `-track`, with the
57
+ thumb derived from the surface's own text so it adapts per theme **and** per
58
+ surface (a chrome rail vs the page). Standard `scrollbar-width`/`scrollbar-color`
59
+ everywhere, plus a `@supports selector(::-webkit-scrollbar)` layer (shipped in
60
+ `tokens.css`) for the thumb radius on Blink/WebKit. Both regions also set
61
+ `overflow-anchor: auto` so the reading position survives content reflowing above
62
+ them. (The `--fold-scrollbar-*` knobs are component vars, out of the colour
63
+ catalogue — retune them on any ancestor. The viewport-**resize** anchoring
64
+ correction, which native `overflow-anchor` doesn't cover, is a deferred
65
+ follow-up — see `docs/scroll.md`.)
66
+
67
+ - **`[foldScrollRegion]` + the shell scroll registry — scroll-system Slice B.**
68
+ The one opt-in of the scroll model (`docs/scroll.md`): with `fold-app-shell`
69
+ owning the page scroll, a layout that needs an independently-scrolling area (a
70
+ split list/detail, a data-table body, a sticky sidebar, a panel body) marks it
71
+ with `[foldScrollRegion]` instead of hand-rolling `overflow`. The directive sets
72
+ the three foot-guns (`overflow`, `min-*: 0`, `overscroll-behavior: contain`) and
73
+ the thin house scrollbar, and takes an axis (`block` default · `inline` · `both`).
74
+ It **registers with the new `ScrollRegionRegistry`**, which the shell also feeds
75
+ with its own content scroll box; the panel host freezes the registry when a
76
+ modal opens, so the page stops scrolling behind the overlay even though the
77
+ scroll owner is an inner box, not `document.body`. Freezing toggles a
78
+ `.fold-scroll-frozen` class (`overflow: hidden !important`, shipped in
79
+ `tokens.css`), never an inline write, so a region's own overflow is never
80
+ clobbered. Registry injection is optional, so the directive is a useful bounded
81
+ scroll box even without a shell. Gallery `/scroll-region`; 19 specs. (Migrating
82
+ the data-table / panel bodies onto it is deferred — they already scroll
83
+ correctly; the win there is registry coordination, not the overflow.)
84
+
85
+ - **`fold-back-link` — the “← Back” affordance for a detail page.** Three modes,
86
+ picked by which input is set: an in-app `routerLink`, a plain `href`, or — with
87
+ neither — a `<button>` that goes **back in history** (`Location.back()`).
88
+ Router-coupled but degradable (the history mode needs no router; `RouterLink`
89
+ only instantiates on a `routerLink`). Gallery `/back-link`; 4 specs.
90
+
91
+ - **`fold-breadcrumb` — a hierarchical link trail.** Data-driven: pass `[items]`
92
+ where each crumb links by an Angular `routerLink` **or** a plain `href`, so it
93
+ works in a router app and degrades to anchors without one (importing it never
94
+ forces `@angular/router` — `RouterLink` only instantiates on a crumb that sets
95
+ `routerLink`). The last item renders as the current page (`aria-current="page"`),
96
+ never a link; it's a `navigation` landmark with decorative chevron separators.
97
+ Gallery `/breadcrumb`; 5 specs.
98
+
99
+ - **Panel bottom sheet (`side: 'bottom'`) + responsive `side: 'auto'`.** The panel
100
+ host gained two docking edges beyond `left`/`right`: `bottom` is a full-width
101
+ sheet whose height is content-driven up to a max (`85dvh`) with the body
102
+ scrolling, slides up, rounded top, and a top **grabber** that taps to dismiss
103
+ (honours `disableClose`). `auto` docks **right on a wide host, bottom on a
104
+ narrow one** — the switch is container-driven via `@container` on the panel
105
+ host's own inline-size (fold's "responsive on its own width", not the viewport),
106
+ so it reacts to the content region, not the screen. All the modal machinery
107
+ (focus-trap, `inert` barrier, scroll-lock, `disableClose`) is edge-agnostic and
108
+ reused as-is. Motivated by the LaFolieDouce storefront cart on mobile. Gallery
109
+ `/panel` gained “Bottom sheet” + “Auto (by width)” triggers; +6 host specs.
110
+ (Pointer-drag-to-dismiss on the grabber is a deferred nice-to-have.)
111
+
112
+ - **`fold-panel-footer` — the action bar for panels/dialogs.** Pairs with
113
+ `fold-panel-header`: the tokenised bar at a panel's bottom edge (glass top
114
+ border + padding + button alignment), so a panel no longer hand-rolls a
115
+ `<footer class="foot">`. `align="end"` (default — the Annuler/Confirmer pair)
116
+ · `between` (a leading total + trailing actions) · `start`. Sits with
117
+ `flex: none`, so it stays pinned while the body scrolls — no `position: sticky`.
118
+ Probed against the 2nd consumer (LaFolieDouce): **17** hand-rolled panel footers
119
+ across its 3 apps, so it earns a primitive. Gallery `/panel` “Panel footer”; 3 specs.
120
+
121
+ - **`fold-danger-zone` — the destructive-action block.** A framed region for
122
+ “delete X” settings: a title, a projected explanation, and a guarded action.
123
+ **Two appearances** — `filled` (alert-tinted block) and `section`, a danger
124
+ _section_ with a **normal-background body** so it can host ordinary content, only
125
+ the frame + heading signalling danger (the GitHub “Danger Zone” look). The
126
+ destructive control's confirm **reveals on click**: an `actionLabel` button
127
+ opens an in-place `fold-inline-confirm` — a plain “are you sure?”, or a
128
+ type-to-confirm field when `confirmPhrase` is set (the input is never shown until
129
+ the button is clicked). `(confirmed)` emits the typed text (or `""`); omit
130
+ `actionLabel` for a framed section with no action. `role="group"` +
131
+ `aria-labelledby`. Gallery `/danger-zone`; 5 specs.
132
+
133
+ - **`fold-multiselect` bulk actions — `allowSelectAll` / `allowClear`.** A sticky
134
+ bar at the top of the panel offers **Select all** (adds every enabled option,
135
+ skipping disabled rows and preserving an already-picked disabled one) and
136
+ **Clear** (empties the set). Each button is gated — select-all disables once
137
+ everything enabled is picked, clear disables while empty. Labels are overridable
138
+ (`selectAllLabel` / `clearLabel`). The panel is now a wrapper around the
139
+ `role="listbox"` (the bar sits outside it, so it stays valid ARIA). Gallery
140
+ `/listbox` multiselect tab enables both; 5 specs.
141
+
142
+ - **`fold-optgroup` — labelled option groups for the styleable selects.** The
143
+ counterpart to the native `<optgroup>`: wrap `<fold-option>`s in
144
+ `<fold-optgroup label="…">` to sort a long list into sections. Purely
145
+ presentational — the owning `fold-listbox` / `fold-multiselect` now discovers
146
+ options with a `descendants: true` query, so grouped options join the same
147
+ flat, document-ordered list the roving keyboard core walks; the header carries
148
+ `role="group"` + `aria-labelledby` (no `role="option"`), so nav skips straight
149
+ over it. Also supported in the data-driven **`[options]` array API**: an entry
150
+ is a `FoldSelectOption<T>` or a labelled `FoldSelectOptionGroup<T>` (mix both),
151
+ narrowed by the exported `isFoldSelectOptionGroup` guard. Gallery `/listbox`
152
+ “grouped” tab shows both forms; specs cover projected + array discovery,
153
+ cross-group roving and selection.
154
+
155
+ - **The icon devtool browses by category.** `FoldIconDevtoolComponent` now
156
+ groups the live registry into **collapsible sections** — UI · Navigation ·
157
+ Commerce · Music · Status · People · Brands (+ a **Custom** bucket for
158
+ host-registered icons) — each with its icon count; a search only surfaces the
159
+ categories it hits. New public `FOLD_BUILTIN_ICON_CATEGORIES` (+ `FoldIconCategoryId`)
160
+ is the single source of truth for the grouping (each icon file owns its slice).
161
+
162
+ - **`fold-ng/devtools` now builds as a real ng-packagr secondary entry**, so
163
+ `import("fold-ng/devtools")` resolves for **published (npm) consumers**, not
164
+ only source-consumed ones. It compiles to its own FESM + `d.ts` and imports the
165
+ primary `fold-ng` by name; `finalize-dist` normalises ng-packagr's flattened
166
+ `./src/devtools` export to the public `./devtools` subpath (+ a node10 directory
167
+ manifest). `attw` all-🟢, `publint` clean. The api-surface guard was generalised
168
+ to snapshot every published entry point (`.` + `./devtools`).
169
+
170
+ - **`fold-ng/devtools` — an opt-in dev-tools entry, starting with
171
+ `FoldIconDevtoolComponent`.** A **dev-only** floating panel that browses the live
172
+ `FoldIconRegistry` (built-ins + whatever the host app registered), with a search,
173
+ a preview grid, and a mini playground that builds a `<fold-icon>` snippet and
174
+ copies it. The panel is **draggable** (grab the header) and **minimisable**
175
+ (collapses to a pill). Published from a **separate entry** so it never lands in a
176
+ bundle that doesn't ask for it — import it behind a dev guard
177
+ (`if (isDevMode())` + a dynamic `import("fold-ng/devtools")`) so production
178
+ tree-shakes it away. Dogfooded in the gallery (primary-rail "Dev tools" + the
179
+ `/icons` hero CTA).
180
+ - **`FoldIconRegistry.names()`** — the sorted list of every registered icon name
181
+ (built-ins + runtime additions), reactive. Powers catalogue tooling (the icon
182
+ devtool).
183
+
184
+ - **A `commerce` icon category — 21 e-commerce glyphs.** The built-in set had no
185
+ cart, catalogue, payment or fulfilment icons, so a consumer reused `package` as a
186
+ placeholder for both a cart and a delivery tab (`docs/consumer-friction.md` Round
187
+ 4 #3). New `COMMERCE_ICONS` (the 7th category, wired into `FOLD_BUILTIN_ICONS` and
188
+ the `/icons` gallery): `shopping-cart` · `shopping-bag` · `basket` · `package` ·
189
+ `package-check` · `tag` · `tags` · `barcode` · `qr-code` · `gift` · `credit-card`
190
+ · `wallet` · `receipt` · `coins` · `banknote` · `percent` · `truck` · `store` ·
191
+ `warehouse` · `map-pin` · `package-return`. Same self-contained inlined-SVG /
192
+ `currentColor` contract; names autocomplete on `FoldIconName`.
193
+
194
+ - **`fold-textarea` — the multiline sibling of `fold-input`.** Same box chrome
195
+ (tokens, sizes, `panel` variant, focus/disabled) via `input-shell.scss` and the
196
+ same label / required / hint / error chrome via `fold-input-base` — so a note
197
+ field is no longer a hand-rolled native `<textarea>` + copied box CSS. **No
198
+ resize handle by design**: the box keeps its `rows` height and **wraps +
199
+ scrolls** overflow (`resize: none; overflow-y: auto`), so a user-dragged corner
200
+ can't break a panel layout. `FormValueControl<string>` (`[formField]` or
201
+ `[(value)]`).
202
+ - **`fold-date` + `fold-time` — the temporal-field wrappers.** Two sibling
203
+ controls (distinct selectors for call-site clarity, the same "one control, one
204
+ job" split as `fold-input` vs `fold-number-input`): `fold-date` wraps the native
205
+ `<input type="date">` family (`type`: `date` · `datetime-local` · `month` ·
206
+ `week`), `fold-time` wraps `<input type="time">`. Both wrap the native control
207
+ the way `fold-select` wraps `<select>` — keeping the OS calendar/clock + mobile
208
+ keyboard — and hand back a **typed `[(value)]`** (the native string, `YYYY-MM-DD`
209
+ / `HH:mm`), so consumers stop hand-writing an `inputValue($event)` reader.
210
+ `min` / `max` / `step` pass through; both share the `fold-input` box + field
211
+ chrome. **Not** a calendar popover (that's the `fold-calendar` family) — the plain
212
+ fields. `FormValueControl<string>`.
213
+ - Surfaced by the 2nd consumer (LaFolieDouce B2B); see `docs/consumer-friction.md`
214
+ Round 4 #2. The shared `_field-box.scss` `size()` mixin gained a `$height: false`
215
+ opt-out (a `<textarea>`'s height is content-driven), and `readInputValue` now
216
+ reads `<textarea>` targets.
217
+
218
+ ## [0.8.1] - 2026-08-02
219
+
220
+ ### Changed
221
+
222
+ - **The visual-snapshot tier is no longer a release gate.** Pixel screenshots
223
+ against a live dev server drift for reasons that are not a regression — a
224
+ scrollbar toggling or a font hint moves the frame ~20px — and the baselines
225
+ are per-platform, so gating on them turned the release red on Linux CI _and_
226
+ intermittently on the Mac it was cut from. The shots are now tagged `@visual`
227
+ and run only via `pnpm test:e2e:visual` (a local eyeball aid); the gate
228
+ (`test:e2e`, used by CI and `pnpm release`) excludes them. The geometry that
229
+ matters stays gated, and stably, by measurement in `calendar.spec.ts`. `0.8.0`
230
+ was tagged but never published because of this — `0.8.1` is the first published
231
+ cut of the 0.8 line, and carries the whole calendar family.
232
+
233
+ ## [0.8.0] - 2026-08-02
234
+
235
+ ### Added
236
+
237
+ - **A calendar family, starting with `fold-calendar-month` and the plain-date
238
+ model it stands on.** The grid is a date axis: seven columns of whole days,
239
+ and over them a layer of **bands**, each stretching from the column its event
240
+ starts on to the column it ends on. A span crossing a week is drawn once per
241
+ week with an open edge on the side that continues, so a three-week holiday
242
+ reads as one thing rather than twenty-one chips. Bands pack into lanes
243
+ (earliest start, then longest) under a `maxLanes` budget, and whatever will
244
+ not fit becomes an overflow chip **in the column of each day that lost
245
+ something** — a hidden span counts against every day it would have covered,
246
+ so the chip says _which_ day to open rather than only that the week is
247
+ crowded, and `overflowClick` emits that day. Events sharing a `groupId`
248
+ collapse into one chip spanning the union of their ranges and carrying the
249
+ count; a half-day edge is kept only on the segment holding the event's real
250
+ edge. Inputs cover `weekStartsOn` (any anchor, not just Monday), `fixedWeeks`,
251
+ `locale` and `labels`; `month` is a two-way `model`, so keyboard paging writes
252
+ back. Project an `<ng-template foldCalendarEvent>` to replace the built-in
253
+ chip. Generic over `T`, so an event's `data` survives the round trip to
254
+ `eventClick` without a cast.
255
+ - **`fold-calendar-agenda` — a rail of what is still ahead, grouped by day.**
256
+ The counterpart to the grids: they answer "what does this month look like",
257
+ it answers "what do I do next". Its `todo` slice keeps only the events asking
258
+ for attention — the `warning` and `alert` tones, the **same scale the chips
259
+ paint with**, so the rail needs no second notion of urgency — and carries the
260
+ count as a badge. An event already running is filed under the boundary rather
261
+ than its real start, so a three-week absence that began last week sits at the
262
+ top of what's next instead of in a past day the rail never shows. Days inside
263
+ the next week are named relatively ("Today", "Tomorrow", then the weekday),
264
+ which reads faster than a date at that distance. `mode` and `collapsed` are
265
+ two-way `model`s — persist the collapse if you want it to stick; the package
266
+ stores nothing. Pure `foldBuildAgenda` / `foldCountActionable` behind it.
267
+ - **`fold-calendar-source-filter` — chips that switch each feed of a merged
268
+ calendar on and off.** A calendar usually merges several feeds (a programme,
269
+ staff leave, contracts); an event names its own with `sourceKey`, a
270
+ `FoldCalendarSource` declares the label and dot, and the chips count what each
271
+ contributes. They own the **selection only** — the caller runs the pure
272
+ `foldFilterBySource()` over its own events, so the chips never learn how
273
+ anything is fetched. An event with no `sourceKey` belongs to no feed and no
274
+ chip can hide it. Each chip is a real toggle (`aria-pressed` in both states)
275
+ whose accessible name says which feed and whether it is showing, because the
276
+ tick and the dot are colour and colour cannot be the only carrier.
277
+ - **`fold-calendar-toolbar` — the chrome that makes the views one calendar.**
278
+ Jump to today, page back and forward, the period's name, and the view switch.
279
+ It owns no data: both pieces of state are two-way `model`s, so a page binds
280
+ the same `date` and `view` it hands the view on screen and paging works with
281
+ no output handler. The step matches the reading — a month under the month
282
+ view, a week under the week view, a day under the day view. The title carries
283
+ `aria-live="polite"`, so paging announces where a keyboard user landed instead
284
+ of changing silently. Backed by pure `foldShiftDate`, `foldRangeForView` (the
285
+ window a caller fetches — the whole painted grid for a month, not just its
286
+ days) and `foldViewTitle`.
287
+ - **The three column views that complete the drill-down: `fold-calendar-week`,
288
+ `fold-calendar-day` and `fold-calendar-list`.** Where the month grid packs
289
+ spans into lanes, these simply list what covers each day — so nothing is
290
+ clipped and no lane budget can hide anything. `week` is seven day columns;
291
+ `day` is one day in full, with a tag-qualified `button[empty]` slot for a "new request" action
292
+ when nothing sits on it; `list` is the flat chronological reading, each row
293
+ led by its span (formatted with `Intl`'s own range formatter, which collapses
294
+ a single day to one date and factors out a shared month). All three take the
295
+ same `foldCalendarEvent` template, labels and `locale` as the month view.
296
+ Their a11y is **simpler than the month's on purpose**: because a chip nests
297
+ inside the day it belongs to rather than spanning columns, every control is a
298
+ real child in the natural tab order — no roving tabindex, no `aria-hidden`,
299
+ every event reachable by keyboard. New pure builders `foldBuildWeek` and
300
+ `foldBuildDay` back them.
301
+ - **`FoldCalendarDate` — the package's date primitive is a plain
302
+ `YYYY-MM-DD` string, not a `Date`.** A calendar of all-day spans deals in
303
+ dates, not instants: `new Date("2026-05-18")` is UTC midnight, i.e. the 17th
304
+ anywhere west of Greenwich, and that off-by-one is the most common calendar
305
+ bug there is. Strings remove the class by construction, compare
306
+ lexicographically (`a <= b` _is_ "on or before"), are `===`-equal when they
307
+ mean the same day, and are already the wire format. Arithmetic runs through
308
+ `Date.UTC`, so no DST boundary can repeat or skip a day. Ships with
309
+ `foldToday`, `foldAddDays`, `foldAddMonths`, `foldStartOfWeek`,
310
+ `foldStartOfMonth`, `foldEndOfMonth`, `foldDaysBetween`, `foldWeekdayIndex`,
311
+ `foldWeekdayOf`, `foldIsWeekend`, `foldIsCalendarDate`, `foldToNativeDate` and
312
+ `foldFromNativeDate` (the inbound bridge — the conversion every consumer would
313
+ otherwise write as `toISOString().slice(0, 10)`, which is the very timezone bug
314
+ this design exists to remove), plus the layout entry points
315
+ `foldBuildMonthGrid`, `foldBuildWeek`, `foldBuildDay`, `foldEventsOnDay`,
316
+ `foldEventsInRange` and `foldFilterBySource`. Supported range is
317
+ `0001-01-01`–`9999-12-31`: the four-digit year is what makes the lexicographic
318
+ guarantee hold, so it is enforced rather than assumed.
319
+ `fold-timeline` keeps a native `Date` on purpose — it plots dated _instants_,
320
+ which is the other domain.
321
+ - **Month and weekday names come from `Intl`, not from a label token.** The
322
+ `locale` input drives `Intl.DateTimeFormat`, so every locale works without
323
+ hand-translating twelve month names; `FoldCalendarLabels` (with
324
+ `provideFoldCalendarLabels`) covers only what `Intl` cannot supply — the today
325
+ marker, the overflow chip and the event count.
326
+
327
+ - **Everything the calendar draws _around_ an event is a projectable template.**
328
+ The chip was replaceable from day one; the containers were not, which had it
329
+ backwards — an app could restyle the smallest unit and not the cell behind it.
330
+ Four more `<ng-template>` seams, each with a typed context and an
331
+ `ngTemplateContextGuard` (so `let-` variables are real types under
332
+ `strictTemplates`, not `any`): **`foldCalendarDay`** replaces the inside of a
333
+ month cell — the hook for a public holiday, a closure, "3/8 staffed";
334
+ **`foldCalendarHeading`** replaces the agenda's day heading;
335
+ **`foldCalendarTitle`** replaces the toolbar's `<h2>`, which also lets a page
336
+ give the title the heading level its outline needs; **`foldCalendarOverflow`**
337
+ replaces the `+N` chip. Alongside them, `dayModifiers: (day) => string[]`
338
+ emits an app's own names as one `data-fold-day-modifiers` attribute, matchable
339
+ with `[data-fold-day-modifiers~="holiday"]` — so nobody has to write CSS
340
+ against an internal class name. `foldCalendarEvent` is now generic too, so
341
+ `event.data` comes back as the app's own record.
342
+ - **`fold-calendar-timegrid` — the reading the other four cannot give: when
343
+ _inside_ a day.** Hour columns for a week or a single day (`dayCount`), with
344
+ the all-day strip on top. A meeting is a block whose height is its duration
345
+ and whose width is shared with whatever it collides with; an absence is a band
346
+ across the strip, spanning days through **the same packer the month grid
347
+ uses** — extracted rather than copied, so a three-day leave request reads
348
+ identically in both.
349
+
350
+ Time is modelled as wall-clock `HH:mm` (`FoldCalendarTime`), **not an
351
+ instant** — the same decision as the date, for the same reason: 09:00 is the
352
+ hour on the wall, and an instant re-derives that from a zone every render,
353
+ which is one wrong default away from drawing the wrong hour. The app converts
354
+ once at its own boundary.
355
+
356
+ Two details that are the difference between a real time grid and a demo: the
357
+ overlap test is **exclusive** at the boundary, so back-to-back meetings keep
358
+ the full width instead of each taking half; and events are grouped into
359
+ **clusters**, so one triple-booked morning does not narrow an unrelated
360
+ afternoon. A span crossing midnight becomes one block per day, each with the
361
+ right open edge, rather than one impossible block running off the bottom.
362
+
363
+ `now` is an input, never a clock the package reads — `today` already works
364
+ that way, and a server render that invented one would hydrate to a different
365
+ position. Positions are **fractions of the visible window**, so `dayStart` /
366
+ `dayEnd` and the CSS height stay independent. Backed by pure
367
+ `foldBuildTimeGrid` and `foldLayOutOverlaps`.
368
+
369
+ - **`foldFromTemporal` — the family is Temporal-native without depending on
370
+ it.** `Temporal.PlainDate.toString()` **is** `YYYY-MM-DD`: the primitive
371
+ chosen for correctness turns out to be exactly Temporal-shaped, which no
372
+ `Date`-based calendar can say. The bridge accepts a `PlainDate`,
373
+ `PlainDateTime` or `ZonedDateTime` — typed structurally, so it compiles and
374
+ runs on an engine that has none of them — and drops the time, which is what a
375
+ whole-day calendar wants **and** keeps the day the value itself means rather
376
+ than the one UTC would have picked. The other direction needs no helper:
377
+ `Temporal.PlainDate.from(foldDate)` already accepts one of ours.
378
+ - **The week now comes from the locale, not from a guess.** `Intl.Locale`
379
+ knows which day a locale's week opens on and which days it rests — most
380
+ calendars stop at the month names and hard-code Monday, which is right in
381
+ Paris and wrong in Chicago, Cairo and Malé. `weekStartsOn` and `weekendDays`
382
+ default to `foldLocaleWeekInfo(locale)` and stay overridable, so the common
383
+ case needs no input at all. Both `getWeekInfo()` and the older `weekInfo`
384
+ getter are probed, and a runtime with neither falls back to Monday + Sat/Sun.
385
+ - **ISO week numbers, as an optional leading column.** `showWeekNumbers` on
386
+ `fold-calendar-month`, backed by `foldIsoWeek` / `foldIsoWeekYear`. ISO weeks
387
+ start on Monday and belong to the year holding their **Thursday**, so they are
388
+ deliberately _not_ derived from the calendar's own anchor — 1 January is
389
+ sometimes week 53 of the year before, which is the whole point of the rule and
390
+ the reason European B2B reporting asks for it. The column is a real grid
391
+ track, so every placed element — cells, bands, overflow chips — shifts with it.
392
+ - **A `formats` input beside `labels`.** Labels let a locale translate; this
393
+ lets it **reformat**. Every `Intl` option bag the family uses lives in one
394
+ table (`FOLD_CALENDAR_FORMATS`, now exported); `formats` merges over it per
395
+ instance — a narrow weekday header, a numeric month, a four-digit year.
396
+ - **A print stylesheet for the month.** Browsers drop backgrounds but keep text
397
+ colour, so a dark theme printed as-is is light-on-white — unreadable. The
398
+ print block re-expresses everything in system colours (`Canvas`/`CanvasText`/
399
+ `GrayText`), which are neither theme tokens nor hard-coded values, and asks
400
+ for ink on exactly one thing: the tone bar, the last cue telling two bands
401
+ apart on paper.
402
+ - **The month layout is ~5× faster, and the benchmark ships with it.** The cost
403
+ was never the algorithm — it was the representation: every comparison in the
404
+ candidate sort called `foldDaysBetween`, which re-parsed two strings and built
405
+ two `Date`s, and the whole feed was re-filtered once per week row. Spans now
406
+ carry epoch-day bounds computed once, rows are bucketed in a single pass
407
+ (`O(rows × N)` → `O(N)`), and clipping is integer arithmetic. The public model
408
+ is unchanged — this is internal only. Measured on a month layout, mean of 20:
409
+ 1 000 events 4.8 ms → **0.99 ms**, 5 000 28.9 → **5.0**, 20 000 124 → **22**.
410
+ `pnpm bench:calendar` re-runs it against a committed budget and exits non-zero
411
+ when a size blows it.
412
+ - **`foldCalendarNextFocus` is public — the geometry tier is now complete.**
413
+ The pure builders (`foldBuildMonthGrid`, `foldBuildWeek`, `foldBuildDay`,
414
+ `foldBuildAgenda`) and the period functions (`foldShiftDate`,
415
+ `foldRangeForView`, `foldViewTitle`) let an app lay a calendar out and draw it
416
+ its own way; the arrow-key map was the one piece missing, and a hand-rolled
417
+ date grid owes its users the same keyboard as the built-in one. Documented as
418
+ a **tier** in the README rather than left as an accident of what happened to
419
+ be exported. `foldFocusDayCell` deliberately stays internal: it reads a
420
+ `data-fold-day` attribute this package writes, and exporting it would freeze
421
+ that attribute into the public contract.
422
+ - **The view switch is open.** `FoldCalendarView` keeps the four built-ins as
423
+ autocompleting literals but accepts any string, and `views` takes
424
+ `{ value, label }` — so an app's own reading (a resource grid, a timeline) can
425
+ sit in the same toolbar without the library knowing about it. Paging and
426
+ titling an unrecognised view fall back to month semantics, which always lands
427
+ on a real date.
428
+ - **A `FoldCalendarDay` now carries its own `eventCount` and `hiddenCount`.**
429
+ Both were previously recomputed per cell per change-detection cycle (an O(N)
430
+ filter, with an allocation, 35 times a tick) or exposed as a positional
431
+ `hiddenByDay` array only readable when crossed with the row's dates. Counting
432
+ once while the grid is built is both cheaper and self-describing, and it is
433
+ what lets a day cell announce "5 events, 2 not shown".
434
+
435
+ ### Changed
436
+
437
+ - **A standalone `fold-view-nav` now separates itself from the content it heads
438
+ by the same gap a `fold-nav-layout` applies.** A horizontal bar used on its own
439
+ (outside a layout) gained a `margin-block-end` equal to `--fold-nav-layout-gap`
440
+ (default `--fold-space-lg`, 16px) — the value is now shared through a
441
+ `nav-content-gap` mixin (`layout/_nav-gap.scss`) that both the layout and the
442
+ bar `@use`, so the two can't drift and consumers stop hand-rolling a margin.
443
+ Applied only to a **standalone horizontal** bar: inside a `fold-nav-layout` the
444
+ layout still owns the gap (no double space), and a vertical bar is a side rail,
445
+ not a header. `fold-tabs` is unaffected (an in-place widget, not a bar that
446
+ introduces following content). Purely additive spacing — the layout's own
447
+ rendering is unchanged.
448
+
449
+ - **The weekend is its own input, because it is its own fact.**
450
+ `foldIsWeekEnd(date, weekStartsOn)` defined the weekend as "the last two
451
+ columns", which shades **Friday and Saturday** on a Sunday-first calendar and
452
+ calls Sunday a working day. Replaced by `foldIsWeekend(date, weekendDays)`
453
+ with a `weekendDays` input (default `['sat', 'sun']`) on the month and week
454
+ views — the anchor moves the columns, it does not move the days people rest
455
+ on, and a Saturday-first calendar resting Fri+Sat is now expressible.
456
+ - **`foldRangeForView('list')` returns the month, not the painted grid.** It
457
+ shared the month branch, so a May list showed late-April events: a month view
458
+ paints padding days and needs their events, a list has none.
459
+ - **A projected template replaces a list row's whole inside**, as it already did
460
+ in the other four views. The bar and the date used to render outside the
461
+ branch, so the same template rendered differently depending on which view
462
+ hosted it, and a one-element template landed in a four-column grid.
463
+ - **The month day cell is a `gridcell`, not a `<button>` wearing the role.**
464
+ `role="gridcell"` on a button replaces its native role, so activation is now
465
+ wired explicitly (`Enter`/`Space`) and the cell is the focusable widget the
466
+ ARIA grid pattern asks for. `role="grid"` and its accessible name moved onto
467
+ the host, which also removes the second root the keyboard helper had to find.
468
+ - **`fold-calendar-week`'s `date` is a plain input.** It was a
469
+ `model.required` that the component never wrote to — a two-way binding
470
+ promising something it does not do. Pair it with the toolbar, which does own
471
+ the paging.
472
+ - **The five views share one host directive for their chrome.** Labels, locale
473
+ and the projected chip template were eight identical lines copied five times,
474
+ and the `Intl.DateTimeFormat` option bags were duplicated with small
475
+ divergences — which is how the day view came to ask for
476
+ `{ weekday: 'long', month: 'long' }` and print "Saturday May", a phrase in no
477
+ locale. There is now one table of formats (`FOLD_CALENDAR_FORMATS`) and one
478
+ cache keyed by locale.
479
+ - **Tones are written once.** The four roles were reimplemented in three places
480
+ (the month's bands, the shared chip, the list's rows, which had drifted into
481
+ its own copy); they are now one mixin parameterised by selector.
482
+ - **RTL decorations follow the reading direction.** Continuation edges, the
483
+ chevrons, the cell separators and the chip padding were physical properties,
484
+ so a right-to-left calendar squared off the wrong side. All logical now.
485
+ - **The label token drops `dateRange`** (declared, translated by consumers,
486
+ used nowhere — `Intl.formatRange` already orders a span per locale) and gains
487
+ `hiddenCount`, `agendaModes` and `agendaMore`.
488
+
489
+ ### Fixed
490
+
491
+ - **Two events sharing an `id` no longer merge into one.** The layout keyed its
492
+ identity map on the id alone, so a duplicate was indistinguishable from an
493
+ explicit `groupId`: the second event was **never rendered**, both bands showed
494
+ the first one's label, a bogus group counter appeared, and the four column
495
+ views threw `NG0955` on their `track event.id`. Duplicates now stay separate,
496
+ with a dev-mode warning naming the id.
497
+ - **`maxLanes` is clamped in one place, and coerced from an attribute.** The
498
+ invariant lived in two and only one enforced it: `maxLanes="2"` as a string
499
+ built an overflow row of `"22"`, a negative value made `repeat(-5, …)` and the
500
+ CSS parser dropped the whole `grid-template-rows` rule, and `NaN` **disabled
501
+ the lane budget entirely** (`lane >= NaN` is never true) so nothing was ever
502
+ counted as hidden.
503
+ - **A reversed range (`end < start`) is put back in order.** Left alone it
504
+ rendered _twice, wrongly_: `grid-column: 5 / 2`, which CSS Grid silently
505
+ swaps, so a band covered four days that every other view — asking
506
+ `start <= day && end >= day` — reported as empty. Same input, two
507
+ contradictory renders, no error.
508
+ - **A month that is not a date yields no grid, and says so.** `foldIsCalendarDate`
509
+ existed, was tested, and was called by nothing: `month="not-a-date"` produced
510
+ a `role="grid"` with headers and zero rows, and `month="2026-13-45"` produced
511
+ a December calendar displayed with total confidence.
512
+ - **A collapsed group shows its most severe member.** It kept the first event in
513
+ document order and merged only the dates, so a cancelled first member greyed
514
+ out a whole group containing an alert, and an open-ended member inside a
515
+ closed group had its open edge dropped — a contract with no end drawn as
516
+ finished. Open edges are now the OR of the members, and the representative is
517
+ the most severe by tone (ties to document order), so tone, icon, label and
518
+ source all come from one event that really exists.
519
+ - **The agenda's `limit` can no longer empty the rail.** `limit: 0` printed
520
+ "Nothing to handle — all up to date." directly under a badge saying otherwise,
521
+ and `NaN` did the same (`slice(0, NaN)` returns nothing). It now clamps to at
522
+ least one day and reports what it cut off, so "there is more" stops rendering
523
+ as "there is nothing".
524
+ - **Paging with the keyboard twice in a row keeps the focus.** The deferred
525
+ focus target was a signal that was never reset, so repeating the _same_
526
+ transition wrote the same value, the effect did not re-run, and focus fell
527
+ onto `<body>`. The request is now consumed, and applied in an
528
+ `afterNextRender` rather than an `effect` — the ordering of an effect against
529
+ the DOM it wants to touch is not contracted, and has already changed between
530
+ Angular versions.
531
+ - **The day view's `[empty]` slot no longer swallows a child.** `empty` is also
532
+ an input on `fold-data-table` and `fold-field`; an unqualified selector
533
+ captured either of them, and with no default slot the child simply vanished
534
+ (rule 4.8). Tag-qualified, with a default slot behind it and a projection test.
535
+ - **Years below 100 no longer jump 1900 years.** `Date.UTC(99, …)` means 1999,
536
+ so `foldAddDays("0099-12-31", 1)` returned `"2000-01-01"`. Years are also
537
+ zero-padded to four digits, without which they sort before every other date.
538
+ - **`foldFilterBySource` accepts `null`** — the initial value of the very model
539
+ it exists to consume. Every caller was writing the same ternary; the gallery
540
+ did too.
541
+ - **The band icon takes the tone's colour**, and the collapsed agenda spine
542
+ names the slice it will open into rather than always saying "To handle". The
543
+ agenda's slice switch has its own accessible name instead of repeating the
544
+ rail's, and its badge honours a caller's `isActionable`, so it can no longer
545
+ disagree with the list under it.
546
+ - **The calendar is now honestly Gregorian in every locale.** Names were
547
+ formatted in the locale's _own_ calendar system, so `locale="ar-SA"` printed
548
+ Hijri month names over the Gregorian day numbers the grid counts, on rows that
549
+ break on Gregorian months — a calendar contradicting itself. The `Intl`
550
+ formatters are pinned to `calendar: 'gregory'`, so a locale now localises the
551
+ _language_ of the names, not the calendar behind them. A true non-Gregorian
552
+ layout is a separate widget this family does not pretend to be.
553
+ - **`foldIsCalendarTime` rejects an impossible minute field.** `"10:75"` and
554
+ `"23:60"` are `HH:mm`-shaped and under 1440 minutes, so a range check on the
555
+ total waved them through; the hour and minute fields are now validated
556
+ separately (`00:00`–`23:59`, plus `24:00`), the same rigour the date guard
557
+ applies to `2026-02-30`.
558
+
11
559
  ## [0.7.0] - 2026-07-29
12
560
 
13
561
  ### Added
@@ -715,7 +1263,10 @@ design-token stylesheet.
715
1263
  `currentColor`; `prefers-reduced-motion` + `forced-colors` are respected;
716
1264
  strings localise via inputs / providers (`provideFoldPanelLabels`).
717
1265
 
718
- [unreleased]: https://github.com/hugoheynard/fold-ng/compare/v0.7.0...HEAD
1266
+ [unreleased]: https://github.com/hugoheynard/fold-ng/compare/v0.9.0...HEAD
1267
+ [0.9.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.9.0
1268
+ [0.8.1]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.8.1
1269
+ [0.8.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.8.0
719
1270
  [0.7.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.7.0
720
1271
  [0.6.1]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.6.1
721
1272
  [0.6.0]: https://github.com/hugoheynard/fold-ng/releases/tag/v0.6.0