@usememos/ui 26.11.17 → 26.11.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt CHANGED
@@ -19,6 +19,8 @@ It is not a general-purpose UI library for outside developers. Components are ge
19
19
  | --- | --- |
20
20
  | [Design reference: Linear](design-reference.md) | Official source evidence and how we apply it to Memos UI |
21
21
  | [Principles and voice](principles.md) | The taste in six rules, and how interface text is written |
22
+ | [Keeping the system consistent](consistency.md) | Rule ownership, app styling boundaries and the documentation-to-verification loop |
23
+ | [Component verification](testing.md) | Prop coverage, component fixtures, browser checks and screenshot maintenance |
22
24
  | [Color](color.md) | Color roles, every color token in Light and Dark, and how themes are chosen |
23
25
  | [Depth](depth.md) | Flat surface roles, borders and focus without shadows or outlines |
24
26
  | [Typography](typography.md) | The system font, the type scale and text rules |
@@ -59,6 +61,8 @@ Linear's product interface is the reference for our hierarchy, restraint and int
59
61
  - The [contextual-menu account](https://linear.app/now/invisible-details) improves the pointer's path into a submenu. Craft includes reducing effort even when no visible effect is added.
60
62
  - Emil Kowalski's [animation guidance](https://emilkowal.ski/ui/you-dont-need-animations), with examples from his work at Linear, distinguishes purposeful motion from repeated effects that slow work. It supports short content transitions and immediate keyboard navigation; it does not prescribe one timing for every Linear component.
61
63
 
64
+ A signed-in browser inspection on 14 November 2026 checked Display options, Preferences and the workspace menu. Display-option and settings-page names were 500; Select values and options were 400; sampled workspace-menu text and helper descriptions were 450. Compact-panel names used a quieter color than values, while settings-page names used primary text with quieter descriptions. These are observations of those views, not a complete Linear type specification. Memos UI adopts the name/value distinction with its existing system fonts, 400/500 weights and size scale; it does not copy Linear's Inter Variable font, 450 weight or 12px panel text.
65
+
62
66
  ## How we apply this
63
67
 
64
68
  These are Memos UI decisions informed by those sources and product review, not claims about Linear's private CSS, tokens or animation timings. A screenshot of a focused composer does not establish how all of Linear's inputs or keyboard paths behave.
@@ -73,6 +77,7 @@ These are Memos UI decisions informed by those sources and product review, not c
73
77
  | Simplifying event feedback also removes opening and closing animations | Retain minimal content transitions; keep hover, focus, press and row navigation immediate | Content visibility and repeated control feedback have different purposes |
74
78
  | A switch fades its track, border and knob colors | Update colors immediately; only the knob moves briefly | Motion explains a saved position |
75
79
  | Screens restyle controls to solve local taste problems | Fix shared roles and components, then test the contract | Products stay consistent |
80
+ | Horizontal settings names lose the weight of a field label | Keep all Field labels at 500; keep values and option text at 400; use contextual text colors | A name and its chosen value have distinct roles |
76
81
 
77
82
  ## Boundaries
78
83
 
@@ -101,17 +106,123 @@ State feedback is immediate. Motion is reserved for spatial changes, appearing o
101
106
  - Destructive confirmations name the object and the outcome: "Delete this note?" and "This can't be undone."
102
107
  - Name an action with its verb, never "OK".
103
108
  - Errors say how to fix the problem, not only what went wrong.
104
- - No emoji in the interface. Numbers use digits ("3 notes").
109
+ - No decorative emoji in interface labels. User content and reaction choices may contain emoji; compose those as content, not Lucide icons. Numbers use digits ("3 notes").
105
110
 
106
111
  ## What to avoid
107
112
 
108
113
  - A brand hue on controls, or blue anywhere at rest except links.
109
- - Cards and boxes around content. Group with spacing, a heading and a `Separator`. The exceptions are rows of settings or records: a [List](components/list.md), or a [Table](components/table.md) with `bordered`.
114
+ - Cards and boxes around content. Group with spacing, a heading and a `Separator`. The exceptions are settings or records in a [List](components/list.md), a [Table](components/table.md) with `bordered`, or a small [Form](components/form.md) with `bordered` among them.
110
115
  - Textures, glass or blur behind text, and neumorphism.
111
- - Pills for buttons or inputs.
116
+ - Pills as the default for buttons or inputs. Use the opt-in Button/Toggle `shape="pill"` for compact reaction controls and similar content choices; ordinary actions keep the size-row corners.
112
117
  - Color as the only signal. Status colors always come with a word or an icon.
113
118
  - Illustrations in empty states.
114
119
 
120
+ <!-- docs/consistency.md -->
121
+
122
+ # Keeping the system consistent
123
+
124
+ Use shared rules for a visual role, then compose components from those rules. A new screen is not a reason for a new padding, text style or state treatment.
125
+
126
+ ## Rule ownership
127
+
128
+ Each decision has one owner. Component pages describe their API and map parts to these roles; they do not redefine the roles. Summaries and examples link to the owner. If they disagree, fix the summary or example and check the implementation against the owner.
129
+
130
+ | Decision | Owner |
131
+ | --- | --- |
132
+ | Product character and interface voice | [Principles](principles.md) |
133
+ | Color roles and theme values | [Color](color.md) |
134
+ | Text roles, sizes and weights | [Typography](typography.md) |
135
+ | Surface and boundary treatments | [Depth](depth.md) |
136
+ | Size rows, measured insets, row anatomy and shape | [Layout](layout.md) |
137
+ | Combined states and their precedence | [States](states.md#combined-states) |
138
+ | Icon boxes, strokes and accessibility | [Icons](icons.md) |
139
+ | Transitions and reduced motion | [Motion](motion.md) |
140
+ | Screen composition | [Patterns](patterns.md) and [Usage](usage.md) |
141
+
142
+ Change the owning rule first. An exception belongs beside that rule, with its reason and scope, and a link from the relevant component or pattern. Update affected examples, implementation and verification in the same change. Do not add a second rule to work around the first.
143
+
144
+ ## Styling ownership
145
+
146
+ `className` and `style` are layout and integration hooks, not permission to invent a component appearance. The CSS cascade permits more than the design contract supports.
147
+
148
+ | App owns | Package owns |
149
+ | --- | --- |
150
+ | Placement, external margins, width constraints, flex/grid arrangement and responsive composition | Internal padding and gaps, height/minimum hit area, radius, type, icon geometry, semantic colors and state treatments |
151
+ | Choosing documented props and composing parts | The appearance and behavior promised by those props and parts |
152
+ | Complete color/font themes through the documented theme tokens | All other token values and every shipped class rule |
153
+
154
+ Layout hooks must not clip content, shrink hit areas or force a fixed height that prevents documented wrapping. Wrap a component to add layout spacing instead of changing its internal padding. Use the component's size, variant and shape APIs for supported visual choices. A new supported appearance requires a change to the owning rule and API, not a per-instance utility override.
155
+
156
+ An app-owned part uses Base UI for behavior, class functions or public classes for existing looks, and tokens for its additional structure. It follows the same role, geometry and state contracts; it never restyles a shipped part or changes internal row variables. See [Building your own component](custom-components.md).
157
+
158
+ ```tsx
159
+ // Layout belongs to the app; appearance comes from the component.
160
+ <Button className="w-full" size="sm">Save</Button>
161
+
162
+ // Incorrect: a local appearance that bypasses the size and text roles.
163
+ <Button className="h-12 px-8 font-light">Save</Button>
164
+ ```
165
+
166
+ ## Closing a rule change
167
+
168
+ A visual rule is complete only when its documentation, implementations, compositions and checks agree:
169
+
170
+ 1. Update the owner and every conflicting summary or example. Map the affected parts to the existing roles before adding a token or variant.
171
+ 2. Apply the rule to every implementation of that role, including class-function consumers. Test behavior through Base UI rather than inventing parallel interaction code.
172
+ 3. Exercise relevant prop combinations with ordinary and long localized text, missing optional parts and combined states. Reuse the owning component section and its focused fixtures; keep cross-component seams such as a panel containing controls. Follow [Component verification](testing.md), without adding mock product screens.
173
+ 4. Check computed text roles and actual box coordinates separately from screenshots. Cover Light/Dark, supported sizes, keyboard use, coarse pointers and reduced motion where affected. Assert accessible names and interaction results when structure changes.
174
+ 5. Generate changed baselines with the Linux Screenshots workflow, inspect the changed images, and keep only intentional changes. Record the user-visible result in the changelog. Passing an image comparison alone does not verify a semantic role.
175
+
176
+ Typography changes also need visual inspection in a native system-font browser. Linux snapshots remain the reproducible baseline, but platforms can render 400 and 500 differently. Never change a semantic weight merely to make one platform's screenshot look heavier. Report platforms that were not inspected.
177
+
178
+ The checks have distinct jobs: unit tests cover public props, token tables, class parity and CSS constraints; browser assertions cover roles, geometry and behavior; component and overlay screenshots cover appearance. Keep all three when a change crosses those boundaries. Do not replace one with another or update baselines to hide a failing contract.
179
+
180
+ <!-- docs/testing.md -->
181
+
182
+ # Component verification
183
+
184
+ The playground is a component test fixture. It is not a mock product. Organize cases around the public API exported from `packages/ui/src/index.ts`, including namespace parts and supported prop values.
185
+
186
+ ## Coverage contract
187
+
188
+ For each component, verify:
189
+
190
+ | Input | Required evidence |
191
+ | --- | --- |
192
+ | Package-owned prop | An explicit case in its component unit suite, asserting the observable result |
193
+ | Literal variant, size or orientation | Default/omitted behavior and every supported value; include inherited size and explicit override where the component participates in size context |
194
+ | Optional content | Present and absent; include icon-only and both icon slots where supported |
195
+ | Stateful API | Controlled value plus callback and parent update; uncontrolled default plus internal update; disabled/loading must prevent activation as documented |
196
+ | Composition hooks | Ref target, native/ARIA/data attributes, caller class/style, and `render` where supported. Input-like wrappers distinguish box hooks from control hooks |
197
+ | Folded overlay | Default and custom portal targets, positioning-prop routing, close label, variant and open/close behavior |
198
+ | Public namespace part | A direct render case with its parent context; verify its association, structure or behavior |
199
+ | Meaningful prop combination | A regression case at the seam, such as disabled + selected + description, or icon-only + loading + custom render |
200
+
201
+ `packages/ui/test/prop-coverage.test.ts` derives package-owned props and public parts from the actual TypeScript exports. It fails if a component suite omits an exported part or never explicitly supplies one of those props. This is an inventory guard; it does not judge assertion quality or replace the behavior checks above. Do not satisfy it with an unused fixture or a prop that is supplied but never checked.
202
+
203
+ Base UI and native props are inherited APIs. Verify that our wrappers preserve controlled state, callbacks, refs, `render`, attributes and positioning. Do not copy the upstream suite or build the Cartesian product of unrelated options. Exhaust the package's documented values, then test combinations that can interfere with each other.
204
+
205
+ ## Where cases live
206
+
207
+ - **Unit:** `packages/ui/test/components/<component>.test.tsx`. All props for one component live together, including features added later. Shared size boundaries live in `composition.test.tsx`; class-function parity and token/CSS constraints remain shared suites.
208
+ - **Browser:** `e2e/` verifies real interaction and layout: keyboard paths, focus restoration, label/error associations, sizing, text roles, forced colors, reduced motion and touch targets. Shared contracts stay in `consistency`, `icons`, `surfaces`, `touch` and `interop` suites instead of being duplicated in every component.
209
+ - **Visual fixture:** `playground/sections/<component>.tsx` shows supported appearances and relevant states. Small reusable fixtures in `playground/fixtures/` belong to the components that render them. No settings-page, timeline or empty-page mockups.
210
+ - **Visual baseline:** `e2e/visual.spec.ts` captures each component section in Light/Dark and every supported size, and on a coarse pointer. It also captures the overlay cases defined in that file. Generated baselines come from Linux CI only.
211
+
212
+ One fixture may support multiple assertions when it exercises the same props. Keep focused composed fixtures when they test a real seam, such as a list Popover containing a Menu, controls inside a panel, or a reaction picker. Remove a fixture only after migrating any unique assertion it supports.
213
+
214
+ ## Changing the API or fixtures
215
+
216
+ 1. Read the public prop type and its component guide. Add the case beside that component's existing cases, with an assertion about the caller-visible result.
217
+ 2. Exercise defaults, supported values, and relevant combinations. Keep type restrictions checked by TypeScript, not runtime fallbacks.
218
+ 3. Update the component section when appearance changes. Add an overlay capture when a new appearance is otherwise invisible in its closed section.
219
+ 4. Run typecheck, unit and Chromium tests. The inventory guard must remain strict; do not exclude a new prop or public part just to pass it.
220
+ 5. Generate and inspect Linux screenshots. Remove baselines for deleted fixtures, retain unrelated baselines, then run CI against the final source and images.
221
+
222
+ Use the shared render helper so every React root and portal unmounts after a unit test. Tests must not depend on DOM, timers, open widgets or refs left by an earlier case. Follow [Closing a rule change](consistency.md#closing-a-rule-change) when the API work changes a visual contract.
223
+
224
+ For an overlay's close behavior, assert that it becomes hidden and focus returns as documented. Do not require its DOM to disappear: Base UI may retain hidden content, and supported `keepMounted` APIs deliberately do so. Unit-test cleanup still unmounts every root between cases.
225
+
115
226
  <!-- docs/color.md -->
116
227
 
117
228
  # Color
@@ -125,6 +236,7 @@ Memos UI is ink on paper: a light warm-gray canvas, bright surfaces, `primary` i
125
236
  | Surfaces | `canvas`, `surface`, `overlay` | The page; then inputs, menus, dialogs and toasts one step up. `overlay` dims the page behind dialogs |
126
237
  | Wells | `well` | Switch tracks, segmented groups, unchecked boxes and radios, pressed toggles, inline code |
127
238
  | Hover | `hover` | Rows, ghost buttons, the highlighted menu row |
239
+ | Saved choices | `selected` | Persistent fill on soft toggles, distinct from hover |
128
240
  | Keys | `key`, `key-hover` | Neutral buttons, select triggers, the selected tab, kbd |
129
241
  | Text | `text`, `text-secondary`, `text-disabled` | Primary, secondary and disabled text. Each keeps 4.5:1 on the backgrounds named in the token table below |
130
242
  | Emphasis | `primary`, `primary-hover`, `on-primary` | Primary button, checked checkbox and radio, switch when on, tooltip |
@@ -132,6 +244,16 @@ Memos UI is ink on paper: a light warm-gray canvas, bright surfaces, `primary` i
132
244
  | Status | `success`, `warning`, `danger` and their `-soft` pairs; `danger-key`, `on-danger` | Toasts, badges, field errors, danger buttons |
133
245
  | Lines | `edge`, `control-edge`, `line` | Quiet resting borders on fields, neutral buttons, switch tracks and surfaces; small checkbox/radio markers; separators and table rules |
134
246
 
247
+ ## Text hierarchy
248
+
249
+ Weight identifies a name or content; color determines how much attention it gets. Keep the [text-role weights](typography.md#text-roles) when changing context.
250
+
251
+ - Forms, settings pages and Dialogs: Field labels and setting names use `text`; descriptions, helper text and metadata use `text-secondary`.
252
+ - Compact controls in a Popover: horizontal Field labels use `text-secondary`, while entered text and selected values use `text`. Vertical Field labels keep `text`. The package supplies this treatment; apps do not recolor each label.
253
+ - Menu, Select and Combobox items use `text`, with secondary descriptions and shortcuts. Selection does not lower the text's contrast or change its weight; the component's fill or check identifies the choice.
254
+ - Disabled labels and values use `text-disabled` at their ordinary weight. Invalid fields keep their label's contextual color; the error text and existing control edge use `danger`.
255
+ - A label's contextual color stays fixed under hover, focus and checked states. Interactive Button, Toggle and Tabs labels keep their component's state colors. Never use opacity to make ordinary labels or values quieter.
256
+
135
257
  ## Rules
136
258
 
137
259
  - Light uses near-neutral warm grays for its canvas, wells, hover fills and quiet boundaries: one OKLCH hue (95), with chroma at most 0.008. Surfaces and keys stay white. Text, ink emphasis and stronger control boundaries stay pure gray; Dark keeps its pure-gray neutrals.
@@ -151,7 +273,7 @@ There are two themes: **Light** (the default) and **Dark**.
151
273
  - Each theme sets every color token explicitly. The package's tests check the values against the table on this page and check the contrast promised here.
152
274
  - Light starts with a pale warm-gray paper canvas. White surfaces and keys remain brighter; wells and hover fills are darker steps in the same warm-gray family. This is a flat color treatment, with no texture.
153
275
  - Dark keeps the same flat surface roles. Wells and keys are distinct gray fills, and `primary` flips to near-white with a dark `on-primary`.
154
- - `edge` separates bounded surfaces and stays quiet at rest, on hover and under selected/pressed attributes. Text fields keep it during typing focus too; neutral buttons, Selects and off switch tracks do not strengthen it on hover. `control-edge` is reserved for small unchecked Checkbox, Radio and Prose task marks, at least 3:1 against `canvas` and `surface` in both themes. On toggles use a `well` fill with `text`, without a visible border; segmented choices use a contrasting `key` fill in a quiet well. Text tokens never become persistent borders. Non-text controls retain their focus treatment; invalid fields retain their error edge. Forced colors adds system-colored selection boundaries when fills disappear; `line` separates content. The quiet resting field boundary remains below 3:1; see [boundary contrast](depth.md#rules).
276
+ - `edge` separates bounded surfaces and stays quiet at rest, on hover and under selected/pressed attributes. Text fields keep it during typing focus too; neutral buttons, Selects and off switch tracks do not strengthen it on hover. `control-edge` is reserved for small unchecked Checkbox, Radio and Prose task marks, at least 3:1 against `canvas` and `surface` in both themes. On default toggles use a `well` fill with `text`, without a visible border; soft toggles use `hover` when off and `selected` with `text` when on; segmented choices use a contrasting `key` fill in a quiet well. Text tokens never become persistent borders. Non-text controls retain their focus treatment; invalid fields retain their error edge. Forced colors adds system-colored selection boundaries when fills disappear; `line` separates content. The quiet resting field boundary remains below 3:1; see [boundary contrast](depth.md#rules).
155
277
 
156
278
  ### Choosing a theme
157
279
 
@@ -163,10 +285,10 @@ The package does not read `prefers-color-scheme` itself. The app does, so that a
163
285
 
164
286
  An app sets only these tokens:
165
287
 
166
- - the 28 color tokens in the [table below](#color-tokens),
288
+ - the 29 color tokens in the [table below](#color-tokens),
167
289
  - the three font tokens in [Typography](typography.md#fonts): `font-sans`, `font-display` and `font-mono`.
168
290
 
169
- Every other token (space, radius, sizes, layout widths, layers, motion) and every `mu-*` class rule is internal. Read those tokens in app CSS (`var(--mu-space-4)`), but don't override them, even though the cascade layer lets an app rule win: the products would drift apart, and the package's tests and screenshots cover only its own values. To change one element, pass `className` or `style` to that component (see [API conventions](building.md#api-conventions)).
291
+ Every other token (space, radius, sizes, layout widths, layers, motion) and every `mu-*` class rule is internal. Read those tokens in app CSS (`var(--mu-space-4)`), but don't override them, even though the cascade layer lets an app rule win. `className` and `style` support app-owned layout, not changes to a component's internal appearance. See [Styling ownership](consistency.md#styling-ownership) for the boundary.
170
292
 
171
293
  Internal rules are not private classes. An app applies the [public classes](custom-components.md#public-classes) and the class functions to its own elements; it only never changes what they do.
172
294
 
@@ -184,7 +306,7 @@ An app can offer more themes than the two the package ships, such as a warm Pape
184
306
  ```
185
307
 
186
308
  - Set every color token in the table below. A token the theme leaves out keeps its Light value, so a partial theme mixes two palettes.
187
- - The package exports `themeTokens`, the custom property names of those 28 tokens (`--mu-canvas` to `--mu-on-danger`). Check in the app's tests that its theme sets each one (Vitest reads a stylesheet with `?raw` only when `test.css` is `true` in its config; without it the file comes back empty):
309
+ - The package exports `themeTokens`, the custom property names of those 29 tokens (`--mu-canvas` to `--mu-on-danger`). Check in the app's tests that its theme sets each one (Vitest reads a stylesheet with `?raw` only when `test.css` is `true` in its config; without it the file comes back empty):
188
310
 
189
311
  ```ts
190
312
  import { themeTokens } from "@usememos/ui";
@@ -207,15 +329,16 @@ An app can offer more themes than the two the package ships, such as a warm Pape
207
329
  | `surface` | `#ffffff` | `#1a1a1a` | One lightness step above canvas: inputs, menus, popovers, dialogs, toasts, table headers |
208
330
  | `well` | `oklch(0.934 0.008 95)` | `#252525` | Flat parts that hold a state: switch, tab, checkbox and radio wells, pressed toggles, inline code. A boundary is added only where the component needs one. Not for enabled text entry |
209
331
  | `hover` | `oklch(0.95 0.006 95)` | `#252525` | Hover on rows, ghost buttons and wells; the highlighted row in menus and selects |
332
+ | `selected` | `oklch(0.919 0.004 95)` | `#343434` | Persistent soft-toggle fill, including while hovered; with `text` or `text-disabled`, never a selected border |
210
333
  | `key` | `#ffffff` | `#343434` | Controls and selected parts: neutral buttons, select triggers, the selected tab, kbd. Flat: neutral buttons and selects use `edge`; selected segments use fill without a visible border in normal colors |
211
334
  | `key-hover` | `oklch(0.982 0.004 95)` | `#3c3c3c` | Hover on a neutral key |
212
335
  | `edge` | `oklch(0.858 0.006 95)` | `#343434` | Quiet resting borders of fields, neutral buttons, switch tracks, well/key surfaces, popups, badges, selects and disabled controls |
213
336
  | `control-edge` | `#909090` | `#777777` | Small unchecked Checkbox, Radio and Prose task markers only; never a full-control hover or selected boundary; at least 3:1 on `canvas` and `surface` |
214
337
  | `line` | `oklch(0.919 0.004 95)` | `#252525` | Separators, table rules and menu dividers. About 1.2:1 against canvas in Light, so structural lines also need spacing or a heading |
215
338
  | `overlay` | `rgba(0,0,0,0.32)` | `rgba(0,0,0,0.6)` | Backdrop behind dialogs and sheets |
216
- | `text` | `#1a1a1a` | `#ededed` | Primary text and icons on canvas, surface, well and key (13:1 or more in both themes) |
217
- | `text-secondary` | `#5e5e5e` | `#adadad` | Secondary text: descriptions, placeholders, unselected tabs and toggles, shortcuts, timestamps (4.9:1 or more on every surface) |
218
- | `text-disabled` | `#666666` | `#9e9e9e` | Disabled text and icons, at least 4.5:1 on `canvas`, `surface`, `well` and `key`; never fade the whole control |
339
+ | `text` | `#1a1a1a` | `#ededed` | Primary text and icons on canvas, surface, well, key and selected (10:1 or more in both themes) |
340
+ | `text-secondary` | `#5e5e5e` | `#adadad` | Secondary text: descriptions, placeholders, horizontal Field labels in Popovers, unselected tabs and toggles, shortcuts, timestamps (4.9:1 or more on every surface) |
341
+ | `text-disabled` | `#666666` | `#9e9e9e` | Disabled text and icons, at least 4.5:1 on `canvas`, `surface`, `well`, `key` and `selected`; never fade the whole control |
219
342
  | `primary` | `#1a1a1a` | `#e6e6e6` | The one emphasized action and every on state: primary buttons, checked boxes and radios, switches when on, tooltips. Ink, not a hue |
220
343
  | `primary-hover` | `#333333` | `#ffffff` | Hover on a primary key |
221
344
  | `on-primary` | `#ffffff` | `#0f0f0f` | Text, icons and the switch knob on `primary` (16:1 Light, 15:1 Dark) |
@@ -258,11 +381,11 @@ Use these classes before writing new surface styles. `mu-key` covers both a cont
258
381
  - Boundary strength follows its job. Resting, hovered and persistently selected surfaces never borrow text or focus colors for their borders. Text entry keeps its quiet 1px `edge` under hover, focus and press. A resting text field does not need the same visual weight as a checkbox or switch's state marker. The quiet field boundary is below 3:1 in the built-in themes; this treatment does not guarantee WCAG 1.4.11 for text entry.
259
382
  - A checked checkbox or radio uses `primary` with an `on-primary` mark. A switch also moves its knob.
260
383
  - Neutral buttons, Select triggers and switch tracks keep the quiet `edge` on hover. Button and Select fills provide hover feedback; fields and switches keep their fill. A switch keeps its contrasting `text` knob when off. Checkbox, Radio and Prose task checkboxes alone use `control-edge` around their small unchecked marks. Do not reuse that marker color around a toolbar item or a full control.
261
- - A single choice among peers uses a contrasting `key` fill in a quiet well, with `text` for the selected label and icon. The group and selected key have no visible border in normal colors. Hover only increases label and icon contrast; focus reuses `mu-focus`. Borders reserve space, so geometry stays fixed. In forced colors the selected key gains a text-colored border. An independent on/off toggle uses `well` and `text` when on, with a transparent reserved border. Disabled on toggles keep that fill and use `text-disabled`. In forced colors an on toggle gets a system-colored boundary because its fill may disappear.
384
+ - A single choice among peers uses a contrasting `key` fill in a quiet well, with `text` for the selected label and icon. The group and selected key have no visible border in normal colors. Hover only increases label and icon contrast; focus reuses `mu-focus`. Borders reserve space, so geometry stays fixed. In forced colors the selected key gains a text-colored border. An independent on/off toggle uses `well` and `text` when on (`selected` and `text` for `variant="soft"`), with a transparent reserved border. Disabled on toggles keep that fill and use `text-disabled`. In forced colors an on toggle gets a system-colored boundary because its fill may disappear.
262
385
  - Surface classes describe a surface, not an interaction state. `mu-key` keeps its `key` fill and quiet `edge` even with selected or pressed attributes. Use Toggle for a persistent on/off fill, or a key inside a well for one selected choice. Never turn a text token into a persistent border. Non-text focus and invalid fields are the explicit exceptions to quiet control boundaries.
263
386
  - Floating surfaces have a border so they remain distinct over the same surface color. Modal backdrops still use `overlay`.
264
387
  - Content is grouped by spacing, headings and separators. The boxed exceptions are List, bordered Table and bordered Form.
265
- - Borders count toward the documented padding. Reserve their space and compensate inner padding so states never move text or change a control's size.
388
+ - Reserve border space so states never move text or change a control's size. [Measured insets](layout.md#measured-insets) defines which border a documented padding includes.
266
389
 
267
390
  ## Focus
268
391
 
@@ -282,7 +405,7 @@ Use these classes before writing new surface styles. `mu-key` covers both a cont
282
405
 
283
406
  - Font decisions follow Apple's [typography guidelines](https://developer.apple.com/design/human-interface-guidelines/typography): the system font, no weight lighter than Regular, hierarchy from color, weight and size, no text under its 11pt minimum for iPhone, and no added letter spacing. The package follows these rules; it does not copy Apple's size tables, which differ per platform (13pt text on Mac, 17pt on iPhone). One set of sizes serves every device, and text does not grow on touch screens, except typed text in fields (see the last rule).
284
407
  - Use few sizes and few weights. App text uses four sizes (13, 14, 16 and 20px) and two weights (400 and 500); 11px is reserved for compact control labels, keyboard shortcuts and initials (see `label-xs` and `caption-xs`); user tags and status labels use 13px `caption` even inside a 20px Badge. Hierarchy comes from text color, spacing and grouping first, then from weight, and from size last.
285
- - Weight 400 is for running text, values people typed or chose, and badge text (a badge's tone and fill carry its weight). Weight 500 is for labels, controls, titles and headings. The UI uses no 600 or 700. The one exception is [Prose](components/prose.md): bold text and headings that people wrote are 600, because 500 does not read as bold in system fonts.
408
+ - Weight 500 names a control, setting or section: Field labels, including horizontal settings rows and labels around Checkbox, Switch or Radio, button and layout-toggle labels, titles and headings. Weight 400 is for entered or selected values, Menu/Select/Combobox items, running text, descriptions, metadata and badge text. A row's layout or control type never lowers its label's weight. The UI uses no 450, 600 or 700. The one exception is [Prose](components/prose.md): bold text and headings that people wrote are 600, because 500 does not read as bold in system fonts.
286
409
  - 20px (`heading`) is for the page title only, once per page. 32px (`display`) is for the hero headline on a website only, once per page. Nothing else in the UI is larger than 16px, and 16px is only for `title` and Prose text. Headings that people write inside Prose have their own sizes (see [Prose](#prose)).
287
410
  - A section title inside a page or panel is `label` (14px, 500), set apart by space above it, not by a larger size. See [Page](patterns.md#page).
288
411
  - Secondary text (descriptions, helper text, metadata) is `caption` (13px) in `text-secondary`. No text is smaller than 13px, except `label-xs` and `caption-xs` inside 20px parts.
@@ -296,6 +419,21 @@ Use these classes before writing new surface styles. `mu-key` covers both a cont
296
419
  - Titles and headings (`title`, `heading`, `display`, and headings in Prose) use `text-wrap: balance`, so a heading that wraps does not leave one word on its last line. Prose paragraphs and list items use `text-wrap: pretty`. Browsers without these wrap as usual.
297
420
  - Typed text in fields is 16px on touch screens (`pointer: coarse`), whatever the field's size: below 16px, iOS Safari zooms the page in when a field takes focus. The field keeps its height; its label and placeholder layout do not change.
298
421
 
422
+ ## Text roles
423
+
424
+ Choose weight by what the text does, then color by its context. A less prominent color never calls for a lighter weight. See [Text hierarchy](color.md#text-hierarchy) for the color contract and [Settings](patterns.md#settings) for composition.
425
+
426
+ | Role | Weight | Color |
427
+ | --- | --- | --- |
428
+ | Field labels in forms, settings pages and Dialogs, including labels around Checkbox, Switch or Radio | 500 | `text` |
429
+ | Horizontal Field labels inside a Popover | 500 | `text-secondary` |
430
+ | Entered text and selected values | 400 | `text`; placeholders use `text-secondary` |
431
+ | Menu, Select and Combobox item text, selected or not | 400 | `text`; destructive Menu items keep `danger` |
432
+ | Button, Toggle and Tabs labels; section titles | 500 | The component's documented color or state |
433
+ | Descriptions, helper text and metadata | 400 | `text-secondary`; errors keep `danger` |
434
+
435
+ Disabled text uses `text-disabled` without changing its weight. Selected and unselected items keep the same weight; fill, color or a check communicates selection. Do not apply the settings-name rule to Menu items or sidebar navigation rows: those remain scanned content at 400.
436
+
299
437
  ## Fonts
300
438
 
301
439
  | Family | Stack |
@@ -318,14 +456,14 @@ Nine styles over six sizes. 11, 13 and 14px each have a 400 style for text and a
318
456
  | `caption-xs` | 11 / 16px | 400 | Initials and brief counts inside 20px parts. Never for user tags, status names, running or secondary text. Badges use `caption`: their words need to be as readable as the metadata beside them |
319
457
  | `caption` | 13 / 18px | 400 | Badge text; secondary text: field descriptions and errors, metadata, timestamps, toast and popover descriptions, table captions, sm table cells. Usually in `text-secondary` |
320
458
  | `label-sm` | 13 / 18px | 500 | Labels inside sm controls, tooltips, group labels in menus and lists, table heads |
321
- | `body` | 14 / 20px | 400 | Default running text in app UI; dialog descriptions; the names in rows people scan: Menu and Select items, settings row names, sidebar navigation rows |
459
+ | `body` | 14 / 20px | 400 | Default running text in app UI; entered and selected values; dialog descriptions; Menu and Select items and sidebar navigation rows. Field and settings labels use `label` |
322
460
  | `label` | 14 / 20px | 500 | Labels inside md controls, field labels, buttons, section titles, popover and toast titles. The default control text |
323
461
 
324
462
  ### Headings (`--mu-font-display`)
325
463
 
326
464
  | Style | Size / line | Weight | Usage |
327
465
  | --- | --- | --- | --- |
328
- | `title` | 16 / 24px | 500 | Dialog titles; empty-state titles |
466
+ | `title` | 16 / 24px | 500 | Dialog and Drawer titles; empty-state titles |
329
467
  | `heading` | 20 / 28px | 500 | The page title in an app or on a website. One per page |
330
468
  | `display` | 32 / 40px | 500 | The hero headline on a website. Never in an app |
331
469
 
@@ -344,6 +482,21 @@ The scale used to have 12 sizes and 3 weights. These styles were removed; use th
344
482
  | `body-lg` | 16 / 24px, 400 | `body`, or `Prose` for long-form copy |
345
483
  | `label-lg` | 16 / 24px, 500 | `label` (there is no `lg` control size) |
346
484
 
485
+ ## Content fitting
486
+
487
+ Choose fitting by content role, not by language or a one-off width override. Never reduce the type size or weight to make a string fit.
488
+
489
+ | Content | Fitting rule |
490
+ | --- | --- |
491
+ | Single-line action keys and list option names/selected values | Preserve the full accessible text; allow the label box to shrink and ellipsize within its width constraint. Icons and state slots stay visible |
492
+ | Descriptions, hints, errors and prose | Wrap and grow the block; break an unbroken string when necessary rather than overflow the surface |
493
+ | Setting names and multi-line choice labels | Wrap at word boundaries and grow the row. Keep the associated control usable and aligned |
494
+ | Numeric data in aligned columns | Use tabular numbers and preserve the value; allow the data region to scroll when columns cannot fit |
495
+
496
+ When truncation hides information needed to distinguish choices, the product provides a wrapping presentation, a keyboard/touch-accessible detail view, or a full-text Tooltip as appropriate. A hover-only native `title` is not the sole recovery path. The DOM's accessible name alone is not a visual recovery path. Layout groups wrap or use their documented scroll behavior before clipping controls. Verify with a long localized label and an unbroken value at a 320px viewport; the page must not gain horizontal overflow from controls or hints.
497
+
498
+ Derive an action's accessible name from its visible label, including a displayed shortcut when it is part of that label. Supplementary descriptions are separate accessible descriptions, not replacements for the label or duplicate announcements. Keep those roles intact when a row wraps or truncates.
499
+
347
500
  ## Prose
348
501
 
349
502
  Rendered content (notes, comments, changelogs, website pages) uses the `Prose` styles, not the scale above. It uses 16px text (14px at `sm`) and 13px table heads, and has three heading sizes: 24px `h1`, 20px `h2`, and 16px `h3`–`h6`. 24px appears nowhere else. The reason: notes have sections and subsections, and when `h2` and `h3` were both the size of the text they read as one level, and as bold sentences. At `sm`, `h1` is 16px and `h2`–`h6` are 14px. See [Prose](components/prose.md).
@@ -365,15 +518,57 @@ The table gives desktop sizes. Touch targets reserve additional space as describ
365
518
  - Use `sm` for dense toolbars, table rows and filters inside panels, and `md` everywhere else, including marketing pages, onboarding and sheets. A sidebar with 28px rows puts the `mu-compact` class on itself: md controls and rows inside are `control-compact` (28px) tall, with md text, icons, padding and radius (see [A sidebar navigation row](usage.md#a-sidebar-navigation-row)). It is the only height between sizes, and it is not a `size` value. Floating lists (Menu, Select and Combobox lists) use it for their sm rows too: an sm list has 28px rows with sm text and icons, because a 24px row is cramped inside a 12px-radius popup. Emphasis comes from the `primary` variant, not from a bigger control. On touch screens buttons, fields and option rows grow to at least 44px; checkboxes, radios and switches keep their small marks and reserve 44px of layout space around them.
366
519
  - Use `xs` for secondary actions beside 13px text: actions on a row or an entry, inline formatting, filters in a tight header, checkboxes in a dense list. It is the height of a Badge or a Kbd, so they line up. Don't use `xs` for forms, for navigation, or for the main action of a screen.
367
520
  - Only Button, Toggle, ToggleGroup, Checkbox, RadioGroup and Radio, Switch, Avatar, Spinner and Skeleton take `xs`. Text entry, menus, tabs and tables are too cramped at 20px, so their `size` is `sm | md`, and an `xs` inherited from a parent renders them at `sm`.
368
- - The icon side of a control has 2px less padding, so text and icon look centered.
521
+ - The icon side of a control has 2px less padding, so text and icon look centered. Floating-list items follow the separate contract below. Button and Toggle with `shape="pill"` instead keep equal size-row padding at both ends and use `radius-full`; an icon-only control is a circle. Heights, icon sizes and touch targets stay the same.
369
522
  - On touch screens (`pointer: coarse`), every control reserves at least `hit-area` (44px) in the layout. Buttons and option rows grow; checkboxes, radios and switches reserve margins around their visual marks. Adjacent hit areas must never overlap. A segmented group adds its 2px inset around 44px items.
370
523
 
524
+ ### Floating-list items
525
+
526
+ Menu, Select and Combobox list items, including inline Combobox lists and rows drawn with `menuClasses().item`, share this contract. They do not inherit a button's horizontal padding: changing a list's size changes its height, type, icons and radius, while its content edges stay fixed.
527
+
528
+ | Measurement | sm | md |
529
+ | --- | --- | --- |
530
+ | Row height | 28px | 32px |
531
+ | Row padding, both inline edges | 8px | 8px |
532
+ | Leading icon to label | 8px | 8px |
533
+ | Standalone trailing Menu icon box to row end | 6px | 6px |
534
+ | Group-label inline padding | 8px | 8px |
535
+
536
+ - Leading icons keep the same 8px inset as text-only items; do not apply the control's 2px icon-side reduction. This keeps the row's content edge independent of its children.
537
+ - A standalone trailing icon in a Menu row uses the control's shared 2px optical correction toward the inline end. This applies to built-in submenu arrows and app-supplied trailing icons alike. Icons keep their size-row square; do not add offsets for individual glyphs. Wrapped selection checks keep the ordinary 8px end inset.
538
+ - Menu and Select popups, Combobox lists and list Popovers keep 6px outer padding, including the surface border. A list Popover's title and description use the same 8px inline padding as its rows. Separators retain their 6px block spacing and extend to the surface edges.
539
+ - On coarse pointers rows grow to at least 44px; horizontal spacing stays the same. Combobox grid cells keep their square-cell layout instead of list-item padding.
540
+ - Triggers, text fields, boxed List rows and panel Popovers keep their own spacing contracts. Apps reuse the list classes instead of adding another layer of horizontal padding.
541
+
542
+ ### Measured insets
543
+
544
+ Documented padding measures from a part's outer border edge to its content box, including that part's border. A bordered container and an unbordered container with the same inset therefore align. Subtract each perimeter border from the CSS padding on that side; do not subtract it twice when the padded content is a child. Borderless and full-screen variants restore the full inset. Safe-area space is additional where the component documents it.
545
+
546
+ | Measurement | Reference edges |
547
+ | --- | --- |
548
+ | Control padding | Control's outer edge to text or the SVG square; reserve the control's own focus border |
549
+ | Surface padding | Surface's outer edge to its first/last content box, even when a child supplies the padding |
550
+ | Collection outer inset | Popup's outer edge to the row's highlight box |
551
+ | Row padding | Row's highlight edge to its leading/trailing content box |
552
+ | Content gap | Adjacent content boxes; no container border is added |
553
+
554
+ A boxed row collection's perimeter counts once in its outer rows/columns. Internal cell padding is measured from the cell edge; dividers separate rows and do not add horizontal indentation. At the same size, boxed records, forms and tables share their outer content edge. Measure SVG squares, not the painted glyph inside a viewBox. Only the shared optical corrections above may move those squares.
555
+
556
+ ### Row anatomy
557
+
558
+ Rows in one sibling set share content columns: leading icon, label (and optional description), then trailing information or state. This applies to action and option lists, including app-owned lists built from the class functions.
559
+
560
+ - If any sibling has a leading icon, reserve the same icon square and gap in siblings without one. A wholly text-only set reserves neither. A named group starts its own set; a nested popup never changes its parent's columns. Grid pickers are cells, not list rows.
561
+ - The label and its description start on the same line. Multi-line rows grow; the icon and state marker align with the label's first line. Do not vertically center them against the combined text block.
562
+ - Labels take the remaining width and may shrink. Shortcuts, values and state markers do not shrink into the label. Choice rows reserve their check slot even when unchecked; ordinary action rows do not reserve a check they never show.
563
+ - Group headings align to the row's content edge before the icon column. A separator divides sibling sets without introducing another text inset; it bleeds to the surface's inner border edge, never across the perimeter border or into horizontal overflow.
564
+ - Missing content, selection, hover and disabled states do not change columns, padding or text weight. Text fitting follows [Content fitting](typography.md#content-fitting).
565
+
371
566
  ## Spacing
372
567
 
373
568
  Layout spacing comes from one ladder of nine steps, and each step has a job. Two rules decide every gap:
374
569
 
375
570
  - Closer means more related. A title is nearer to its own text than to the block before it.
376
- - A container's padding is never smaller than the gap between its blocks.
571
+ - Within a bounded panel, the content inset is at least the ordinary gap between peer blocks. Page gutters, separation between page regions, and content-to-footer spacing have different jobs; use their documented steps instead of comparing them to a panel's stack gap.
377
572
 
378
573
  | Token | Value | Job | Where |
379
574
  | --- | --- | --- | --- |
@@ -388,8 +583,8 @@ Layout spacing comes from one ladder of nine steps, and each step has a job. Two
388
583
  | `space-16` | 64px | Website hero and footer | The padding of a hero or a footer |
389
584
 
390
585
  - `inset` (2px) is not a layout step. It is the gap between a well and the key inside it, and between items inside one cluster (a toolbar). Inside a well the gap is 0.
391
- - Inside a control the size row sets the spacing (see [Control sizes](#control-sizes)): 6px between an md icon and its text, 6px of padding at xs and in a menu, 2px less padding on a control's icon side. These are optical values of the control, not layout steps. Don't use 6px or 10px between things.
392
- - `space-8`, `space-12` and `space-16` are for an app's page layout. No component uses them.
586
+ - Inside a control the size row sets the spacing (see [Control sizes](#control-sizes)): 6px between an md icon and its text, 6px of padding at xs, and 2px less padding on a control's icon side. Floating lists use the separate item contract above and a 6px outer inset. These are optical values of the control, not layout steps. Don't use 6px or 10px between things.
587
+ - `space-8`, `space-12` and `space-16` are for page and viewport layout, never ordinary padding or gaps inside controls and panels. Overlay viewports may reserve this space around a panel.
393
588
 
394
589
  Every surface follows the same two tiers. Big surfaces pad `space-5` and stack `space-4`; small ones pad `space-4` and stack `space-3`.
395
590
 
@@ -402,7 +597,7 @@ Every surface follows the same two tiers. Big surfaces pad `space-5` and stack `
402
597
  | Fieldset | | 12px | 8px from the legend to its options | |
403
598
  | Field | | 4px between label, control and hint | | |
404
599
 
405
- The playground has three reference screens built only from these steps: a settings page, a timeline and an empty page (`#screen-settings`, `#screen-timeline` and `#screen-empty`, in `playground/screens.tsx`). The screenshot tests cover them, so a spacing change is judged on a whole screen, not only on one component. An app's pages copy their spacing.
600
+ The playground's component sections exercise size rows, insets and prop combinations. Focused option-row fixtures cover missing icons, long content and disabled saved choices across actions, choices and filtering. Browser assertions check measured edges and cross-component alignment; Linux screenshots check each component and open overlay. See [Component verification](testing.md).
406
601
 
407
602
  ### Spacing in an app
408
603
 
@@ -437,19 +632,19 @@ it("uses only ladder steps for padding, margin and gap", () => {
437
632
 
438
633
  ## Radius
439
634
 
440
- Inner radius = outer radius − `inset`, so nested corners stay concentric. Floating lists follow the same rule with their padding: 6px (`radius-xl` − `radius-md`), so an md row's highlight is concentric with the popup's corner, and an sm row's 4px corner sits inside it.
635
+ Concentric corners subtract the actual distance between their outer edges. A key inside a well uses outer radius − `inset`; content clipped just inside a border subtracts that border. Floating lists use their 6px outer inset (`radius-xl` − `radius-md`), so an md row's highlight is concentric with the popup's corner. Sm keeps its documented 4px size-row radius rather than inventing another radius for that context.
441
636
 
442
637
  | Token | Value | Usage |
443
638
  | --- | --- | --- |
444
639
  | `radius-xs` | 2px | Keys inside sm wells (`radius-sm` minus the 2px inset); inline code |
445
640
  | `radius-sm` | 4px | sm controls |
446
641
  | `radius-md` | 6px | md controls, the default |
447
- | `radius-lg` | 8px | Images in Prose |
642
+ | `radius-lg` | 8px | Boxed record/form/table surfaces and images in Prose |
448
643
  | `radius-xl` | 12px | Menus, select lists, popovers and toasts |
449
644
  | `radius-2xl` | 16px | Dialogs and sheets |
450
- | `radius-full` | 9999px | Avatars, radios, switches and their knobs, and badges only |
645
+ | `radius-full` | 9999px | Avatars, radios, switches and their knobs, badges, and opt-in pill controls |
451
646
 
452
- Buttons and inputs are never pills; badges always are, so a label is never mistaken for a control.
647
+ Ordinary controls use their size-row radius; inputs never use a pill. Badges always use `radius-full`. The explicit exception is Button/Toggle `shape="pill"` for compact reactions and similar content choices: their text controls are pills and their icon-only controls are circles. The shape changes corners and the documented optical padding, never size, text role or interaction treatment. Do not turn ordinary form actions or fields into pills with CSS.
453
648
 
454
649
  ## Sizes
455
650
 
@@ -553,9 +748,9 @@ Behavioral states are complete, but they do not each require a different appeara
553
748
 
554
749
  | State | Looks like |
555
750
  | --- | --- |
556
- | Hover | Interactive keys go to `key-hover`; actionable rows and ghost buttons use `hover`. Passive content and ordinary table rows do not highlight. Bounded controls keep their quiet `edge`; fields and switches keep their fill. Invalid fields keep `danger`. Muted labels go to `text`. Primary keys go to `primary-hover`. |
751
+ | Hover | Interactive keys go to `key-hover`; actionable rows and ghost buttons use `hover`. Passive content and ordinary table rows do not highlight. Bounded controls keep their quiet `edge`; fields and switches keep their fill. Invalid fields keep `danger`. Muted action labels go to `text`; Field labels keep their contextual color. Primary keys go to `primary-hover`. |
557
752
  | Pressed (while held) | Actions reuse their hover highlight immediately: neutral uses `key-hover`, ghost and actionable rows use `hover`, primary uses `primary-hover`, danger retains `danger-key`. No extra border, movement, scale or filter. Toggles do not imitate a selected state before activation |
558
- | Pressed toggle (on) | Uses `well` and the `text` color; the reserved border stays transparent |
753
+ | Pressed toggle (on) | Default: `well` and `text`. Soft: `selected` and `text`, retained on hover; the reserved border stays transparent |
559
754
  | Selected (one of several) | A flat `key` fill in a quiet well that updates immediately to the chosen item, with `text` for its label and icon; no visible group or selected-key border in normal colors. Hover only changes label and icon contrast; focus reuses `mu-focus` |
560
755
  | Checked | A flat `primary` fill with an `on-primary` mark |
561
756
  | Loading | The control becomes inert at once. After 300ms it hides its label and shows a Spinner without changing size; short waits keep the label |
@@ -565,6 +760,21 @@ Behavioral states are complete, but they do not each require a different appeara
565
760
  | Focus (everything else) | Reuse the shared 1px border on `:focus-visible`; primary/checked fills use `on-primary`, danger buttons use `on-danger`; text links get a double underline. Invalid non-text controls reuse the same focus edge; their error message keeps communicating the error |
566
761
  | Focus (inside a widget) | Menu items and options take the `hover` fill. No ring |
567
762
 
763
+ ## Combined states
764
+
765
+ Apply states by what they communicate, not by whichever selector happens to win. These rules cover shipped components and app-owned parts alike.
766
+
767
+ | Combination | Result |
768
+ | --- | --- |
769
+ | Disabled + any pointer/focus state | No hover, press or focus highlight. Label, icon, value, placeholder, description and shortcut inside the unavailable control/row use `text-disabled`, including a danger action. Keep geometry and weight unchanged |
770
+ | Disabled + saved selection | Keep the selected fill, mark or knob position recognizable; dim text and marks. Disabled choice controls use their documented disabled surface. Never erase the saved choice |
771
+ | Invalid + disabled | Disabled control appearance wins. An existing error message outside the control remains `danger`; disabling does not silently remove its explanation |
772
+ | Invalid + keyboard focus | Text entry retains its invalid edge and caret/selection. Other controls use the shared focus edge while the error message continues to identify the error. Forced-colors focus remains visible |
773
+ | Selected + hover/focus | Retain the saved selection. Apply only the family's shared transient treatment, without changing columns, weight or boundary strength |
774
+ | Loading + disabled | Loading immediately blocks activation and keeps the action's identity and dimensions; its delayed spinner communicates waiting. Ordinary disabled styling must not replace the loading treatment |
775
+
776
+ Supporting text outside an unavailable control is still information: a Field hint explaining availability keeps `text-secondary`, and its error keeps `danger`. This differs from the description inside a disabled actionable row, which dims with that row. State selectors must cover all owned parts, including nested indicators, and exclude disabled controls from transient treatments.
777
+
568
778
  ## Rules
569
779
 
570
780
  - An event does not need its own visual style. Text entry keeps its border, surface and geometry under hover, focus and press. Validation alone changes the ordinary field edge, immediately and without animation.
@@ -618,12 +828,12 @@ import { Pin, Trash2 } from "lucide-react";
618
828
  - **Size comes from the control.** An icon inside a component is 12px at xs, 14px at sm and 16px at md (`icon-xs`, `icon-sm`, `icon-md`). Don't pass `size`.
619
829
  - **One stroke, fixed in screen pixels.** Every icon, at every size and in badges, draws the `icon-stroke` token: 1.25px. The check in a checkbox is the one exception, at 2px. Apps don't change the token. At 1.25px a 16px outline glyph (calendar, paperclip, search) reads no heavier than the 14px weight-400 text beside it: an icon puts more ink in its box than a letter does, so matching the letters' stem width would read heavier. CSS applies `vector-effect: non-scaling-stroke`, which matches Lucide's `absoluteStrokeWidth`, so no props are needed. Don't pass `strokeWidth`.
620
830
  - **Color is `currentColor`.** Icons follow the text color: `text` in controls, `text-secondary` in menu rows, the tone color in toasts.
621
- - **Icons sit in a fixed square.** In a menu row or a control, the icon square keeps labels aligned whether or not a row has an icon.
831
+ - **Icons sit in a fixed square.** Collections reserve missing leading icons according to [Row anatomy](layout.md#row-anatomy). Independent controls do not reserve an absent icon.
622
832
  - **Accessibility.** Icons are `aria-hidden`. Every icon-only Button or Toggle has an `aria-label`, and a Tooltip with the same text.
623
833
  - **Standalone icons.** Outside a component, give the icon a size class: `mu-icon-xs` (12px), `mu-icon-sm` (14px) or `mu-icon-md` (16px), all with the 1.25px stroke. Each sets the box and the stroke together, so a heading's icon or an empty state's icon matches the icons in controls. `mu-icon` alone takes the size of the nearest size row (`mu-s-*`), or 16px outside one. Don't size an icon with a utility such as `size-4`: it changes the box but not the stroke.
624
834
 
625
835
  ```tsx
626
- <h2 className="mu-title"><Inbox className="mu-icon-sm" aria-hidden /> Inbox</h2>
836
+ <h2 className="mu-label"><Inbox className="mu-icon-sm" aria-hidden /> Inbox</h2>
627
837
  ```
628
838
 
629
839
  ## Built-in glyphs
@@ -662,6 +872,8 @@ When Lucide doesn't have an icon, draw it on Lucide's 24px grid with 2px round s
662
872
 
663
873
  ## Page template
664
874
 
875
+ Map parts to the shared rules in [Keeping the system consistent](../consistency.md#rule-ownership). This page template does not authorize component-specific versions of typography, geometry or combined states. Put an exception beside its owning rule, then link it from the component page and cover it in the shared reference or an existing fixture.
876
+
665
877
  Pages are reference for agents: short, imperative, no narrative. Every page has these sections, in this order:
666
878
 
667
879
  1. **Title and purpose.** `# Name`, then one line: what it is for.
@@ -707,6 +919,7 @@ The short version. [Using the components](../usage.md) has the full decision gui
707
919
  | A small form (heading, a few fields, buttons) among Lists on a settings page | Form with `bordered` |
708
920
  | A status or tag label | Badge |
709
921
  | A clickable tag filter | Toggle |
922
+ | Compact reactions and their picker | Soft pill Toggles, a ghost pill Button and Popover; see the [composition](popover.md#compact-reaction-picker) |
710
923
  | Content still loading | Skeleton |
711
924
  | A control waiting on a server | the control's `loading` prop |
712
925
 
@@ -714,6 +927,7 @@ The short version. [Using the components](../usage.md) has the full decision gui
714
927
 
715
928
  ```ts
716
929
  type Size = "xs" | "sm" | "md"; // xs only where a page says so; other components take "sm" | "md"
930
+ type ControlShape = "default" | "pill"; // Button and Toggle; defaults keep size-row corners
717
931
  type Tone = "neutral" | "success" | "warning" | "danger";
718
932
  /** A Lucide icon element, such as <Pin /> from lucide-react. Size and stroke come from the component. */
719
933
  type IconElement = React.ReactElement;
@@ -742,13 +956,14 @@ import { Plus } from "lucide-react";
742
956
  interface ButtonProps extends BaseButton.Props {
743
957
  variant?: "primary" | "neutral" | "ghost" | "danger"; // default "neutral"
744
958
  size?: Size;
959
+ shape?: "default" | "pill"; // default "default"; icon-only pills are circular
745
960
  icon?: IconElement; // before the label
746
961
  iconEnd?: IconElement; // after the label: "Show more" with a chevron, "Next" with an arrow
747
962
  loading?: boolean;
748
963
  loadingLabel?: string; // accessible name while loading, such as "Saving"
749
964
  }
750
965
 
751
- function buttonClasses(options?: { variant?: ButtonVariant; size?: Size; iconOnly?: boolean }): string;
966
+ function buttonClasses(options?: { variant?: ButtonVariant; size?: Size; shape?: ControlShape; iconOnly?: boolean }): string;
752
967
  ```
753
968
 
754
969
  Wraps Base UI's [Button](https://base-ui.com/react/components/button): `disabled`, `focusableWhenDisabled`, `render`, `nativeButton` and native button attributes pass through. Renders `<button type="button">`.
@@ -772,7 +987,8 @@ Wraps Base UI's [Button](https://base-ui.com/react/components/button): `disabled
772
987
 
773
988
  - A flat control: 20, 24 or 32px tall. `primary` is an ink key with `on-primary` text; `neutral` a `key` with a quiet `edge` boundary; `ghost` flat `text-secondary` with `hover` on hover; `danger` a `danger-key` with `on-danger` text.
774
989
  - A neutral button keeps its quiet `edge` on hover; its `key-hover` fill provides feedback. Keyboard focus changes the 1px edge to ink; filled actions retain their contrasting focus edge.
775
- - Only an icon: square. The side with an icon (`icon` at the start, `iconEnd` at the end) has 2px less padding.
990
+ - `shape="pill"` uses `radius-full` and equal size-row horizontal padding, even with an icon beside a label. Default shape and padding stay unchanged.
991
+ - Only an icon: square (circular with `shape="pill"`). With the default shape, the side with an icon (`icon` at the start, `iconEnd` at the end) has 2px less padding.
776
992
  - Hover and press reuse one immediate highlight: `key-hover` for neutral, `hover` for ghost, `primary-hover` for primary; danger retains `danger-key`. Holding the pointer adds no new boundary. Keyboard focus owns the contrasting edge. No color fade, translation, scale or brightness filter.
777
993
  - `loading` immediately sets `aria-busy` and `aria-disabled`, ignores presses and stays focusable. The label stays visible for 300ms; if still loading, a Spinner replaces it without changing width. Finishing cancels the delay and restores the label immediately.
778
994
  - Disabled: every variant uses `text-disabled` text, a `well` fill and a quiet `edge`, including ghost buttons. This keeps disabled toolbar actions distinct from enabled ghost actions without dimming the text. Opacity stays 1.
@@ -797,6 +1013,8 @@ import { Pin } from "lucide-react";
797
1013
  ```ts
798
1014
  interface ToggleProps extends BaseToggle.Props {
799
1015
  size?: Size;
1016
+ variant?: "ghost" | "soft"; // default "ghost", preserving the flat off state
1017
+ shape?: "default" | "pill"; // default "default"; icon-only pills are circular
800
1018
  icon?: IconElement; // before the label; alone, the toggle is square
801
1019
  }
802
1020
  ```
@@ -812,8 +1030,12 @@ Wraps Base UI's [Toggle](https://base-ui.com/react/components/toggle): `pressed`
812
1030
 
813
1031
  ## Style
814
1032
 
815
- - Off: flat `text-secondary`, with `hover` on hover. On: a `well` fill and the `text` color, with a transparent reserved border; hover keeps that selected fill. Fill and text changes are immediate. Hover never adds a boundary; keyboard focus reuses `mu-focus`.
816
- - Disabled: `text-disabled`; an on Toggle keeps its well and transparent border. In forced colors an on Toggle uses a system-colored boundary so selection survives when fills disappear.
1033
+ - Default (`variant="ghost"`): off: flat `text-secondary`, with `hover` on hover. On: a `well` fill and the `text` color, with a transparent reserved border; hover keeps that selected fill. Fill and text changes are immediate. Hover never adds a boundary; keyboard focus reuses `mu-focus`.
1034
+ - `variant="soft"`: off uses `hover` and `text-secondary`; hover uses `well` and `text`; on uses the distinct `selected` fill and `text`, retained on hover. The reserved border stays transparent; focus still uses `mu-focus`. No application `data-pressed` color rules are needed.
1035
+ - `shape="pill"` uses `radius-full` and equal size-row horizontal padding. An icon-only pill is circular. Heights, icon sizes and 44px touch targets stay unchanged.
1036
+ - Disabled soft toggles use `text-disabled` and `well` when off; an on toggle keeps `selected`. Neither responds to hover.
1037
+ - Disabled default toggles: `text-disabled`; an on Toggle keeps its well and transparent border. In forced colors an on Toggle uses a system-colored boundary so selection survives when fills disappear.
1038
+ - For emoji, keep the Lucide-only `icon` slot empty. Compose a span in `children`, hide the emoji from assistive technology when the control has an `aria-label`, and use the documented [compact reaction picker](popover.md#compact-reaction-picker) layout for a square emoji cell. This does not rely on the internal icon-only class.
817
1039
  - A label wraps when its row runs out of room; an icon-only toggle never shrinks. A ToggleGroup is at most as wide as its container and grows in height to keep long translated labels readable. On touch screens each Toggle reserves at least 44px in each direction.
818
1040
 
819
1041
  <!-- docs/components/toggle-group.md -->
@@ -967,6 +1189,7 @@ Base UI's props pass through: `open`, `defaultOpen`, `onOpenChange`, `modal` on
967
1189
 
968
1190
  - `mu-popup` with 6px padding (`radius-xl` − `radius-md`, so md highlights are concentric with the corners). As wide as its longest row, at least `menu-width` (160px) and at most `menu-max-width` (280px), both clamped to the space available; labels truncate and descriptions wrap at the maximum. Scrolls when taller than the space available.
969
1191
  - Rows are 28px at sm (`control-compact`, with sm text and icons) and 32px at md, text weight 400; icons in `text-secondary`; the shortcut right-aligned in `text-secondary`. Text starts at the start edge.
1192
+ - Rows and group labels use 8px inline padding at both sizes, with an 8px icon-to-label gap. Leading icons do not reduce the start padding. A standalone trailing icon gets the shared 2px end correction, including in app-built rows; wrapped checks keep the 8px end inset. See [Floating-list items](../layout.md#floating-list-items).
970
1193
  - A row with a description grows: the label, then the description in `caption` and `text-secondary`, with the row's vertical padding above and below. The icon and the check stay on the label's line.
971
1194
  - A Separator has 6px above and below, the same as the popup padding, and runs edge to edge.
972
1195
  - On touch screens rows are at least 44px tall; menus remain anchored floating lists, including submenus. The highlighted or keyboard-focused row uses `hover` with the row's radius (`radius-sm` at sm, `radius-md` at md), and so does a row whose menu is open (a SubmenuTrigger, or a `menuClasses().item` row that is a `Menu.Trigger`). Disabled rows and their icons use `text-disabled` without fading the whole row. `danger` rows use `danger`.
@@ -1155,8 +1378,9 @@ Base UI's props pass through: `name`, `invalid`, `disabled`, `validate`, `valida
1155
1378
 
1156
1379
  ## Style
1157
1380
 
1158
- - Label: `label` style (14px, 500), `space-1` above the control; weight 400 when it wraps a Checkbox, Switch or Radio.
1159
- - Horizontal: a two-column grid. The label (weight 400, as every settings row name is; see [Page](../patterns.md#page)) and description fill the first column; the control sits at the end, centered on them, `space-4` from the text. The error spans the row under them.
1381
+ - Label: `label` style (14px, 500), `text`, `space-1` above the control. It stays 500 when it wraps a Checkbox, Switch or Radio, and in horizontal rows. A control's name is distinct from the value someone enters or selects, which stays 400.
1382
+ - Horizontal: a two-column grid. The label and description fill the first column; the control sits at the end, centered on them, `space-4` from the text. The error spans the row under them. In a Popover, the horizontal label uses `text-secondary`; in forms, settings pages and Dialogs it uses `text`. Vertical labels keep `text` in either context. See [Settings](../patterns.md#settings).
1383
+ - Disabled labels use `text-disabled`, including inside a Popover, without changing weight. This applies when the Field is disabled or a directly wrapped Checkbox, Switch or Radio is individually disabled. Hover, focus, checked and invalid states do not change a label's contextual color; errors retain their separate `danger` text.
1160
1384
  - Description and error: `caption` (13px), `space-1` below the control. The error hides the description and turns the control's edge `danger`.
1161
1385
  - Fieldset: its options stack `space-3` apart. The legend (`label`) sits `space-2` above the first one, closer than the options are to each other, so it reads as their name.
1162
1386
 
@@ -1319,7 +1543,7 @@ Base UI's props pass through: `items`, `value`, `defaultValue`, `onValueChange`,
1319
1543
  ## Style
1320
1544
 
1321
1545
  - Trigger: a neutral key ([Button](button.md) `neutral`), a quiet `edge` at rest and on hover, value left-aligned at weight 400, a single `ChevronDown` in `text-secondary`. `ghost`: a ghost Button with the `text` color, `hover` on hover and while open. Placeholder in `text-secondary`, also weight 400. Invalid: a `danger` edge at rest and on hover; focus reuses the shared 1px edge while the error message stays visible.
1322
- - List: `mu-popup`, 6px padding, at least `menu-width` and the trigger's width, at most `menu-max-width` or the trigger's width when that is wider. Rows like [Menu](menu.md) items: 28px at sm, 32px at md, highlight in the row's radius; the highlighted row uses `hover`.
1546
+ - List: `mu-popup`, 6px padding, at least `menu-width` and the trigger's width, at most `menu-max-width` or the trigger's width when that is wider. Rows like [Menu](menu.md) items: 28px at sm, 32px at md, highlight in the row's radius; the highlighted row uses `hover`. Items and group labels use 8px inline padding at both sizes, including when an item has a leading icon; the icon-to-label gap is 8px. See [Floating-list items](../layout.md#floating-list-items).
1323
1547
  - Opens below the trigger, aligned to its start edge; collision handling can flip it when space is limited. `alignItemWithTrigger={true}` opts into value-aligned positioning. Opening and closing use the shared [Motion](../motion.md) treatment: a 100ms fade and `space-1` offset, with no scale or chevron rotation. Under reduced motion both are immediate. The chosen value uses one check; row highlighting identifies the current pointer or keyboard target and updates without animation.
1324
1548
 
1325
1549
  <!-- docs/components/combobox.md -->
@@ -1424,7 +1648,7 @@ Base UI's props pass through: `items`, `value`, `defaultValue`, `onValueChange`,
1424
1648
 
1425
1649
  - The box is an [Input](input.md): flat `surface`, `edge`, with no border or surface change under hover, focus or press. The caret or text selection indicates typing focus, without animation. The search icon and chevron are `text-secondary`; the chevron turns `text` on hover. Disabled text and both icons use `text-disabled`. The chevron has a built-in Tooltip using `triggerLabel`.
1426
1650
  - List: `mu-popup`, 6px padding, as wide as the input and at least `menu-width`. Rows like [Menu](menu.md) items: 28px at sm, 32px at md, highlight in the row's radius. The list is at most `list-height` (240px) tall, then it scrolls.
1427
- - Rows are text weight 400; the highlighted row uses `hover`; the chosen row has a check at the end.
1651
+ - Rows are text weight 400; the highlighted row uses `hover`; the chosen row has a check at the end. List items and group labels use 8px inline padding at both sizes, including when an item has a leading icon; the icon-to-label gap is 8px. Inline lists follow the same [floating-list item contract](../layout.md#floating-list-items); grid cells keep the square layout below.
1428
1652
  - Empty text: `body` in `text-secondary`, `space-3` by `space-4` padding.
1429
1653
  - Grid cells: square, a control height, `inset` apart, `row-radius`; the highlighted cell uses `hover`, the chosen cell sits in a `well` like a pressed Toggle. No check.
1430
1654
  - Opening and closing use the shared [Motion](../motion.md) fade and `space-1` offset over `duration-fade`. Both are immediate under reduced motion; input feedback and option highlighting never animate.
@@ -1883,10 +2107,110 @@ Base UI's props pass through: `open`, `defaultOpen`, `onOpenChange`, `modal` on
1883
2107
  ## Style
1884
2108
 
1885
2109
  - `mu-popup` (`surface`, a 1px `edge`, `radius-xl`), `space-4` padding, `popover-width` wide. Children stack `space-3` apart.
2110
+ - Horizontal Field labels are 500 in `text-secondary`; selected and entered values remain 400 in `text`. Vertical Field labels stay 500 in `text`. Disabled labels keep `text-disabled`, and state changes never promote a setting name's color. See [Settings](../patterns.md#settings).
1886
2111
  - Title: `label`. Description: `caption` in `text-secondary`, `space-1` under the title.
1887
- - `list`: 6px padding and no gap, like a Menu. A title and description inside line up with the rows' text, with `space-2` above the title and below the description.
2112
+ - `list`: 6px padding and no gap, like a Menu. Rows use the [floating-list item contract](../layout.md#floating-list-items). A title and description use the same 8px inline padding as text-only rows, with `space-2` above the title and below the description; they do not reserve a leading icon column.
1888
2113
  - Opening and closing use the shared [Motion](../motion.md) fade and `space-1` offset over `duration-fade`. Both are immediate under reduced motion.
1889
2114
 
2115
+
2116
+ ## Compact reaction picker
2117
+
2118
+ Compose Popover, Toggle and Button; the library does not ship a ReactionPicker. The app owns counts, user identity, requests, pending/disabled state and whether a successful choice closes the popup. Both places read the same controlled `pressed` values; do not give the picker a separate selection state. This example keeps the popup open so several choices can be toggled.
2119
+
2120
+ On desktop the strip is 24px high (`sm`): soft pills, 14px emoji, 13px counts and 4px between them. The ghost add button is circular and the same height. The picker uses `mu-compact` for 28px cells, 2px between cells and 8px total inset (including its border). It sizes to its contents and Base UI anchors it to the add button. The default eight choices form one row; narrow viewports and coarse pointers use four columns. On coarse pointers all controls actually reserve at least 44px in the layout, so adjacent hit areas never overlap. Text and emoji stay the same size.
2121
+
2122
+ `icon` remains a Lucide slot. An emoji is content in `children`, with its own type class; the small layout class below makes an emoji-only Toggle square without depending on undocumented icon-slot behavior or internal `mu-*` classes. This is a CSS grid of ordinary toggles, not an ARIA grid or menu: Tab reaches each choice, Space/Enter toggles it, Escape dismisses an open Tooltip first, then the popup and restores trigger focus. Give each emoji a localized accessible name and a matching Tooltip. The popup is named with `aria-label` because this compact example has no visible title.
2123
+
2124
+ ```tsx
2125
+ import { Button, Popover, Toggle, Tooltip } from "@usememos/ui";
2126
+ import { SmilePlus } from "lucide-react";
2127
+
2128
+ const choices = [
2129
+ { emoji: "👍", label: "Thumbs up" },
2130
+ { emoji: "❤️", label: "Heart" },
2131
+ { emoji: "🎉", label: "Celebrate" },
2132
+ { emoji: "😄", label: "Smile" },
2133
+ { emoji: "🤔", label: "Thinking" },
2134
+ { emoji: "👀", label: "Eyes" },
2135
+ { emoji: "🚀", label: "Rocket" },
2136
+ { emoji: "🙏", label: "Thanks" },
2137
+ ];
2138
+
2139
+ type Reaction = { emoji: string; label: string; count: number; pressed: boolean };
2140
+
2141
+ // The parent supplies server counts and the current user's selected choices.
2142
+ function CompactReactions({ reactions, onPressedChange }: {
2143
+ reactions: Reaction[];
2144
+ onPressedChange: (emoji: string, pressed: boolean) => void;
2145
+ }) {
2146
+ return (
2147
+ <Tooltip.Provider>
2148
+ <div className="reaction-strip" role="group" aria-label="Reactions">
2149
+ {reactions.filter((reaction) => reaction.count > 0).map((reaction) => (
2150
+ <Toggle
2151
+ key={reaction.emoji}
2152
+ variant="soft" shape="pill" size="sm"
2153
+ aria-label={`${reaction.label}, ${reaction.count} reactions`}
2154
+ pressed={reaction.pressed}
2155
+ onPressedChange={(pressed) => onPressedChange(reaction.emoji, pressed)}
2156
+ >
2157
+ <span className="reaction-label">
2158
+ <span className="mu-body" aria-hidden>{reaction.emoji}</span>
2159
+ <span className="mu-caption reaction-count" aria-hidden>{reaction.count}</span>
2160
+ </span>
2161
+ </Toggle>
2162
+ ))}
2163
+ <Popover.Root>
2164
+ <Tooltip.Root>
2165
+ <Tooltip.Trigger render={
2166
+ <Popover.Trigger render={
2167
+ <Button variant="ghost" shape="pill" size="sm" icon={<SmilePlus />} aria-label="Add reaction" />
2168
+ } />
2169
+ } />
2170
+ <Tooltip.Popup>Add reaction</Tooltip.Popup>
2171
+ </Tooltip.Root>
2172
+ <Popover.Popup align="start" aria-label="Choose reactions" className="mu-compact reaction-popup">
2173
+ <div className="reaction-options">
2174
+ {choices.map(({ emoji, label }) => (
2175
+ <Tooltip.Root key={emoji}>
2176
+ <Tooltip.Trigger render={
2177
+ <Toggle
2178
+ variant="soft" className="reaction-option"
2179
+ aria-label={label}
2180
+ pressed={reactions.some((reaction) => reaction.emoji === emoji && reaction.pressed)}
2181
+ onPressedChange={(pressed) => onPressedChange(emoji, pressed)}
2182
+ >
2183
+ <span className="mu-body" aria-hidden>{emoji}</span>
2184
+ </Toggle>
2185
+ } />
2186
+ <Tooltip.Popup>{label}</Tooltip.Popup>
2187
+ </Tooltip.Root>
2188
+ ))}
2189
+ </div>
2190
+ </Popover.Popup>
2191
+ </Popover.Root>
2192
+ </div>
2193
+ </Tooltip.Provider>
2194
+ );
2195
+ }
2196
+ ```
2197
+
2198
+ The remaining CSS describes content and layout, not control colors, borders, focus or disabled states. Put it in the app's stylesheet, after the library (or in a later cascade layer). `Popover.Popup` supplies the surface and viewport collision constraints; keep its `max-width`. Its size context is md even if the trigger is sm. `mu-compact` must be on the popup itself because it is portaled.
2199
+
2200
+ ```css
2201
+ .reaction-strip { display: flex; flex-wrap: wrap; align-items: center; gap: var(--mu-space-1); }
2202
+ .reaction-label { display: inline-flex; align-items: center; gap: var(--mu-space-1); }
2203
+ .reaction-count { font-variant-numeric: tabular-nums; }
2204
+ .reaction-popup { width: max-content; padding: calc(var(--mu-space-2) - 1px); }
2205
+ .reaction-options { display: grid; grid-auto-flow: column; gap: var(--mu-inset); max-height: var(--mu-list-height); overflow-y: auto; }
2206
+ .reaction-option { width: var(--mu-row-height); padding: 0; }
2207
+ @media (max-width: 639px), (pointer: coarse) {
2208
+ .reaction-options { grid-auto-flow: row; grid-template-columns: repeat(4, max-content); }
2209
+ }
2210
+ ```
2211
+
2212
+ A custom app theme now supplies `selected` alongside its other [color tokens](../color.md#app-themes); it must stay distinct from `hover`/`well` and keep at least 4.5:1 with `text` and `text-disabled`. Consumers do not need `data-pressed:bg-*`, `data-pressed:hover:bg-*`, forced-color rules, a custom focus treatment, control corner overrides or touch-size overrides.
2213
+
1890
2214
  <!-- docs/components/tooltip.md -->
1891
2215
 
1892
2216
  # Tooltip
@@ -2420,7 +2744,7 @@ Every native attribute passes through.
2420
2744
 
2421
2745
  - `surface` fill, a 1px `line` edge, `radius-lg`. No shadow: it is flat, like a field.
2422
2746
  - Rows pad `space-3` × `space-4` at md and `space-2` × `space-3` at sm, divided by a 1px `line`. No hover on a plain row. An ItemButton fills its row, padded as the row is, lays out its children in a row `space-3` apart, highlights immediately with `hover` on pointer hover or touch press, and rounds with the box at the first and last row; keyboard focus darkens the row's reserved border.
2423
- - Text is `body` in `text`.
2747
+ - Record text is `body` in `text`. Settings rows follow [Field](field.md): the name is 500 in `text`, values are 400, and descriptions are `caption` in `text-secondary`.
2424
2748
 
2425
2749
  <!-- docs/components/prose.md -->
2426
2750
 
@@ -2714,7 +3038,7 @@ const [confirming, setConfirming] = useState(false);
2714
3038
  </List.Root>
2715
3039
  ```
2716
3040
 
2717
- A horizontal Field puts the setting's name first, at weight 400, and the Switch or Select at the end of the row. The section title above it is the `label` (500). A [List](components/list.md) boxes the rows of one section and divides them with lines. Inside a Dialog, leave out the List and stack the Fields. For a short list of options under one heading (notification types), a Switch or Checkbox inside its `Field.Label` also works.
3041
+ A horizontal Field puts the setting's name first at weight 500 and the Switch or Select at the end of the row. Selected values stay 400. A [List](components/list.md) boxes the rows of one section and divides them with lines. Inside a Dialog or Popover, leave out the List and stack the Fields. Names use `text` on a settings page or in a Dialog; horizontal names inside a Popover use `text-secondary`, with the chosen values in `text`. The package supplies the label colors. A Switch, Checkbox or Radio inside its `Field.Label` also keeps a 500 label. See [Settings](patterns.md#settings).
2718
3042
 
2719
3043
  No Save button and no toast: the Switch shows the result. If saving fails, move the Switch back and show a danger Toast.
2720
3044
 
@@ -2784,7 +3108,7 @@ The package ships no navigation component: the sidebar's rows are the app's link
2784
3108
  - `mu-compact` changes only md. sm (24px) and xs (20px) controls inside keep their heights, so an xs ghost Button for a row action still fits.
2785
3109
  - Popups opened from the sidebar (the Select's list, a Menu) mount outside it, at the normal 32px. To give a panel of rows the same 28px, put `mu-compact` on its popup: `<Popover.Popup variant="list" className="mu-compact">`.
2786
3110
  - `mu-s-md` gives the rows 14px text, 16px icons with a 1.25px stroke, and md padding and radius; `mu-base` sets the font. Never set the `--mu-row-*` variables or the size tokens yourself; `mu-compact` is the only way to change a row's height.
2787
- - Rows are weight 400 (`body`), like Menu items, Select items and settings row names. A column of rows is content people scan; at 500 every row competes and the sidebar reads as bold. 500 is for what names a group: a section title in the sidebar and the "New" Button's label. The current row stands out by its fill and `text` color, not by weight, so the text doesn't shift when the current page changes.
3111
+ - Navigation rows are weight 400 (`body`), like Menu and Select items. A column of navigation is content people scan; at 500 every row competes and the sidebar reads as bold. 500 is for a section title, the "New" Button's label and Field labels that name settings. The current navigation row stands out by its fill and `text` color, not by weight, so the text doesn't shift when the current page changes.
2788
3112
  - Mark the current page with `aria-current="page"`, not with color alone; the fill shows it too.
2789
3113
  - Rows stack `inset` (2px) apart. On `pointer: coarse`, set `min-height: var(--mu-hit-area)` on each navigation row so it reserves 44px in the layout; never extend overlapping hit areas around tightly stacked rows.
2790
3114
  - A row with a count or a menu at its end adds it after the label with `margin-inline-start: auto`.
@@ -2846,6 +3170,10 @@ In an extension or an embed, pass the same `container` to every `Popup` and to `
2846
3170
  <Toast.Viewport container={shadowRoot} />
2847
3171
  ```
2848
3172
 
3173
+ ## Compact reactions
3174
+
3175
+ Use `Toggle variant="soft" shape="pill" size="sm"` for the display strip and a ghost pill Button for its add action. Compose the picker from Popover and controlled Toggles with `mu-compact`. See the [complete compact reaction picker recipe](components/popover.md#compact-reaction-picker) for emoji content, shared state, content-sized placement and the four-column touch layout. Counts and requests stay in the app.
3176
+
2849
3177
  ## Size
2850
3178
 
2851
3179
  - **Set `size` once, on the outermost part that takes it.** `Menu.Root`, `Select.Root`, `Combobox.Root`, `Tabs.Root`, `Collapsible.Root`, `Table.Root`, `Field.Root`, `Fieldset.Root`, `RadioGroup` and `ToggleGroup` pass their size to every part and every Button, Toggle, Checkbox or Input inside them. Don't repeat it on the parts.
@@ -2978,7 +3306,7 @@ Names and parts from other libraries and where they go.
2978
3306
 
2979
3307
  # Patterns
2980
3308
 
2981
- How the components go together. Each product builds its screens from these patterns. None of them is specific to one product. The gaps come from the [spacing ladder](layout.md#spacing), and the playground shows a settings page, a timeline and an empty page built this way (`playground/screens.tsx`).
3309
+ How the components go together. Each product builds its screens from these patterns. None of them is specific to one product. The gaps come from the [spacing ladder](layout.md#spacing). The playground verifies component props and focused compositions; product screen mockups belong to the products, not this package's test fixture.
2982
3310
 
2983
3311
  ## Page
2984
3312
 
@@ -2987,7 +3315,7 @@ How the components go together. Each product builds its screens from these patte
2987
3315
  - Sidebar navigation rows (pages, views, tags) are `body` text, weight 400, `text-secondary`, and the current row is `text` on the `hover` fill. A sidebar section title ("Tags") is `label-sm` or `label`, 500, and the "New" Button keeps its 500 label. Rows are scanned content; weight is for the few things that name them.
2988
3316
  - Content sits in a column of at most `content-width` (720px), padded `space-4` on phones and `space-6` on desktop.
2989
3317
  - A page header is a `heading` with an optional `body` line `space-1` under it, and the page's one primary action at the right. `heading` appears once per page.
2990
- - Sections inside a page have a `label` title (14px, 500) with `space-6` above it and `space-2` below. Section titles are never larger than the text around them; space and grouping set them apart. A section's rows are horizontal Fields: the name at weight 400 (`body`) and the description in `caption`, `text-secondary`. The section title above is the only `label` (500); the row names stay 400 so the title stands out from them. Two blocks of one section (a List and the Form that adds to it) are `space-4` apart.
3318
+ - Sections inside a page have a `label` title (14px, 500) with `space-6` above it and `space-2` below. Section titles are never larger than the text around them; space and grouping set them apart. A section's rows are horizontal Fields: the name at weight 500 in `text`, the description in `caption` and `text-secondary`, and any entered or selected value at 400. Space separates a section title from its rows; do not lower the rows' label weight. Two blocks of one section (a List and the Form that adds to it) are `space-4` apart.
2991
3319
 
2992
3320
  ## Form
2993
3321
 
@@ -2999,6 +3327,14 @@ How the components go together. Each product builds its screens from these patte
2999
3327
  - Errors appear under the field, in words that say how to fix it.
3000
3328
  - A small form on a settings page, among Lists ("Add a tag rule", a reactions picker), is a `bordered` [Form](components/form.md): a title, its fields, and the buttons in a footer bar.
3001
3329
 
3330
+ ## Settings
3331
+
3332
+ - Use horizontal Fields for a setting's name and its Switch or Select. Names stay 500, including when a Checkbox, Switch or Radio is inside `Field.Label`; values and Select options stay 400. Descriptions are `caption`, 400.
3333
+ - On a settings page, put a section's Fields in one List: names use `text`, descriptions use `text-secondary`. Inside a Dialog, stack Fields without a List and keep the same colors.
3334
+ - For a compact view-options panel, use a Popover and stack horizontal Fields without a List. The package gives these labels `text-secondary`; current values stay `text`, making the chosen configuration easier to scan. Vertical form labels inside a Popover still use `text`. Do not add per-label font or color overrides.
3335
+ - A section or panel title stays 500 in `text`. Layout toggles keep their 500 labels in every state. Chosen values and Menu, Select or Combobox options keep 400 in every state; selection uses the component's fill or check.
3336
+ - Disabled text keeps its weight and uses `text-disabled`. Do not change label colors on hover or while a setting is checked. See [Text roles](typography.md#text-roles).
3337
+
3002
3338
  ## Entry
3003
3339
 
3004
3340
  A repeated unit of content in a list: a note, a comment, a changelog item.
@@ -3077,7 +3413,7 @@ Every token is a custom property named `--mu-<token>`, so the `surface` token is
3077
3413
 
3078
3414
  ### Cascade layer
3079
3415
 
3080
- Both stylesheets sit in one [cascade layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) named `mu`. Unlayered CSS always wins over layered CSS, so an app's own rules override a component without a specificity fight: a `className` on a `Button` that sets `width` wins over the button's own rule.
3416
+ Both stylesheets sit in one [cascade layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) named `mu`. Unlayered CSS always wins over layered CSS, so app-owned layout needs no specificity fight: a `className` on a `Button` that sets `width` wins over the button's own rule. This is a cascade mechanism, not permission to restyle the component; follow [Styling ownership](consistency.md#styling-ownership).
3081
3417
 
3082
3418
  When the app also puts its CSS in layers, declare the order once, before any import, so that `mu` comes after resets and before the app's components and utilities. For an app on Tailwind CSS v4:
3083
3419
 
@@ -3158,11 +3494,11 @@ import { Ellipsis, Pin, Trash2 } from "lucide-react";
3158
3494
  - `pressed` + `onPressedChange`, or `defaultPressed` (Toggle)
3159
3495
  - `open` + `onOpenChange`, or `defaultOpen` (Menu, Dialog, AlertDialog, Popover, Tooltip, Collapsible, Select, Combobox)
3160
3496
  - **`icon`** takes a Lucide element (`icon={<Pin />}`), never a string name.
3161
- - **`render`** on every component and part swaps the rendered element, as in Base UI: a router's link in `Link`, a Button as a `Menu.Trigger`. Base UI doesn't render links through Button; a link that looks like a button is an anchor with `buttonClasses()` (see [Button](components/button.md#rules)).
3162
- - **`className` and `style`** pass through to the part's element, after our classes.
3497
+ - **`render`**, where the public prop type exposes it, swaps the rendered element through Base UI or `useRender`: a router's link in `Link`, a Button as a `Menu.Trigger`. Fixed native-element wrappers keep their documented element. Base UI doesn't render links through Button; a link that looks like a button is an anchor with `buttonClasses()` (see [Button](components/button.md#rules)).
3498
+ - **`className` and `style`** pass through to the part's element, after our classes, for layout and integration within [Styling ownership](consistency.md#styling-ownership).
3163
3499
  - **Accessible names.** A control without a visible label takes `aria-label`. Icon-only controls always do.
3164
3500
  - **`tone`** describes a label or status treatment. The shared status tones are `"neutral" | "success" | "warning" | "danger"`. Badge also keeps `"outline"` for a quiet label and `"primary"` for emphasis; these are label treatments, not statuses. Badge's user-chosen `color` is allowed only with `"neutral"` (or an omitted tone). Toasts use the shared status tones through Base UI's `type` prop.
3165
- - **`variant`** selects a component's predefined appearance or structure. Values belong to that component, not a global variant set: Button has `"primary" | "neutral" | "ghost" | "danger"`; Select.Trigger has `"neutral" | "ghost"`; Popover.Popup has `"panel" | "list"`; Dialog.Popup and Drawer.Popup have `"panel" | "bare"`. See each component's page for usage and defaults.
3501
+ - **`variant`** selects a component's predefined appearance or structure. Values belong to that component, not a global variant set: Button has `"primary" | "neutral" | "ghost" | "danger"`; Toggle has `"ghost" | "soft"`; Select.Trigger has `"neutral" | "ghost"`; Popover.Popup has `"panel" | "list"`; Dialog.Popup and Drawer.Popup have `"panel" | "bare"`. See each component's page for usage and defaults.
3166
3502
  - **Interaction states are not variants.** Hover, focus, pressed, selected, disabled, invalid and loading use the component's behavior props and state attributes, with the shared treatments in [States](states.md). Do not add variants or component-specific tokens for these states.
3167
3503
  - **Native props pass through** to the element a component or part renders.
3168
3504
 
@@ -3216,11 +3552,11 @@ A class function returns the classes that make any element look like a component
3216
3552
 
3217
3553
  | Function | Returns | For an app's own |
3218
3554
  | --- | --- | --- |
3219
- | `buttonClasses({ variant, size, iconOnly })` | A string | Anchor that looks like an action |
3555
+ | `buttonClasses({ variant, size, shape, iconOnly })` | A string | Anchor that looks like an action |
3220
3556
  | `inputClasses({ size })` | A string | One-line field, such as a tag input around a native `<input>` |
3221
3557
  | `textareaClasses({ size })` | A string | Multi-line field, such as a content-editable editor |
3222
3558
  | `badgeClasses({ tone })` | A string | Label, such as a tag that is also a link |
3223
- | `menuClasses({ size })` | `popup`, `item`, `dangerItem`, `label`, `text`, `description`, `shortcut`, `separator`, `groupLabel` | Floating list, such as an editor's suggestion list, or rows inside a Popover with `variant="list"`. Rows are 28px at sm and 32px at md, as in a Menu |
3559
+ | `menuClasses({ size })` | `popup`, `item`, `dangerItem`, `label`, `text`, `description`, `shortcut`, `separator`, `groupLabel` | Floating list, such as an editor's suggestion list, or rows inside a Popover with `variant="list"`. Rows are 28px at sm and 32px at md, with fixed 8px inline padding; see [Floating-list items](layout.md#floating-list-items) |
3224
3560
  | `popoverClasses({ variant })` | `popup`, `title`, `description` | Floating panel, such as an emoji picker |
3225
3561
 
3226
3562
  State is set with attributes, never with extra classes:
@@ -3253,14 +3589,14 @@ A popup built this way still needs its behavior from Base UI or the editor it be
3253
3589
 
3254
3590
  ## Public classes
3255
3591
 
3256
- Apply these classes to an app's own elements. Don't override their rules: the products would drift apart, and the package's tests cover only its own values. Every other `mu-*` class, such as a component part's `mu-menu-item`, is internal; reach it through a class function.
3592
+ Apply these classes to an app's own elements. Don't override their rules; see [Styling ownership](consistency.md#styling-ownership). Every other `mu-*` class, such as a component part's `mu-menu-item`, is internal; reach it through a class function. Text roles are defined in [Typography](typography.md#text-roles); the class table is an index of those styles.
3257
3593
 
3258
3594
  | Class | What it applies | Use it for |
3259
3595
  | --- | --- | --- |
3260
3596
  | `mu-s-xs` | The xs size row: 20px | Secondary actions beside 13px text |
3261
3597
  | `mu-s-sm` | The sm size row: 24px | Dense toolbars, table rows, filters |
3262
3598
  | `mu-s-md` | The md size row: 32px | Everything else |
3263
- | `mu-compact` | md controls and rows inside are `control-compact` (28px) tall; text, icons, padding and radius stay md | An app's sidebar with 28px rows, or a list Popover beside it |
3599
+ | `mu-compact` | md controls and rows inside are `control-compact` (28px) tall; text, icons, padding and radius stay md | An app's sidebar with 28px rows, a list Popover beside it, or a [compact picker](components/popover.md#compact-reaction-picker) |
3264
3600
  | `mu-base` | The system font, the row's text size and weight 500, the `text` color, and icon sizing for Lucide icons inside | The root of a control |
3265
3601
  | `mu-well` | `well` and a quiet 1px `edge` | A recessed surface; add a separate treatment for selection |
3266
3602
  | `mu-key` | `key` and a quiet 1px `edge`, including with selected or pressed attributes | A bounded control surface, or the bright selected face inside a well. It does not add interaction-state styling; use Toggle or ToggleGroup for choices |
@@ -3279,8 +3615,8 @@ Apply these classes to an app's own elements. Don't override their rules: the pr
3279
3615
  | `mu-caption` | 13 / 18px, 400 | Secondary text |
3280
3616
  | `mu-label-sm` | 13 / 18px, 500 | Small labels |
3281
3617
  | `mu-body` | 14 / 20px, 400 | Body text |
3282
- | `mu-label` | 14 / 20px, 500 | Labels and buttons |
3283
- | `mu-title` | 16 / 24px, 500 | Section titles |
3618
+ | `mu-label` | 14 / 20px, 500 | Labels, buttons and section titles |
3619
+ | `mu-title` | 16 / 24px, 500 | Dialog, Drawer and empty-state titles |
3284
3620
  | `mu-heading` | 20 / 28px, 500 | The page title |
3285
3621
  | `mu-display` | 32 / 40px, 500 | A website hero only |
3286
3622
 
@@ -3303,7 +3639,7 @@ A size row class sets these variables on its element, and children inherit them.
3303
3639
  | `--mu-row-icon-stroke` | 1.25px | 1.25px | 1.25px |
3304
3640
  | `--mu-row-check-box` | 14px | 16px | 18px |
3305
3641
 
3306
- `--mu-row-padding-icon` is the padding on the side of a control that starts or ends with an icon. Inside `mu-compact`, the md `--mu-row-height` is 28px. `--mu-row-field-font-size` and `--mu-row-field-line-height` are for text people type; on touch screens they are 16px and 20px at every size, so iOS Safari does not zoom in when a field takes focus.
3642
+ `--mu-row-padding-icon` is the padding on the side of a control that starts or ends with an icon. Floating-list items use their [fixed 8px spacing](layout.md#floating-list-items) instead of these control-padding values; `menuClasses().item` supplies it for app-built rows too. Inside `mu-compact`, the md `--mu-row-height` is 28px. `--mu-row-field-font-size` and `--mu-row-field-line-height` are for text people type; on touch screens they are 16px and 20px at every size, so iOS Safari does not zoom in when a field takes focus.
3307
3643
 
3308
3644
  ```css
3309
3645
  .app-tag-input-chip {
@@ -3325,7 +3661,7 @@ An app on Tailwind CSS v4 loads `tailwind.css` after the package's other stylesh
3325
3661
  @import "@usememos/ui/tailwind.css";
3326
3662
  ```
3327
3663
 
3328
- It maps the 28 color tokens, seven radius tokens and three font tokens to utilities with an `mu-` prefix, so they never clash with an app's own colors:
3664
+ It maps the 29 color tokens, seven radius tokens and three font tokens to utilities with an `mu-` prefix, so they never clash with an app's own colors:
3329
3665
 
3330
3666
  | Token | Utility |
3331
3667
  | --- | --- |