maquina-components 0.6.1 → 0.7.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/app/assets/stylesheets/badge.css +3 -2
  4. data/app/assets/stylesheets/breadcrumbs.css +42 -10
  5. data/app/assets/stylesheets/calendar.css +8 -6
  6. data/app/assets/stylesheets/card.css +3 -2
  7. data/app/assets/stylesheets/combobox.css +10 -1
  8. data/app/assets/stylesheets/date_picker.css +7 -6
  9. data/app/assets/stylesheets/drawer.css +4 -3
  10. data/app/assets/stylesheets/dropdown_menu.css +1 -1
  11. data/app/assets/stylesheets/form.css +83 -34
  12. data/app/assets/stylesheets/menu_button.css +3 -2
  13. data/app/assets/stylesheets/pagination.css +4 -3
  14. data/app/assets/stylesheets/sidebar.css +42 -18
  15. data/app/assets/stylesheets/toast.css +7 -5
  16. data/app/assets/stylesheets/toggle_group.css +4 -3
  17. data/app/helpers/maquina_components/breadcrumbs_helper.rb +10 -2
  18. data/app/helpers/maquina_components/icons_helper.rb +35 -3
  19. data/app/javascript/controllers/breadcrumb_controller.js +97 -40
  20. data/app/javascript/controllers/dropdown_menu_controller.js +46 -1
  21. data/app/javascript/controllers/menu_button_controller.js +38 -0
  22. data/app/javascript/controllers/sidebar_controller.js +23 -0
  23. data/app/views/components/_breadcrumbs.html.erb +3 -7
  24. data/app/views/components/_sidebar.html.erb +4 -0
  25. data/app/views/components/alert/_description.html.erb +1 -1
  26. data/app/views/components/alert/_title.html.erb +1 -1
  27. data/app/views/components/card/_description.html.erb +1 -1
  28. data/app/views/components/card/_title.html.erb +1 -1
  29. data/app/views/components/combobox/_label.html.erb +1 -1
  30. data/app/views/components/combobox/_trigger.html.erb +1 -1
  31. data/app/views/components/drawer/_description.html.erb +1 -1
  32. data/app/views/components/drawer/_title.html.erb +1 -1
  33. data/app/views/components/sidebar/_menu_badge.html.erb +1 -1
  34. data/app/views/components/table/_caption.html.erb +1 -1
  35. data/app/views/components/table/_cell.html.erb +1 -1
  36. data/app/views/components/table/_head.html.erb +1 -1
  37. data/app/views/components/toast/_description.html.erb +1 -1
  38. data/app/views/components/toast/_title.html.erb +1 -1
  39. data/lib/maquina_components/doctor.rb +208 -14
  40. data/lib/maquina_components/version.rb +1 -1
  41. data/lib/tasks/maquina_components_tasks.rake +1 -1
  42. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a445c4fe008c07069fd7d0e9fd4249c9a169410e9a53fa6cdaa6c2455351b92
4
- data.tar.gz: c0964a27ecfdbd92797ee8c03a24d7c9e6db2b5e41147d2d81bf18ff2e38540d
3
+ metadata.gz: ca1b928bf464480638e3f7504164a10cfecab249eed3a970dbaaad5a3d5b311a
4
+ data.tar.gz: 27998f7d8cdf85c6e49af5b4db527086093149130af50a722105e8970c01838c
5
5
  SHA512:
6
- metadata.gz: 7c59d1819376a7242cbac12758ceffdb6e221184a29150b6f70364b275545f84d8da7c3235a5d9bdc3a1044e08622dba5b379615e2bc7053e8647506f83b6b7c
7
- data.tar.gz: 340d7fcd15e476d17e2244cb7e7b29563b27e7fff360c09f9427d2ccc0491379b4dc5c412e4b8be9447159bbb75fd9d47bade573fbfb9497284d3d9ada629931
6
+ metadata.gz: 8105dbdafd0bbf1f1be19ff9ba7f920f42f79b74703098e4229131b5310fb50cbd7e3ecdbf2af2cecfcd7f2f241f15be247e8592d5534995ed3cba931de50e7e
7
+ data.tar.gz: 31286d951a265d58bb22021f625242ce36e612ce9d0fe6a8a8ac3c057d0f845d34ea240ba1d2cf15aa4a87a33be8592bee5e1c8483a26b4bc733b069528ba27e
data/README.md CHANGED
@@ -165,7 +165,8 @@ bin/rails maquina:doctor
165
165
  ```
166
166
 
167
167
  It scans your CSS, views and JavaScript and prints file:line for every pattern
168
- the new release changes, grouped `BREAKING` / `REVIEW` / `CLEANUP`. It is
168
+ the new release changes, grouped `BREAKING` / `REVIEW` / `CLEANUP`, each tagged
169
+ with the release it came from. It is
169
170
  advisory: it never edits anything and never fails a build. See
170
171
  [docs/upgrading.md](docs/upgrading.md) for what changed and how to keep the
171
172
  previous look.
@@ -133,8 +133,9 @@
133
133
 
134
134
  /* Focus state for keyboard navigation */
135
135
  [data-component="badge"]:where(a:focus-visible, button:focus-visible) {
136
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
137
- var(--focus-ring-color, var(--ring));
136
+ outline-width: var(--focus-ring-width, 3px);
137
+ outline-style: var(--focus-ring-style, solid);
138
+ outline-color: var(--focus-ring-color, var(--ring));
138
139
  outline-offset: var(--focus-ring-offset, 0px);
139
140
  }
140
141
 
@@ -55,7 +55,7 @@
55
55
  @apply text-sm;
56
56
  font-weight: var(--label-weight, 500);
57
57
  @apply underline-offset-4;
58
- @apply transition-colors;
58
+ @apply transition-[color,background-color,border-color,text-decoration-color];
59
59
  color: var(--muted-foreground);
60
60
  }
61
61
 
@@ -73,9 +73,16 @@
73
73
  @apply underline;
74
74
  }
75
75
 
76
+ /* The underline is kept, but it cannot be the *only* signal: on a page whose
77
+ breadcrumbs are the sole keyboard stops, a text-decoration change reads as
78
+ nothing happening. Ring first, underline as reinforcement. */
76
79
  [data-breadcrumb-part="link"]:where(:focus-visible) {
77
- @apply outline-none underline;
80
+ @apply underline;
78
81
  color: var(--foreground);
82
+ outline-width: var(--focus-ring-width, 3px);
83
+ outline-style: var(--focus-ring-style, solid);
84
+ outline-color: var(--focus-ring-color, var(--ring));
85
+ outline-offset: var(--focus-ring-offset, 0px);
79
86
  }
80
87
 
81
88
  /* ===== Breadcrumb Page (current) ===== */
@@ -126,6 +133,15 @@
126
133
  color: var(--foreground);
127
134
  }
128
135
 
136
+ /* It is a trigger, so it is a tab stop, so it rings. */
137
+ [data-breadcrumb-part="ellipsis"]:where(:focus-visible) {
138
+ color: var(--foreground);
139
+ outline-width: var(--focus-ring-width, 3px);
140
+ outline-style: var(--focus-ring-style, solid);
141
+ outline-color: var(--focus-ring-color, var(--ring));
142
+ outline-offset: var(--focus-ring-offset, 0px);
143
+ }
144
+
129
145
  /* ===== Overflow Dropdown (top-layer popover) ===== */
130
146
  /* Visual styling comes from dropdown_menu.css via data-dropdown-menu-part.
131
147
  Anchor positioning places the popover in modern browsers;
@@ -156,17 +172,38 @@
156
172
  @apply shrink-0 whitespace-nowrap;
157
173
  }
158
174
 
159
- /* Allow the last item (current page) to truncate gracefully instead of hard-clipping */
175
+ /* The last item (current page) is the overflow sponge of last resort: when a
176
+ single long title cannot be helped by collapsing anything, it truncates
177
+ rather than pushing the bar out of its box. */
160
178
  :where([data-controller="breadcrumb"]) [data-breadcrumb-part="item"]:where(:last-child) {
161
179
  @apply shrink min-w-0 overflow-hidden;
162
- text-overflow: ellipsis;
163
180
  }
164
181
 
182
+ /* display:block, not the inline-flex the other parts get. text-overflow has
183
+ no effect on a flex container's own box, so this rule used to hard-clip
184
+ mid-word -- "A Very Long Current Pag" -- while claiming to truncate
185
+ gracefully. A block box renders the ellipsis. An icon inside still flows
186
+ inline and keeps its margin. */
165
187
  :where([data-controller="breadcrumb"] [data-breadcrumb-part="item"]:last-child) :is([data-breadcrumb-part="page"], [data-breadcrumb-part="link"]) {
166
- @apply overflow-hidden;
188
+ @apply block overflow-hidden whitespace-nowrap;
167
189
  text-overflow: ellipsis;
168
190
  }
169
191
 
192
+ /* Measuring mode, toggled by breadcrumb_controller.js for the length of one
193
+ measurement. The sponge above is exactly what makes scrollWidth useless:
194
+ flex resolves a deficit by shrinking a shrinkable item *before* it lets the
195
+ line overflow, so the label absorbs everything down to 0px and
196
+ scrollWidth === clientWidth at every width. Measured: content wanting 430px
197
+ in a 300px box reported no overflow at all, which is why the width-based
198
+ collapse never fired and collapse_after had to be invented to fake it.
199
+ Pinning the last item to its natural width for the measurement makes the
200
+ list report the space it actually wants. */
201
+ :where([data-controller="breadcrumb"]) [data-breadcrumb-part="list"].breadcrumb-measuring [data-breadcrumb-part="item"]:where(:last-child) {
202
+ flex-shrink: 0;
203
+ min-width: max-content;
204
+ overflow: visible;
205
+ }
206
+
170
207
  /* Items hidden by Stimulus controller */
171
208
  :where([data-controller="breadcrumb"]) [data-breadcrumb-target="item"]:where(.hidden),
172
209
  :where([data-controller="breadcrumb"]) [data-breadcrumb-target="ellipsisSeparator"]:where(.hidden) {
@@ -183,11 +220,6 @@
183
220
  [data-breadcrumb-part="list"] {
184
221
  @apply gap-1;
185
222
  }
186
-
187
- /* Hide middle items on very small screens by default */
188
- :where([data-component="breadcrumbs"][data-auto-collapse]) [data-breadcrumb-part="item"]:where(:not(:first-child):not(:last-child)) {
189
- @apply hidden sm:inline-flex;
190
- }
191
223
  }
192
224
 
193
225
  /* ===== Separator Variants ===== */
@@ -65,7 +65,7 @@
65
65
  cursor: pointer;
66
66
  background-color: transparent;
67
67
  color: var(--foreground);
68
- @apply transition-colors duration-150;
68
+ @apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
69
69
  }
70
70
 
71
71
  [data-calendar-part="header"] button svg {
@@ -78,8 +78,9 @@
78
78
  }
79
79
 
80
80
  [data-calendar-part="header"] button:where(:focus-visible) {
81
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
82
- var(--focus-ring-color, var(--ring));
81
+ outline-width: var(--focus-ring-width, 3px);
82
+ outline-style: var(--focus-ring-style, solid);
83
+ outline-color: var(--focus-ring-color, var(--ring));
83
84
  outline-offset: var(--focus-ring-offset, 0px);
84
85
  }
85
86
 
@@ -135,7 +136,7 @@
135
136
  cursor: pointer;
136
137
  background-color: transparent;
137
138
  color: var(--foreground);
138
- @apply transition-colors duration-150;
139
+ @apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
139
140
  }
140
141
 
141
142
  /* ===== Day Variants ===== */
@@ -228,8 +229,9 @@
228
229
  [data-calendar-part="day"]:where(:focus-visible) {
229
230
  position: relative;
230
231
  z-index: 10;
231
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
232
- var(--focus-ring-color, var(--ring));
232
+ outline-width: var(--focus-ring-width, 3px);
233
+ outline-style: var(--focus-ring-style, solid);
234
+ outline-color: var(--focus-ring-color, var(--ring));
233
235
  outline-offset: var(--focus-ring-offset, 0px);
234
236
  }
235
237
  }
@@ -127,8 +127,9 @@
127
127
  }
128
128
 
129
129
  [data-component="card"]:where(a:focus-visible, button:focus-visible) {
130
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
131
- var(--focus-ring-color, var(--ring));
130
+ outline-width: var(--focus-ring-width, 3px);
131
+ outline-style: var(--focus-ring-style, solid);
132
+ outline-color: var(--focus-ring-color, var(--ring));
132
133
  outline-offset: var(--focus-ring-offset, 0px);
133
134
  }
134
135
  }
@@ -159,6 +159,15 @@
159
159
  @apply disabled:cursor-not-allowed disabled:opacity-50;
160
160
  }
161
161
 
162
+ /* The search field is a real tab stop -- the popover opens onto it -- so the
163
+ `outline-none` above has to be paid back here, like every other field. */
164
+ [data-combobox-part="input"]:where(:focus-visible) {
165
+ outline-width: var(--focus-ring-width, 3px);
166
+ outline-style: var(--focus-ring-style, solid);
167
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
168
+ outline-offset: var(--focus-ring-offset, 0px);
169
+ }
170
+
162
171
  /* ===== Options List ===== */
163
172
  [data-combobox-part="list"] {
164
173
  @apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1;
@@ -172,7 +181,7 @@
172
181
  cursor: default;
173
182
  user-select: none;
174
183
  color: var(--popover-foreground, var(--foreground));
175
- @apply gap-2 py-1.5 text-sm outline-none transition-colors;
184
+ @apply gap-2 py-1.5 text-sm outline-none transition-[color,background-color,border-color,text-decoration-color];
176
185
  border-radius: var(--combobox-item-radius, var(--control-radius, 0.375rem));
177
186
  @apply pl-8 pr-2; /* Space for check icon on left */
178
187
  }
@@ -48,7 +48,7 @@
48
48
  color: var(--foreground);
49
49
  cursor: pointer;
50
50
  text-align: left;
51
- @apply transition-colors duration-150;
51
+ @apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
52
52
  }
53
53
 
54
54
  [data-date-picker-part="trigger"] svg {
@@ -102,8 +102,9 @@
102
102
 
103
103
  [data-date-picker-part="trigger"]:where(:focus-visible) {
104
104
  border-color: var(--ring);
105
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
106
- var(--focus-ring-color, var(--ring));
105
+ outline-width: var(--focus-ring-width, 3px);
106
+ outline-style: var(--focus-ring-style, solid);
107
+ outline-color: var(--focus-ring-color, var(--ring));
107
108
  outline-offset: var(--focus-ring-offset, 0px);
108
109
  }
109
110
 
@@ -112,11 +113,11 @@
112
113
  }
113
114
 
114
115
  /* Error state — the root :has() drives the trigger */
115
- :where([data-component="date-picker"]:has(input:invalid), [data-component="date-picker"]:has(input[aria-invalid="true"])) [data-date-picker-part="trigger"] {
116
- border-color: var(--destructive);
116
+ :where([data-component="date-picker"]:has(input:user-invalid), [data-component="date-picker"]:has(input[aria-invalid="true"])) [data-date-picker-part="trigger"] {
117
+ border-color: var(--destructive-border, var(--destructive));
117
118
  }
118
119
 
119
- :where([data-component="date-picker"]:has(input:invalid), [data-component="date-picker"]:has(input[aria-invalid="true"])) [data-date-picker-part="trigger"]:where(:focus-visible) {
120
+ :where([data-component="date-picker"]:has(input:user-invalid), [data-component="date-picker"]:has(input[aria-invalid="true"])) [data-date-picker-part="trigger"]:where(:focus-visible) {
120
121
  --focus-ring-color: var(--destructive);
121
122
  }
122
123
 
@@ -114,7 +114,7 @@
114
114
  /* ===== Drawer Close Button ===== */
115
115
  [data-drawer-part="close"] {
116
116
  @apply inline-flex items-center justify-center p-1;
117
- @apply transition-colors;
117
+ @apply transition-[color,background-color,border-color,text-decoration-color];
118
118
  border-radius: var(--drawer-close-radius, var(--control-radius, 0.375rem));
119
119
  color: var(--foreground);
120
120
  }
@@ -130,8 +130,9 @@
130
130
 
131
131
  /* outline, not a ring: the drawer panel clips box-shadow rings */
132
132
  [data-drawer-part="close"]:where(:focus-visible) {
133
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
134
- var(--focus-ring-color, var(--ring));
133
+ outline-width: var(--focus-ring-width, 3px);
134
+ outline-style: var(--focus-ring-style, solid);
135
+ outline-color: var(--focus-ring-color, var(--ring));
135
136
  outline-offset: var(--focus-ring-offset, 0px);
136
137
  }
137
138
 
@@ -149,7 +149,7 @@
149
149
  cursor: default;
150
150
  user-select: none;
151
151
  color: var(--popover-foreground, var(--foreground));
152
- @apply gap-2 px-2 py-1.5 text-sm outline-none transition-colors;
152
+ @apply gap-2 px-2 py-1.5 text-sm outline-none transition-[color,background-color,border-color,text-decoration-color];
153
153
  border-radius: var(--dropdown-menu-item-radius, var(--control-radius, 0.375rem));
154
154
  }
155
155
 
@@ -95,7 +95,7 @@
95
95
  @apply border px-3 py-1;
96
96
  @apply text-base md:text-sm;
97
97
  @apply outline-none;
98
- @apply transition-[color,box-shadow,outline-color];
98
+ @apply transition-[color,box-shadow];
99
99
 
100
100
  border-radius: var(--input-radius, var(--control-radius, 0.375rem));
101
101
  background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
@@ -141,8 +141,9 @@
141
141
  /* Focus state — keyboard only. The bare :focus used to ring on mouse click. */
142
142
  [data-component="input"]:where(:focus-visible) {
143
143
  border-color: var(--ring);
144
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
145
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
144
+ outline-width: var(--focus-ring-width, 3px);
145
+ outline-style: var(--focus-ring-style, solid);
146
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
146
147
  outline-offset: var(--focus-ring-offset, 0px);
147
148
  }
148
149
 
@@ -153,11 +154,18 @@
153
154
 
154
155
  /* Invalid/error state — the destructive halo is themeable through
155
156
  --focus-ring-color, which the :focus-visible rule above also reads. */
156
- [data-component="input"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
157
+ [data-component="input"]:where([aria-invalid="true"], :user-invalid) {
157
158
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
158
- border-color: var(--destructive);
159
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
160
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
159
+ border-color: var(--destructive-border, var(--destructive));
160
+ }
161
+
162
+ /* The destructive halo is a focus affordance, not a resting one: painting an
163
+ outline on an unfocused field doubles the border and reads as a second
164
+ error. Gated to match the :focus-visible rule above. */
165
+ [data-component="input"]:where([aria-invalid="true"], :user-invalid):where(:focus-visible) {
166
+ outline-width: var(--focus-ring-width, 3px);
167
+ outline-style: var(--focus-ring-style, solid);
168
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
161
169
  outline-offset: var(--focus-ring-offset, 0px);
162
170
  }
163
171
 
@@ -178,7 +186,7 @@
178
186
  @apply border px-3 py-2;
179
187
  @apply text-base md:text-sm;
180
188
  @apply outline-none resize-y;
181
- @apply transition-[color,box-shadow,outline-color];
189
+ @apply transition-[color,box-shadow];
182
190
 
183
191
  border-radius: var(--textarea-radius, var(--control-radius, 0.375rem));
184
192
  background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
@@ -194,8 +202,9 @@
194
202
  /* ----- States ----- */
195
203
  [data-component="textarea"]:where(:focus-visible) {
196
204
  border-color: var(--ring);
197
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
198
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
205
+ outline-width: var(--focus-ring-width, 3px);
206
+ outline-style: var(--focus-ring-style, solid);
207
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
199
208
  outline-offset: var(--focus-ring-offset, 0px);
200
209
  }
201
210
 
@@ -203,11 +212,18 @@
203
212
  @apply cursor-not-allowed opacity-50;
204
213
  }
205
214
 
206
- [data-component="textarea"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
215
+ [data-component="textarea"]:where([aria-invalid="true"], :user-invalid) {
207
216
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
208
- border-color: var(--destructive);
209
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
210
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
217
+ border-color: var(--destructive-border, var(--destructive));
218
+ }
219
+
220
+ /* The destructive halo is a focus affordance, not a resting one: painting an
221
+ outline on an unfocused field doubles the border and reads as a second
222
+ error. Gated to match the :focus-visible rule above. */
223
+ [data-component="textarea"]:where([aria-invalid="true"], :user-invalid):where(:focus-visible) {
224
+ outline-width: var(--focus-ring-width, 3px);
225
+ outline-style: var(--focus-ring-style, solid);
226
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
211
227
  outline-offset: var(--focus-ring-offset, 0px);
212
228
  }
213
229
 
@@ -220,7 +236,7 @@
220
236
  @apply border px-3 py-1 pr-8;
221
237
  @apply text-base md:text-sm;
222
238
  @apply outline-none appearance-none cursor-pointer;
223
- @apply transition-[color,box-shadow,outline-color];
239
+ @apply transition-[color,box-shadow];
224
240
 
225
241
  border-radius: var(--select-radius, var(--control-radius, 0.375rem));
226
242
  background-color: var(--control-fill, var(--maquina-select-fill-default, var(--background)));
@@ -257,8 +273,9 @@
257
273
  /* ----- States ----- */
258
274
  [data-component="select"]:where(:focus-visible) {
259
275
  border-color: var(--ring);
260
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
261
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
276
+ outline-width: var(--focus-ring-width, 3px);
277
+ outline-style: var(--focus-ring-style, solid);
278
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
262
279
  outline-offset: var(--focus-ring-offset, 0px);
263
280
  }
264
281
 
@@ -268,7 +285,7 @@
268
285
 
269
286
  [data-component="select"]:where([aria-invalid="true"]) {
270
287
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
271
- border-color: var(--destructive);
288
+ border-color: var(--destructive-border, var(--destructive));
272
289
  }
273
290
 
274
291
  /* ===================================================================
@@ -338,8 +355,9 @@
338
355
 
339
356
  [data-component="checkbox"]:where(:focus-visible) {
340
357
  border-color: var(--ring);
341
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
342
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
358
+ outline-width: var(--focus-ring-width, 3px);
359
+ outline-style: var(--focus-ring-style, solid);
360
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
343
361
  outline-offset: var(--focus-ring-offset, 0px);
344
362
  }
345
363
 
@@ -349,9 +367,10 @@
349
367
 
350
368
  [data-component="checkbox"]:where([aria-invalid="true"]) {
351
369
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
352
- border-color: var(--destructive);
353
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
354
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
370
+ border-color: var(--destructive-border, var(--destructive));
371
+ outline-width: var(--focus-ring-width, 3px);
372
+ outline-style: var(--focus-ring-style, solid);
373
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
355
374
  outline-offset: var(--focus-ring-offset, 0px);
356
375
  }
357
376
 
@@ -392,8 +411,9 @@
392
411
 
393
412
  [data-component="radio"]:where(:focus-visible) {
394
413
  border-color: var(--ring);
395
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
396
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
414
+ outline-width: var(--focus-ring-width, 3px);
415
+ outline-style: var(--focus-ring-style, solid);
416
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
397
417
  outline-offset: var(--focus-ring-offset, 0px);
398
418
  }
399
419
 
@@ -435,8 +455,9 @@
435
455
  }
436
456
 
437
457
  [data-component="switch"]:where(:focus-visible) {
438
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
439
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
458
+ outline-width: var(--focus-ring-width, 3px);
459
+ outline-style: var(--focus-ring-style, solid);
460
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
440
461
  outline-offset: var(--focus-ring-offset, 0px);
441
462
  }
442
463
 
@@ -474,7 +495,15 @@
474
495
  @apply h-9 px-4 py-2;
475
496
  @apply cursor-default;
476
497
  @apply outline-none;
477
- @apply transition-colors;
498
+ /* NOT `transition-colors`. Tailwind v4 folds outline-color into that
499
+ utility, so the focus ring would animate from its pre-focus value -- the
500
+ initial `currentColor`, i.e. the control's own text color -- over 150ms.
501
+ On a filled variant that is a near-white ring for the first frames, which
502
+ is no focus indicator at all; it also makes every getComputedStyle read
503
+ taken right after a Tab press report the previous color, which is how
504
+ this was mis-reported as an inert --focus-ring-color token. Keep
505
+ outline-color out of every transition in this engine. */
506
+ @apply transition-[color,background-color,border-color,text-decoration-color];
478
507
  @apply select-none;
479
508
 
480
509
  /* SVG icons inside buttons */
@@ -591,10 +620,20 @@
591
620
  * variant declares, so a single selector suffices and no variant can clobber
592
621
  * it; it also survives forced-colors mode and overflow-hidden ancestors, and
593
622
  * needs no knowledge of the backdrop color.
623
+ *
624
+ * Longhands, not the `outline` shorthand. The shorthand is invalid at
625
+ * computed-value time as a unit: if any one var() fails to resolve, all three
626
+ * longhands reset to their initial values and the ring vanishes entirely --
627
+ * and `outline-color: currentColor` looks plausible on a dark-text control,
628
+ * which is how a missing ring stays unreported. Longhands isolate a failure
629
+ * to the property that actually failed.
630
+ *
631
+ * The base rule must never transition outline-color -- see the note there.
594
632
  */
595
633
  [data-component="button"]:where(:focus-visible) {
596
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
597
- var(--focus-ring-color, var(--ring));
634
+ outline-width: var(--focus-ring-width, 3px);
635
+ outline-style: var(--focus-ring-style, solid);
636
+ outline-color: var(--focus-ring-color, var(--ring));
598
637
  outline-offset: var(--focus-ring-offset, 0px);
599
638
  }
600
639
 
@@ -626,7 +665,11 @@
626
665
  [data-form-part="error"] {
627
666
  @apply text-sm;
628
667
  font-weight: var(--label-weight, 500);
629
- color: var(--destructive-foreground);
668
+ /* Error text sits on the page, not on a destructive fill, so it is the one
669
+ place --destructive-foreground is the wrong role. Which of the pair is
670
+ readable as text depends on the host palette's convention, so it routes
671
+ through its own token: see docs/theming.md. */
672
+ color: var(--destructive-text, var(--destructive-foreground));
630
673
  }
631
674
 
632
675
  /* Rails error wrapper compatibility */
@@ -638,9 +681,15 @@
638
681
  :where(.field_with_errors) [data-component="textarea"],
639
682
  :where(.field_with_errors) [data-component="select"] {
640
683
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
641
- border-color: var(--destructive);
642
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
643
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
684
+ border-color: var(--destructive-border, var(--destructive));
685
+ }
686
+
687
+ :where(.field_with_errors) [data-component="input"]:where(:focus-visible),
688
+ :where(.field_with_errors) [data-component="textarea"]:where(:focus-visible),
689
+ :where(.field_with_errors) [data-component="select"]:where(:focus-visible) {
690
+ outline-width: var(--focus-ring-width, 3px);
691
+ outline-style: var(--focus-ring-style, solid);
692
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
644
693
  outline-offset: var(--focus-ring-offset, 0px);
645
694
  }
646
695
 
@@ -77,8 +77,9 @@
77
77
  }
78
78
 
79
79
  [data-menu-button-part="trigger"]:where(:focus-visible) {
80
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
81
- var(--focus-ring-color, var(--sidebar-ring));
80
+ outline-width: var(--focus-ring-width, 3px);
81
+ outline-style: var(--focus-ring-style, solid);
82
+ outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
82
83
  outline-offset: var(--focus-ring-offset, 0px);
83
84
  }
84
85
 
@@ -58,7 +58,7 @@
58
58
  @apply text-sm;
59
59
  border-radius: var(--pagination-radius, var(--control-radius, 0.375rem));
60
60
  font-weight: var(--label-weight, 500);
61
- @apply transition-colors duration-150;
61
+ @apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
62
62
 
63
63
  /* Size */
64
64
  @apply h-9 min-w-9 px-3;
@@ -136,8 +136,9 @@
136
136
  [data-pagination-part="link"]:where(:focus-visible),
137
137
  [data-pagination-part="previous"]:where(:focus-visible),
138
138
  [data-pagination-part="next"]:where(:focus-visible) {
139
- outline: var(--focus-ring-width, 3px) var(--focus-ring-style, solid)
140
- var(--focus-ring-color, var(--ring));
139
+ outline-width: var(--focus-ring-width, 3px);
140
+ outline-style: var(--focus-ring-style, solid);
141
+ outline-color: var(--focus-ring-color, var(--ring));
141
142
  outline-offset: var(--focus-ring-offset, 0px);
142
143
  }
143
144