actual-css 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -6
  3. package/components.json +12 -18
  4. package/dist/actual.full.css +780 -479
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/brand/favicon.svg +5 -0
  8. package/docs/brand/lockup.svg +12 -0
  9. package/docs/brand/logo-dark.svg +9 -0
  10. package/docs/brand/logo-light.svg +9 -0
  11. package/docs/brand/logo.svg +11 -0
  12. package/docs/brand/mark.svg +11 -0
  13. package/docs/design-notes/architecture.md +67 -0
  14. package/docs/design-notes/steps.md +45 -28
  15. package/docs/navigation.json +3 -2
  16. package/docs/pages/components/alert.md +2 -10
  17. package/docs/pages/components/avatar.md +17 -11
  18. package/docs/pages/components/badge.md +3 -9
  19. package/docs/pages/components/breadcrumb.md +16 -2
  20. package/docs/pages/components/card.md +31 -2
  21. package/docs/pages/components/dialog.md +121 -33
  22. package/docs/pages/components/drawer.md +2 -2
  23. package/docs/pages/components/list.md +9 -13
  24. package/docs/pages/components/navbar.md +49 -30
  25. package/docs/pages/components/pagination.md +19 -0
  26. package/docs/pages/components/steps.md +24 -20
  27. package/docs/pages/components/table.md +3 -3
  28. package/docs/pages/enhancements/flyout.md +2 -2
  29. package/docs/pages/enhancements/scrollspy.md +7 -7
  30. package/docs/pages/enhancements/status-bar.md +2 -2
  31. package/docs/pages/enhancements/tabs.md +74 -3
  32. package/docs/pages/examples/overview.md +2 -1
  33. package/docs/pages/forms/date-picker.md +90 -0
  34. package/docs/pages/forms/detached-actions.md +37 -30
  35. package/docs/pages/forms/floating-field.md +21 -7
  36. package/docs/pages/forms/form-actions.md +58 -28
  37. package/docs/pages/forms/input-policy.md +1 -1
  38. package/docs/pages/forms/overview.md +6 -5
  39. package/docs/pages/forms/states-and-sizes.md +15 -15
  40. package/docs/pages/forms/sticky-actions.md +85 -83
  41. package/docs/pages/forms/validation.md +5 -5
  42. package/docs/pages/foundations/philosophy.md +102 -4
  43. package/docs/pages/foundations/theming.md +15 -4
  44. package/docs/pages/foundations/tokens.md +89 -36
  45. package/docs/pages/foundations/typography.md +4 -7
  46. package/docs/pages/guides/bootstrap.md +2 -2
  47. package/docs/pages/guides/design-tools.md +2 -2
  48. package/docs/pages/guides/integrating-actual-css.md +79 -9
  49. package/docs/pages/guides/introduction.md +5 -5
  50. package/docs/pages/guides/modular-import.md +3 -0
  51. package/docs/pages/guides/progressive-enhancement.md +27 -9
  52. package/docs/pages/guides/tailwind.md +70 -0
  53. package/docs/pages/guides/upgrading.md +151 -0
  54. package/docs/pages/layout/center.md +2 -1
  55. package/docs/pages/layout/cluster.md +37 -4
  56. package/docs/pages/layout/grid.md +2 -0
  57. package/docs/pages/layout/overview.md +1 -1
  58. package/docs/pages/layout/sidebar-layout.md +3 -0
  59. package/docs/pages/layout/stack.md +21 -16
  60. package/docs/pages/layout/switcher.md +2 -0
  61. package/docs/pages/layout/topbar.md +7 -5
  62. package/docs/pages/patterns/icon-slot.md +17 -1
  63. package/docs/pages/patterns/navigation-list.md +16 -46
  64. package/docs/pages/patterns/overview.md +3 -4
  65. package/docs/pages/utilities/flex.md +30 -20
  66. package/docs/pages/utilities/sizing-wrapping.md +52 -7
  67. package/docs/pages/utilities/spacing.md +4 -3
  68. package/package.json +6 -6
  69. package/reserved-classes.json +3 -3
  70. package/skills/actual-css/references/enhancements.md +10 -7
  71. package/src/cli/actual-css.js +39 -13
  72. package/src/css/components/alert.css +34 -34
  73. package/src/css/components/avatar.css +8 -17
  74. package/src/css/components/badge.css +1 -6
  75. package/src/css/components/breadcrumb.css +74 -8
  76. package/src/css/components/button.css +3 -9
  77. package/src/css/components/card.css +18 -10
  78. package/src/css/components/chat.css +4 -5
  79. package/src/css/components/drawer.css +42 -45
  80. package/src/css/components/flyout.css +3 -1
  81. package/src/css/components/index.css +0 -1
  82. package/src/css/components/join.css +2 -2
  83. package/src/css/components/list.css +10 -10
  84. package/src/css/components/modal.css +113 -134
  85. package/src/css/components/navbar.css +22 -31
  86. package/src/css/components/pagination.css +5 -0
  87. package/src/css/components/scrollspy.css +1 -1
  88. package/src/css/components/steps.css +66 -12
  89. package/src/css/components/tab.css +134 -4
  90. package/src/css/core/base.css +1 -1
  91. package/src/css/core/focus.css +8 -6
  92. package/src/css/core/reset.css +2 -1
  93. package/src/css/core/theme.css +10 -1
  94. package/src/css/core/tokens.css +32 -12
  95. package/src/css/core/variants.css +41 -14
  96. package/src/css/forms/choice-card.css +1 -1
  97. package/src/css/forms/choice.css +1 -1
  98. package/src/css/forms/control.css +1 -1
  99. package/src/css/forms/floating-field.css +20 -18
  100. package/src/css/forms/form-actions.css +14 -12
  101. package/src/css/forms/native.css +2 -2
  102. package/src/css/forms/otp.css +4 -5
  103. package/src/css/forms/switch.css +1 -1
  104. package/src/css/forms/validation.css +23 -30
  105. package/src/css/layout/center.css +2 -0
  106. package/src/css/layout/cluster.css +25 -0
  107. package/src/css/layout/grid.css +7 -0
  108. package/src/css/layout/scroller.css +19 -3
  109. package/src/css/layout/sidebar-layout.css +3 -0
  110. package/src/css/layout/switcher.css +3 -0
  111. package/src/css/layout/topbar.css +12 -6
  112. package/src/css/utilities/base.css +5 -0
  113. package/src/css/utilities/extra.css +3 -7
  114. package/src/js/full.js +4 -0
  115. package/src/js/inline-visible.js +54 -0
  116. package/src/js/parsed.js +21 -0
  117. package/src/js/tab.js +33 -7
  118. package/src/tooling/color.js +65 -0
  119. package/src/tooling/design-export.js +1 -1
  120. package/src/tooling/design-tokens.js +53 -34
  121. package/src/tooling/theme-contrast.js +217 -0
  122. package/docs/pages/patterns/actions.md +0 -52
  123. package/src/css/components/actions.css +0 -27
@@ -8,10 +8,11 @@ function usage() {
8
8
  return [
9
9
  "actual-css bundle INPUT --out FILE [--minify]",
10
10
  "actual-css design [--theme FILE] [--name NAME] --out DIR",
11
+ "actual-css contrast [--theme FILE] [--name NAME]",
11
12
  "",
12
13
  " -o, --out FILE write the bundle to FILE / the export to DIR (required)",
13
14
  " --minify collapse comments and whitespace",
14
- " --theme FILE export this [data-theme] file instead of the default theme",
15
+ " --theme FILE read this theme file ([data-theme] or :root) over the default",
15
16
  " --name NAME the data-theme name, when the file declares several",
16
17
  " -h, --help show this message",
17
18
  " --version print the actual-css version",
@@ -23,7 +24,13 @@ function usage() {
23
24
  "",
24
25
  "design: export a theme's tokens for Figma and Penpot, plus Button, Input, Badge,",
25
26
  "Alert and Card measured in headless Chrome and a Penpot plugin that builds them.",
26
- "Needs Bun (bunx actual-css design ...).",
27
+ "Needs Bun (bunx --bun actual-css design ...).",
28
+ "",
29
+ "contrast: measure the pairs a theme tunes by hand in headless Chrome, in every",
30
+ "scheme it declares: soft ink on its resting and hovered fill, the focus line on",
31
+ "--surface and --surface-solid, the invalid-field focus line, and the inverse",
32
+ "text. Exits 1 when a pair misses its threshold. Needs Bun (bunx --bun actual-css",
33
+ "contrast ...).",
27
34
  ].join("\n");
28
35
  }
29
36
 
@@ -43,8 +50,8 @@ function parseArgs(argv) {
43
50
  return { version: true };
44
51
  }
45
52
 
46
- if (command === "design") {
47
- return parseDesignArgs(rest);
53
+ if (command === "design" || command === "contrast") {
54
+ return parseThemeArgs(command, rest);
48
55
  }
49
56
 
50
57
  if (command !== "bundle") {
@@ -101,9 +108,10 @@ function parseArgs(argv) {
101
108
  return { help: false, command: "bundle", input, out, minify };
102
109
  }
103
110
 
104
- function parseDesignArgs(rest) {
105
- const options = { help: false, command: "design", themeFile: null, name: null, out: "" };
106
- const valued = { "--theme": "themeFile", "--name": "name", "--out": "out", "-o": "out" };
111
+ function parseThemeArgs(command, rest) {
112
+ const options = { help: false, command, themeFile: null, name: null, out: "" };
113
+ const valued = { "--theme": "themeFile", "--name": "name" };
114
+ if (command === "design") Object.assign(valued, { "--out": "out", "-o": "out" });
107
115
 
108
116
  for (let i = 0; i < rest.length; i += 1) {
109
117
  const arg = rest[i];
@@ -116,23 +124,40 @@ function parseDesignArgs(rest) {
116
124
  i += 1;
117
125
  }
118
126
 
119
- if (!options.out) throw new Error("Missing required --out DIR option.");
127
+ if (command === "design" && !options.out) throw new Error("Missing required --out DIR option.");
120
128
  return options;
121
129
  }
122
130
 
123
- async function design({ themeFile, name, out }) {
124
- // Checked before the import: under Node the module does not even parse
125
- // (`await using`), and `bundle` must stay usable there.
131
+ /* Checked before the tooling import: under Node those modules do not even
132
+ parse (`await using`), and `bundle` must stay usable there. The shebang is
133
+ Node for that reason, so plain `bunx` honors it and lands here: --bun is
134
+ what makes Bun run the script. */
135
+ function requireBun(command) {
126
136
  if (typeof Bun === "undefined") {
127
137
  throw new Error(
128
- "actual-css design needs Bun: it reads the theme in headless Chrome through Bun.WebView. Run it with bunx.",
138
+ `actual-css ${command} needs Bun: it reads the theme in headless Chrome through Bun.WebView. Run it with bunx --bun actual-css ${command} ...`,
129
139
  );
130
140
  }
141
+ }
142
+
143
+ async function design({ themeFile, name, out }) {
144
+ requireBun("design");
131
145
  const { exportDesign } = await import("../tooling/design-export.js");
132
146
  const written = await exportDesign({ themeFile, name, out: resolve(out) });
133
147
  console.log(`Exported ${written.length} files to ${resolve(out)}`);
134
148
  }
135
149
 
150
+ async function contrast({ themeFile, name }) {
151
+ requireBun("contrast");
152
+ const { formatContrast, measureThemeFile } = await import("../tooling/theme-contrast.js");
153
+ const { text, misses } = formatContrast(await measureThemeFile({ themeFile, name }));
154
+ console.log(text);
155
+ if (misses > 0) {
156
+ console.error(`\n${misses} pair${misses === 1 ? "" : "s"} under threshold.`);
157
+ process.exitCode = 1;
158
+ }
159
+ }
160
+
136
161
  async function bundle({ input, out, minify }) {
137
162
  const inputPath = resolve(input);
138
163
  const outputPath = resolve(out);
@@ -167,7 +192,8 @@ async function main() {
167
192
  return;
168
193
  }
169
194
 
170
- await (args.command === "design" ? design(args) : bundle(args));
195
+ const commands = { bundle, contrast, design };
196
+ await commands[args.command](args);
171
197
  }
172
198
 
173
199
  main().catch((error) => {
@@ -107,10 +107,11 @@
107
107
  }
108
108
  }
109
109
 
110
- /* Decorative leading icon — use .alert-icon on the icon element. */
110
+ /* Decorative leading icon — use .alert-icon on the icon element. display and
111
+ vertical-align are the no-:has() layout (Degraded tier: the icon sits inline
112
+ before the text); the grid anatomy below replaces them. */
111
113
  .alert > .alert-icon {
112
114
  display: inline-block;
113
- flex: none;
114
115
  inline-size: var(--alert-icon-size);
115
116
  block-size: var(--alert-icon-size);
116
117
  font-size: var(--alert-icon-size);
@@ -142,45 +143,44 @@
142
143
 
143
144
  /* Grid anatomy — activated by a leading icon, a trailing dismiss button, or
144
145
  both. Icon and dismiss are pinned to the outer columns; anything else (the
145
- message content) flows into the remaining text column. */
146
- @supports selector(.alert:has(> *)) {
147
- .alert:has(> .alert-icon, > .close) {
148
- display: grid;
149
- grid-template-columns: minmax(0, 1fr);
150
- align-items: var(--items-align, start);
151
- column-gap: var(--space-30);
152
- }
153
-
154
- .alert:has(> .alert-icon) {
155
- grid-template-columns: auto minmax(0, 1fr);
156
- }
146
+ message content) flows into the remaining text column. Without :has(), the
147
+ grid rules drop together and the alert keeps ordinary block flow. */
148
+ .alert:has(> .alert-icon, > .close) {
149
+ display: grid;
150
+ /* No grid-template-columns: the icon and close branches below always set
151
+ it, since one of them is what matched. */
152
+ align-items: var(--items-align, start);
153
+ column-gap: var(--space-30);
154
+ }
157
155
 
158
- .alert:has(> .alert-icon) > .alert-icon {
159
- grid-column: 1;
160
- grid-row: 1 / -1;
161
- display: inline-grid;
162
- place-items: center;
163
- margin-block-start: 0.125em;
164
- }
156
+ .alert:has(> .alert-icon) {
157
+ grid-template-columns: auto minmax(0, 1fr);
158
+ }
165
159
 
166
- .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
167
- grid-column: 2;
168
- }
160
+ .alert:has(> .alert-icon) > .alert-icon {
161
+ grid-column: 1;
162
+ grid-row: 1 / -1;
163
+ display: inline-grid;
164
+ place-items: center;
165
+ margin-block-start: 0.125em;
166
+ }
169
167
 
170
- .alert:has(> .close) {
171
- grid-template-columns: minmax(0, 1fr) auto;
172
- }
168
+ .alert:has(> .alert-icon) > :not(.alert-icon, .close) {
169
+ grid-column: 2;
170
+ }
173
171
 
174
- .alert:has(> .alert-icon):has(> .close) {
175
- grid-template-columns: auto minmax(0, 1fr) auto;
176
- }
172
+ .alert:has(> .close) {
173
+ grid-template-columns: minmax(0, 1fr) auto;
174
+ }
177
175
 
178
- .alert:has(> .close) > .close {
179
- grid-column: -2 / -1;
180
- grid-row: 1;
181
- }
176
+ .alert:has(> .alert-icon):has(> .close) {
177
+ grid-template-columns: auto minmax(0, 1fr) auto;
182
178
  }
183
179
 
180
+ .alert:has(> .close) > .close {
181
+ grid-column: -2 / -1;
182
+ grid-row: 1;
183
+ }
184
184
  /* Links */
185
185
  .alert :where(a:not(.btn)) {
186
186
  color: inherit;
@@ -11,13 +11,13 @@
11
11
  * --avatar-size
12
12
  * --avatar-radius
13
13
  * --avatar-font-size
14
+ * --avatar-bg
14
15
  * --avatar-stack-size
15
16
  * --avatar-stack-font-size
16
17
  * --avatar-stack-overlap
17
18
  * --avatar-stack-ring
18
19
  *
19
20
  * Internal:
20
- * --avatar-bg
21
21
  * --avatar-fg
22
22
  * --avatar-status-translate-x
23
23
  */
@@ -95,16 +95,13 @@
95
95
 
96
96
  .avatar > img,
97
97
  .avatar > picture > img {
98
+ display: block;
98
99
  inline-size: 100%;
99
100
  block-size: 100%;
100
101
  object-fit: cover;
101
102
  border-radius: inherit;
102
103
  }
103
104
 
104
- .avatar > picture > img {
105
- display: block;
106
- }
107
-
108
105
  button.avatar {
109
106
  appearance: none;
110
107
  cursor: pointer;
@@ -198,19 +195,13 @@ a.avatar[aria-disabled="true"] {
198
195
  --avatar-stack-font-size: var(--font-size-lg);
199
196
  }
200
197
 
201
- @supports selector(:has(> .avatar)) {
202
- .avatar-stack:has(> .avatar.sm) {
203
- --avatar-stack-size: 1.75rem;
204
- --avatar-stack-font-size: var(--font-size-xs);
205
- }
206
-
207
- .avatar-stack:has(> .avatar.lg) {
208
- --avatar-stack-size: 3rem;
209
- --avatar-stack-font-size: var(--font-size-lg);
210
- }
211
- }
212
-
198
+ /* Overlapping avatars hide their neighbour: a translucent fill (--surface-subtle
199
+ on a glass palette) would show the next avatar's edge through it, most of all
200
+ when hover raises one above the other. The fill is layered over the opaque
201
+ role; on an opaque fill the base never shows. */
213
202
  .avatar-stack > .avatar {
203
+ background-color: var(--surface-opaque);
204
+ background-image: linear-gradient(var(--avatar-bg), var(--avatar-bg));
214
205
  box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface-opaque);
215
206
  }
216
207
 
@@ -137,7 +137,6 @@
137
137
  --badge-size: var(--badge-dot-size);
138
138
  --badge-bg: var(--intent, var(--neutral));
139
139
  --badge-fg: var(--intent-fg, var(--neutral-fg));
140
- --badge-border: transparent;
141
140
  padding: 0;
142
141
  border: 0;
143
142
  inline-size: var(--badge-size);
@@ -169,10 +168,6 @@
169
168
  }
170
169
 
171
170
  @supports (color: color-mix(in oklch, red, white)) {
172
- :is(a.badge, button.badge):is(.solid, :empty) {
173
- --ui-hover-bg: color-mix(in oklch, var(--intent, var(--neutral)) 88%, black);
174
- }
175
-
176
171
  :is(a.badge, button.badge):where(:not(.solid, .outline, :empty)) {
177
172
  --badge-default-hover-bg: color-mix(
178
173
  in oklab,
@@ -199,7 +194,7 @@
199
194
  :where(:disabled, [aria-disabled="true"])
200
195
  ):hover {
201
196
  /* Huge inset spread paints the overlay over the pill radius (see button.css). */
202
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
197
+ box-shadow: inset 0 0 0 999px var(--hover-overlay-solid, var(--hover-overlay));
203
198
  }
204
199
 
205
200
  button.badge {
@@ -4,14 +4,21 @@
4
4
  * Docs: docs/pages/components/breadcrumb.md
5
5
  *
6
6
  * Inline list separated by "/". Mark the current page with `aria-current="page"`
7
- * either on the item or on the current link.
7
+ * either on the item or on the current link. Ancestor labels sit in an <a> or a
8
+ * <span>, the box that truncates.
9
+ *
10
+ * Breadcrumbs are a single navigation hierarchy. Wrapping makes hierarchy look
11
+ * like separate rows and causes unstable header height, and scrolling hides the
12
+ * root behind a scrollbar. A trail that does not fit truncates instead:
13
+ * ancestors first, the current page last, each label behind an ellipsis.
8
14
  */
15
+
9
16
  .breadcrumb {
10
17
  display: flex;
11
- flex-direction: row;
12
- flex-wrap: wrap;
18
+ flex-wrap: nowrap;
13
19
  align-items: center;
14
20
  gap: var(--space-10);
21
+ min-inline-size: 0;
15
22
  margin: 0;
16
23
  padding: 0;
17
24
  color: var(--text-subtle);
@@ -19,15 +26,74 @@
19
26
  list-style: none;
20
27
  }
21
28
 
22
- .breadcrumb li {
23
- display: inline-flex;
29
+ /* The landmark around the trail must shrink with it in a flex or grid row, or
30
+ it keeps the trail's full width and the labels never get to truncate.
31
+ Without :has() the landmark keeps its width; min-inline-size: 0 on the
32
+ wrapper is the fallback. */
33
+ :where(nav:has(> .breadcrumb)) {
34
+ min-inline-size: 0;
35
+ }
36
+
37
+ /* Grid, not flex: under a min-content constraint a grid track keeps its base
38
+ size, which is the label's minimum below, so the item's own minimum is
39
+ separator + label floor without anyone measuring a "/". A flex item would
40
+ report the full label as its minimum and never shrink.
41
+
42
+ Ancestors take the shrink before the current page, so the location is the
43
+ last thing to lose characters. The ratio is deliberately huge: at 1000 the
44
+ current page still took a fraction of a pixel, enough to trigger its
45
+ ellipsis while ancestors had room. Its own factor stays 1, because flex
46
+ distributes only a share of the deficit when the unfrozen factors sum below
47
+ one. Keyed on the last item, the current page by convention, so the order
48
+ holds without :has(). */
49
+ .breadcrumb > li {
50
+ display: inline-grid;
51
+ flex: 0 1000000 auto;
52
+ grid-auto-flow: column;
24
53
  align-items: center;
25
- gap: var(--space-10);
54
+ white-space: nowrap;
26
55
  }
27
56
 
28
- .breadcrumb li + li::before {
29
- content: "/";
57
+ .breadcrumb > li:last-child {
58
+ flex-shrink: 1;
59
+ }
60
+
61
+ /* A margin rather than a grid gap, so the separator keeps its space when the
62
+ current page below lays it out inline. The weight stays normal there, where
63
+ it would inherit the current page's. */
64
+ .breadcrumb > li + li::before {
65
+ margin-inline-end: var(--space-10);
30
66
  color: var(--text-subtle);
67
+ font-weight: var(--font-weight-normal);
68
+ content: "/";
69
+ }
70
+
71
+ /* The ellipsis lives on the label, not on the item: an element never clips
72
+ its own outline, but an overflow-hidden item would clip its link's focus
73
+ line, which sits outside the link box.
74
+
75
+ The floor keeps a truncated label readable as a step ("Po…") instead of
76
+ collapsing to its separator. calc-size() lets a shorter label keep its own
77
+ width; without it, the plain 2em floor leaves a label under 2em a little
78
+ trailing room. */
79
+ .breadcrumb > li > :is(a, span) {
80
+ min-inline-size: 2em;
81
+ min-inline-size: calc-size(max-content, min(size, 2em));
82
+ overflow: hidden;
83
+ text-overflow: ellipsis;
84
+ }
85
+
86
+ /* The current page written as bare text has no label box, so its item
87
+ truncates itself. Only with no element inside: an aria-current item that
88
+ holds a link would clip that link's focus line, and keeps the label rule
89
+ above instead. Its own rule, never merged with the one above: a browser
90
+ without :has() drops it alone, and bare text then keeps its full width. */
91
+ .breadcrumb > li[aria-current="page"]:not(:has(> *)) {
92
+ display: block;
93
+ min-inline-size: 2em;
94
+ min-inline-size: calc-size(max-content, min(size, 2em));
95
+ overflow: hidden;
96
+ text-overflow: ellipsis;
31
97
  }
32
98
 
33
99
  .breadcrumb a {
@@ -28,7 +28,6 @@
28
28
  * --btn-bg
29
29
  * --btn-fg
30
30
  * --btn-border
31
- * --btn-default-hover-bg
32
31
  * --btn-border-block-end-color
33
32
  * --btn-border-block-end-width
34
33
  * --btn-border-block
@@ -54,9 +53,8 @@
54
53
  --btn-bg: var(--ui-bg, var(--btn-default-bg, var(--intent, var(--neutral))));
55
54
  --btn-fg: var(--ui-fg, var(--btn-default-fg, var(--intent-fg, var(--neutral-fg))));
56
55
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
57
- --btn-default-hover-bg: var(--btn-bg);
58
56
  --btn-radius: var(--radius);
59
- --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
57
+ --btn-hover-overlay: var(--hover-overlay-solid, var(--hover-overlay));
60
58
  --btn-border-block: calc(
61
59
  var(--border-width) +
62
60
  var(--btn-border-block-end-width, var(--border-width))
@@ -136,10 +134,6 @@
136
134
  }
137
135
 
138
136
  @supports (color: color-mix(in oklch, red, white)) {
139
- .btn {
140
- --btn-default-hover-bg: color-mix(in oklch, var(--btn-bg) 88%, black);
141
- }
142
-
143
137
  .btn.soft {
144
138
  --ui-hover-bg: color-mix(
145
139
  in oklab,
@@ -155,9 +149,9 @@
155
149
  }
156
150
 
157
151
  /* The .soft/.outline/.ghost hover recipe (--ui-hover-bg) is set locally above;
158
- the overlay darkening for solid/soft is .btn-specific. */
152
+ the overlay darkening for solid is .btn-specific. */
159
153
  .btn:not(:where(:disabled, [aria-disabled="true"])):hover {
160
- background: var(--ui-hover-bg, var(--btn-default-hover-bg));
154
+ background: var(--ui-hover-bg, var(--btn-bg));
161
155
  /* Huge inset spread paints the overlay over any border-radius without a
162
156
  pseudo-element; a plain background would replace the fill instead of
163
157
  darkening it. */
@@ -4,11 +4,11 @@
4
4
  * Docs: docs/pages/components/card.md
5
5
  *
6
6
  * Surface, border, radius, padding, density, and elevation stay local here.
7
- * Direct header/footer children are structural slots; a direct footer is
8
- * anchored to the card's bottom edge when extra block space is available (so
9
- * price/action rows align across equal-height cards in a grid). A bare card
10
- * owns its direct-child rhythm; a layout primitive on the same element takes
11
- * over that layout. Page layout remains the job of markup and layout
7
+ * Direct header/footer children are structural slots with a default split row;
8
+ * a direct footer is anchored to the card's bottom edge when extra block space
9
+ * is available (so price/action rows align across equal-height cards in a
10
+ * grid). A bare card owns its direct-child rhythm and its slot rows; a layout
11
+ * primitive on the card or on a slot takes over that layout. Page layout remains the job of markup and layout
12
12
  * primitives outside the card.
13
13
  *
14
14
  * Public hooks:
@@ -46,6 +46,9 @@
46
46
 
47
47
  .card {
48
48
  --card-pad: var(--space-40);
49
+ /* Local anatomy, like the padding. Never var(--gap): that token is
50
+ inherited, so the --gap of a parent grid became the rhythm inside every
51
+ card (+79px per card on a 38px grid). */
49
52
  --card-gap: var(--space-30);
50
53
  --card-radius: var(--radius-lg);
51
54
  --card-max-inline-size: none;
@@ -87,7 +90,11 @@
87
90
  margin-block: 0;
88
91
  }
89
92
 
90
- .card > :where(header, footer) {
93
+ /* The slot row is a convenience default, not anatomy: like the root box it
94
+ lives at zero specificity, so a primitive, role or utility on the slot
95
+ (.cluster, .media, .form-actions, .justify-content-*) replaces it instead of
96
+ losing to components/ on source order. */
97
+ :where(.card > header, .card > footer) {
91
98
  display: flex;
92
99
  flex-wrap: wrap;
93
100
  align-items: var(--items-align, center);
@@ -96,15 +103,15 @@
96
103
  min-inline-size: 0;
97
104
  }
98
105
 
106
+ :where(.card > header) {
107
+ align-items: var(--items-align, flex-start);
108
+ }
109
+
99
110
  .card > :where(header, footer) > * {
100
111
  min-inline-size: 0;
101
112
  margin-block: 0;
102
113
  }
103
114
 
104
- .card > header {
105
- align-items: var(--items-align, flex-start);
106
- }
107
-
108
115
  /* A direct footer is pinned to the bottom when the card has free space.
109
116
  Specificity beats .stack > * so a .card.stack keeps this contract. */
110
117
  .card > footer {
@@ -119,6 +126,7 @@
119
126
  }
120
127
  .card.compact {
121
128
  --card-pad: var(--space-20);
129
+ --card-gap: var(--space-20);
122
130
  }
123
131
 
124
132
  @media print {
@@ -94,9 +94,8 @@
94
94
  }
95
95
 
96
96
  /* Remove the reserved avatar gutter when a message has no avatar. This is a
97
- cosmetic enhancement; the two-column baseline remains fully readable. */
98
- @supports selector(:has(*)) {
99
- .chat:not(:has(> .chat-avatar)) {
100
- column-gap: 0;
101
- }
97
+ cosmetic enhancement; the two-column baseline remains fully readable, and a
98
+ browser without :has() drops only this rule. */
99
+ .chat:not(:has(> .chat-avatar)) {
100
+ column-gap: 0;
102
101
  }
@@ -22,8 +22,10 @@
22
22
  * Internal:
23
23
  * --drawer-closed-translate (side/RTL positioning state)
24
24
  * --drawer-focus-bleed
25
+ * --drawer-close-reserve (header room for a corner close; zero without a close)
25
26
  * --drawer-motion-duration (shared by panel and ::backdrop so exit duration
26
27
  * changes in one place; entry state redefines it longer)
28
+ * --drawer-motion-ease (panel easing owned by the open/closed state)
27
29
  */
28
30
  :where(dialog).drawer {
29
31
  --drawer-size: 20rem;
@@ -31,9 +33,14 @@
31
33
  /* The inset a direct .bleed child cancels (components/bleed.css). */
32
34
  --surface-pad: var(--drawer-pad);
33
35
  --drawer-closed-translate: -100%;
36
+ /* Header room for a corner close. Unconditional baseline: without :has()
37
+ (Degraded tier) a header keeps too much room rather than a title running
38
+ under the close. Zeroed when the drawer has no close. */
39
+ --drawer-close-reserve: calc(var(--close-size, var(--control-size)) + var(--space-30));
34
40
  /* Exit is shorter than entry so the panel clears quickly; the scrim reads
35
41
  the same variable through inheritance and never lags the panel. */
36
42
  --drawer-motion-duration: var(--duration);
43
+ --drawer-motion-ease: var(--ease-exit);
37
44
 
38
45
  /* Keep the surface anchored to the viewport in the top layer. This also
39
46
  provides the containing block for the absolutely positioned close button.
@@ -65,21 +72,21 @@
65
72
  box-shadow: var(--shadow);
66
73
  opacity: 0;
67
74
  transform: translateX(var(--drawer-closed-translate));
68
- transition:
69
- opacity var(--drawer-motion-duration) var(--ease-exit),
70
- transform var(--drawer-motion-duration) var(--ease-exit);
75
+ /* Unsupported discrete behavior drops alone, preserving opacity/transform. */
76
+ transition-property: opacity, transform, overlay, display;
77
+ transition-duration: var(--drawer-motion-duration);
78
+ transition-timing-function: var(--drawer-motion-ease);
79
+ transition-behavior: allow-discrete;
71
80
  }
72
81
 
73
82
  :where(dialog).drawer[open] {
74
83
  --drawer-motion-duration: var(--duration-slow);
84
+ --drawer-motion-ease: var(--ease-enter);
75
85
  display: grid;
76
86
  grid-template-rows: auto minmax(0, 1fr) auto;
77
87
  gap: var(--space-40);
78
88
  opacity: 1;
79
89
  transform: translateX(0);
80
- transition:
81
- opacity var(--drawer-motion-duration) var(--ease-enter),
82
- transform var(--drawer-motion-duration) var(--ease-enter);
83
90
  }
84
91
 
85
92
  /* A form is the drawer's content wrapper when actions need method="dialog".
@@ -99,7 +106,10 @@
99
106
  opacity: 0;
100
107
  /* Scrim curve stays neutral `ease`; only the panel's character curves
101
108
  apply. Duration follows --drawer-motion-duration through inheritance. */
102
- transition: opacity var(--drawer-motion-duration) ease;
109
+ transition-property: opacity, overlay, display;
110
+ transition-duration: var(--drawer-motion-duration);
111
+ transition-timing-function: ease;
112
+ transition-behavior: allow-discrete;
103
113
  }
104
114
 
105
115
  :where(dialog).drawer[data-side="end"] {
@@ -133,47 +143,23 @@
133
143
  exclusion list is easier to trust than its `position: absolute`. */
134
144
  :where(dialog).drawer > :not(:is(header, footer, form, .close)),
135
145
  :where(dialog).drawer > form > :not(:is(header, footer, .close)) {
136
- --drawer-focus-bleed: calc(
137
- var(--focus-outline-offset) +
138
- max(var(--focus-ring-width), var(--border-width) * 2)
139
- );
146
+ /* Declared here, not as a :root alias: an inherited alias arrives already
147
+ computed, so a --focus-ring-width override on the drawer would not reach
148
+ it. */
149
+ --drawer-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
140
150
 
141
151
  min-block-size: 0;
142
- /* Keep focus paint inside the clip without moving the body off the fixed
143
- header and footer edges. The reserve covers the thicker of the two
144
- outside indicators: the :focus baseline (2 × border) and the component
145
- line (--focus-ring-width, 3px in bootstrap-v6). */
146
- margin-inline: calc(var(--drawer-focus-bleed) * -1);
147
- padding-inline: var(--drawer-focus-bleed);
152
+ /* Keep focus paint inside the clip, on all four sides (overflow: auto clips
153
+ both axes), without moving the content. Every outside indicator is
154
+ --focus-ring-width wide (core/focus.css). On the block axis the reserve
155
+ reaches into the slot gap, which is wider, so the scrollport never slides
156
+ under the header or footer. */
157
+ margin: calc(var(--drawer-focus-bleed) * -1);
158
+ padding: var(--drawer-focus-bleed);
148
159
  overflow: auto;
149
160
  overscroll-behavior: contain;
150
161
  }
151
162
 
152
- @supports (transition-behavior: allow-discrete) {
153
- :where(dialog).drawer {
154
- transition:
155
- opacity var(--drawer-motion-duration) var(--ease-exit),
156
- transform var(--drawer-motion-duration) var(--ease-exit),
157
- overlay var(--drawer-motion-duration) allow-discrete,
158
- display var(--drawer-motion-duration) allow-discrete;
159
- }
160
-
161
- :where(dialog).drawer[open] {
162
- transition:
163
- opacity var(--drawer-motion-duration) var(--ease-enter),
164
- transform var(--drawer-motion-duration) var(--ease-enter),
165
- overlay var(--drawer-motion-duration) allow-discrete,
166
- display var(--drawer-motion-duration) allow-discrete;
167
- }
168
-
169
- :where(dialog).drawer::backdrop {
170
- transition:
171
- opacity var(--drawer-motion-duration) ease,
172
- overlay var(--drawer-motion-duration) allow-discrete,
173
- display var(--drawer-motion-duration) allow-discrete;
174
- }
175
- }
176
-
177
163
  /* Safari before 16.4 does not support :dir(); keep the ancestor-attribute
178
164
  path as the Degraded-tier fallback. The :dir() rules are dropped on their
179
165
  own there, so they need no wrapper. :dir() follows inherited HTML
@@ -210,9 +196,20 @@
210
196
  }
211
197
  }
212
198
 
199
+ /* No close anywhere: reclaim the reserve. Deliberately coarse — a close in
200
+ the body keeps the reserve, which costs only room, never overlap. */
201
+ :where(dialog).drawer:not(:has(.close)) {
202
+ --drawer-close-reserve: 0px;
203
+ }
204
+
213
205
  /* The header reserves inline room so the title never runs under the close
214
- control. */
206
+ control. A .bleed header keeps at least its band inset. */
215
207
  :where(dialog).drawer > header,
216
- :where(dialog).drawer > form > header {
217
- padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
208
+ :where(dialog).drawer > :where(form) > header {
209
+ padding-inline-end: var(--drawer-close-reserve);
210
+ }
211
+
212
+ :where(dialog).drawer > header:where(.bleed),
213
+ :where(dialog).drawer > :where(form) > header:where(.bleed) {
214
+ padding-inline-end: max(var(--bleed-pad, 0px), var(--drawer-close-reserve));
218
215
  }