@orkestrel/scaffold 0.0.64 → 0.0.66

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.
@@ -1,6 +1,6 @@
1
1
  # Bootstrap 5 Deep Reference — Theming, Forms, JS, Accessibility, Enterprise Patterns
2
2
 
3
- > Part of the `enterprise-bootstrap` package. Bootstrap **5.3.x**.
3
+ > Part of the `enterprise-bootstrap` skill. Bootstrap **5.3.x**.
4
4
  > Component markup lookups: [components.md](components.md). Utility classes: [utilities.md](utilities.md).
5
5
  > This file holds what those do not: setup, color modes, theming/tokens, forms in
6
6
  > production, the JS lifecycle, accessibility depth, and enterprise app patterns.
@@ -10,7 +10,7 @@
10
10
  - [Quick start](#quick-start)
11
11
  - [Breakpoints & layout](#breakpoints--layout)
12
12
  - [Color modes (light / dark / custom)](#color-modes-light--dark--custom)
13
- - [Theming & design tokens](#theming--design-tokens)
13
+ - [Theming & design tokens](#theming--design-tokens) — [Define the working scales](#define-the-working-scales) · [Elevation and depth](#elevation-and-depth) · [Layout and type extensions](#layout-and-type-extensions)
14
14
  - [Forms in production](#forms-in-production)
15
15
  - [JavaScript lifecycle](#javascript-lifecycle)
16
16
  - [Accessibility](#accessibility)
@@ -23,7 +23,7 @@
23
23
 
24
24
  ## Quick Start
25
25
 
26
- CDN (5.3.8 is the current — and final — 5.3.x patch before 5.4):
26
+ Pinned CDN example (5.3.8). Prefer the installed compatible version; this is not an upgrade instruction:
27
27
 
28
28
  ```html
29
29
  <!doctype html>
@@ -49,6 +49,22 @@ CDN (5.3.8 is the current — and final — 5.3.x patch before 5.4):
49
49
 
50
50
  ## Breakpoints & Layout
51
51
 
52
+ Stock 5.3.8 values: breakpoints `sm` 576 · `md` 768 · `lg` 992 · `xl` 1200 · `xxl` 1400 px (`min-width`; `xs` has no infix); `.container` maxima 540 · 720 · 960 · 1140 · 1320 px; gutter and container padding 1.5 rem (296 px of content at 320 px). Only `d`, `flex`, `justify-content`, `align-*`, `order`, `float`, `gap`, spacing, text alignment, and `object-fit` utilities ship breakpoint infixes. Generate a missing responsive role only where a `$utilities` entry owns the property, per [Utilities API](#utilities-api). Full inventory and recipes: [responsive-layout.md](responsive-layout.md) → Bootstrap's responsive surface.
53
+
54
+ Start with the feature's content and narrow layout, then choose its container and breakpoints.
55
+ Take the region contract, content parity, and test matrix from [responsive-layout.md](responsive-layout.md).
56
+ Use fluid columns for content that needs to scale together; keep rails, forms, and reading measures
57
+ bounded where it does not. A full-width shell does not require full-width text or fields.
58
+ Take the missing role-based utilities from [Layout and type extensions](#layout-and-type-extensions).
59
+
60
+ Give a form, dialog, or login card a content-led maximum and let it shrink only when the viewport
61
+ is narrower: `w-100 mx-auto measure-form`, not `col-md-8 offset-md-2 col-lg-6 offset-lg-3`, whose
62
+ width changes at every breakpoint and is narrower on `lg` than at some `md` widths. Give a
63
+ sidebar a fixed rail (`shell-rail-lg-fixed flex-shrink-0`) beside a flexible `min-inline-0` main
64
+ region, not `col-3`, which grows on wide screens and collapses below its minimum on narrow ones.
65
+ Put supporting explanation beside a narrow form in a second column rather than widening its
66
+ fields. Percentage widths belong only where columns must scale together.
67
+
52
68
  | Breakpoint | Class Infix | Dimensions |
53
69
  | ----------- | ----------- | ---------- |
54
70
  | Extra small | (none) | <576px |
@@ -85,77 +101,162 @@ The 5.3 color-mode system replaces the old per-component `*-dark` classes.
85
101
 
86
102
  ### Mechanics
87
103
 
88
- - `data-bs-theme="light|dark"` on `<html>` sets the mode globally; on any element it scopes the mode to that subtree (nested scopes win over ancestors). Default is light.
89
- - Mode switching works by re-pointing root CSS variables — components never change their own rules. Core variables swapped per mode: `--bs-body-bg`, `--bs-body-color`, `--bs-emphasis-color`, `--bs-secondary-color`, `--bs-secondary-bg`, `--bs-tertiary-color`, `--bs-tertiary-bg`, `--bs-border-color`, `--bs-heading-color`, `--bs-link-color`.
90
- - Each theme color also gets a mode-adaptive triplet — `--bs-{color}-text-emphasis`, `--bs-{color}-bg-subtle`, `--bs-{color}-border-subtle` — surfaced as `.text-{color}-emphasis`, `.bg-{color}-subtle`, `.border-{color}-subtle`. These are the workhorses for status UI that must read in both modes.
91
- - **The triplet is a recipe, not a guarantee.** `text-{color}-emphasis` on `bg-{color}-subtle` is _designed_ to pass, and it usually does in stock Bootstrap — but the values are tokens, and a compatible skin redefines them. Resolve the actual computed values from the compiled cascade the page loads (the shipped CSS, dependency stylesheets included) and measure each pairing once per theme before you rely on it. A class with no rule of its own may still inherit one; never accept a value from documentation.
92
- - Deprecated by this system: `.navbar-dark`, `.dropdown-menu-dark`, `.btn-close-white`, `.carousel-dark` → put `data-bs-theme="dark"` on the component or an ancestor instead.
104
+ Read [color-modes.md](color-modes.md) before choosing color classes or repairing a theme failure.
105
+ It owns inheritance, adaptive-versus-fixed families, surface boundaries, and component exceptions.
106
+ Use the installed build's attribute or media-query strategy; do not assume every `--bs-*` variable
107
+ changes with the mode.
93
108
 
94
109
  ### Author rules
95
110
 
96
- - Paint custom CSS from `var(--bs-…)` — never hard-coded hex — so both modes track automatically.
97
- - Prefer `bg-body`, `bg-body-secondary`, `bg-body-tertiary` and `text-body`, `text-body-secondary` over `bg-white`/`bg-light`/`text-dark`, which freeze a mode.
98
- - A dark region inside a light page (or vice versa) is one attribute: `<footer data-bs-theme="dark">`.
111
+ Preserve ordinary text inheritance and native component states. Prefer adaptive body/subtle
112
+ surfaces. Establish an explicit foreground only at an owned solid or mode boundary, or for a
113
+ measured role that requires it. Do not turn every subtle panel into a custom color pair.
99
114
 
100
115
  ### Theme toggle
101
116
 
102
- Bootstrap ships **no** mode picker — you build the toggle. The essentials: read the stored preference, fall back to `prefers-color-scheme`, set `data-bs-theme` on `document.documentElement`, and do it in a script early in `<head>` so the first paint does not flash the wrong mode.
103
-
104
- ```js
105
- const stored = localStorage.getItem('theme')
106
- const preferred =
107
- stored ?? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
108
- document.documentElement.setAttribute('data-bs-theme', preferred)
109
- // On toggle: setAttribute + localStorage.setItem("theme", value)
110
- ```
117
+ Reuse the host controller. When implementing one, follow [Scope the mode](color-modes.md#scope-the-mode)
118
+ for validated preference, automatic-mode resolution, storage failure, first paint, and overlay
119
+ mounts. Bootstrap ships no picker; an attribute example is not a complete controller.
111
120
 
112
121
  ### Custom modes
113
122
 
114
- A custom mode is a named scope overriding the same variables:
123
+ Add a custom mode only when the brief requires it. Map its used body, surface, link, border,
124
+ validation, and component-state variables, including RGB companions. Resolve embedded component
125
+ images and `color-scheme` where relevant. Do not claim a complete mode from a partial token block.
126
+
127
+ In Sass, use `$enable-dark-mode`, `$color-mode-type: data` for local attribute scopes, or
128
+ `media-query` for system-driven mode without per-component scoping. Use `color-mode()` rather than
129
+ competing selectors; keep overrides in the host theme source.
130
+
131
+ ## Theming & Design Tokens
132
+
133
+ ### The tiered token model
134
+
135
+ Keep literal values in declared primitives, map primitives to semantic roles, and let component
136
+ variables consume those roles. Reuse Bootstrap's `--bs-*` semantic and component layers rather
137
+ than adding a parallel palette. Name semantics by purpose, not a particular shade.
138
+
139
+ Distinguish token structure from runtime behavior. Bootstrap also generates fixed values through
140
+ Sass; a component variable is not necessarily mode-adaptive. Map the actual consumer, including its
141
+ states, and resolve aliases at the scope where they must change. Take the constraints from
142
+ [Extend the theme](color-modes.md#extend-the-theme).
143
+
144
+ ### Define the working scales
145
+
146
+ Reuse the installed theme and its scales first. Declare new values only for a role the feature
147
+ needs; refine one shared definition instead of accumulating per-component exceptions. Every
148
+ system in the following table has a Bootstrap source, a utility, and a known gap; extend the source, never the
149
+ markup.
150
+
151
+ | System | Sass source | Utility | Stock steps (default root) | Gap |
152
+ | -------------- | ----------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------- |
153
+ | Font size | `$font-sizes`, `$h1…h6-font-size`, `$display-font-sizes` | `fs-1…6`, `.h1…h6`, `display-1…6` | 16 · 20 · 24 · 28 · 32 · 40 px; display 40–80 px | No `rem` step below 16 px; `.small` is `.875em` |
154
+ | Font weight | `$font-weight-*`, `$headings-font-weight` | `fw-light…bold` | 300 · 400 · 500 · 600 · 700; headings 500 | Headings barely heavier than body |
155
+ | Line height | `$line-height-*`, `$headings-line-height` | `lh-1`, `lh-sm`, `lh-base`, `lh-lg` | 1 · 1.25 · 1.5 · 2; headings 1.2 | — |
156
+ | Color | `$gray-100…900`, `$blue-100…900` …, `$theme-colors`, triads | `text-*`, `bg-*`, `border-*` families | 9 shades per hue; subtle/emphasis per role | Ramps are mechanical mixes ([color-modes.md](color-modes.md) → Extend the theme) |
157
+ | Spacing | `$spacers` | `m-*`, `p-*`, `gap-*`, `g-*` | 0 · 4 · 8 · 16 · 24 · 48 px | No 12 or 32 px; nothing above 48 px |
158
+ | Width | `$container-max-widths`, the grid | `w-*`, `mw-100`, `col-*` | 25 / 50 / 75 / 100 % | No content-led maximums |
159
+ | Shadow | `$box-shadow`, `-sm`, `-lg`, `-inset` | `shadow-sm`, `shadow`, `shadow-lg` | 3 steps | Single-layer; modal shares the dropdown step |
160
+ | Radius | `$border-radius*`, `$enable-rounded` | `rounded-0…5`, `-pill`, `-circle` | 0 · 4 · 6 · 8 · 16 · 32 px | — |
161
+ | Border width | `$border-widths` | `border-1…5` | 1–5 px | Sets every side at once |
162
+ | Opacity | — | `opacity-*`, `text-opacity-*`, `bg-opacity-*` | 0 · 10 · 25 · 50 · 75 · 100 | Not a text tier |
163
+ | Letter-spacing | — | none | — | Generate ([Layout and type extensions](#layout-and-type-extensions)) |
164
+
165
+ - **Color:** neutral, brand, and required status/categorical ramps. Pick base, light surface, and
166
+ dark text shades in real components, then fill the gaps. Use HSL when it helps tune related
167
+ shades; keep the project's existing format. Review fixed shade pairs in each theme rather than
168
+ generating a new `lighten`, `darken`, or `color-mix` result at each use site. Stock ramps and
169
+ triads are tint/shade mixes with a fixed hue; override the shade variables and triad
170
+ variables per brand hue, and the greys as one temperature-matched set
171
+ ([color-modes.md](color-modes.md) → Extend the theme).
172
+ - **Type:** display/body/utility roles, finite `rem` sizes, working weights, and line-height per
173
+ role. Roles may share a font. RFS scales sizes above 1.25 rem down below a 1200 px viewport
174
+ (`h1`–`h4`, `display-*`, `fs-1`–`fs-4`); body, `fs-5`, `fs-6`, `.lead`, and controls hold — do not
175
+ fight it with `em` heading sizes. Take 14 px and 12 px roles from generated `fs-sm`/`fs-xs`, not
176
+ nested `.small`. Never globally scale body text down to make a display treatment fit.
177
+ - **Space and size:** internal, group, panel, and section gaps; control sizes; reading/form widths;
178
+ rail width. Start with Bootstrap's shipped scale. Add a missing step through the utilities API
179
+ only where the adjacent steps cannot express the intended relationship. Button sizes already
180
+ scale padding faster than font (4/8 px at 14 px, 6/12 at 16, 8/16 at 20); use the shipped
181
+ sizes rather than deriving one with `em` padding.
182
+ - **Radius and elevation:** a small consistent family, assigned to real component/layer roles.
183
+ Set `$border-radius` once and let components inherit it; do not hand-mix `rounded-*` per
184
+ element. Reuse component variables and shadow utilities ([Elevation and depth](#elevation-and-depth)).
185
+ No shadow is a valid surface role.
186
+
187
+ Record these roles in the existing token source or a compact design contract, not a second design
188
+ system. Keep literal colors and raw scale values in named primitive definitions; component rules
189
+ consume semantic or component tokens. [inspection.md](inspection.md) → Token discipline checks
190
+ that boundary; [frontend-design.md](frontend-design.md) owns the visual choices.
191
+
192
+ ### Elevation and depth
193
+
194
+ Bootstrap ships `--bs-box-shadow-sm` (`0 .125rem .25rem` at .075), `--bs-box-shadow`
195
+ (`0 .5rem 1rem` at .15), and `--bs-box-shadow-lg` (`0 1rem 3rem` at .175), plus
196
+ `--bs-box-shadow-inset`. Assign by z-position: `sm` for raised cards and controls, base for
197
+ floating menus and a dragged item, `lg` for dialogs. Stock dropdowns, popovers, toasts, and
198
+ modals all sit on `--bs-box-shadow` (modal: `-sm` below 576 px), which puts a blocking dialog at
199
+ dropdown elevation. Lift it at the extension rung, in the project stylesheet after Bootstrap's so
200
+ the rule wins the `sm`-up media rule:
115
201
 
116
202
  ```css
117
- [data-bs-theme='midnight'] {
118
- --bs-body-bg: #0b1020;
119
- --bs-body-color: #dfe4f2;
120
- --bs-tertiary-bg: #131a30;
121
- --bs-border-color: #26304f;
122
- }
123
- [data-bs-theme='midnight'] .dropdown-menu {
124
- --bs-dropdown-bg: var(--bs-tertiary-bg);
203
+ .modal {
204
+ --bs-modal-box-shadow: var(--bs-box-shadow-lg);
125
205
  }
126
206
  ```
127
207
 
128
- In Sass: `$enable-dark-mode` (default true), `$color-mode-type: data` (attribute selectors) or `media-query` (`prefers-color-scheme` — loses per-component scoping), and the `@include color-mode(dark) { … }` mixin. Dark defaults live in `_variables-dark.scss`.
208
+ Two-part shadows — a broad cast plus a tight contact shadow that fades with elevation — are a
209
+ token change: redefine `$box-shadow-sm`, `$box-shadow`, and `$box-shadow-lg` as two-layer values
210
+ and every consumer follows.
129
211
 
130
- ## Theming & Design Tokens
212
+ `$enable-shadows: true` (off by default) paints light-from-above on controls: buttons take
213
+ `inset 0 1px 0 rgba(#fff, .15), 0 1px 1px rgba(#000, .075)` (lit top edge, tight cast shadow),
214
+ inputs `inset 0 1px 2px rgba(#000, .075)` (recessed), and an active button `inset 0 3px 5px`
215
+ (pressed). Enable it when the direction wants tactile controls, and verify every declared theme; the alphas
216
+ are fixed white and black. Without the flag the `box-shadow` mixin emits nothing, so
217
+ `--bs-btn-box-shadow` and `--bs-box-shadow-inset` have no consumer and an extension-rung override
218
+ does nothing; the recipe is then a proposed rule.
131
219
 
132
- ### The tiered token model
220
+ Flat depth: a `bg-body` panel on `bg-body-tertiary` reads raised and `bg-body-secondary` inside
221
+ `bg-body` reads inset, both mode-adaptive with no shadow. A hard offset shadow is a `$box-shadow`
222
+ override.
133
223
 
134
- Enterprise theming survives rebrands and dark mode only when tokens are tiered:
224
+ Overlap: `position-relative translate-middle-y`, or `mt-n*` after `$enable-negative-margins`.
225
+ Ring overlapping images in the surface color through the border variable so the ring follows the
226
+ mode, where `border-white` does not:
135
227
 
136
- 1. **Primitives** — raw values (`--brand-blue-600`, a spacing scale). Never referenced by component CSS directly.
137
- 2. **Semantic tokens** — intent (`--bs-primary`, `--bs-body-bg`, `--bs-border-color`, `--bs-danger`). Reference primitives.
138
- 3. **Component tokens** — one component's knobs (`--bs-btn-bg`, `--bs-card-spacer-y`). Reference semantics.
228
+ ```css
229
+ .ring-body {
230
+ --bs-border-color: var(--bs-body-bg);
231
+ }
232
+ ```
139
233
 
140
- **In Bootstrap, the `--bs-*` variables ARE your semantic and component layers.** Define your primitives, map them onto `--bs-*`, and let components read only `var(--bs-…)`. Dark mode then becomes a re-point of semantic tokens under `[data-bs-theme="dark"]` — if dark mode ever requires editing a component rule, the tier boundary leaked. Name semantics by role, never appearance (`--surface-sunken`, not `--gray-100`): a value change must never force a rename. Raw hex scattered in component CSS is a primitive referenced directly — the root cause of un-themable UI.
234
+ Then `rounded-circle border border-3 ring-body`.
141
235
 
142
236
  ### The CSS-variables-only path (no Sass build)
143
237
 
144
- Every component exposes local `--bs-{component}-*` variables (`--bs-btn-color`, `--bs-card-bg`, `--bs-nav-link-padding-x`, `--bs-table-bg`, …). The documented no-build theming route — right for consuming the CDN build:
238
+ When the deliverable is one self-contained HTML file, the project stylesheet is a single `<style>` block in `<head>` — tokens, declared role utilities, and extension-rung variable overrides, in that order — loaded after the Bootstrap `<link>` so equal-specificity rules win by source order. It is still one stylesheet: no `style` attribute on authored markup and no second `<style>` block beside a component ([SKILL.md](../SKILL.md) → The styling ladder owns that placement rule).
239
+
240
+ Use native components and adaptive utilities before adding overrides. For a recurring component
241
+ surface role, use its local variable rather than repainting the whole component. This optional
242
+ project-defined class changes the card background without assigning a foreground:
145
243
 
146
244
  ```css
147
- :root {
148
- --bs-primary: #6f42c1; /* note: utility classes derived at build */
149
- --bs-primary-rgb: 111, 66, 193; /* time need the -rgb partner updated too */
150
- }
151
- .btn-brand {
152
- --bs-btn-bg: var(--bs-primary);
153
- --bs-btn-color: #fff;
154
- --bs-btn-hover-bg: color-mix(in srgb, var(--bs-primary), black 10%);
245
+ .card-quiet {
246
+ --bs-card-bg: var(--bs-tertiary-bg);
155
247
  }
156
248
  ```
157
249
 
158
- Overriding component variables in a scope beats high-specificity override rules every time: it composes with color modes, keeps specificity flat, and documents intent.
250
+ Use `class="card card-quiet"` only after declaring that extension in the host theme stylesheet.
251
+ Do not add it when `card bg-body-tertiary` already expresses the requirement. Measure the card's
252
+ inherited foreground and its header/footer layers in the loaded skin.
253
+
254
+ When a custom button variant is required, define rest, hover, focus, active/checked, and disabled
255
+ component variables as one contract. Include borders and the focus-ring RGB value; test busy
256
+ content without changing geometry. Do not generate a custom tinted button merely to distinguish a
257
+ secondary action, and do not assume reversing a subtle/emphasis pair produces valid states.
258
+ Changing root `--bs-primary` alone does not rebuild Sass-generated button states or utility RGB
259
+ consumers; follow [Extend the theme](color-modes.md#extend-the-theme).
159
260
 
160
261
  ### The Sass path (compiled builds)
161
262
 
@@ -175,7 +276,7 @@ Import order matters — override maps **before** the files that consume them:
175
276
  @import 'bootstrap/scss/utilities/api'; // generates utilities — keep LAST
176
277
  ```
177
278
 
178
- Feature flags worth knowing: `$enable-dark-mode`, `$enable-rounded`, `$enable-shadows`, `$enable-gradients`, `$enable-rfs` (fluid type), `$enable-validation-icons`, `$enable-negative-margins`, `$enable-important-utilities`, `$enable-reduced-motion`. Custom theme colors added to `$theme-colors` also need entries in the subtle/emphasis maps (`$theme-colors-text`, `$theme-colors-bg-subtle`, `$theme-colors-border-subtle` — and their `-dark` twins) to get full color-mode support.
279
+ Feature flags worth knowing: `$enable-dark-mode`, `$enable-rounded`, `$enable-shadows` (light-from-above button/input/active shadows, [Elevation and depth](#elevation-and-depth)), `$enable-gradients` (a white fade on every `bg-*`; not a two-hue gradient), `$enable-rfs` (sizes above 1.25 rem shrink below 1200 px), `$enable-validation-icons`, `$enable-negative-margins` (`mt-n*` for overlap), `$enable-important-utilities`, `$enable-reduced-motion`. For added theme colors, extend the light/dark emphasis and subtle maps and inspect the generated utility-value maps; see [Extend the theme](color-modes.md#extend-the-theme). Do not infer a generated class from a token alone.
179
280
 
180
281
  ### Utilities API
181
282
 
@@ -191,7 +292,7 @@ $utilities: map-merge(
191
292
  class: cursor,
192
293
  values: auto pointer grab,
193
294
  ),
194
- // modify an existing one, e.g. make width responsive:
295
+ // Modify an existing utility: make width responsive.
195
296
  'width': map-merge(
196
297
  map-get($utilities, 'width'),
197
298
  (
@@ -205,23 +306,131 @@ $utilities: map-merge(
205
306
 
206
307
  Remove with `map-remove($utilities, "width")` or set the key to `null`. This is the sanctioned answer when the shipped scale is missing a step (for example, a `vh-50` the design truly needs).
207
308
 
309
+ **`responsive: true` reaches a utility family and nothing else.** It adds breakpoint infixes to one
310
+ entry in the `$utilities` map, so it generates `w-md-auto` from the `width` entry and `ls-lg-tight`
311
+ from an added `letter-spacing` entry. A component threshold (`navbar-expand-*`, `offcanvas-*`,
312
+ `table-responsive-*`, `modal-fullscreen-*-down`), a grid class, and a helper (`visually-hidden`,
313
+ `stretched-link`, `ratio`, `vstack`) are not `$utilities` entries, so the key cannot reach them —
314
+ change the component's own breakpoint class instead, and never write a responsive helper name the
315
+ map cannot produce. Read the generated selector out of the compiled output before using it.
316
+
317
+ ### Layout and type extensions
318
+
319
+ These are **project-generated classes**, not stock Bootstrap utilities. Use the existing project
320
+ roles when present. Otherwise add only the needed entries; the values in the following block are illustrative role
321
+ definitions, not universal sizes.
322
+
323
+ Scale steps go in the map-override slot (after `variables-dark`, before `maps`). Keys `0`–`5`
324
+ keep their shipped meaning; an intermediate 12 px or 32 px step is a new key, never a decimal.
325
+ `$font-sizes` feeds only the `fs-*` utility in 5.3.8, and RFS leaves values at or below 1.25 rem
326
+ alone, so extending it is safe:
327
+
328
+ ```scss
329
+ $spacers: map-merge(
330
+ $spacers,
331
+ (
332
+ 6: $spacer * 4,
333
+ 7: $spacer * 6,
334
+ )
335
+ ); // 64px section gap, 96px page rhythm
336
+ $font-sizes: map-merge(
337
+ $font-sizes,
338
+ (
339
+ sm: 0.875rem,
340
+ xs: 0.75rem,
341
+ )
342
+ ); // fs-sm 14px captions, fs-xs 12px eyebrows only
343
+ ```
344
+
345
+ Role utilities go after importing `utilities` and before `utilities/api`:
346
+
347
+ ```scss
348
+ $utilities: map-merge(
349
+ $utilities,
350
+ (
351
+ 'content-measure': (
352
+ property: max-inline-size,
353
+ class: measure,
354
+ values: (
355
+ prose: 65ch,
356
+ form: 36rem,
357
+ ),
358
+ ),
359
+ 'shell-rail': (
360
+ property: inline-size,
361
+ class: shell-rail,
362
+ responsive: true,
363
+ values: (
364
+ fixed: 16rem,
365
+ ),
366
+ ),
367
+ 'inline-minimum': (
368
+ property: min-inline-size,
369
+ class: min-inline,
370
+ values: (
371
+ 0: 0,
372
+ ),
373
+ ),
374
+ 'table-viewport': (
375
+ property: max-block-size,
376
+ class: max-block,
377
+ responsive: true,
378
+ values: (
379
+ table: 70vh,
380
+ ),
381
+ ),
382
+ 'tabular-figures': (
383
+ property: font-variant-numeric,
384
+ class: figures,
385
+ values: (
386
+ tabular: tabular-nums,
387
+ ),
388
+ ),
389
+ 'letter-spacing': (
390
+ property: letter-spacing,
391
+ class: ls,
392
+ values: (
393
+ tight: -0.02em,
394
+ wide: 0.05em,
395
+ ),
396
+ ),
397
+ )
398
+ );
399
+ // Generate once, after all utility-map additions:
400
+ @import 'bootstrap/scss/utilities/api';
401
+ ```
402
+
403
+ Use `w-100 measure-form` for a bounded form, `measure-prose` for a reading column,
404
+ `shell-rail-lg-fixed flex-shrink-0` for an inline desktop rail, `min-inline-0` for its flexible
405
+ sibling, `max-block-lg-table` only for a warranted wide-screen bounded table scroller, `figures-tabular` for comparable
406
+ quantities, `ls-tight` on `display-*` and `fs-1`, and `ls-wide` with `text-uppercase` labels (`em`
407
+ is correct for tracking: it follows the element's own size). Verify those selectors in the
408
+ compiled output before using those classes. The font must support tabular figures. A `ch`
409
+ measure is a starting width, not a character-count proof.
410
+
411
+ Without a Sass build, take an existing equivalent; otherwise propose the smallest stylesheet rule
412
+ under [SKILL.md](../SKILL.md) → When custom CSS is justified. Never ship an unresolved utility name.
413
+
208
414
  ## Forms in Production
209
415
 
210
416
  ### Layout & labels
211
417
 
212
- - **Top-aligned labels by default** — the evidence (eye-tracking form research) shows fastest completion and the cleanest single-column scan, and they survive narrow screens without reflow. Reserve left-aligned labels for dense read-back forms where vertical compression matters more than speed.
418
+ - **Top-aligned labels by default** — keep a consistent single-column scan. Reserve side labels for a deliberate dense layout that still reads correctly at narrow widths.
213
419
  - Visible label or `.form-floating` — never placeholder-only (disappears on input, fails accessibility).
214
- - **Do not say the same thing twice.** When the host already names the request — a card heading, a dialog title, a section header stating the question — the form associates with that name through `aria-labelledby` instead of repeating the prompt in its own label. Repetition reads as separate questions to a screen-reader user and as clutter to everyone else.
215
- - One column beats multi-column for completion; use the form grid (`row g-3` + `col-md-*`) only for genuinely paired fields (city/state/zip).
420
+ - **Name the form without duplicating its heading.** Associate the form with an existing visible title through `aria-labelledby` where useful. Keep each control's own visible label; a form name does not label its fields.
421
+ - Use one field column by default; add columns only for genuinely related fields. At wide widths,
422
+ put supporting explanation beside the form rather than stretching its fields.
423
+ - Keep each label, control, help text, and error in one group. Use a smaller internal gap than the
424
+ gap to the next field group, and recheck the relationship when errors or long labels wrap.
216
425
 
217
426
  ```html
218
427
  <form class="row g-3">
219
- <div class="col-md-6">
428
+ <div class="col-12">
220
429
  <label for="inputEmail4" class="form-label">Email</label>
221
430
  <input type="email" class="form-control" id="inputEmail4" aria-describedby="emailHelp" />
222
431
  <div id="emailHelp" class="form-text">Work address preferred.</div>
223
432
  </div>
224
- <div class="col-md-6">
433
+ <div class="col-12">
225
434
  <label for="inputPassword4" class="form-label">Password</label>
226
435
  <input type="password" class="form-control" id="inputPassword4" />
227
436
  </div>
@@ -234,8 +443,9 @@ Remove with `map-remove($utilities, "width")` or set the key to `null`. This is
234
443
  ### Validation timing (the rules that matter)
235
444
 
236
445
  - Validate a field **on blur** — after the user leaves it — never on every keystroke, and never before the user has reached the field. Exception: live feedback that _helps_ while typing (password strength, username availability, character counts).
237
- - Once a field is in an error state, re-validate as the user types so they see the fix land.
238
- - Always re-check everything on submit. Keep the submit button **enabled** — a disabled submit hides _what is_ wrong; a validating submit shows it.
446
+ - After a field enters an error state, re-validate as the user types so they see the fix land.
447
+ - Always re-check everything on submit. Keep the submit button **enabled** while fields are invalid — a disabled submit hides _what is_ wrong; a validating submit shows it.
448
+ - **Separate invalid from pending.** While a submit is in flight, mark the button busy — `aria-busy="true"`, a `spinner-border spinner-border-sm` inside it, its label held so the geometry does not move — and refuse a second submit from the handler. Blocking a duplicate submit of a form that already validated is a pending state; the preceding rule bars only the disable that stands in for validation. Clear the busy state on both the resolved and the failed path, and put the failure in the error summary.
239
449
  - On failed submit of a long form, render an **error summary** at the top (focus it; link each item to its field) _and_ inline messages at each field — never summary-only, never inline-only.
240
450
  - Error style = color + icon + text, stating what is wrong and how to fix it. Wire message to field with `aria-describedby`, mark the field `aria-invalid="true"`. Never report errors through a hover tooltip.
241
451
 
@@ -325,7 +535,7 @@ el.addEventListener('hidden.bs.modal', () => {
325
535
  })
326
536
  ```
327
537
 
328
- - **Framework reality check:** Bootstrap's JS and a virtual-DOM framework both mutating the same nodes causes bugs (stuck dropdowns, ghost backdrops). In React/Vue/Angular apps, prefer the framework-native implementations (React Bootstrap, BootstrapVueNext, ng-bootstrap) which reuse Bootstrap's CSS but own the DOM. Use raw `bootstrap.*` JS in SPAs only for leaf widgets you fully control, and dispose them on unmount.
538
+ - **In a virtual-DOM app, take the framework-native implementation** — React Bootstrap, BootstrapVueNext, ng-bootstrap — which reuses Bootstrap's CSS and owns the DOM. Use raw `bootstrap.*` JS there only for a leaf widget the component fully controls, and dispose it on unmount. Bootstrap's JS and the framework mutating the same nodes produces stuck dropdowns and ghost backdrops.
329
539
 
330
540
  ### Popper
331
541
 
@@ -344,23 +554,26 @@ Hold the baseline in [SKILL.md](../SKILL.md) → Accessibility baseline. Its Boo
344
554
 
345
555
  **Measuring the bars:**
346
556
 
347
- - Hold the bars from [SKILL.md](../SKILL.md) → Surfaces, color, contrast: **≥ 4.5:1** for everything information-bearing, **≥ 3:1** for textless marks and state chrome. WCAG 2.2 permits 3:1 for large text; this package does not — size grants no lower tier.
348
- - Measure in **both themes**, from the compiled cascade, never from the token names. A pairing that passes in light routinely fails in dark, and a skin's values are its own.
557
+ - Hold the bars from [SKILL.md](../SKILL.md) → Surfaces, color, contrast, which owns them. WCAG 2.2 permits 3:1 for large text; this skill does not — size grants no lower tier.
558
+ - Measure each declared theme from the compiled cascade, never from token names. A light-theme result does not establish a dark-theme result, and a skin's values are its own.
349
559
  - Focus rings and hover fills are UI graphics: they are in scope for the 3:1 bar.
350
560
  - Disabled controls are exempt from the bars by the spec. That exemption covers legibility, not meaning — see [Destructive actions](#destructive-actions) for the one disabled state that still has to change color.
351
561
 
352
- **The instrument.** Bootstrap paints in translucent layers: a card header and footer are a 3% tint of the body color over the card's own background. A reader that stops at the first painted ancestor and drops its alpha treats that tint as full-strength paint, and is then wrong in **both** directions — it green-lights a pairing nobody can read, and it red-flags one that reads fine. Use a reader that:
562
+ **The instrument.** Refuse a reading from a reader that stops at the first painted ancestor and drops its alpha. Bootstrap paints in translucent layers — a card header and footer are a 3% tint of the body color over the card's own background — so a flattening reader passes an unreadable pairing and fails a readable one. Use a reader that:
353
563
 
354
564
  - collects every painted layer from the element upward to the first opaque one, then composites them top over bottom (Porter-Duff `over`) onto that opaque base;
355
565
  - composites a translucent foreground over that result before taking the ratio, rather than reading the declared color;
356
- - measures both themes in one run, since the theme swap re-points the tokens under every layer;
566
+ - measures each declared theme in the same run, because a theme swap re-points the tokens under every layer;
357
567
  - carries a negative control drawn from outside the population it covers — a pairing known to fail — and voids the run if that negative control passes.
358
568
 
359
- Wire the reader into the suite once it has settled a question.
569
+ Include ancestor opacity in the painted stack. For images, gradients, masks, or blend modes the
570
+ reader does not support, use a suitable rendered-background measurement or leave the pairing open;
571
+ never flatten a variable background to its average color. Name reached states beside every result.
572
+ Wire the reader into the suite after it has settled a question.
360
573
 
361
- ### WCAG 2.2 deltas that bite dense app UI
574
+ ### WCAG 2.2 requirements for app UI
362
575
 
363
- - **Target size ≥ 24×24 CSS px (2.5.8, AA).** Icon buttons, row actions, close buttons, sort carets, checkbox hit-areas. A smaller visual target passes if a 24px spacing circle around it stays undisturbed — so in tight `table-sm` toolbars, pad the hit area rather than enlarging the glyph.
576
+ - **Target size (2.5.8, AA) — this section owns the skill's target dimensions.** Hold every applicable target at ≥ 24×24 CSS px: icon buttons, row actions, close buttons, sort carets, checkbox hit-areas, and color swatches. A smaller visual target passes only where a 24px spacing circle around it stays undisturbed — so in tight `table-sm` toolbars, pad the hit area rather than enlarging the glyph. Prefer 44×44 CSS px for a primary mobile control. Measure the rendered hit area; never infer it from a size class such as `btn-sm`. Enlarge the button or its associated label, not the icon's surrounding decoration.
364
577
  - **Focus not obscured (2.4.11, AA).** Sticky headers/footers/action bars and toast overlays must not bury the focused element. Reserve space with `scroll-margin-top` on focusables (or `scroll-padding-top` on the scroll container) equal to the sticky chrome height.
365
578
  - **Dragging alternatives (2.5.7, AA).** Any drag (row reorder, kanban, slider, resize) needs a non-drag single-pointer path: move up/down buttons, numeric input, click-to-place.
366
579
  - **Accessible authentication (3.3.8, AA).** Never block paste in password/OTP fields; support password managers; no puzzle as the only way in.
@@ -383,9 +596,9 @@ Wire the reader into the suite once it has settled a question.
383
596
  Browsers handle focus on full page loads; in an SPA **you** do:
384
597
 
385
598
  - On route change, move focus to the new view's `h1` (or the `<main>` with `tabindex="-1"`) so SR users hear where they landed.
386
- - On failed submit, focus the error summary. On destructive confirm, focus the dialog's safe action.
599
+ - On failed submit, focus the error summary. On a compact destructive confirm, focus the safe action; in a scrolling or structured dialog, focus a static heading at the start when an action would scroll its context away.
387
600
  - After deleting a row, move focus to a sensible neighbor (next row / the table region), never let it fall to `<body>`.
388
- - Anything focused programmatically under sticky chrome needs the `scroll-margin-top` offset (2.4.11 above).
601
+ - Anything focused programmatically under sticky chrome needs the `scroll-margin-top` offset (Focus not obscured, 2.4.11, under [WCAG 2.2 requirements for app UI](#wcag-22-requirements-for-app-ui)).
389
602
 
390
603
  ### Reduced motion
391
604
 
@@ -395,7 +608,10 @@ Bootstrap wraps its transitions and animations (`.fade`, `.collapsing`, carousel
395
608
 
396
609
  ### App shell
397
610
 
398
- **Structure:** persistent left sidebar for dense apps with many top-level destinations (it scales, nests, and stays stable while content changes); top-bar-only nav for shallow apps (≤ ~5 destinations). A collapsible sidebar reclaims width for data.
611
+ **Structure:** reuse the product's shell. For a new product, let implemented features and their
612
+ navigation needs decide between a sidebar and a shallow top bar; do not design the shell first.
613
+ Give an inline rail a content-led width and the task the remaining space. A collapsible rail can
614
+ reclaim width for comparison data.
399
615
 
400
616
  The Bootstrap implementation — a responsive offcanvas that renders inline above `lg` and becomes a drawer below it, with no custom JS:
401
617
 
@@ -421,7 +637,7 @@ The Bootstrap implementation — a responsive offcanvas that renders inline abov
421
637
 
422
638
  <div class="d-flex">
423
639
  <div
424
- class="offcanvas-lg offcanvas-start border-end"
640
+ class="offcanvas-lg offcanvas-start border-end shell-rail-lg-fixed flex-shrink-0"
425
641
  tabindex="-1"
426
642
  id="appSidebar"
427
643
  aria-labelledby="appSidebarLabel"
@@ -436,7 +652,7 @@ The Bootstrap implementation — a responsive offcanvas that renders inline abov
436
652
  aria-label="Close"
437
653
  ></button>
438
654
  </div>
439
- <div class="offcanvas-body d-lg-block p-lg-3" style="width: 260px;">
655
+ <div class="offcanvas-body d-lg-block p-lg-3">
440
656
  <nav aria-label="Primary">
441
657
  <ul class="nav nav-pills flex-column gap-1">
442
658
  <li class="nav-item">
@@ -448,8 +664,8 @@ The Bootstrap implementation — a responsive offcanvas that renders inline abov
448
664
  </nav>
449
665
  </div>
450
666
  </div>
451
- <main id="main" class="flex-grow-1 p-3 p-lg-4" style="min-width: 0;">
452
- <!-- min-width: 0 lets tables shrink instead of blowing out the flex row -->
667
+ <main id="main" class="flex-grow-1 p-3 p-lg-4 min-inline-0">
668
+ <!-- Generated min-inline-0 lets the task shrink inside the flex row. -->
453
669
  </main>
454
670
  </div>
455
671
  </body>
@@ -464,16 +680,26 @@ The Bootstrap implementation — a responsive offcanvas that renders inline abov
464
680
 
465
681
  ### Dense data tables
466
682
 
467
- **Semantics first — table vs grid.** Default to a static `<table>`: links and buttons inside cells ride the natural tab order and screen readers get real table navigation free. Reserve `role="grid"` for _editable, cell-interactive_ spreadsheet-like UIs — grid means you now own roving tabindex and full arrow-key cell navigation. Never bolt `role="grid"` onto a read-only table because it "looks like a data grid": semantics follow interaction, not appearance.
683
+ **Semantics first — table vs grid.** Default to a static `<table>`: links and buttons inside cells ride the natural tab order and screen readers get real table navigation free. Reserve `role="grid"` for _editable, cell-interactive_ spreadsheet-like UIs — a grid hands roving tabindex and full arrow-key cell navigation to the implementation. Never bolt `role="grid"` onto a read-only table because it "looks like a data grid": semantics follow interaction, not appearance.
468
684
 
469
685
  **Craft rules:**
470
686
 
471
- - **Align by type:** numbers, currency, dates right-aligned (`text-end`, header too); text left. Use tabular figures so digits stack into comparable columns: `font-variant-numeric: tabular-nums` on numeric cells (one small custom rule that earns its place).
687
+ - **Align by comparison:** quantities and currency right-aligned (`text-end`, header too), with
688
+ consistent units and precision. Use the generated `figures-tabular` utility or the project's
689
+ equivalent. Keep text start-aligned; choose date alignment by its format and comparison task.
690
+ - **Group related content:** combine identity and supporting detail only when they do not need
691
+ independent column comparison or sorting. Keep key comparison columns explicit. Quiet repeated
692
+ labels and row actions before increasing density.
472
693
  - **Density:** `table-sm` for compact; offer density as a user toggle (comfortable/compact) driven by one token or wrapper class, not per-cell tweaks. Do not shrink font below readability to fake density.
473
- - **Sticky header** once the table meaningfully scrolls (roughly a viewport / ~15+ rows). Not built into Bootstrap — the pattern:
694
+ - **Sticky header** when the table scrolls its own header out of view. Not built into Bootstrap — the pattern:
474
695
 
475
696
  ```html
476
- <div class="table-responsive" style="max-height: 70vh;">
697
+ <div
698
+ class="table-responsive max-block-lg-table"
699
+ role="region"
700
+ aria-label="Comparison table"
701
+ tabindex="0"
702
+ >
477
703
  <table class="table table-sm align-middle">
478
704
  <thead class="sticky-top">
479
705
  <tr>
@@ -485,30 +711,30 @@ The Bootstrap implementation — a responsive offcanvas that renders inline abov
485
711
  </div>
486
712
  ```
487
713
 
488
- Give header cells an **opaque background** (`bg-body-secondary` or a `.table-*` tone class) — table backgrounds are transparent by default, so rows show through a sticky header otherwise. Sticky chrome is the prime Focus-Not-Obscured offender: add `scroll-margin-top` on row focusables equal to the header height. Sticky first column only when row identity is lost on horizontal scroll — it costs paint and complexity.
714
+ Keep sticky header cells on an **opaque, mode-aware surface** such as `bg-body-secondary`. Stock `.table` cells use the body background; verify that a skin or override has not made them translucent. Do not substitute a `.table-*` color variant and assume it adapts. Inspect cell overlays through [Tables and overlays](color-modes.md#tables-and-overlays). Sticky chrome is the prime Focus-Not-Obscured offender: add `scroll-margin-top` on row focusables equal to the header height. Sticky first column only when row identity is lost on horizontal scroll — it costs paint and complexity.
489
715
 
490
716
  - **Sorting:** the whole header is a button (not a bare caret), with a visible direction indicator, and `aria-sort="ascending|descending"` on the active `<th>` only:
491
717
 
492
718
  ```html
493
719
  <th scope="col" aria-sort="ascending">
494
- <button type="button" class="btn btn-link p-0 fw-semibold text-body text-decoration-none">
720
+ <button type="button" class="btn btn-link p-0 fw-semibold">
495
721
  Amount <span aria-hidden="true">↑</span>
496
722
  </button>
497
723
  </th>
498
724
  ```
499
725
 
500
- - **Row actions:** 1–3 high-frequency actions inline; the rest behind a per-row kebab (dropdown). Hover-only reveal fails touch and keyboard — keep at least the overflow trigger always visible and ≥24px.
726
+ - **Row actions:** keep the high-frequency actions inline and put the rest behind a per-row kebab (dropdown). Hover-only reveal fails touch and keyboard — keep at least the overflow trigger always visible and at the floor in [WCAG 2.2 requirements for app UI](#wcag-22-requirements-for-app-ui).
501
727
  - **Selection & bulk actions:** header checkbox with indeterminate state for partial selection; per-row checkboxes with `aria-label` naming the row ("Select INV-1042"). When selection > 0, swap the toolbar's content in place for a contextual bar — "3 selected", the batch actions, and a clear-selection escape — never push the layout down (layout-shifting chrome is an anti-pattern). Announce the count through a polite live region.
502
728
  - **Pagination vs scrolling:** paginate when users need position, totals, deep links, and "go to page N" — most enterprise CRUD. Virtualize (windowed rendering) for long uniform lists where scrolling is natural. True infinite scroll is for exploratory feeds only — never where users need a footer or a findable end.
503
- - **Responsive, ranked:** (1) _priority columns_ — hide low-value columns per breakpoint (`d-none d-lg-table-cell`), always keeping the identifying + decision columns; (2) _horizontal scroll_ (`table-responsive`) when every column matters — remember it clips dropdowns; (3) _card-ify_ into label:value stacks below `md` for low row counts. Never card-ify a wide comparison table — comparison is the point.
729
+ - **Responsive, by task:** use a compact record list for record work, a locally scrollable semantic table for essential comparison, or priority columns with an operable detail path. Preserve identity, decision fields, and actions. Choose expansion from available container width, not `md` by habit. Keep one state model across variants; take the contract from [Keep the task intact](responsive-layout.md#keep-the-task-intact).
504
730
  - **Table states:** loading → **skeleton rows** matching the real column count/widths (a centered spinner collapses the layout); empty → distinguish _no data yet_ (invite the first action) from _no results for these filters_ (offer "Clear filters"); error → inline retry inside the table region, header and toolbar preserved.
505
731
 
506
732
  ### Filter & search bars
507
733
 
508
- - One toolbar above the table: search input first (`role="search"` on the form), then the 2–4 highest-value filters as `form-select`/segmented controls, overflow filters behind a "Filters" button (offcanvas on mobile, dropdown/collapse on desktop).
734
+ - One toolbar above the table: search input first (`role="search"` on the form), then the highest-value filters as `form-select`/segmented controls, overflow filters behind a "Filters" button (offcanvas on mobile, dropdown/collapse on desktop). Promote a filter to the toolbar because the task reaches for it, not to fill the row.
509
735
  - **Active filters must be visible and dismissible** — chips/badges with an ✕ and a "Clear all" — users must see _why_ the list is short. A filtered-empty state repeats the escape hatch.
510
736
  - Debounce live search; show result counts ("128 results") so feedback is immediate; filter state belongs in the URL when views are shareable.
511
- - Toolbars that overflow: `flex-nowrap overflow-auto` beats wrapping the toolbar onto a second row mid-task — do not crush icon targets below 24px.
737
+ - At the base, give search a full row; stack or wrap actions and filters without shrinking labels or targets. Expand with `col-12 col-md`, `col-md-auto`, or `d-grid d-sm-flex` when they fit. Reserve a horizontal scroller for a documented spatial interaction, not an ordinary toolbar. Keep active filters and the clear path outside any disclosed extras.
512
738
 
513
739
  ### Wizards & multi-step forms
514
740
 
@@ -516,7 +742,7 @@ Give header cells an **opaque background** (`bg-body-secondary` or a `.table-*`
516
742
  ⟨total⟩", where the wizard fills in its own runtime position and total); `list-group-numbered` or
517
743
  a simple nav renders it honestly.
518
744
  - Validate per step before advancing; never let a step advance carrying invalid data.
519
- - Back never loses data. Persist partial state (save-and-resume) for anything beyond ~3 steps or that crosses sessions.
745
+ - Back never loses data. Persist partial state (save-and-resume) for a long sequence or one that crosses sessions.
520
746
  - Never re-ask what a previous step collected (Redundant Entry, 3.3.7) — carry it forward or offer "same as above".
521
747
  - Review step: a review summary with per-section edit links, then one clearly-named commit action ("Create account", not "Submit").
522
748
 
@@ -525,9 +751,15 @@ Give header cells an **opaque background** (`bg-body-secondary` or a `.table-*`
525
751
  Design **every one** for every data surface: ideal (populated), empty, loading, partial, error. A component is not done until all of them exist.
526
752
 
527
753
  - **Skeleton vs spinner:** skeleton (`placeholder` + `placeholder-glow`) when you know the content's shape and it fills a region — tables, cards, detail panes — because it holds layout and shortens perceived wait. Spinner for short, indeterminate, or in-control waits (inside a button, a small inline fetch).
528
- - **Thresholds (guidance):** under ~1s show nothing — a flashed loader is worse than none; ~1–10s show a spinner or skeleton; beyond ~10s show determinate progress (percent or step) so it does not feel hung.
754
+ - **Wait feedback:** acknowledge the action promptly, avoid flashing a loader for trivial waits, and keep the known layout stable. For longer work, show actual steps or measured progress when available; otherwise state that work continues and offer cancellation where supported. Never invent a percentage.
529
755
  - **Optimistic vs pessimistic:** apply UI immediately and reconcile (rolling back loudly on failure) for reversible high-frequency actions — toggles, stars, reorders. Await confirmation for money, audited records, and anything a rollback would confuse.
530
- - **Empty states** invite the next action (button + one line of why). Never-had-data and filtered-empty are different states with different escapes — never ship one generic "nothing here".
756
+ - **First-use empty:** name what belongs here and the useful create/import action. Drop tabs or
757
+ filters only when they genuinely have no data to operate on. An illustration may support that
758
+ action; it must not replace it.
759
+ - **Filtered-empty:** retain the active filters and result context, explain that nothing matched,
760
+ and offer a clear-filter path. Never hide the controls needed to undo the empty result.
761
+ - **Partial:** keep available data readable, identify the missing or stale part, and scope recovery
762
+ to it. Missing is not zero. Do not collapse the whole surface into an error when some data exists.
531
763
  - **Every error state states what failed and how to fix it**, carries a keyboard-reachable retry in place, and preserves surrounding context — a body fetch failure must not blow away the toolbar and filters.
532
764
 
533
765
  ### Feedback discipline
@@ -545,10 +777,14 @@ Blocking errors are never toasts. Keep the acting verb consistent across the flo
545
777
 
546
778
  Match friction to reversibility × blast radius:
547
779
 
548
- 1. **Undo** (soft-delete + toast with Undo) for reversible, low-stakes, frequent actions — least friction, best experience. Prefer making actions undoable over interrupting them.
549
- 2. **Confirm dialog** for irreversible-but-scoped operations. Restate the specific consequence ("This permanently deletes 3 invoices"), verb-labeled buttons ("Delete invoices" / "Cancel" — never Yes/No), destructive action visually separated from safe; `alertdialog` semantics; focus lands on the safe action.
780
+ 1. **Undo** (soft-delete + toast with Undo) for reversible, low-stakes, frequent actions. Prefer making actions undoable over interrupting them.
781
+ 2. **Confirm dialog** for irreversible-but-scoped operations. Restate the specific consequence ("This permanently deletes 3 invoices"), verb-labeled buttons ("Delete invoices" / "Cancel" — never Yes/No), destructive action visually separated from safe; `alertdialog` semantics; focus lands on the safe action for a compact confirmation, or a static top heading when focusing an action would scroll the consequences out of view.
550
782
  3. **Type-to-confirm** (type the entity name) only for high-blast-radius irreversible operations — delete an org, drop a dataset.
551
783
 
784
+ Keep action rank separate from consequence. A row-level destructive action can use a measured
785
+ quiet treatment; emphasize the final destructive commit where the ladder makes it the decision.
786
+ Do not make every row's delete button compete with the page's primary action.
787
+
552
788
  Do not type-gate a single-row delete; do not one-tap a tenant wipe. Confirm only where this ladder calls for it — a confirmation on every action gets clicked through.
553
789
 
554
790
  **Neutralize a disabled destructive control.** `btn-danger` at full saturation reads as armed whatever the `disabled` attribute says, and the contrast exemption for disabled controls does not excuse it. While the action is unavailable, drop to the neutral or outline `btn-*` class (or let the disabled state mute the fill) so the color stops promising an action, and say _why_ it is unavailable in text the assistive layer reaches: `aria-describedby` pointing at the reason, with `title` only as the pointer-user convenience on top. Never use `title` alone — it never reaches a keyboard or screen-reader user, and it disappears on touch.
@@ -567,19 +803,19 @@ Do not type-gate a single-row delete; do not one-tap a tenant wipe. Confirm only
567
803
 
568
804
  ## Performance
569
805
 
570
- - **Ship one CSS system and no more.** Bootstrap plus a second framework (or a parallel bespoke layer) doubles payload and guarantees specificity fights.
571
- - **Compressed, the full build is cheap; incomplete builds are not.** Trimming through a Sass-subset build (import only the parts used — see [Theming](#theming--design-tokens)) is the sanctioned diet. Aggressive purge tools are the risky one: Bootstrap adds classes **at runtime** (`show`, `showing`, `fade`, `collapsing`, `modal-open`, `modal-backdrop`, `offcanvas-backdrop`, tooltip/popover generated markup) — purging without safelisting them ships UIs whose modals silently stop rendering. If you purge, safelist every JS-toggled class and test every overlay.
806
+ - **Keep one CSS system.** Extend the installed Bootstrap theme; do not add a competing framework or a parallel palette to restyle the surface.
807
+ - **Ship the full compressed build, or trim it with a Sass-subset build** that imports only the parts used (see [Theming](#theming--design-tokens)). Do not reach for a CSS purge tool first. Bootstrap adds classes **at runtime** — `show`, `showing`, `fade`, `collapsing`, `modal-open`, `modal-backdrop`, `offcanvas-backdrop`, and tooltip/popover generated markup — so a purge without a safelist ships a UI whose modals stop rendering. Where the project purges anyway, safelist every JS-toggled class and drive every overlay before shipping.
572
808
  - **Icons:** Bootstrap Icons is a separate package — prefer inline SVG or an SVG sprite (crisp, styleable through `currentColor`, no font flash) over the icon font; load only the icons used.
573
809
  - **JS:** the bundle is small, but only load it where behavior exists; per-component ESM imports (`bootstrap/js/dist/modal`) trim further in bundlers.
574
- - **Fonts:** each display face is a payload decision; subset and `font-display: swap` characterful faces, and let the data face fall back to the system stack when the brief allows.
810
+ - **Fonts:** reuse the existing families and load only needed weights/scripts. Add a display face only for a distinct role; use `font-display: swap` and test fallback wrapping. Keep the data face legible before and after fonts load.
575
811
 
576
812
  ## When Not to Hand-Roll
577
813
 
578
814
  Bootstrap has **no** combobox/autocomplete, date picker, multi-select tags input, data grid, or tree view. The boundary rule:
579
815
 
580
- - **Reach for native first:** `<input type="date">`, `<datalist>` for light autocomplete, `<select multiple>` where acceptable. Native widgets bring keyboard and AT behavior free.
581
- - **Reach for an established accessible library second** when the product genuinely needs the richer widget (combobox with async search, spreadsheet grid, drag-reorder tree). Budget for auditing it against the APG contract.
582
- - **Hand-roll last**, only with the APG contract in hand ([Accessibility](#accessibility) → Pattern contracts) and budget for the _keyboard_ half, which is most of the work.
816
+ - **Use native controls:** `<input type="date">`, `<datalist>` for light autocomplete, and `<select multiple>` where suitable.
817
+ - **Use an established accessible library** when native controls cannot meet the product's widget requirements; audit it against the APG contract.
818
+ - **Implement a custom widget** only when native controls and an established accessible library cannot satisfy the requirements; read [Accessibility](#accessibility) → Pattern contracts and cover the required keyboard behavior.
583
819
  - Never fake it: a `.dropdown-menu` posing as a select, a `<div>` grid with click handlers, or a scroll-anchor "wizard" each break keyboard and AT users in ways a demo never shows.
584
820
 
585
821
  ## Common Layout Patterns
@@ -587,8 +823,8 @@ Bootstrap has **no** combobox/autocomplete, date picker, multi-select tags input
587
823
  ### Centered content
588
824
 
589
825
  ```html
590
- <div class="d-flex justify-content-center align-items-center vh-100">
591
- <div>Centered content</div>
826
+ <div class="d-flex flex-column min-vh-100 p-3">
827
+ <div class="my-auto">Centered content</div>
592
828
  </div>
593
829
  ```
594
830
 
@@ -616,3 +852,20 @@ Bootstrap has **no** combobox/autocomplete, date picker, multi-select tags input
616
852
  <div class="d-none d-md-block">Hidden on mobile, visible md+</div>
617
853
  <div class="d-md-none">Visible only below md</div>
618
854
  ```
855
+
856
+ `d-none` removes the element from layout and the accessibility tree, which is what makes a dual presentation legal: only the active view exposes its controls. It is not a content strategy — anything hidden at the base must remain reachable through an operable path ([responsive-layout.md](responsive-layout.md) → Keep the task intact). A generated responsive role, for a utility-map property with no shipped infix:
857
+
858
+ ```scss
859
+ $utilities: map-merge(
860
+ $utilities,
861
+ (
862
+ 'width': map-merge(
863
+ map-get($utilities, 'width'),
864
+ (
865
+ responsive: true,
866
+ )
867
+ ),
868
+ )
869
+ );
870
+ // generates w-md-auto, w-lg-50, … alongside the stock w-*
871
+ ```