@expressivecss/expressive 0.7.0 → 0.9.0

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