@expressivecss/expressive 0.7.0-rc.0 → 0.8.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +1311 -1
  2. package/README.md +14 -11
  3. package/dist/css/expressive.css +4215 -1668
  4. package/dist/css/expressive.css.map +1 -1
  5. package/dist/css/expressive.min.css +1 -1
  6. package/dist/css/expressive.min.css.map +1 -1
  7. package/dist/js/expressive.cjs +1231 -1253
  8. package/dist/js/expressive.cjs.map +4 -4
  9. package/dist/js/expressive.js +1231 -1253
  10. package/dist/js/expressive.js.map +4 -4
  11. package/dist/js/expressive.min.js +12 -12
  12. package/dist/js/expressive.min.js.map +4 -4
  13. package/dist/js/expressive.mjs +1231 -1253
  14. package/dist/js/expressive.mjs.map +4 -4
  15. package/dist/types/behaviors/bottomSheets.d.ts +19 -11
  16. package/dist/types/behaviors/sheetDrag.d.ts +40 -0
  17. package/dist/types/behaviors/sideSheets.d.ts +2 -9
  18. package/dist/types/components/autocomplete.d.ts +15 -3
  19. package/dist/types/components/buttons.d.ts +5 -1
  20. package/dist/types/components/carousel.d.ts +87 -53
  21. package/dist/types/components/chips.d.ts +21 -1
  22. package/dist/types/components/expandingCard.d.ts +31 -0
  23. package/dist/types/components/index.d.ts +21 -20
  24. package/dist/types/components/lightbox.d.ts +1 -0
  25. package/dist/types/components/menu.d.ts +7 -1
  26. package/dist/types/components/{sidenav.d.ts → navigationDrawer.d.ts} +16 -16
  27. package/dist/types/components/registry.d.ts +8 -8
  28. package/dist/types/components/slider.d.ts +26 -85
  29. package/dist/types/components/snackbar.d.ts +20 -5
  30. package/dist/types/components/tabs.d.ts +12 -0
  31. package/dist/types/components/timepicker.d.ts +12 -9
  32. package/dist/types/core/utils.d.ts +64 -53
  33. package/dist/types/index.d.ts +8 -9
  34. package/package.json +15 -1
  35. package/src/sass/README.md +61 -11
  36. package/src/sass/abstracts/_breakpoints.scss +17 -19
  37. package/src/sass/abstracts/_mixins.scss +103 -21
  38. package/src/sass/abstracts/_variables.scss +50 -5
  39. package/src/sass/base/_global.scss +6 -24
  40. package/src/sass/base/_grid.scss +15 -12
  41. package/src/sass/base/_normalize.scss +34 -38
  42. package/src/sass/base/_typography.scss +4 -18
  43. package/src/sass/components/_badges.scss +2 -3
  44. package/src/sass/components/_banners.scss +298 -0
  45. package/src/sass/components/_bottom-app-bar.scss +180 -0
  46. package/src/sass/components/_bottom-sheet.scss +12 -4
  47. package/src/sass/components/_button-groups.scss +186 -0
  48. package/src/sass/components/_buttons.scss +245 -92
  49. package/src/sass/components/_cards.scss +494 -73
  50. package/src/sass/components/_carousel.scss +562 -68
  51. package/src/sass/components/_chips.scss +64 -5
  52. package/src/sass/components/_datepicker.scss +24 -24
  53. package/src/sass/components/_dialog.scss +17 -11
  54. package/src/sass/components/_drag-handle.scss +130 -0
  55. package/src/sass/components/_expanding-card.scss +260 -0
  56. package/src/sass/components/_fab-menu.scss +232 -0
  57. package/src/sass/components/_floating-sheet.scss +86 -0
  58. package/src/sass/components/_icon-buttons.scss +194 -0
  59. package/src/sass/components/_icons-material-design.scss +16 -4
  60. package/src/sass/components/_index.scss +22 -6
  61. package/src/sass/components/_list.scss +16 -13
  62. package/src/sass/components/_loading-indicator.scss +106 -0
  63. package/src/sass/components/_menu.scss +335 -61
  64. package/src/sass/components/_navbar.scss +71 -24
  65. package/src/sass/components/_navigation-bar.scss +13 -10
  66. package/src/sass/components/{_sidenav.scss → _navigation-drawer.scss} +123 -33
  67. package/src/sass/components/_navigation-rail.scss +22 -22
  68. package/src/sass/components/_page-footer.scss +19 -16
  69. package/src/sass/components/_pagination.scss +24 -6
  70. package/src/sass/components/_panes.scss +52 -11
  71. package/src/sass/components/{_preloader.scss → _progress.scss} +11 -3
  72. package/src/sass/components/_scrim.scss +37 -0
  73. package/src/sass/components/_search.scss +259 -0
  74. package/src/sass/components/_segmented-buttons.scss +188 -0
  75. package/src/sass/components/_side-sheet.scss +2 -2
  76. package/src/sass/components/_snackbar.scss +20 -15
  77. package/src/sass/components/_split-button.scss +196 -0
  78. package/src/sass/components/_tabs.scss +7 -8
  79. package/src/sass/components/_timepicker.scss +67 -58
  80. package/src/sass/components/_toolbar.scss +206 -80
  81. package/src/sass/components/forms/_checkboxes.scss +49 -12
  82. package/src/sass/components/forms/_fieldset.scss +4 -4
  83. package/src/sass/components/forms/_file-input.scss +8 -1
  84. package/src/sass/components/forms/_index.scss +1 -1
  85. package/src/sass/components/forms/_input-fields.scss +50 -44
  86. package/src/sass/components/forms/_radio-buttons.scss +42 -10
  87. package/src/sass/components/forms/_select.scss +0 -1
  88. package/src/sass/components/forms/{_range.scss → _slider.scss} +8 -9
  89. package/src/sass/components/forms/_switches.scss +6 -7
  90. package/src/sass/tokens/_index.scss +4 -1
  91. package/src/sass/tokens/_reference.scss +70 -61
  92. package/src/sass/tokens/_state.scss +24 -0
  93. package/src/sass/tokens/_theme.scss +6 -3
  94. package/src/sass/tokens/_vibrant.scss +76 -0
  95. package/src/sass/utilities/_colors.scss +14 -1
  96. package/src/sass/utilities/_helpers.scss +5 -5
  97. package/src/sass/utilities/_visibility.scss +48 -26
  98. package/dist/types/behaviors/waves.d.ts +0 -15
  99. package/dist/types/components/parallax.d.ts +0 -16
  100. package/dist/types/components/range.d.ts +0 -43
  101. package/dist/types/core/bounding.d.ts +0 -6
  102. package/dist/types/core/edges.d.ts +0 -6
  103. package/src/sass/components/_parallax.scss +0 -104
  104. package/src/sass/components/_pulse.scss +0 -37
  105. package/src/sass/components/_slider.scss +0 -129
package/CHANGELOG.md CHANGED
@@ -5,6 +5,1314 @@ the format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.8.0] - 2026-08-27
9
+
10
+ Completes the HTML semantics sweep. Component markup is corrected so that
11
+ the element states what a component is, with the ARIA that element choice
12
+ implies. Scope is deliberately bounded: keyboard interaction, focus management
13
+ and colour contrast are **not** part of it.
14
+
15
+ The sweep is breaking, and lands in 0.8.0. Legacy markup covered by the
16
+ semantics sweep keeps rendering — nothing warns at runtime — so each
17
+ **Migration** entry below is the whole story for that component.
18
+
19
+ ### Added
20
+
21
+ - **Split button** (`div.split-button`, the five button sizes), one of M3
22
+ Expressive's headline components. One action the user will take most of the
23
+ time, next to a menu of the ones they might take instead: a leading button
24
+ and a trailing `.menu-trigger`, 2dp apart, round on the outside, square-ish
25
+ on the seam between them.
26
+
27
+ M3 states this as **five token families**
28
+ (`md.comp.split-button.{xsmall…xlarge}`, DSP 34.0.21). They collapse to one
29
+ family here the way the button's own ladder does - the size class rewrites
30
+ the tokens and the two halves map them onto the button tokens once. The
31
+ height, the leading half's outer inset and the outline width are the button
32
+ ladder's own, so they are not restated; `between-space` is 2dp in all five
33
+ families, so it is written once.
34
+
35
+ **There is no new script.** The trailing half *is* a Menu trigger, so
36
+ `AutoInit()` starts it, `data-target` names the surface, and Menu's keyboard
37
+ model is unchanged. `aria-expanded` is Menu's to write (SEMANTICS rule 1),
38
+ which is what lets the expanded shape - the chevron turned over, the seam
39
+ swollen to fully round - be drawn from a selector alone. Never author it: the
40
+ shape is drawn from the attribute, so an authored `"true"` draws an open split
41
+ button over a closed menu.
42
+
43
+ **The halves are matched with `:where()`, not `:is()`.** `:is()` takes the
44
+ specificity of its heaviest argument, and `a.button` put the hover morph one
45
+ class above the expanded rule - so hovering an open split button collapsed the
46
+ seam Material says stays round for as long as the menu is up.
47
+
48
+ The container declares no role. `toolbar` is *rejected* rather than withheld:
49
+ the two halves are independent commands reached with Tab, so there is no
50
+ arrow-key model to write. The one composite widget here is the `<menu>`, and
51
+ Menu owns its contract.
52
+
53
+ - **Button groups** (`div.button-group`, `.connected`, the five button sizes),
54
+ one of M3 Expressive's headline components. A row of
55
+ related buttons that reads as one control without becoming one: standard
56
+ keeps each item's round shape and closes the gap as the buttons grow
57
+ (18/12/8/8/8dp), connected sits every item 2dp apart with round ends and
58
+ squared joins that square one step further while pressed.
59
+
60
+ M3 states this as **ten token families** (`md.comp.button-group.{standard,
61
+ connected}.{xsmall…xlarge}`, DSP 34.0.21). They collapse to one family here
62
+ the way the button's own ladder does — the size class rewrites the tokens, and
63
+ only the numbers that actually differ (the standard gap, the connected corners
64
+ at `large` and `xlarge`) are named twice.
65
+
66
+ **The size goes on the group, not on every button.** A group sets the button
67
+ tokens its items inherit, so `<div class="button-group medium">` sizes the row;
68
+ an item carrying its own size class still wins for itself. That is what moved
69
+ the size ladder out of `components/_buttons.scss` and into
70
+ `abstracts/_variables.scss` as `$button-sizes`, with `button-size()` beside the
71
+ other mixins: two partials read the table now, and a partial may not `@use`
72
+ another partial. No class, token or value changed.
73
+
74
+ **An icon-only item is `<button class="button circle">`, not an
75
+ `.icon-button`.** That component sets its own size ladder, insets and pressed
76
+ shape on the element itself, so a group can neither size it by handing its
77
+ items tokens nor reshape it without fighting the morph it already draws. A
78
+ `.circle` grows by its width on press, where a label item grows by its
79
+ insets - padding inside a stated width would squeeze the icon rather than
80
+ widen the button.
81
+
82
+ **The item contract is enforced, not just documented.** A group item is a
83
+ `<button>` or an `<a class="button" href>` - a bare `<a href>` passes for a
84
+ control and renders as a link, reaching none of the group's shape or sizing -
85
+ and an `.icon-button` is refused outright. Both are `semantics.json` rules, so
86
+ markup that would render wrong fails the suite rather than the eye.
87
+
88
+ **No selection, deliberately.** M3's connected group draws a selected state,
89
+ and holding one takes a control that remembers the answer — which is
90
+ `.segmented-button`, a `<fieldset>` of radios or checkboxes. A row of
91
+ `<button>`s has nothing to read it off, so the selected shape is the segmented
92
+ button's and the group stays a set of commands.
93
+
94
+ **No composite role, and it is rejected rather than withheld.** Toolbar's
95
+ `toolbar` waits on a keyboard model somebody still has to write; a button
96
+ group is not a composite widget at all — its items are independent commands
97
+ reached with Tab. A group that needs a name takes `role="group"` with an
98
+ `aria-label`, which promises no keyboard contract.
99
+
100
+ The press-time growth of a standard group's item is M3's 15%, taken from the
101
+ item's height rather than its width and with the items beside it sliding
102
+ rather than compressing: compression needs each item's measured width, which
103
+ is script. The token holds the figure as a ratio because CSS cannot multiply
104
+ a length by a percentage.
105
+
106
+ - **Scrim** (`--md-comp-scrim-color`), the wash a modal surface paints behind
107
+ itself, defined once. The dialog, the navigation drawer and the navigation
108
+ rail were each mixing `scrim` at 32% themselves — four rules across three
109
+ partials, none of which knew about the others. All four now read the token.
110
+
111
+ Both sheets are consumers but needed no edit: they never had a value of their
112
+ own, taking the modal wash from the `dialog::backdrop` rule they already
113
+ share and only suppressing it for the standard variant.
114
+
115
+ A **foundation**, not a component: there is no markup an author writes to get
116
+ one, so `semantics.json` carries it with `kind: "foundation"` and no rules,
117
+ the way `transitions` is carried. Its consumers already own the rows for the
118
+ markup that ends up wearing it.
119
+
120
+ It is declared on `:root, :host` rather than per surface because four of the
121
+ five consumers paint into `::backdrop`, which has no box of its own to hang a
122
+ token on — only the rail uses a `::before`. `::backdrop` inherits from the
123
+ element it belongs to, so setting `--md-comp-scrim-color` on one `<dialog>`
124
+ dims just that one, and setting it on the root rethemes every scrim on the
125
+ page. `:host` is there for the reason `tokens/_theme.scss` and
126
+ `tokens/_state.scss` carry it: a sheet loaded inside a shadow root cannot
127
+ reach its own host with `:root`, and an undefined token would take
128
+ `background-color` down with it and leave the surface with no scrim at all.
129
+
130
+ **One thing narrows, deliberately.** The mix resolves at `:root`, so
131
+ `--md-sys-color-scrim` is substituted there and overriding *that* token on a
132
+ subtree no longer reaches a scrim below it. Override it at the root to move
133
+ every scrim, or set `--md-comp-scrim-color` on a surface to move one. A scrim
134
+ is painted by a top-layer or fixed box covering the viewport, so there is no
135
+ meaningful subtree for it to have followed.
136
+
137
+ Blur is deliberately not folded in. Only the basic dialog frosts what is
138
+ behind it (`--md-comp-basic-dialog-scrim-blur`, unchanged); the drawer and the
139
+ rail slide over the page and would smear it. Nothing changes visually — all
140
+ four sites computed `oklab(0 0 0 / 0.32)` before and after.
141
+ - **Floating sheet** (`dialog.floating-sheet`), the third member of M3's sheet
142
+ family: secondary content on a surface detached from every window edge, where
143
+ a bottom sheet docks to the bottom and a side sheet to one side. `show()` is
144
+ standard (no scrim, the page stays interactive), `showModal()` is modal.
145
+
146
+ **No behavior module.** It does not drag, so the only dismissal it needs
147
+ beyond Escape and its action row is a scrim tap — which `Dialogs.Init()`
148
+ already gives every `<dialog>`. `BottomSheets` and `SideSheets` exist for the
149
+ drag gesture; a floating sheet has none, so adding a third module would have
150
+ been a parallel copy of light dismiss.
151
+
152
+ It is a `<dialog>` and reuses the dialog slots rather than restating them: the
153
+ partial re-points `--md-comp-basic-dialog-container-*` at its own tokens and
154
+ overrides placement, elevation and the standard variant's scrim. Its action
155
+ row keeps sheet buttons instead of the dialog's text buttons, like the other
156
+ two sheets.
157
+
158
+ Tokens are `md.comp.sheet.floating` (DSP 34.0.21), which publishes exactly
159
+ three values: `surface-container-low`, 28dp corners, elevation 1. **Placement
160
+ is not in the spec**, so the 24dp inset and 400dp cap are this framework's and
161
+ are custom properties (`--md-comp-floating-sheet-inset`,
162
+ `--md-comp-floating-sheet-container-max-width`) rather than fixed numbers.
163
+
164
+ **There are deliberately no edge modifier classes.** `.bottom` already selects
165
+ a bottom sheet and `.left` / `.right` a side sheet, so a floating sheet
166
+ borrowing those names would match two sheets' rules at once. Anchor it to a
167
+ corner with `inset` / `margin` instead.
168
+
169
+ - **Bottom app bar** (`.bottom-app-bar`), an 80dp row of the current screen's
170
+ commands at the bottom edge, with an optional FAB. CSS only.
171
+
172
+ **It is not the navigation bar, and the markup is where that is settled.** A
173
+ navigation bar holds destinations that do not change per screen and is a
174
+ `<nav>` landmark; this holds the actions of the screen the reader is already
175
+ on, so it is not a `<nav>` and takes no landmark (rule 4). It takes no
176
+ `role="toolbar"` either — that is a composite role promising arrow-key
177
+ navigation nothing here implements (rule 2), so it is *withheld*, the same
178
+ account the toolbar gives. `semantics.json` enforces both, plus a name on
179
+ every icon-only action.
180
+
181
+ This component existed once before, keyed on `footer:has(> nav:only-child)`,
182
+ and was removed with the navigation bar's arrival because an element-driven
183
+ host claimed the landmark from the markup alone and left the two bars one slip
184
+ apart. The host is a class now, and the rule forbids every landmark element,
185
+ `<footer>` included — not just `<nav>`.
186
+
187
+ **Nothing in it collides with `nav.navigation-bar`.** Material says never show
188
+ both; a page that does anyway gets two intact bars rather than one broken one,
189
+ and `tests/bottom-app-bar.test.js` asserts no selector on either side reaches
190
+ the other.
191
+
192
+ Tokens are `md.comp.bottom-app-bar.*` (DSP 34.0.21): 80dp,
193
+ `surface-container`, elevation 2, square. One height with or without a FAB —
194
+ the 72dp `with-fab` height is deprecated. The 4dp inline padding is Compose's
195
+ `16.dp - 12.dp`: a 48dp action already insets its 24dp glyph by 12, so the
196
+ glyph lands 16dp from the container edge. The in-bar FAB is flat and
197
+ `secondary-container` per `BottomAppBarDefaults`, not the free-standing FAB's
198
+ lifted `primary-container`, and `margin-inline-start: auto` puts it at the end
199
+ with no spacer element.
200
+
201
+ M3 Expressive's docked toolbar (`.toolbar.docked`) is the newer 64dp answer to
202
+ the same problem and is unchanged. Both ship.
203
+
204
+ - **A bottom sheet's handle dismisses it when the handle is a `<button>`.** The
205
+ slot was pointer-only: `BottomSheets` bound `pointerdown`/`move`/`up`, so a
206
+ handle written as `<button aria-label="Dismiss">` — which the docs taught —
207
+ did nothing on Enter. The label promised an action no code performed, and
208
+ dragging is a pointer gesture, so the only keyboard route to the same outcome
209
+ was Escape, which the button did not advertise. Activation now closes the
210
+ sheet. A handle written as any other element stays decoration and stays inert.
211
+
212
+ **The click that ends a drag is told apart from a tap**, because a drag
213
+ finishes with a `click` on the element it started on and would otherwise
214
+ dismiss the sheet the drag had just declined to dismiss. Movement past 4px
215
+ marks the sequence as a drag, and the flag is cleared on `pointerdown` so an
216
+ abandoned sequence cannot leave it set. Keyboard activation does not consult
217
+ that flag at all — a `click` carrying `detail: 0` came from Enter or Space, so
218
+ a stale drag can never swallow a key press.
219
+
220
+ `tests/bottom-sheet.test.js` covers both directions: activation dismisses
221
+ through either spelling of the class, a snapped-back drag's trailing click
222
+ does not, a keyboard activation still lands after one, and a decorative handle
223
+ is not a control.
224
+
225
+ - **Drag handle** (`.drag-handle`), the affordance that makes something legible
226
+ as draggable. CSS only, no plugin, no registry entry.
227
+
228
+ **Which element you write is the whole semantic.** A
229
+ `<span class="drag-handle" aria-hidden="true">` is decoration, which is the
230
+ usual case — the handle draws a bar and holds no text, so exposed it arrives
231
+ in the reading order as an unlabelled blank. A `<button>` with an `aria-label`
232
+ is a control, and is written only when activating it does something.
233
+ `semantics.json` enforces both directions, including the trap in the middle:
234
+ `aria-hidden` on a button hides it from assistive technology without taking it
235
+ out of the tab order. The hover, focus and pressed states key off
236
+ `button.drag-handle` for that reason, so a decorative handle stays inert under
237
+ a pointer merely crossing it — and that scoping is only correct *because* the
238
+ rule holds every other element to `aria-hidden`.
239
+
240
+ **Nothing here drags, and the docs say so rather than implying otherwise.**
241
+ Dragging is a pointer gesture, so an outcome reachable only by dragging is
242
+ unreachable without a pointer. No reordering behaviour ships to hang off this
243
+ handle; a page that builds one owes it a keyboard path of its own, and
244
+ `llm.md`, `m3-guidelines.md` §6.4 and the docs page each state that plainly.
245
+
246
+ **`md.comp.drag-handle` is the *vertical* handle**, which is easy to mistake
247
+ for the bottom sheet's grabber and is not it. Material tokenised the bar that
248
+ sits in the gutter between two panes — Compose's `VerticalDragHandle` — 4×48dp
249
+ in a 24dp hit target, swelling to 12×52dp on a 12dp corner while held. The
250
+ sheet's grabber is a different component with its own token set,
251
+ `md.comp.sheet-bottom.docked.drag-handle`, horizontal and 32×4dp at 40%
252
+ `on-surface-variant`. So one class serves both hosts and neither one's values
253
+ are invented: inside a sheet, `_bottom-sheet.scss` supplies the geometry and
254
+ suppresses the vertical bar this partial draws in `::before`, which would
255
+ otherwise print across the sheet's own.
256
+
257
+ The bar is a pseudo-element rather than the box because the box is the hit
258
+ target — 4dp of anything is not something a pointer can be asked to find — and
259
+ because the state layer needs a surface the bar's own shape does not clip. The
260
+ container is sized to the *pressed* bar, so holding the handle moves nothing
261
+ beside it.
262
+
263
+ **`.handle` is the pre-1.0 name for the bottom sheet's slot and still works.**
264
+ The rename is additive in the usual way and `tests/m3-naming.test.js` asserts
265
+ it: every rule reaching `.handle` reaches `.drag-handle` too.
266
+
267
+ - **Banners** (`.banner`, `.banner.rich`), Material 3 Expressive's persistent
268
+ in-flow message. A `<div class="banner">` holding a `<p>`, an optional leading
269
+ icon, an optional `.actions` row of text buttons and an optional close icon
270
+ button; `rich` adds a heading and takes an 80dp `<img>` in place of the icon.
271
+ Two colour variants (`vibrant` for `primary-container`) and one shape
272
+ modifier (`square`). CSS only — closing a banner is removing it from the page,
273
+ so there is nothing to initialize and nothing to tear down.
274
+
275
+ **It is the third way of talking back, and the docs say which of the three to
276
+ reach for.** Snackbar is transient and floats; banner is persistent and sits
277
+ in the flow; dialog blocks. A message nobody needs to dismiss is a snackbar,
278
+ and a message the user is allowed to ignore is not a dialog — `llm.md`,
279
+ `m3-guidelines.md` §3.3 and §7.5, and the docs page all state the trade the
280
+ same way, because shipping the third one without that guidance is how
281
+ component sprawl starts.
282
+
283
+ A banner is **not** `role="banner"`, which is the page header landmark; a page
284
+ has one of those and may have several banners. `semantics.json` blocks both
285
+ spellings of the confusion — `<header class="banner">` and the role on the
286
+ container. Its action row is `.actions` and never a `<nav>` (rule 4).
287
+
288
+ The basic and rich layouts are scoped `:not(.rich)` and `.rich` rather than
289
+ left to source order, so they tie on specificity and neither reaches into the
290
+ other. The rich layout is a named grid, so the five parts can be written in
291
+ any order and a missing one collapses its track. Values are
292
+ `md.comp.banners.*`, `md.comp.banners.basic.*` and `md.comp.banners.rich.*` at
293
+ DSP 34.0.21; the four the sheet does not spend — the `two-lines` height, both
294
+ `body-text.trailing-space` close-button reserves, and
295
+ `rich.leading-element.trailing-space` — are named in the partial with the
296
+ reason each is unspendable in CSS.
297
+
298
+ - **Loading indicator** (`.loading-indicator`), M3 Expressive's indicator for a
299
+ wait under about five seconds: a shape that morphs through a circle and four
300
+ cookie shapes while it rotates. One empty `<span>`, CSS-only, no plugin.
301
+ `contained` puts it on a `secondary-container` circle for backgrounds the
302
+ plain indicator would be lost against.
303
+
304
+ **It supersedes the indeterminate circular case of `.progress`** — write it
305
+ anywhere you would have written `<span class="progress circular">` with no
306
+ value. `.progress` is unchanged and keeps both linear bars and every
307
+ determinate case, including the determinate circular one. Neither is
308
+ deprecated; the docs, `llm.md` and `m3-guidelines.md` all state which is the
309
+ default for which wait.
310
+
311
+ The morph is `clip-path` keyframes, which interpolate vertex by vertex and so
312
+ need every shape to have the same vertex count. Each is 24 points on a polar
313
+ curve, generated by `_petal-shape()` in the partial: 24 divides 4, 6, 8 and
314
+ 12, so every cusp lands on a vertex. `prefers-reduced-motion: reduce` stops
315
+ both animations and leaves the circle. The element reports nothing on its own,
316
+ so the author gives it `role="status"` and a name — not `role="progressbar"`,
317
+ which promises an `aria-valuenow` an indeterminate indicator does not have.
318
+
319
+ - **The vibrant emphasis axis is a foundation, not a component variant.** The
320
+ `vibrant` attribute remaps the surface family of `--md-sys-color-*` roles for
321
+ its whole subtree onto an accent container, so a component needs no vibrant
322
+ code of its own to sit on one — the same inheritance trick `tokens/_theme.scss`
323
+ uses for `light-dark()`. It points at the live role names rather than the
324
+ `-light` / `-dark` pair, so a runtime theme switch reaches it. There is one
325
+ ramp, tertiary, which is what Material uses for the axis: accent and outline
326
+ roles are deliberately left alone, since pointing the surfaces at primary or
327
+ secondary would erase every component whose own fill is that container, and
328
+ deriving outline from the text colour would turn every checkbox, radio and
329
+ switch border in the subtree translucent. Nothing is restyled by default.
330
+ `:host([vibrant])` is matched alongside `[vibrant]`, so a shadow-DOM host can
331
+ carry it — a sheet loaded in a shadow root cannot reach its own host with a
332
+ descendant selector. The elevation ladder collapses inside a vibrant subtree
333
+ — every `surface-container` rung resolves to the one container colour, which
334
+ is what a vibrant surface is in Material, and `on-surface-variant` collapses
335
+ onto `on-surface` with it — so it belongs on a component, not a page.
336
+
337
+ The `.vibrant` classes on Menu and Toolbar keep the colours Material's own
338
+ per-component token sets give each one, and Toolbar's is untouched. Menu's
339
+ now also answers to `[vibrant]`: a selected menu item is filled with
340
+ `tertiary-container`, the very colour the attribute paints the menu with, so
341
+ selection would otherwise disappear into its own surface. Material's
342
+ `menus-vibrant` moves selection to solid `tertiary`, which the class already
343
+ did, and FormSelect's option fill reads the same token so it follows.
344
+ - **The extended FAB took Material 3's size and colour axes.** `extend medium`
345
+ is 80dp with a 28dp icon, 20dp corners and a `title-large` label; `extend
346
+ large` is 96dp with a 36dp icon, 28dp corners and a `headline-small` label.
347
+ The container colour is a role rather than a value — `primary-container` is
348
+ the default, `secondary-container` and `tertiary-container` are the other
349
+ two — and each sets `--md-comp-extended-fab-container-color` and
350
+ `--md-comp-extended-fab-label-text-color` together, so the hover and focus
351
+ state layers, which mix one into the other, follow the role with it. The
352
+ three colour utilities that name those roles step aside for `.extend`
353
+ (`.primary-container:not(.extend)`, and the same for the other two):
354
+ utilities win by layer rather than weight, so a bare role class on an
355
+ extended FAB would have repainted the container and left the label colour
356
+ and both state layers behind. The other 46 roles are untouched — a role the
357
+ component does not name has nothing to lose.
358
+
359
+ **Migration.** `extend small` carries the `title-medium` label Material
360
+ gives it, where it used to keep `label-large` from the sizeless `extend`.
361
+ A sizeless `extend` is unchanged.
362
+ - **Search bar and search view.** `search.search-bar` is Material 3's search
363
+ bar — 56dp on a full corner, `surface-container-high` at elevation 3, holding
364
+ a bare `<input type="search">` with none of the text-field chrome. Values are
365
+ `md.comp.search-bar.*` / `md.comp.search-view.*` at DSP 34.0.21. End spacing
366
+ reads the bar's own children: 16dp beside a bare glyph, 4dp beside an icon
367
+ button, which already insets its glyph by 12dp. The `<search>` element is the
368
+ landmark, so there is no `role="search"` to author.
369
+
370
+ `.search-view` is the surface the bar expands into, and it is two different
371
+ native elements because the variants want two different behaviours. Docked,
372
+ it is a plain element inside the bar, toggled with `hidden`: a modeless
373
+ `<dialog>.show()` moves focus into the dialog and would take the caret out of
374
+ the input being typed in. Full-screen, it is a
375
+ `dialog.search-view.full-screen` opened with `showModal()`, so Escape and
376
+ light dismiss come from the platform and `behaviors/dialogs.ts`. Its header
377
+ is another `.search-bar` with the pill and shadow taken off — the same markup
378
+ either way. No JavaScript component ships with it.
379
+
380
+ **Suggestions stay Autocomplete's.** `class="autocomplete"` on the bar's
381
+ input gives the combobox, its listbox and the keyboard contract already
382
+ implemented and tested there, so nothing here duplicates them. The search
383
+ view itself *rejects* `listbox` rather than owing it: its contents are links
384
+ and buttons reached with Tab, and a second suggestion listbox here would be
385
+ the same promise with no keyboard model behind it. `semantics.json` records
386
+ the rejection.
387
+
388
+ **Migration.** `.searchbar`, the pre-1.0 Materialize navbar search field,
389
+ is now an alias of `.search-bar` and moves out of `forms/_input-fields.scss`
390
+ into `components/_search.scss`. Old markup keeps working and picks up the M3
391
+ container; `tests/m3-naming.test.js` holds the alias to every rule.
392
+ - **FAB menu.** `fab-menu` is Material 3 Expressive's FAB menu: a FAB that
393
+ expands into a list of labelled pills — 56dp tall, full corner, 24dp insets,
394
+ a `title-medium` label — and morphs into the close button while it is open.
395
+ The colour axis is the same three roles as the extended FAB, and one class
396
+ moves both halves: the actions take `<role>-container` and the close button
397
+ the solid `<role>`, which is the pairing Material specifies. Expanded state
398
+ is the framework's — the constructor stamps `aria-expanded` and `open()` /
399
+ `close()` move it with the `active` class, and `semantics.json` forbids
400
+ authoring either.
401
+
402
+ It runs on the existing `FloatingActionButton` rather than a second class,
403
+ so `AutoInit()`, `open()`, `close()`, `isOpen` and `destroy()` all work
404
+ unchanged; `direction` and `hoverEnabled` are inert on it, because a FAB
405
+ menu opens upward and on click and both are decided in CSS. It does not
406
+ touch the `.fab` speed dial or the `.fab.toolbar` transition: `.fab` is a
407
+ whole class name and never matches `.fab-menu`.
408
+ - **Segmented buttons.** A `fieldset.segmented-button` of `<input>` and
409
+ `label.segment` pairs is Material 3's outlined segmented button — 40dp on
410
+ a full corner, equal columns filling the width it is given, a 1dp outline
411
+ with dividers between segments, 12dp insets, an
412
+ optional 18dp icon, and `secondary-container` / `on-secondary-container` for
413
+ the chosen one. Radios make the group single-select and checkboxes make it
414
+ multi-select; the input type is the entire difference between the two
415
+ variants.
416
+
417
+ There is no plugin. The control is the label's sibling and not its child,
418
+ the way a filter chip is written: a `<label>` wrapping a radio or a checkbox
419
+ *is* one to `forms/_radio-buttons` and `forms/_checkboxes`, which would paint
420
+ a 20dp ring on the control and stretch the label to a 48dp row. Selection is
421
+ read off the control with `input:checked + .segment`, so `checked` states
422
+ the initial value and the browser owns it from there — there is no class to
423
+ keep in sync and no ARIA copy to go stale, and `semantics.json` refuses
424
+ `aria-checked`,
425
+ `aria-selected` and `aria-pressed` on a segment. The composite role is
426
+ *rejected* rather than withheld, which is Carousel's case and not Tabs':
427
+ rule 2 asks for an implemented keyboard contract before a component claims a
428
+ composite role, and a `<fieldset>` of radios already has one the browser
429
+ implements — so `radiogroup` is declined because the element already carries
430
+ it, not deferred. `forbid-composite-roles` keeps that and the other nine out
431
+ of the markup. What the rules enforce instead is that the markup stays
432
+ native: a `<div>` root, a `<button>` segment or a radio without the shared
433
+ `name` takes the group name, the arrow keys and the checked state with it. The `<legend>` names the group and is
434
+ hidden, since Material's anatomy has no visible group label. Nothing shows
435
+ it: the root is both the fieldset and the grid of segments, so a legend back
436
+ in flow is another column beside them, and a group that needs a visible label
437
+ writes one before the fieldset.
438
+ - **Carousel can advance on its own, with a pause contract that cannot be
439
+ switched off.** An `interval` (milliseconds, `0` and off by default) cycles
440
+ the track; `pause()` and `start()` stop and resume it. The gap follows each
441
+ transition rather than containing it, so a full cycle takes
442
+ `duration + interval`; each rest is armed by the move before it rather than
443
+ running on a fixed phase, so a rest can never end on a move still animating
444
+ nor be cut to the remainder of one that did. Auto-advancing content
445
+ is WCAG 2.2.2 territory, so unlike the Slideshow it replaces, Carousel offers
446
+ no `pauseOnHover` / `pauseOnFocus` booleans to turn off — an interval always
447
+ pauses while the pointer is over the carousel, while focus is inside it, and
448
+ while the tab is hidden, and `prefers-reduced-motion: reduce` suppresses
449
+ auto-advance entirely.
450
+ - Carousel takes a fixed `height` in pixels, reproducing the Slideshow layout:
451
+ the height sizes the track and the indicators take their own row below it
452
+ (`--md-comp-carousel-indicator-allowance`, 40px) instead of covering the
453
+ media. Markup can ask for the same layout on its own, by carrying
454
+ `.fixed-height` and setting `--carousel-height`. An explicit `noWrap: true`
455
+ stops auto-advance after one pass rather than looping.
456
+
457
+ ### Changed
458
+
459
+ - **`.button.circle` sizes its glyph from the button ladder.** It used to pin
460
+ 24px whatever size class it wore, so it got the size's *box* and a fixed
461
+ glyph in it: 24px in a 32dp `xsmall` disc, and — the visible one — 24px
462
+ adrift in a 136dp `xlarge` disc. The glyph is now
463
+ `--md-comp-filled-button-icon-size` like every other button's, which is
464
+ 20 / 20 / 24 / 32 / 40dp across the five rungs. `.circle.extra` and
465
+ `.circle.large` are the FAB and are unaffected: they read
466
+ `--md-comp-fab-icon-size` and always did.
467
+
468
+ **Migration.** The default `<button class="button circle">` carries no size
469
+ class, so it is the `small` rung and its glyph moves **24px → 20px**. That is
470
+ a visible change on every page using one. 24px in a 40dp disc is the *icon
471
+ button's* number (`md.comp.icon-button.small.icon-size`), not the common
472
+ button's, so the control that keeps it is `.icon-button` — which is the real
473
+ M3 icon button, with the whole ladder and its own colours:
474
+
475
+ ```html
476
+ <!-- 40dp box, 20dp glyph (the button ladder) -->
477
+ <button class="button circle" aria-label="Add">…</button>
478
+ <!-- 40dp box, 24dp glyph (the icon-button ladder) -->
479
+ <button class="icon-button" aria-label="Add">…</button>
480
+ ```
481
+
482
+ Or state the rung you meant: `.circle.medium` is 56dp with a 24dp glyph.
483
+
484
+ One host is worth checking by eye: the **top app bar** sizes its own
485
+ icon-only actions at 24dp but steps aside for anything that styles itself,
486
+ `.circle` included — so a `.button.circle` in a bar now shows a 20dp glyph
487
+ beside the bar's 24dp ones. The bar's own spelling is a bare
488
+ `<button class="navigation-drawer-trigger">` with no `.button`, which the bar
489
+ sizes itself; `.icon-button` is the other 24dp answer.
490
+
491
+ This subsumes the button-group-local override shipped with button groups,
492
+ which handed a group's `.circle` items the token so a sized group would not
493
+ get the wrong glyph. That rule and its test are gone; the fix is general now.
494
+
495
+ - **Toolbars are the four M3 Expressive variants, from two independent axes.**
496
+ A *shape* — floating or docked — and a *colour style* — standard or vibrant —
497
+ now compose freely, and their values come from the 34.0.21 sets
498
+ `md.comp.toolbar.{standard, vibrant, docked, floating, floating.fab}` rather
499
+ than from the prose the component was first written against. Nothing in the
500
+ markup contract moves: the bar is still not a `<nav>`, and it still withholds
501
+ `role="toolbar"` (rule 2), which `semantics.json` still enforces.
502
+
503
+ Both defaults are written as the *absence* of the other side rather than as
504
+ a selector of their own, which is what makes `.floating` and `.standard`
505
+ writable without four rules or two inert ones — a bar naming either still
506
+ matches the default arm. `.filled` and `.max` keep their old meanings.
507
+
508
+ The corrected values: a floating bar lifts to elevation **3** (was 2) and
509
+ takes **8dp** end insets (was 12dp), 16dp from the viewport edge and 24dp
510
+ when vertical. A docked bar takes **16dp** end insets and spaces its actions
511
+ between 4dp and 32dp apart as it widens, instead of `space-around`, which
512
+ honoured neither bound. Action icons and labels are **`on-surface-variant`**
513
+ (was `on-surface`), and a vibrant bar's selected action is
514
+ **`surface-container`** (was `surface`).
515
+
516
+ `.vibrant` stays M3's own toolbar-vibrant set and deliberately does *not*
517
+ also answer to the `vibrant` attribute. That attribute is the emphasis
518
+ foundation, whose one ramp is tertiary; a bar inside it is repainted by the
519
+ foundation alone, with no toolbar-specific code, because every colour the
520
+ component draws is now a `--md-comp-toolbar-*` token pointed at a
521
+ `--md-sys-color-*` role. Matching the attribute here as well would render
522
+ one variant two ways and would contradict `tokens/_vibrant.scss`.
523
+
524
+ The companion FAB in `.toolbar-group` takes `md.comp.toolbar.floating.fab`:
525
+ `secondary-container` beside a standard bar, `tertiary-container` beside a
526
+ vibrant one, at elevation 1 where the bar is at 3. Mark only the bar. Docs
527
+ that hand-applied colour utilities to that FAB no longer need to.
528
+
529
+ - **The FAB no longer claims to be a menu.** `FloatingActionButton` used to set
530
+ `role="menu"` on the action list and `aria-haspopup="menu"` on the trigger.
531
+ A menu is a composite widget whose items are reached with the arrow keys, and
532
+ this component has no such keyboard model, so both were a promise it does not
533
+ keep (SEMANTICS rule 2). They are gone; `aria-expanded` stays, which is what a
534
+ disclosure actually is. `semantics.json` records `menu` as withheld pending
535
+ the keyboard model rather than leaving the gap unaccounted for.
536
+
537
+ **Migration.** A script or stylesheet selecting `.fab [role="menu"]` needs a
538
+ different selector. Nothing about the markup an author writes changes.
539
+ - **Buttons took Material 3 Expressive's two modifier axes.** Five styles
540
+ (`filled`, `tonal`, `outlined`, `elevated`, `text`) and five sizes
541
+ (`xsmall`, `small`, `medium`, `large`, `xlarge`) are now independent classes
542
+ on `.button`, and any style combines with any size — nine classes, not
543
+ twenty-five, because the size rules set tokens and the style rules set
544
+ colour. `filled` is new, and is what the styleless default already was.
545
+
546
+ **Migration.** Three things behave differently:
547
+
548
+ - `small` is 40dp now, not 32dp — M3's small *is* the default size, so
549
+ writing it changes nothing. The old 32dp button is `xsmall`.
550
+ - `large` is 96dp, not 56dp. The old 56dp button is `medium`, and `extra`
551
+ still resolves to it, so `class="button extra"` is unchanged. `circle
552
+ extra`, `circle large`, `extend` and every FAB size are untouched.
553
+ - Spacing is symmetric at every size. Expressive dropped the old "24dp
554
+ inset, but 16dp next to an icon" asymmetry, so a button with an icon is
555
+ no longer inset differently on the icon's side, and the default inset is
556
+ 16dp rather than 24dp. The default icon is 20dp (was 18dp) and corners
557
+ are fully round at every size.
558
+
559
+ An `outlined` button's border also thickens with the size — 1dp up to
560
+ medium, 2dp large, 3dp extra large — from the new
561
+ `--md-comp-filled-button-outline-width` token.
562
+
563
+ - Pane layouts now use M3 spacing: 16dp inline margins on Compact, then
564
+ 24dp margins and 24dp spacers on Medium through Extra-large.
565
+ - **Responsive breakpoints now match Material 3 exactly.** Compact is below
566
+ 600px, Medium is 600–839px, Expanded is 840–1199px, Large is
567
+ 1200–1599px, and Extra-large starts at 1600px. The Sass keys are now
568
+ `compact`, `medium`, `expanded`, `large`, and `extra-large`; consumers that
569
+ used `small`, `xlarge`, or `xxlarge` must migrate, and code that used
570
+ `bp-up("large")` for the old desktop boundary should use
571
+ `bp-up("expanded")`. Grid markup keeps `.s` / `.m` / `.l` / `.xl` /
572
+ `.xxl`, mapped to those five classes in order. Visibility helpers now name
573
+ and isolate the same M3 ranges; legacy `small`, `med`, and `xxl` spellings
574
+ remain aliases where their meaning is unambiguous.
575
+ - **Components took the names Material 3 uses for them.** Every rename is
576
+ additive — the old class stays in the selector list, the old export stays as
577
+ an alias — with one exception, called out below.
578
+
579
+ | M3 | was | now |
580
+ | --- | --- | --- |
581
+ | Slider | `.range` / `Range` | `.slider` / `Slider` |
582
+ | Navigation drawer | `.sidenav` / `Sidenav` | `.navigation-drawer` / `NavigationDrawer` |
583
+ | FAB | `.fixed-action-btn` | `.fab` |
584
+ | Progress indicators | `.preloader` | `.progress.circular` |
585
+ | Date / Time pickers | `.datepicker` / `.timepicker` | `.date-picker` / `.time-picker` |
586
+
587
+ - **`Slider` changed meaning, and that one is a break.** It was the image
588
+ slideshow; it is now the range control, because that is what M3 calls a
589
+ slider. Aliasing it would have defeated the rename. Before 0.8.0, `.slider`
590
+ markup and `Expressive.Slider.init()` started the slideshow; both now name the
591
+ range control. Migrate slideshow markup and script to Carousel using the
592
+ removal guide below. `Expressive.Range` still resolves to the renamed
593
+ `Slider`.
594
+ - **`.field` deliberately did not become `.text-field`.** M3 names the
595
+ component "Text fields", but the same container wraps `<select>`,
596
+ autocomplete and file inputs, so the M3 name would be wrong for most of what
597
+ it does.
598
+ - Sass partials moved with their components: `_sidenav` → `_navigation-drawer`,
599
+ `forms/_range` → `forms/_slider`, and `_preloader` → `_progress`. The old
600
+ slideshow partial, `_slider`, was deleted with that component.
601
+ - **The semantics roster stopped counting things that are not components.** A
602
+ component is a part of the framework an author writes markup for, and four of
603
+ the 45 rows in `semantics.json` were not one. Three now state a `kind`
604
+ instead: `transitions` is a foundation, `docked-display` and
605
+ `character-counter` are behaviors. `table_of_contents` left the file — the
606
+ list it styles is Scrollspy's markup, so `scrollspy` took its rule, and the
607
+ partial is recorded under the new `notComponents` map, which is the only way
608
+ a partial leaves the roster and which requires a live partial and a stated
609
+ reason. The standard now reads **44 of 44 rows enforced, 41 of them
610
+ components**, and the exempt list is still empty.
611
+
612
+ Nothing was deleted to get there: no Sass, no script, no markup, and no rule
613
+ stopped running — a non-component row is checked exactly as a component's is.
614
+ The data file's `components` key is now `rows`, since it never held only
615
+ components.
616
+
617
+ ### Removed
618
+
619
+ - **The retired Flask documentation pipeline is gone.** Astro is the sole
620
+ documentation generator, so the Flask application and Jinja templates,
621
+ Frozen-Flask configuration, Python environment, migration script, committed
622
+ `website/` snapshot and generated root `llms.txt` have been removed. The
623
+ documentation site now builds, verifies and publishes from a clean checkout
624
+ with Node alone; visual regression builds both revisions through that same
625
+ Astro production path.
626
+
627
+ - **Waves is gone, replaced by state layers.** Material 3 has no ink ripple; it
628
+ has a state layer, and the framework's is complete — a `md.sys.state` opacity
629
+ per state, documented on the State layers page, painted by the components
630
+ themselves. Keeping a second, script-driven feedback system beside it meant
631
+ two answers to one question, so the ripple is deleted rather than deprecated:
632
+ `behaviors/waves.ts`, the import-time `Waves.Init()`, the `Expressive.Waves`
633
+ export, and the Waves docs page. The bundle no longer attaches a delegated
634
+ `click` listener to `document.body`.
635
+
636
+ **Migration.** `.waves-effect`, `.waves-light` and `.waves-circle` never
637
+ matched a style rule — they were hooks the script read — so markup carrying
638
+ them still renders exactly as it does today, minus the ripple. **Delete
639
+ them**; nothing reads them any more. There is no replacement class to add,
640
+ because a state layer is not authored: an interactive component paints its
641
+ own. `Expressive.Waves.renderWaveEffect()` has no successor.
642
+
643
+ Five surfaces relied on Waves for their only press feedback and now carry a
644
+ pressed state layer of their own: the media-covering `.card-reveal-trigger`
645
+ and `.expanding-card-trigger` buttons, the actions inside a `.fab.toolbar`,
646
+ navigation drawer rows, and pagination items. The last two matter most on
647
+ touch, where hover never fires and both already suppressed the tap
648
+ highlight. The two card triggers read the card's own
649
+ `--md-comp-card-*-state-layer-*` tokens, so overriding a card still reaches
650
+ them; the other three have no component token and read `--md-sys-state-*`.
651
+
652
+ - **Slideshow is gone, and Carousel takes the case.** M3 has no slideshow — its
653
+ Carousel is the component for a band of media — and once Carousel could
654
+ advance on its own,
655
+ the two expressed one concept in 590 lines of duplicate. Deleted outright:
656
+ `components/_slideshow.scss`, `components/slideshow.ts`, the `slideshow`
657
+ `semantics.json` row, and the docs coverage on the Media page.
658
+ `Expressive.Slideshow` no longer resolves. **There is no alias**, because an
659
+ alias keeps both concepts alive while pretending one of them died.
660
+
661
+ **`.slider` is unambiguous again**, which is the bonus worth stating. The
662
+ intermediate content-discrimination selectors are gone with the component:
663
+ `.slider` is the range control and nothing else, and nothing in the sheet asks
664
+ what a `.slider` holds. The range control itself is otherwise untouched.
665
+
666
+ **Migration.** Carousel with a fixed `height` reproduces the layout — the
667
+ height sizes the track and the indicators take their own row beneath it:
668
+
669
+ ```html
670
+ <!-- before -->
671
+ <div class="slider">
672
+ <ul class="slides">
673
+ <li><img src="1.jpg" alt="First"><div class="caption"><h3>One</h3></div></li>
674
+ <li><img src="2.jpg" alt="Second"><div class="caption"><h3>Two</h3></div></li>
675
+ </ul>
676
+ </div>
677
+
678
+ <!-- after -->
679
+ <div class="carousel">
680
+ <div class="carousel-item">
681
+ <img src="1.jpg" alt="First">
682
+ <div class="carousel-item-content"><h3>One</h3></div>
683
+ </div>
684
+ <div class="carousel-item">
685
+ <img src="2.jpg" alt="Second">
686
+ <div class="carousel-item-content"><h3>Two</h3></div>
687
+ </div>
688
+ </div>
689
+ ```
690
+
691
+ `.carousel-item-content` is the caption slot and is not optional: the item
692
+ clips its overflow and the media fills its height, so a heading left as a
693
+ bare sibling is pushed out of the box rather than laid over the image the way
694
+ `.caption` was. It is also what the small and medium size roles hide and trim
695
+ against.
696
+
697
+ ```js
698
+ // before
699
+ Expressive.Slider.init(el, { height: 400, indicators: true, interval: 6000 });
700
+ // after
701
+ Expressive.Carousel.init(el, { height: 400, indicators: true, interval: 6000, fullWidth: true });
702
+ ```
703
+
704
+ Four options do not carry over cleanly.
705
+
706
+ - `duration` exists on both but means the transition, not the crossfade —
707
+ and Carousel's rest **follows** each transition, so a cycle takes
708
+ `duration + interval`. `interval: 6000` above therefore paces at 6.2s
709
+ rather than 6s; subtract `duration` to keep the old cadence exactly.
710
+ - `pauseOnHover` and `pauseOnFocus` have no equivalent and are not needed: an
711
+ auto-advancing carousel always pauses under the pointer, while focus is
712
+ inside it, and while the tab is hidden — WCAG 2.2.2 territory, so it is not
713
+ switchable.
714
+ - `indicatorLabelFunc` is replaced by two new `i18n` keys, `indicators` and
715
+ `slide`, which name the indicator row and prefix each dot. They are new in
716
+ this release: those were the last two strings Carousel generated in
717
+ hardcoded English, and SEMANTICS rule 5 wants every generated string on an
718
+ `i18n` option. The replacement is a pair of strings rather than a callback,
719
+ so a label that varied per index — "Go to slide 3 (Current)" — has no
720
+ equivalent; the current dot already carries `aria-current="true"`, which is
721
+ what that suffix was standing in for.
722
+
723
+ **`fullscreen` has no exact equivalent, and `.carousel.full-screen` is not
724
+ it.** A `fullscreen` slideshow filled its positioned ancestor and stayed
725
+ horizontal at every size. `.carousel.full-screen` is M3's immersive feed:
726
+ `100dvh`, square corners, and **vertical**, turning horizontal on its own in
727
+ landscape or at an expanded width (the component adds
728
+ `.full-screen-horizontal` from the rendered geometry, so setting that class
729
+ by hand does not stick). It also sizes itself, ignoring the `height` option.
730
+ Take it for an edge-to-edge feed; for a fixed horizontal band, keep the
731
+ `height` above and leave `full-screen` off.
732
+
733
+ - **The coverflow carousel layout is gone.** `.coverflow` was the 3D
734
+ perspective tween inherited from the vendored MaterializeCSS source, and
735
+ Material 3 defines it at no layout and no size. It hand-wrote a momentum
736
+ simulation the browser already performs — velocity smoothing over a
737
+ `Date.now()` delta, exponential-decay easing toward a target, modular index
738
+ wrapping, per-item opacity and z-translation recomputed every frame — and ran
739
+ a second set of pointer handlers on the same element as the snap track's, so
740
+ a reader of the component had to establish which of two drag models was live
741
+ before tracing a gesture. Deleted with it: the `.carousel.coverflow` Sass
742
+ block, and the `dist`, `shift`, `padding` and `numVisible` options.
743
+
744
+ Every `:not(.coverflow)` guard elsewhere in the partial is now unconditional
745
+ — the reduced-motion block, the fixed-height indicator row, the contained
746
+ edge fades and the grab cursor state their intent directly rather than by
747
+ exclusion. `.carousel` is the Material 3 carousel and nothing else.
748
+
749
+ **Migration.** Delete the class. The nearest Material 3 equivalent to
750
+ coverflow's centred, overlapping look is the **hero** layout, which shows one
751
+ large item flanked by small previews:
752
+
753
+ ```html
754
+ <!-- before -->
755
+ <div class="carousel coverflow">
756
+ <a class="carousel-item" href="#one"><img src="1.jpg" alt="One"></a>
757
+ <a class="carousel-item" href="#two"><img src="2.jpg" alt="Two"></a>
758
+ </div>
759
+
760
+ <!-- after -->
761
+ <div class="carousel hero center-aligned">
762
+ <a class="carousel-item" href="#one"><img src="1.jpg" alt="One"></a>
763
+ <a class="carousel-item" href="#two"><img src="2.jpg" alt="Two"></a>
764
+ </div>
765
+ ```
766
+
767
+ The four removed options are deleted rather than accepted and ignored, so
768
+ passing one is a type error instead of silence. There is no runtime warning
769
+ and no shim: a shim keeps both concepts alive while pretending one has died.
770
+
771
+ **`duration` and `noWrap` survive and mean what they always did for a snap
772
+ track.** `duration` bounds how long the component waits for `scrollend`
773
+ before assuming a programmatic scroll landed, and is added to `interval` to
774
+ time each auto-advance rest; `noWrap` stops auto-advance after one pass
775
+ instead of looping. Auto-advance — `interval`, `duration`, `noWrap` — behaves
776
+ exactly as it did.
777
+
778
+ - **Parallax and Pulse are gone**, with no successor. This removal is separate
779
+ from the semantics sweep above. Both were decorative
780
+ motion opinions of our own, and M3 Expressive ships a `MotionScheme` of
781
+ spring specs that says something different — the same reasoning that removed
782
+ the legacy palette. Deleted outright: the `.parallax` and `.pulse` styles,
783
+ the `Parallax` component and its registry entry (so `AutoInit()` no longer
784
+ claims `.parallax`, and `Expressive.Parallax` no longer resolves), and both
785
+ docs pages.
786
+
787
+ **Migration.** For a `.pulse` halo, write the animation yourself or use a
788
+ component that already reports what it means — a badge for a count, a
789
+ progress indicator for work in progress. For a `.parallax` hero, the markup
790
+ it wrapped is an image in a container: keep the `<img>` with
791
+ `object-fit: cover` and a fixed container height. Restoring the motion takes
792
+ more than one property — the container needs `view-timeline-name`, the image
793
+ needs to be taller than its clip (`height: calc(100% + 2 * travel)`) and to
794
+ run a `translateY` keyframe pair through `animation-timeline` with
795
+ `animation-range: cover`. Copy the deleted `_parallax.scss` out of the
796
+ history if you want it. A carousel is the M3 component for a scrolling band
797
+ of media.
798
+
799
+ ### Fixed
800
+
801
+ - **The sheet is now usable as a shadow root's only stylesheet.** Token
802
+ declarations were split between `:root, :host` and `:root` alone — three
803
+ blocks paired, five not, among them the whole reference layer at 338
804
+ declarations. A sheet adopted only into a shadow root
805
+ (`shadowRoot.adoptedStyleSheets`, no document-level copy) therefore lost every
806
+ one of those tokens, and because `tokens/_theme.scss` resolves each role as
807
+ `light-dark(var(--md-sys-color-<role>-light), …)` out of the `:root`-only
808
+ pairs, it lost every colour in the sheet rather than only the missing tokens.
809
+ Every token-declaring rule now carries its `:host` twin, `[icon-style]`, the
810
+ three `[theme]` selectors and the grid's `--gap-size` on `body` included — so
811
+ a shadow tree can be pinned to a theme or an icon style the way a page can,
812
+ and a `.row` in one has a gutter instead of an invalid `gap`.
813
+ `adr/0002-shadow-only-stylesheet-adoption.md` records the commitment and
814
+ `tests/shadow-dom.test.js` enforces it. The ordinary setup — framework in the
815
+ document, also adopted into components — was never affected, because custom
816
+ properties inherit through shadow boundaries.
817
+
818
+ - **Runtime-created elements now stay in the tree that created them.** Six
819
+ places built an element and appended it to `document.body` — the tooltip
820
+ surface, the snackbar container, the lightbox caption, the drawer's edge drag
821
+ target, and the datepicker's two month/year menus. A sheet adopted into a
822
+ shadow root cannot match a node outside that root, so every one of them
823
+ arrived unstyled in the shadow-only setup the previous entry made possible.
824
+ `Utils.portalRoot(el)` returns `el.getRootNode()` when that is a shadow root
825
+ and `document.body` otherwise, and each site goes through it. The lightbox's
826
+ ancestor-overflow walk hops the shadow boundary through the host instead of
827
+ throwing on the root.
828
+
829
+ **This gives up an escape hatch on purpose.** A portal on `document.body`
830
+ escapes an ancestor's `overflow: hidden` and stacking context; inside a shadow
831
+ root it escapes neither, because the host's ancestors are still in the
832
+ flattened tree. Nothing recomputes coordinates, so a positioned ancestor above
833
+ the host re-anchors the `absolute`-positioned tooltip and a transformed one
834
+ re-anchors the three `fixed` portals. `adr/0002` states the bound; behaviour in
835
+ a plain document is unchanged.
836
+
837
+ Native `<dialog>` was never affected — a top-layer element is painted outside
838
+ the document's paint order but stays in its own tree.
839
+
840
+ **Migration:** `Snackbar` has no originating element, so it takes a new `root`
841
+ option — any element in the target tree. Without it the container still goes to
842
+ `document.body`, exactly as before. Because only one snackbar shows at a time,
843
+ the one shared container moves between roots rather than being duplicated.
844
+
845
+ - **A trigger and its target in the same shadow root can find each other.** The
846
+ mirror of the portal fix: four places resolved an id with
847
+ `document.getElementById` — the menu's target, the tooltip's rich content, the
848
+ snackbar's template and the drawer's trigger — and an id inside a shadow root
849
+ is invisible to the document, so every one of those lookups returned null.
850
+ `Utils.getElementById(el, id)` resolves against `el.getRootNode()`.
851
+
852
+ - **Two ancestor walks stepped onto a shadow root and threw.** `Menu` looks for
853
+ the nearest clipping ancestor with `getComputedStyle`, which rejects a
854
+ non-Element, so a menu whose container was a shadow root could not open;
855
+ `Lightbox` read `.style` on the same node. Both now step over the root to its
856
+ host, so the clipping ancestors above it are still found. `Lightbox` also put
857
+ every ancestor back to `''` rather than to the value it found, discarding an
858
+ author's inline `overflow` the first time a lightbox opened under it — it now
859
+ records and restores the original.
860
+
861
+ - **A menu opened from a trigger narrower than 112dp is sized to its own
862
+ content.** `constrainWidth` wrote the trigger's width onto the surface and the
863
+ surface's own `min-width: 112px` then overrode it, so any trigger under that
864
+ floor left the menu at exactly 112dp with every item inside it wrapped. An
865
+ icon button is 40dp and a split button's chevron is 48dp, so both were always
866
+ in that state. The constraint now applies only to a trigger wide enough to
867
+ mean something; a wider trigger still constrains as before.
868
+
869
+ - **The renames reached the styling and stopped short of the behaviour.** The
870
+ Sass alias made `.navigation-drawer-fixed` *look* docked while the component
871
+ still read only `sidenav-fixed`, so at the Expanded breakpoint a canonically
872
+ named drawer kept its drag target live and `open()` could turn it into a
873
+ modal. `_host()` in Slider knew only the legacy classes, so a dual-handle
874
+ control in a `.slider` host never clamped its handles and never tracked the
875
+ interval.
876
+ - **A circular indicator also matched the bare linear-progress rule**, drawing
877
+ a linear fill over the conic one — `<span class="progress circular">` is not
878
+ a `<progress>` and has no determinate child, so it fell through to the
879
+ fallback.
880
+ - **Four documented snippets kept the old names in script** — `.sidenav`
881
+ queries that matched nothing, and `Expressive.Slider.init` still being used
882
+ to start slideshows after `Slider` became the range control. A new check
883
+ reads every `Expressive.X` and every selector string in the docs and fails on
884
+ a name the bundle or the sheet does not have.
885
+ - **`.fab` was not excluded from the toolbar selector** the way
886
+ `.fixed-action-btn` is, so a FAB-to-toolbar transition written with the new
887
+ name picked up toolbar styling. Found by the rename test itself, which walks
888
+ every rule naming an old class and fails if the new one is missing from it.
889
+ - **`llm.md` documented a token that does not exist** — `--sidenav-width`. The
890
+ real one is `--md-comp-nav-drawer-width`.
891
+ - **The top app bar told an icon from a label by its element.** The 48dp
892
+ circular target was keyed on `<i>`, and the canonical icon is a
893
+ `<span class="material-symbols">` — so every bar written the way the docs
894
+ teach, this site's own header included, lost the rule outright. The leading
895
+ action fell through to the generic button pill (72×40, tonal fill), the
896
+ trailing icon links were swept up by the *text-destination* rule and drawn at
897
+ `label-large` with 12dp padding, and the headline sat 76px from the edge
898
+ instead of the spec's 56. Keyed on the shared `$icon` list now, the way
899
+ `_buttons` and `_toolbar` already were.
900
+ - **Five more tokens were declared or referenced but not both**, found by the
901
+ new check below. The time picker's AM/PM buttons referenced Materialize's
902
+ `--btn-padding` and `--btn-border-radius`, which no longer exist — the
903
+ declarations were invalid at computed-value time, which does *not* fall back
904
+ to the rule underneath, so the padding rendered as 0 while a base button rule
905
+ offered 24px. They now say the 0 they have always rendered as. The checkbox
906
+ and radio `state-layer-size` tokens were declared and read by nothing, with
907
+ the 40dp geometry written out as pre-computed `11px` / `10px` shadow spreads;
908
+ the spreads derive from the tokens now, to the same pixel.
909
+
910
+ - **The app bar's trailing icon token was declared and used by nothing.**
911
+ Both icon actions took the *leading* token, so the documented way to opt into
912
+ the spec's muted trailing icons changed nothing. The two are told apart the
913
+ way the rest of the bar is — by DOM order, leading before the headline and
914
+ trailing after it — and each token now colours its own side.
915
+ - **The medium and large app bars missed their spec insets**, and a
916
+ title-only one put the headline where the icons belong. The expanded
917
+ headline sat 20dp from the inline edge rather than 16, medium left 16dp
918
+ above the container bottom rather than 20, and the icon row sat 4dp high
919
+ instead of centred in the 64dp row the bar collapses to. Separately, a bar
920
+ with no icons has a single flex line, and `align-content: space-between`
921
+ packs one line to the *top* — so the headline rose to the icon row. The top
922
+ row now has a height of its own and the headline a basis that cannot share
923
+ a line with it.
924
+ - **Ten more components had the same defect as the app bar above.** The
925
+ canonical icon is `<span class="material-symbols">`, but the navigation bar,
926
+ dialog, panes, list, tabs, pagination, side sheet, toolbar, menu and
927
+ fieldset legend each still asked `> i`. Nothing warned; the rules simply stopped applying —
928
+ the navigation bar lost its active-indicator pill, a tab with an icon kept
929
+ the short container, menu icons rendered at 24dp instead of 20dp.
930
+
931
+ Three of them broke twice over, because the rule on the *other* side of the
932
+ question over-matched instead. `:has(> i:only-child)` failing meant the
933
+ dialog's close button fell into the `:not(:has(…))` **text button** branch
934
+ and rendered as an auto-width 40dp pill rather than a 48dp circle; in a
935
+ list, `> span { grid-column: 2 }` swallowed the leading icon
936
+ and drew it inside the text column. Icons now go through `$icon` and labels
937
+ through `$icon-label`, which is what those variables are for.
938
+ - **A labelled header action was squeezed into the icon-button circle.**
939
+ `:only-child` counts elements, not text nodes, so
940
+ `<button><span icon/> Save</button>` reads as icon-only and took the 48dp
941
+ circle with its label overflowing — while the complementary
942
+ `:not(:has(…))` branch excluded it from the text-button treatment, leaving
943
+ no way to write it at all. CSS cannot see the text node, so `.button` is now
944
+ the author's opt-out on dialog and pane headers, matching the escape hatch
945
+ the app bar already had. Writing the label as its own `<span>` works too, and
946
+ always did.
947
+ - **The navigation bar's active indicator was the navigation rail's.**
948
+ `md.comp.navigation-bar.active-indicator.width` is 64dp and
949
+ `md.comp.navigation-rail.active-indicator.width` is 56dp; both were 56 here.
950
+ The rail is unchanged; the bar is now 64×32.
951
+ - **The remaining four components, and the cascade bug hiding underneath
952
+ them.** `_buttons`, `_navigation-rail`, `_snackbar` and `forms/_slider` asked
953
+ `> i` the same way. Two failed harder than the rest: the navigation rail
954
+ hides its FAB *label* with `> :not(i)`, so once the icon became a `<span>`
955
+ that rule hid the icon instead and the FAB rendered as an empty 56dp box;
956
+ and the FAB toolbar's collapsed actions kept `opacity: 0`.
957
+
958
+ Fixing `_buttons` exposed a second defect. A component icon rule and the base
959
+ `.material-symbols` rule both carry a class, so they tie on specificity and
960
+ source order alone decides — and `icons-material-design` was forwarded
961
+ *after* `buttons`, `list` and `badges`. Those three silently lost the tie, so
962
+ a button drew an 18px box around a 24px glyph. Icons now load first, which is
963
+ the order every other component already had. **A standard button's icon is
964
+ now 18dp** (`--md-comp-filled-button-icon-size`) rather than the 24dp font
965
+ default; circle, FAB and extended buttons stay 24dp. `list` and `badges` are
966
+ unchanged — their tokens already matched the default.
967
+
968
+ ### Added
969
+
970
+ - **A token liveness check** (`tests/tokens.test.js`). Two failure modes, both
971
+ silent — no syntax error, nothing in a browser console. A `var()` naming a
972
+ token nothing declares makes its whole declaration invalid at computed-value
973
+ time: it still wins the cascade and *then* resolves to unset, so the property
974
+ falls to inherited-or-initial rather than to the rule underneath. And a token
975
+ declared and read by nobody is a promise the sheet does not keep. Only
976
+ `--md-comp-*` is checked for being read — `--md-sys-*` and `--md-ref-*` are
977
+ the public palette, where 111 unread ramp entries are the point. Unread
978
+ component tokens can be exempted with a stated reason, and the check fails on
979
+ a stale exemption too, so the list can only shrink.
980
+
981
+ - **The sweep is finished — 45 of 45 components enforced.** The last pass takes
982
+ the remaining 23: icons, badges, buttons, cards, toolbar, list, tooltip,
983
+ preloader, dialog, panes, carousel, parallax, lightbox, and the ten that
984
+ state no markup of their own and now say so in writing.
985
+ - Two ways to account for a composite role a component keeps out of its markup.
986
+ **Conformance debt** (`conformance`) is a promise deferred until the code can
987
+ keep it — Tabs and Toolbar withhold `tablist` and `toolbar` pending a keyboard
988
+ model. A **rejected role** (`rejects`) is a promise declined: Carousel's
989
+ keyboard contract is implemented and tested, and it writes the ARIA carousel
990
+ pattern instead, so `tablist` is not owed. Both link to the rule that enforces
991
+ them, and the suite refuses either without the other in both directions, so a
992
+ role cannot be withheld silently and debt cannot be recorded without teeth.
993
+ - A fourth rule kind, **`forbid-composite-roles`**. The rule states the
994
+ component's own selector and the checker expands it over the composite-role
995
+ vocabulary, so the ten roles are named once rather than once per component and
996
+ adding one tightens every such rule. It replaced two hand-written rules that
997
+ each named a single role and left the other nine legal.
998
+ - A third rule kind, **`require-accessible-name`**. It is the one question a
999
+ selector cannot ask: whether a control ends up with a name depends on text
1000
+ *nodes*, and CSS cannot see them — `:has(> .icon:only-child)` counts
1001
+ elements, so it flags `<a><span icon/>Five</a>`, a link that is perfectly
1002
+ well named. The check reads the content instead.
1003
+ - **Navigation is the third sweep** — `landmarks`, `navbar`, `navigation-bar`,
1004
+ `navigation-rail`, `sidenav`, `breadcrumb`, `pagination`, `tabs`, `menu`,
1005
+ `table_of_contents`, `page-footer`. **22 of 45 components are now enforced;
1006
+ 23 remain.**
1007
+ - **`m3-guidelines.md` is checked**, against `fragmentSafe` rules only. It
1008
+ states markup as inline code spans in prose rather than as fenced examples,
1009
+ so a rule may run against it only if it fires on a *wrong thing present*. A
1010
+ fragment cannot be wrong about what it omits — omitting is what a fragment
1011
+ is for.
1012
+ - `landmarks`, a component row for the cross-cutting rules that belong to no
1013
+ single component: every `<nav>` carries a name, and `<main>` does not nest.
1014
+ - **Forms are the second sweep** — `input-fields`, `fieldset`, `checkboxes`,
1015
+ `radio-buttons`, `switches`, `select`, `file-input`, `range`, plus
1016
+ `autocomplete` and `character-counter`. **11 of 44 components are now
1017
+ enforced; 33 remain.**
1018
+ - `Autocomplete` announces itself. The input is a `combobox` with
1019
+ `aria-autocomplete="list"` and `aria-controls`; the suggestion list is a
1020
+ `listbox`; every entry is an `option` with an id and `aria-selected`; and
1021
+ arrowing sets `aria-activedescendant`. It previously had **none** of these -
1022
+ the highlight moved with a class nothing but a sighted user could follow.
1023
+ Rule 2 permits the roles because the keyboard contract was already there.
1024
+ - `CharacterCounter` writes into a polite, atomic live region, so the remaining
1025
+ count is announced instead of silently running out.
1026
+ - A test fails any markup sample in `llm.md` fenced as ` ```text ` instead of
1027
+ ` ```html `. The extractor keys on the tag, so 37 blocks - including every
1028
+ Fieldsets example - were markup no check could see.
1029
+ - **`SEMANTICS.md`**, the normative per-component markup standard, generated
1030
+ from `semantics.json` by `npm run build:semantics`. `CONTEXT.md` defines the
1031
+ vocabulary it uses.
1032
+ - **`tests/semantics.test.js`** enforces it against all three surfaces that
1033
+ state markup: `llm.md`, `docs/templates/**` and `tests/fixtures.js`. A
1034
+ component is `enforced` or `exempt`; the exempt list is the remaining backlog
1035
+ and only shrinks. A component cannot be added without a row, so anything new
1036
+ is checked from its first commit. A single example can opt out with a stated
1037
+ reason (` ```html ignore-semantics: why `, or `code(check=false, reason=…)`).
1038
+ - `Chips` gains an `i18n` option (`{ remove: 'Remove' }`) for the delete
1039
+ button's accessible name.
1040
+
1041
+ ### Changed
1042
+
1043
+ - **An icon is a `<span>`, and it says whether it is decoration or an image.**
1044
+ 465 `<i class="material-icons">` became
1045
+ `<span class="material-symbols" aria-hidden="true">`. `<i>` means idiomatic
1046
+ text and an icon is none of those; more to the point the glyph comes from a
1047
+ ligature, so the icon's content is real text and was being read out — every
1048
+ icon-only button announced itself twice, as "add, Add". An icon is now
1049
+ either `aria-hidden` with the control carrying the name, or `role="img"`
1050
+ with a label of its own.
1051
+ - **Hiding every icon exposed the controls that had nothing else.** 47
1052
+ icon-only links and buttons had no accessible name at all once the glyph
1053
+ stopped being read; they carry one now.
1054
+ - **A badge nested in an icon is hidden with it.** `aria-hidden` covers the
1055
+ subtree and `aria-hidden="false"` on a descendant does not undo that, so a
1056
+ count that lived only inside a decorative icon was a count nobody heard.
1057
+ Inside a control the name carries it (`aria-label="Inbox, 3 unread"`);
1058
+ standing alone the icon becomes the image.
1059
+ - A **toolbar is a `<div class="toolbar">`**, not a `<nav>` — it holds
1060
+ commands, not destinations — and not `role="toolbar"` either, which is a
1061
+ composite role promising arrow keys nothing here implements.
1062
+ - A **card's action row is a `<div class="actions">`**. A row of buttons is not
1063
+ a landmark, and one per card floods the landmark list.
1064
+ - A **`<dialog>` is named**, by `aria-labelledby` pointing at the heading it
1065
+ already has. Including the one `Sidenav` builds: it copies the drawer's name
1066
+ onto the dialog, because once that dialog opens modally the `<nav>` holding
1067
+ the label is outside it.
1068
+ - A **`<div class="progress">` reports progress** with `role="progressbar"`, and
1069
+ a determinate one reports `aria-valuenow` too — a progressbar with no value
1070
+ *is* an indeterminate one, which is a lie if the bar visibly shows 70%.
1071
+ `<progress>` reports itself; a div drawn with CSS reports nothing.
1072
+ - A **CSS-only `.tooltip` is referenced with `aria-describedby`.** Inside its
1073
+ control it was swallowed — with an `aria-label` present the label wins and
1074
+ the tooltip was never announced at all.
1075
+ - **`.lightboxed` images are operable.** They open an overlay on click, which
1076
+ makes them controls, and a bare `<img>` is not focusable. The keypress
1077
+ handler was already there; only `tabindex` and a role were missing.
1078
+ - `aria-selected` came off plain `<li>`, and a pane is a `<section>`, not a
1079
+ second `<main>`.
1080
+ - **Every `<nav>` now carries a name, and things that are not navigation
1081
+ stopped claiming to be.** A row of radios or checkboxes is a
1082
+ `<div class="inline">`, not a `<nav>` — it was a flex hook wearing a
1083
+ landmark. The icons page used `<nav>` to mean "any element that inherits";
1084
+ that is a `<div>`. A footer column with no links is a `<section>`. A top app
1085
+ bar that holds only a title and controls — as the docs chrome does — is a
1086
+ `.bar`, because an empty navigation landmark is worse than none.
1087
+ - **Breadcrumbs are an ordered list.** `<nav aria-label="Breadcrumb"><ol>`,
1088
+ with `aria-current="page"` on the last crumb. The flat run of
1089
+ `<a class="breadcrumb">` gave no count and no position. The Sass already
1090
+ supported the `<ol>` form; only the documentation was behind.
1091
+ - **Pagination lives in a labelled `<nav>`** with `<ol>`, `aria-current="page"`
1092
+ on the current link, and a `<span>` rather than an `<a href>` for a disabled
1093
+ prev/next — a disabled link is still focusable and still navigates.
1094
+ - Tabs, the navigation bar and the navigation rail mark the active destination
1095
+ with `aria-current="page"`, not just a class.
1096
+ - A `<menu>` separator is `<li class="divider" role="separator">`. `<menu>` is
1097
+ a list and its content model permits only `<li>`, so a bare `<hr>` between
1098
+ entries was invalid. It still renders.
1099
+ - The sidenav and the table of contents sit inside a labelled `<nav>`.
1100
+ - **Form markup now says what it is.** `.field` is the container; `.input-field`
1101
+ never was one (the only rule matching it is `.chips.input-field`). Field icons
1102
+ are `<span class="material-symbols" aria-hidden="true">` and name their side
1103
+ with `prefix` or `suffix`. Supporting text carries an `id` and the control
1104
+ points at it with `aria-describedby`, or it is never read out with the field.
1105
+ Radios live in a `<fieldset>` with a `<legend>` naming the question. A file
1106
+ input is a `<label>` pointing at the input rather than a `<button>` wrapping
1107
+ it, and the path field is `readonly`. Switch on/off captions are
1108
+ `aria-hidden`, because as bare text they made the switch announce itself as
1109
+ "Off On".
1110
+ - **Chips are the first component swept.** Each kind of chip is now written
1111
+ with the element that says what it is — the four Material 3 types plus a
1112
+ non-interactive display chip, across three root elements: display is
1113
+ `<span class="chip">`, assist and suggestion are
1114
+ `<button type="button" class="chip">`, filter is
1115
+ `<input type="checkbox" class="chip-input">` + `<label class="chip">`, and an
1116
+ input chip is a `<span class="chip">` holding a
1117
+ `<button type="button" class="close" aria-label>`. A filter chip's state is
1118
+ `:checked`, so it needs no JavaScript.
1119
+ - Selection is the `selected` class rather than `:focus`, which previously had
1120
+ to stand in for it. `active` still works.
1121
+ - Icons inside a chip are `<span class="material-symbols" aria-hidden="true">`.
1122
+ The ligature is real text and was being read out next to the label.
1123
+ - The icon modifiers `.left`, `.right`, `.tiny`, `.small`, `.medium` and
1124
+ `.large` no longer depend on the element being `<i>`. They were keyed on `i`
1125
+ in `base/_global.scss`, so writing an icon as a `<span>` silently dropped
1126
+ them; `.left` and `.right` moved onto the icon class list, where the sizes
1127
+ already were.
1128
+
1129
+ ### Fixed
1130
+
1131
+ - **The footer's legal bar was unstyled whenever the footer used `.container`.**
1132
+ `.container` is a supported columns wrapper — the column rules already reach
1133
+ through it — but the copyright-bar selector only matched a direct
1134
+ `footer > small`, so the documented grid layout produced a bare copyright
1135
+ row. The docs' own chrome had it on every page.
1136
+ - **The docs taught four classes that do not exist.** `.page-footer` and
1137
+ `.footer-copyright` were described as aliases that "stay" — nothing in the
1138
+ sheet matches either; `carousel-slider` was said to still enable flat mode,
1139
+ but only `.flat` and `fullWidth` are read; and `btn-large`, removed three
1140
+ releases ago, was still used in a waves example. `.prev` / `.next` are
1141
+ accepted but inert, and the responsive-pagination prose still described the
1142
+ 10%/80% split they used to drive, which `.nowrap` scrolling replaced.
1143
+ - **`m3-guidelines.md` declared `nav.toolbar` five times** — a shape
1144
+ `semantics.json` forbids — plus the toolbars page prose, which still called
1145
+ the bar a `<nav>` after the markup beside it had been changed. The fragment
1146
+ reader could not see any of it: it looks for `<tag …>` forms, and a
1147
+ selector-style mention in a sentence or an anatomy table has no angle
1148
+ brackets. A check now derives every `tag.class` shape from the fragment-safe
1149
+ forbid rules and fails when a document names one, allowing a deliberate
1150
+ negation so a rename can still be recorded.
1151
+ - **Icon anatomy prose omitted `aria-hidden`** on three pages while the next
1152
+ sentence claimed the icon was hidden — so a reader following it would build
1153
+ a button announced as "add Create".
1154
+ - **Prose lagged the sweeps.** The code blocks were rewritten mechanically and
1155
+ the sentences around them were not: a dozen places still called the icon an
1156
+ `<i>`, cards still described a `<nav>` action row, lists and menus still
1157
+ taught `aria-selected` on a plain `<li>`, and the pickers told you to wrap an
1158
+ input in `.input-field` — four sections after `llm.md` says that class is not
1159
+ the container and never was.
1160
+ - **A docs page could carry 31 landmarks called "Main."** Every app bar demo on
1161
+ a page is a specimen, and they all had the canonical label. The live demos
1162
+ are named by their section now; the samples a reader copies keep the plain
1163
+ name.
1164
+ - **Off-screen carousel slides were hidden but still focusable.** A slide is an
1165
+ `<a>`, and `aria-hidden` alone left the tab stop in place — focus landed on a
1166
+ link the user had no way to perceive. They are `inert` now, which removes
1167
+ them from the tab order, from assistive technology and from hit-testing.
1168
+ - **The drawer wrapper became a second app bar.** Wrapping the sidenav in a
1169
+ `<nav>` while it still lived inside `<header>` made it a direct child of an
1170
+ app bar host, so the navbar rules gave it `display: flex`, full width and a
1171
+ 64dp minimum — an empty bar-sized band under the real one, on every docs
1172
+ page. The drawer is a sibling of the bar now, which is what
1173
+ `m3-guidelines.md` said all along: the trigger may live in the bar, the
1174
+ drawer must not.
1175
+ - **A `header.fixed` whose bar is a `.bar` kept its looks but lost its
1176
+ behaviour.** The fixed selector still required a `nav` child, so such a
1177
+ header got the app bar's appearance without `position: sticky`, its z-index
1178
+ or the scroll-fill.
1179
+ - **A row of checkboxes stacked vertically.** `_radio-buttons.scss` learned
1180
+ `.inline`; `_checkboxes.scss` did not, and each label is `display: flex`.
1181
+ - **Tabs announced the wrong tab as current.** `aria-current` was written into
1182
+ the markup and then never moved: the class changed on click, the attribute
1183
+ did not. Its value changes with interaction, so maintaining it is the
1184
+ component's job — `Tabs` now moves the class and the attribute together, on
1185
+ click and on a hash-selected init.
1186
+ - **`.bar` escaped its selector and styled every element that used it.** The
1187
+ new app bar host was interpolated as a bare comma-separated list, so
1188
+ `header.medium > #{$_bar}` expanded to `header.medium > nav:not(…), .bar` —
1189
+ a top-level `.bar` rule applying app bar layout page-wide. Wrapping the
1190
+ variable in `:is()` keeps it one compound selector, and the specificity
1191
+ where it was. The existing app bar tests caught it.
1192
+ - **A field icon written as a `<span>` inherited the floating label's
1193
+ position** - 16px from the edge with `pointer-events: none`, i.e. indented
1194
+ past its own padding. Five hand-copied "everything that is not the label"
1195
+ exclusion lists had already drifted from each other; they are now one
1196
+ `$_not-label` variable, which is what stops the sixth from being missed.
1197
+ - The field icon modifiers were keyed on `i` with `:first-of-type` /
1198
+ `:last-of-type`. Those count elements of the same *type*, so they could not
1199
+ be widened to spans at all - a field is full of other spans. The documented
1200
+ markup names the side instead; the positional `i` rules stay for old markup.
1201
+ - **The file input trigger is a `<label>` wrapping its control**, not a label
1202
+ pointing at a sibling. `label.button` joins the button selector list, and the
1203
+ floating-label rule now excludes it — without both, the trigger rendered as
1204
+ invisible, unclickable field furniture. Wrapping also scopes the invisible
1205
+ input to the button, so the picker no longer opens from a click anywhere in
1206
+ the field.
1207
+ - **The trailing field icon swallowed clicks.** Excluding the icons from the
1208
+ floating-label rule took `pointer-events: none` with it; `.prefix` restated
1209
+ it, `.suffix` did not.
1210
+ - **`aria-selected` no longer doubles as the highlight.** Active and selected
1211
+ are different things: folding the arrow-key highlight into `aria-selected`
1212
+ announced a committed multi-select choice as unselected the moment the user
1213
+ arrowed past it. The highlight is `aria-activedescendant` alone.
1214
+ - **`Autocomplete.destroy()` left a timer running.** `open()` defers
1215
+ `menu.open()` to `setTimeout(0)`; destroying in the same tick let it fire
1216
+ against a menu whose element was gone. It surfaced only as an uncaught
1217
+ error *after* the test had passed. Calling `open()` twice before the callback
1218
+ fired also orphaned the first timer, which `destroy()` could not then cancel.
1219
+ - **A rendered chip was a `<div tabindex="0">` with no role and no accessible
1220
+ name** — in the tab order, announcing nothing. The chip is no longer a
1221
+ control; its delete button is, and that button carries the name.
1222
+ - The chip delete affordance was an `<i class="close">`: not focusable, not
1223
+ labelled, and unreachable without a mouse. It is now a real
1224
+ `<button type="button">`.
1225
+ - `.chip:focus` set `outline: none`, which would have removed the focus
1226
+ indicator from the newly focusable chips. Focus now goes through the
1227
+ `focus-visible` mixin.
1228
+ - `closeIconClass` was documented with a default of `'material-icons'`; the
1229
+ code has always defaulted to `'material-symbols'`.
1230
+
1231
+ ### Migration
1232
+
1233
+ - `<i class="material-icons">add</i>` →
1234
+ `<span class="material-symbols" aria-hidden="true">add</span>`, and the
1235
+ control around it needs an `aria-label` if the icon was its only content.
1236
+ - `<nav class="toolbar">` → `<div class="toolbar">`.
1237
+ - A card's `<nav>` action row → `<div class="actions">`.
1238
+ - `<dialog>` → add `aria-labelledby` pointing at its heading.
1239
+ - `<div class="progress">` → add `role="progressbar"`, plus `aria-valuenow`
1240
+ when it is determinate.
1241
+ - `<img class="lightboxed">` → add `tabindex="0" role="button"`.
1242
+ - `<li aria-selected="true">` → `<li class="selected">`.
1243
+ - Every `<nav>` needs `aria-label` (or `aria-labelledby`).
1244
+ - `<nav>` used to lay out radios, checkboxes or switches →
1245
+ `<div class="inline">`.
1246
+ - `<nav class="breadcrumb-wrapper">` with `<a class="breadcrumb">` →
1247
+ `<nav aria-label="Breadcrumb"><ol><li><a>`, last crumb `aria-current="page"`.
1248
+ - `<ul class="pagination">` → `<nav class="pagination" aria-label="Pagination"><ol>`.
1249
+ The bare list still renders.
1250
+ - `<hr>` inside a `<menu>` → `<li class="divider" role="separator"></li>`.
1251
+ - `<ul class="sidenav">` → wrap it in `<nav aria-label="…">`.
1252
+ - A top app bar with no destinations → `<div class="bar">` instead of `<nav>`.
1253
+ - `<div class="input-field">` → `<div class="field">`.
1254
+ - `<i class="material-icons">place</i>` in a field →
1255
+ `<span class="material-symbols prefix" aria-hidden="true">place</span>`. The
1256
+ side is now required.
1257
+ - `<small>help</small>` in a field → give it an `id` and add
1258
+ `aria-describedby` to the control.
1259
+ - Radios → wrap the group in `<fieldset><legend>`.
1260
+ - `<button><input type="file"></button>` →
1261
+ `<label class="button" for="x">File</label><input id="x" type="file">`, and
1262
+ add `readonly` to `input.file-path`.
1263
+ - `<div class="chip">` → `<span class="chip">`, or `<button type="button"
1264
+ class="chip">` if it is actionable. A filter chip becomes a checkbox and its
1265
+ label.
1266
+ - `<i class="close material-icons">close</i>` →
1267
+ `<button type="button" class="close" aria-label="Remove …"><span
1268
+ class="material-symbols" aria-hidden="true">close</span></button>`.
1269
+ - `.chip.active` still works; `.chip.selected` is the name going forward.
1270
+ - Old chip markup still renders. It is no longer documented.
1271
+
1272
+ ## [0.7.0] - 2026-08-21
1273
+
1274
+ Promotes 0.7.0-rc.0 to `latest`. The framework itself is **unchanged** from
1275
+ 0.6.0 — `src/ts` and `src/sass` carry no commits between the tags, so the built
1276
+ `dist/` is identical. What changed is around it: package metadata, the
1277
+ LLM-facing documentation, and the docs site's own templates.
1278
+
1279
+ ### Fixed
1280
+
1281
+ - **`llm.md` taught classes that do not exist.** It is the markup contract other
1282
+ tools and models read, and several sections still carried Materialize-era
1283
+ names the Material Design 3 rewrite removed. Each replacement was verified
1284
+ against `dist/css/expressive.css` and `src/ts`:
1285
+ - `.btn-floating`, `.btn-large` and `.btn-small` (16 references) — a FAB is
1286
+ `circle extra`, 40dp is `circle extra small`, `extend` is the extended FAB,
1287
+ and `circle large halfway-fab` anchors one to card media. On an `<a>` the
1288
+ `button` class is required, since the size rules only match
1289
+ `:where(button, a.button)`.
1290
+ - The circular preloader was documented as the old nested `preloader-wrapper`
1291
+ / `spinner-layer` / `circle-clipper` / `gap-patch` tree with Google brand
1292
+ colors. It is one `<span class="preloader">` with `small` / `big` /
1293
+ `determinate` and `--md-comp-progress-value`.
1294
+ - `page-footer` and `footer-copyright` are gone — a bare `<footer>` with
1295
+ `<nav>` columns and a trailing `<small>` is the component.
1296
+ - The claim that `.card` and `.card-panel` "remain as aliases" was false;
1297
+ `_cards.scss` is `$_card: "article"`.
1298
+ - `carousel-slider` was renamed `flat`.
1299
+
1300
+ An audit of all 213 classes `llm.md` teaches now leaves four, all correct: the
1301
+ three `chips-*` selector hooks it documents as having no styles of their own,
1302
+ and a `custom-class` placeholder.
1303
+
1304
+ ### Changed
1305
+
1306
+ - `homepage` in `package.json` points at <https://www.expressivecss.com>.
1307
+ Registry metadata is fixed per version, so 0.6.0 keeps the old link.
1308
+ - The docs templates moved onto a macro layer: navigation renders from one table
1309
+ instead of being written three times, each page names itself once, the table
1310
+ of contents is generated from the sections that register themselves, and code
1311
+ samples are written as real markup rather than 2,256 hand-typed entities.
1312
+ This fixed a real drift — three sections in the grid page were missing the
1313
+ `section` class and had lost their spacing.
1314
+
1315
+
8
1316
  ## [0.7.0-rc.0] - 2026-08-21
9
1317
 
10
1318
  A prerelease on the `next` dist-tag. `latest` stays on 0.6.0.
@@ -139,7 +1447,9 @@ are no compatibility aliases.
139
1447
  - `.tabs` nested in a header or app bar as a secondary row. `.tabs` is a
140
1448
  standalone component; the `.tabs.transparent` app bar variant is gone with it.
141
1449
 
142
- [Unreleased]: https://github.com/BaezFJ/ExpressiveCSS/compare/v0.7.0-rc.0...HEAD
1450
+ [Unreleased]: https://github.com/BaezFJ/ExpressiveCSS/compare/v0.8.0...HEAD
1451
+ [0.8.0]: https://github.com/BaezFJ/ExpressiveCSS/compare/v0.7.0...v0.8.0
1452
+ [0.7.0]: https://github.com/BaezFJ/ExpressiveCSS/compare/v0.6.0...v0.7.0
143
1453
  [0.7.0-rc.0]: https://github.com/BaezFJ/ExpressiveCSS/compare/v0.6.0...v0.7.0-rc.0
144
1454
  [0.6.0]: https://github.com/BaezFJ/ExpressiveCSS/compare/v0.5.0...v0.6.0
145
1455
  [0.5.0]: https://github.com/BaezFJ/ExpressiveCSS/compare/v0.4.0...v0.5.0