maquina-components 0.5.1 → 0.6.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/app/assets/stylesheets/alert.css +169 -135
  4. data/app/assets/stylesheets/badge.css +143 -138
  5. data/app/assets/stylesheets/breadcrumbs.css +183 -166
  6. data/app/assets/stylesheets/calendar.css +219 -206
  7. data/app/assets/stylesheets/card.css +126 -120
  8. data/app/assets/stylesheets/combobox.css +208 -191
  9. data/app/assets/stylesheets/date_picker.css +144 -134
  10. data/app/assets/stylesheets/drawer.css +158 -125
  11. data/app/assets/stylesheets/dropdown_menu.css +203 -190
  12. data/app/assets/stylesheets/empty.css +125 -117
  13. data/app/assets/stylesheets/form.css +625 -525
  14. data/app/assets/stylesheets/header.css +63 -47
  15. data/app/assets/stylesheets/menu_button.css +121 -98
  16. data/app/assets/stylesheets/pagination.css +130 -125
  17. data/app/assets/stylesheets/separator.css +23 -11
  18. data/app/assets/stylesheets/sidebar.css +447 -401
  19. data/app/assets/stylesheets/stats.css +77 -67
  20. data/app/assets/stylesheets/table.css +184 -166
  21. data/app/assets/stylesheets/toast.css +287 -278
  22. data/app/assets/stylesheets/toggle_group.css +132 -139
  23. data/app/assets/tailwind/maquina_components_engine/engine.css +54 -15
  24. data/app/assets/tailwind/maquina_components_engine/tokens.css +101 -0
  25. data/app/helpers/maquina_components/combobox_helper.rb +32 -24
  26. data/app/helpers/maquina_components/components_helper.rb +30 -6
  27. data/app/helpers/maquina_components/dropdown_menu_helper.rb +12 -12
  28. data/app/helpers/maquina_components/icons_helper.rb +26 -2
  29. data/app/javascript/controllers/drawer_trigger_controller.js +24 -3
  30. data/app/views/components/_label.html.erb +14 -0
  31. data/app/views/components/_table.html.erb +2 -2
  32. data/app/views/components/calendar/_week.html.erb +4 -12
  33. data/app/views/components/drawer/_close.html.erb +1 -1
  34. data/app/views/components/drawer/_description.html.erb +6 -0
  35. data/app/views/components/drawer/_header.html.erb +1 -1
  36. data/app/views/components/drawer/_provider.html.erb +13 -4
  37. data/app/views/components/drawer/_section.html.erb +7 -0
  38. data/app/views/components/drawer/_separator.html.erb +11 -0
  39. data/app/views/components/drawer/_title.html.erb +6 -0
  40. data/app/views/components/drawer/_trigger.html.erb +24 -7
  41. data/app/views/components/sidebar/_group_action.html.erb +26 -0
  42. data/app/views/components/sidebar/_menu_action.html.erb +28 -0
  43. data/app/views/components/sidebar/_menu_badge.html.erb +10 -0
  44. data/app/views/components/sidebar/_menu_button.html.erb +3 -2
  45. data/app/views/components/sidebar/_menu_link.html.erb +3 -2
  46. data/app/views/components/sidebar/_separator.html.erb +12 -0
  47. data/lib/generators/maquina_components/install/install_generator.rb +36 -1
  48. data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +5 -0
  49. data/lib/generators/maquina_components/install/templates/shape_state_tokens.css.tt +76 -0
  50. data/lib/generators/maquina_components/install/templates/theme.css.tt +10 -3
  51. data/lib/maquina-components.rb +29 -1
  52. data/lib/maquina_components/doctor.rb +280 -0
  53. data/lib/maquina_components/version.rb +1 -1
  54. data/lib/tasks/maquina_components_tasks.rake +16 -4
  55. metadata +14 -2
@@ -4,614 +4,714 @@
4
4
  Usage: Add data-component="..." to Rails form helpers
5
5
  =================================================================== */
6
6
 
7
- /* ===================================================================
8
- FORM CONTAINER
9
- Usage: <%= form_with model: @user, data: { component: "form" } do |f| %>
10
- =================================================================== */
11
- [data-component="form"] {
12
- @apply grid gap-6;
13
- }
7
+ /* -------------------------------------------------------------------
8
+ Cascade contract (identical in every engine stylesheet)
9
+
10
+ Everything below lives in `@layer components` and every selector is
11
+ flattened to specificity 0,1,0 by pushing variant / size / state /
12
+ ancestor qualifiers into `:where()`. Two consequences, both deliberate:
13
+
14
+ * A caller's Tailwind utility (the `css_classes:` parameter) wins,
15
+ because `@layer utilities` is defined to beat `@layer components`.
16
+ A consumer's own unlayered override CSS wins too, whatever its
17
+ specificity.
18
+ * Nothing here can be resolved by specificity any more, so SOURCE
19
+ ORDER is the only tie-breaker. Rules must be written in this order:
20
+
21
+ 1. base [data-component="x"]
22
+ 2. sizes [data-component="x"]:where([data-size="…"])
23
+ 3. variants [data-component="x"]:where([data-variant="…"])
24
+ 4. states [data-component="x"]:where(:hover, :focus-visible,
25
+ :disabled, [aria-invalid], …)
26
+ 5. dark — none; dark-mode differences are tokens, see
27
+ app/assets/tailwind/maquina_components_engine/tokens.css
28
+
29
+ States come last so a state always beats a variant, which is what
30
+ "state" means. Cross-file order is fixed in engine.css — do not
31
+ re-alphabetize the imports.
32
+ ------------------------------------------------------------------- */
33
+
34
+ @layer components {
35
+ /* ===================================================================
36
+ FORM CONTAINER
37
+ Usage: <%= form_with model: @user, data: { component: "form" } do |f| %>
38
+ =================================================================== */
39
+ [data-component="form"] {
40
+ @apply grid gap-6;
41
+ /* Pack rows at the start so a stretched/height-constrained parent does not
42
+ distribute leftover height between implicit rows. */
43
+ align-content: start;
44
+ }
14
45
 
15
- /* Form Group - wraps label + input + help/error text */
16
- [data-form-part="group"] {
17
- @apply grid gap-2;
18
- }
46
+ /* Form Group - wraps label + input + help/error text */
47
+ [data-form-part="group"] {
48
+ @apply grid gap-2;
49
+ align-content: start;
50
+ }
19
51
 
20
- /* Inline form group (horizontal layout) */
21
- [data-form-part="group"][data-layout="inline"] {
22
- @apply flex items-center gap-3;
23
- }
52
+ /* Inline form group (horizontal layout) */
53
+ [data-form-part="group"]:where([data-layout="inline"]) {
54
+ @apply flex items-center gap-3;
55
+ }
24
56
 
25
- /* ===================================================================
26
- LABEL
27
- Usage: <%= f.label :email, data: { component: "label" } %>
28
- =================================================================== */
29
- [data-component="label"] {
30
- @apply flex items-center gap-2;
31
- @apply text-sm leading-none font-medium select-none;
32
- color: var(--foreground);
33
- }
57
+ /* ===================================================================
58
+ LABEL
59
+ Usage: <%= f.label :email, data: { component: "label" } %>
60
+ =================================================================== */
61
+ [data-component="label"] {
62
+ @apply flex items-center gap-2;
63
+ @apply text-sm leading-none select-none;
64
+ font-weight: var(--label-weight, 500);
65
+ color: var(--foreground);
66
+ }
34
67
 
35
- /* Label disabled state (when used with peer) */
36
- [data-component="label"]:has(+ :disabled),
37
- :disabled + [data-component="label"] {
38
- @apply cursor-not-allowed opacity-50;
39
- }
68
+ /* Required indicator */
69
+ [data-component="label"]:where([data-required])::after {
70
+ content: "*";
71
+ @apply ml-0.5;
72
+ color: var(--destructive);
73
+ }
40
74
 
41
- /* Required indicator */
42
- [data-component="label"][data-required]::after {
43
- content: "*";
44
- @apply ml-0.5;
45
- color: var(--destructive);
46
- }
75
+ /* Label disabled state (when used with peer) */
76
+ [data-component="label"]:where(:has(+ :disabled)),
77
+ :where(:disabled) + [data-component="label"] {
78
+ @apply cursor-not-allowed opacity-50;
79
+ }
47
80
 
48
- /* ===================================================================
49
- INPUT
50
- Usage: <%= f.text_field :email, data: { component: "input" } %>
51
- Matches: h-9 w-full min-w-0 rounded-md border border-input bg-transparent
52
- px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none
53
- md:text-sm placeholder:text-muted-foreground
54
- focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
55
- disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
56
- aria-invalid:ring-destructive/20 aria-invalid:border-destructive
57
- =================================================================== */
58
- [data-component="input"] {
59
- @apply h-9 w-full min-w-0;
60
- @apply rounded-md border px-3 py-1;
61
- @apply text-base md:text-sm;
62
- @apply outline-none;
63
- @apply transition-[color,box-shadow];
64
-
65
- background-color: transparent;
66
- border-color: var(--input);
67
- color: var(--foreground);
68
- box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));
69
- }
81
+ /* ===================================================================
82
+ INPUT
83
+ Usage: <%= f.text_field :email, data: { component: "input" } %>
84
+ Matches: h-9 w-full min-w-0 rounded-md border border-input bg-transparent
85
+ px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none
86
+ md:text-sm placeholder:text-muted-foreground
87
+ focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
88
+ disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
89
+ aria-invalid:ring-destructive/20 aria-invalid:border-destructive
90
+ Shape, focus ring and elevation route through the engine tokens; see
91
+ app/assets/tailwind/maquina_components_engine/tokens.css.
92
+ =================================================================== */
93
+ [data-component="input"] {
94
+ @apply h-9 w-full min-w-0;
95
+ @apply border px-3 py-1;
96
+ @apply text-base md:text-sm;
97
+ @apply outline-none;
98
+ @apply transition-[color,box-shadow,outline-color];
99
+
100
+ border-radius: var(--input-radius, var(--control-radius, 0.375rem));
101
+ background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
102
+ border-color: var(--input);
103
+ color: var(--foreground);
104
+ box-shadow: var(--elevation-control, var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)));
105
+ }
70
106
 
71
- /* Placeholder */
72
- [data-component="input"]::placeholder {
73
- color: var(--muted-foreground);
74
- }
107
+ /* Placeholder */
108
+ [data-component="input"]::placeholder {
109
+ color: var(--muted-foreground);
110
+ }
75
111
 
76
- /* Selection */
77
- [data-component="input"]::selection {
78
- background-color: var(--primary);
79
- color: var(--primary-foreground);
80
- }
112
+ /* Selection */
113
+ [data-component="input"]::selection {
114
+ background-color: var(--primary);
115
+ color: var(--primary-foreground);
116
+ }
81
117
 
82
- /* Focus state */
83
- [data-component="input"]:focus,
84
- [data-component="input"]:focus-visible {
85
- border-color: var(--ring);
86
- box-shadow:
87
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
88
- 0 0 0 3px color-mix(in oklch, var(--ring) 50%, transparent);
89
- }
118
+ /* ----- Sizes ----- */
119
+ [data-component="input"]:where([data-size="sm"]) {
120
+ @apply h-8 px-2.5 text-xs;
121
+ }
90
122
 
91
- /* Disabled state */
92
- [data-component="input"]:disabled {
93
- @apply pointer-events-none cursor-not-allowed opacity-50;
94
- }
123
+ [data-component="input"]:where([data-size="lg"]) {
124
+ @apply h-11 px-4 text-base;
125
+ }
95
126
 
96
- /* Invalid/error state */
97
- [data-component="input"][aria-invalid="true"],
98
- [data-component="input"]:invalid:not(:placeholder-shown) {
99
- border-color: var(--destructive);
100
- box-shadow:
101
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
102
- 0 0 0 3px color-mix(in oklch, var(--destructive) 20%, transparent);
103
- }
127
+ /* ----- Type variants ----- */
128
+ [data-component="input"]:where([type="file"]) {
129
+ @apply py-1.5;
130
+ }
104
131
 
105
- /* File input */
106
- [data-component="input"][type="file"] {
107
- @apply py-1.5;
108
- }
132
+ [data-component="input"]:where([type="file"])::file-selector-button {
133
+ @apply inline-flex h-7 items-center border-0 bg-transparent;
134
+ @apply text-sm;
135
+ font-weight: var(--label-weight, 500);
136
+ color: var(--foreground);
137
+ }
109
138
 
110
- [data-component="input"][type="file"]::file-selector-button {
111
- @apply inline-flex h-7 items-center border-0 bg-transparent;
112
- @apply text-sm font-medium;
113
- color: var(--foreground);
114
- }
139
+ /* ----- States ----- */
115
140
 
116
- /* Input sizes */
117
- [data-component="input"][data-size="sm"] {
118
- @apply h-8 px-2.5 text-xs;
119
- }
141
+ /* Focus state — keyboard only. The bare :focus used to ring on mouse click. */
142
+ [data-component="input"]:where(:focus-visible) {
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));
146
+ outline-offset: var(--focus-ring-offset, 0px);
147
+ }
120
148
 
121
- [data-component="input"][data-size="lg"] {
122
- @apply h-11 px-4 text-base;
123
- }
149
+ /* Disabled state */
150
+ [data-component="input"]:where(:disabled) {
151
+ @apply pointer-events-none cursor-not-allowed opacity-50;
152
+ }
124
153
 
125
- /* Dark mode */
126
- .dark [data-component="input"] {
127
- background-color: color-mix(in oklch, var(--input) 30%, transparent);
128
- }
154
+ /* Invalid/error state — the destructive halo is themeable through
155
+ --focus-ring-color, which the :focus-visible rule above also reads. */
156
+ [data-component="input"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
157
+ --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));
161
+ outline-offset: var(--focus-ring-offset, 0px);
162
+ }
129
163
 
130
- /* ===================================================================
131
- TEXTAREA
132
- Usage: <%= f.text_area :bio, data: { component: "textarea" } %>
133
- Matches: min-h-[60px] w-full rounded-md border border-input bg-transparent
134
- px-3 py-2 text-base shadow-xs outline-none md:text-sm
135
- placeholder:text-muted-foreground
136
- focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
137
- disabled:cursor-not-allowed disabled:opacity-50
138
- aria-invalid:ring-destructive/20 aria-invalid:border-destructive
139
- =================================================================== */
140
- [data-component="textarea"] {
141
- @apply w-full min-w-0 min-h-16;
142
- @apply rounded-md border px-3 py-2;
143
- @apply text-base md:text-sm;
144
- @apply outline-none resize-y;
145
- @apply transition-[color,box-shadow];
146
-
147
- background-color: transparent;
148
- border-color: var(--input);
149
- color: var(--foreground);
150
- box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));
151
- }
164
+ /* ===================================================================
165
+ TEXTAREA
166
+ Usage: <%= f.text_area :bio, data: { component: "textarea" } %>
167
+ Matches: min-h-[60px] w-full rounded-md border border-input bg-transparent
168
+ px-3 py-2 text-base shadow-xs outline-none md:text-sm
169
+ placeholder:text-muted-foreground
170
+ focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
171
+ disabled:cursor-not-allowed disabled:opacity-50
172
+ aria-invalid:ring-destructive/20 aria-invalid:border-destructive
173
+ Shape, focus ring and elevation route through the engine tokens; see
174
+ app/assets/tailwind/maquina_components_engine/tokens.css.
175
+ =================================================================== */
176
+ [data-component="textarea"] {
177
+ @apply w-full min-w-0 min-h-16;
178
+ @apply border px-3 py-2;
179
+ @apply text-base md:text-sm;
180
+ @apply outline-none resize-y;
181
+ @apply transition-[color,box-shadow,outline-color];
182
+
183
+ border-radius: var(--textarea-radius, var(--control-radius, 0.375rem));
184
+ background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
185
+ border-color: var(--input);
186
+ color: var(--foreground);
187
+ box-shadow: var(--elevation-control, var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)));
188
+ }
152
189
 
153
- [data-component="textarea"]::placeholder {
154
- color: var(--muted-foreground);
155
- }
190
+ [data-component="textarea"]::placeholder {
191
+ color: var(--muted-foreground);
192
+ }
156
193
 
157
- [data-component="textarea"]:focus,
158
- [data-component="textarea"]:focus-visible {
159
- border-color: var(--ring);
160
- box-shadow:
161
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
162
- 0 0 0 3px color-mix(in oklch, var(--ring) 50%, transparent);
163
- }
194
+ /* ----- States ----- */
195
+ [data-component="textarea"]:where(:focus-visible) {
196
+ 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));
199
+ outline-offset: var(--focus-ring-offset, 0px);
200
+ }
164
201
 
165
- [data-component="textarea"]:disabled {
166
- @apply cursor-not-allowed opacity-50;
167
- }
202
+ [data-component="textarea"]:where(:disabled) {
203
+ @apply cursor-not-allowed opacity-50;
204
+ }
168
205
 
169
- [data-component="textarea"][aria-invalid="true"],
170
- [data-component="textarea"]:invalid:not(:placeholder-shown) {
171
- border-color: var(--destructive);
172
- box-shadow:
173
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
174
- 0 0 0 3px color-mix(in oklch, var(--destructive) 20%, transparent);
175
- }
206
+ [data-component="textarea"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
207
+ --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));
211
+ outline-offset: var(--focus-ring-offset, 0px);
212
+ }
176
213
 
177
- /* Dark mode */
178
- .dark [data-component="textarea"] {
179
- background-color: color-mix(in oklch, var(--input) 30%, transparent);
180
- }
214
+ /* ===================================================================
215
+ SELECT (Native)
216
+ Usage: <%= f.select :country, options, {}, data: { component: "select" } %>
217
+ =================================================================== */
218
+ [data-component="select"] {
219
+ @apply h-9 w-full min-w-0;
220
+ @apply border px-3 py-1 pr-8;
221
+ @apply text-base md:text-sm;
222
+ @apply outline-none appearance-none cursor-pointer;
223
+ @apply transition-[color,box-shadow,outline-color];
224
+
225
+ border-radius: var(--select-radius, var(--control-radius, 0.375rem));
226
+ background-color: var(--control-fill, var(--maquina-select-fill-default, var(--background)));
227
+ border-color: var(--input);
228
+ color: var(--foreground);
229
+ box-shadow: var(--elevation-control, var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)));
230
+
231
+ /* Chevron icon.
232
+ var() cannot be interpolated into url() and a data URI is a separate SVG
233
+ document, so currentColor never resolves inside it — the whole image is
234
+ exposed as an overridable property instead, with ready-made second and
235
+ third inks below. */
236
+ background-image: var(--select-chevron-image,
237
+ var(--maquina-select-chevron-default,
238
+ url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")));
239
+ background-position: right 0.5rem center;
240
+ background-repeat: no-repeat;
241
+ background-size: 1.25rem 1.25rem;
242
+ }
181
243
 
182
- /* ===================================================================
183
- SELECT (Native)
184
- Usage: <%= f.select :country, options, {}, data: { component: "select" } %>
185
- =================================================================== */
186
- [data-component="select"] {
187
- @apply h-9 w-full min-w-0;
188
- @apply rounded-md border px-3 py-1 pr-8;
189
- @apply text-base md:text-sm;
190
- @apply outline-none appearance-none cursor-pointer;
191
- @apply transition-[color,box-shadow];
192
-
193
- background-color: var(--background);
194
- border-color: var(--input);
195
- color: var(--foreground);
196
- box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));
197
-
198
- /* Chevron icon */
199
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
200
- background-position: right 0.5rem center;
201
- background-repeat: no-repeat;
202
- background-size: 1.25rem 1.25rem;
203
- }
244
+ /* ----- Mark inks -----
245
+ Declared on the control, so they beat both the engine's inherited default
246
+ and a consumer's :root override an explicit opt-in should win over a
247
+ global default. Works in dark mode too, now that the dark ink is an
248
+ inherited default rather than a rule on the control. */
249
+ [data-component="select"]:where([data-mark="dark"]) {
250
+ --select-chevron-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23000000' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
251
+ }
204
252
 
205
- [data-component="select"]:focus,
206
- [data-component="select"]:focus-visible {
207
- border-color: var(--ring);
208
- box-shadow:
209
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
210
- 0 0 0 3px color-mix(in oklch, var(--ring) 50%, transparent);
211
- }
253
+ [data-component="select"]:where([data-mark="light"]) {
254
+ --select-chevron-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
255
+ }
212
256
 
213
- [data-component="select"]:disabled {
214
- @apply pointer-events-none cursor-not-allowed opacity-50;
215
- }
257
+ /* ----- States ----- */
258
+ [data-component="select"]:where(:focus-visible) {
259
+ 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));
262
+ outline-offset: var(--focus-ring-offset, 0px);
263
+ }
216
264
 
217
- [data-component="select"][aria-invalid="true"] {
218
- border-color: var(--destructive);
219
- }
265
+ [data-component="select"]:where(:disabled) {
266
+ @apply pointer-events-none cursor-not-allowed opacity-50;
267
+ }
220
268
 
221
- /* Dark mode */
222
- .dark [data-component="select"] {
223
- background-color: color-mix(in oklch, var(--input) 30%, transparent);
224
- }
269
+ [data-component="select"]:where([aria-invalid="true"]) {
270
+ --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
271
+ border-color: var(--destructive);
272
+ }
225
273
 
226
- /* ===================================================================
227
- CHECKBOX
228
- Usage: <%= f.check_box :terms, data: { component: "checkbox" } %>
229
- Matches: peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs
230
- transition-shadow outline-none
231
- data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground
232
- data-[state=checked]:border-primary
233
- focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
234
- disabled:cursor-not-allowed disabled:opacity-50
235
- aria-invalid:ring-destructive/20 aria-invalid:border-destructive
236
- =================================================================== */
237
- [data-component="checkbox"] {
238
- @apply size-4 shrink-0;
239
- @apply rounded-[4px] border;
240
- @apply cursor-pointer appearance-none;
241
- @apply transition-shadow;
242
-
243
- border-color: var(--input);
244
- background-color: transparent;
245
- box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));
246
- }
274
+ /* ===================================================================
275
+ CHECKBOX
276
+ Usage: <%= f.check_box :terms, data: { component: "checkbox" } %>
277
+ Matches: peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs
278
+ transition-shadow outline-none
279
+ data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground
280
+ data-[state=checked]:border-primary
281
+ focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]
282
+ disabled:cursor-not-allowed disabled:opacity-50
283
+ aria-invalid:ring-destructive/20 aria-invalid:border-destructive
284
+ Shape, focus ring and elevation route through the engine tokens; see
285
+ app/assets/tailwind/maquina_components_engine/tokens.css.
286
+ =================================================================== */
287
+ [data-component="checkbox"] {
288
+ @apply size-4 shrink-0;
289
+ @apply border;
290
+ @apply cursor-pointer appearance-none;
291
+ @apply transition-shadow;
292
+
293
+ border-radius: var(--checkbox-radius, var(--mark-radius, 4px));
294
+ border-color: var(--input);
295
+ background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
296
+ box-shadow: var(--elevation-control, var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)));
297
+ }
247
298
 
248
- [data-component="checkbox"]:focus,
249
- [data-component="checkbox"]:focus-visible {
250
- @apply outline-none;
251
- border-color: var(--ring);
252
- box-shadow:
253
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
254
- 0 0 0 3px color-mix(in oklch, var(--ring) 50%, transparent);
255
- }
299
+ /* Dark ink — one attribute fixes a light --primary */
300
+ [data-component="checkbox"]:where([data-mark="dark"]) {
301
+ --checkbox-mark-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23000000' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
302
+ --checkbox-indeterminate-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23000000' xmlns='http://www.w3.org/2000/svg'%3e%3crect x='3' y='7' width='10' height='2' rx='1'/%3e%3c/svg%3e");
303
+ }
256
304
 
257
- [data-component="checkbox"]:checked {
258
- background-color: var(--primary);
259
- border-color: var(--primary);
305
+ /* ----- States ----- */
306
+ [data-component="checkbox"]:where(:checked) {
307
+ background-color: var(--primary);
308
+ border-color: var(--primary);
309
+
310
+ /* Checkmark icon.
311
+ Marks are overridable images, not masks: mask-image would mask the whole
312
+ element (box, border and shadow included), appearance:none inputs cannot
313
+ carry ::before/::after reliably, and neither currentColor nor var()
314
+ resolves inside a data URI. A light --primary makes the white ink measure
315
+ 1.15:1, so ship a second ink rather than baking one in.
316
+
317
+ The default is the fallback rather than a declaration on the control,
318
+ because an element's own custom property beats an inherited one — so
319
+ declaring it here would put it out of reach of :root. Setting it at :root
320
+ retints every control; the data-mark attributes below still win, since an
321
+ explicit opt-in should beat a global default. */
322
+ background-image: var(--checkbox-mark-image,
323
+ url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"));
324
+ background-position: center;
325
+ background-repeat: no-repeat;
326
+ background-size: 100% 100%;
327
+ }
260
328
 
261
- /* Checkmark icon */
262
- background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
263
- background-position: center;
264
- background-repeat: no-repeat;
265
- background-size: 100% 100%;
266
- }
329
+ [data-component="checkbox"]:where(:indeterminate) {
330
+ background-color: var(--primary);
331
+ border-color: var(--primary);
332
+ background-image: var(--checkbox-indeterminate-image,
333
+ url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3crect x='3' y='7' width='10' height='2' rx='1'/%3e%3c/svg%3e"));
334
+ background-position: center;
335
+ background-repeat: no-repeat;
336
+ background-size: 100% 100%;
337
+ }
267
338
 
268
- [data-component="checkbox"]:disabled {
269
- @apply cursor-not-allowed opacity-50;
270
- }
339
+ [data-component="checkbox"]:where(:focus-visible) {
340
+ 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));
343
+ outline-offset: var(--focus-ring-offset, 0px);
344
+ }
271
345
 
272
- [data-component="checkbox"][aria-invalid="true"] {
273
- border-color: var(--destructive);
274
- box-shadow:
275
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
276
- 0 0 0 3px color-mix(in oklch, var(--destructive) 20%, transparent);
277
- }
346
+ [data-component="checkbox"]:where(:disabled) {
347
+ @apply cursor-not-allowed opacity-50;
348
+ }
278
349
 
279
- [data-component="checkbox"]:indeterminate {
280
- background-color: var(--primary);
281
- border-color: var(--primary);
282
- background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3crect x='3' y='7' width='10' height='2' rx='1'/%3e%3c/svg%3e");
283
- background-position: center;
284
- background-repeat: no-repeat;
285
- background-size: 100% 100%;
286
- }
350
+ [data-component="checkbox"]:where([aria-invalid="true"]) {
351
+ --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));
355
+ outline-offset: var(--focus-ring-offset, 0px);
356
+ }
287
357
 
288
- /* Dark mode */
289
- .dark [data-component="checkbox"]:not(:checked) {
290
- background-color: color-mix(in oklch, var(--input) 30%, transparent);
291
- }
358
+ /* ===================================================================
359
+ RADIO
360
+ Usage: <%= f.radio_button :plan, "basic", data: { component: "radio" } %>
361
+ =================================================================== */
362
+ [data-component="radio"] {
363
+ @apply size-4 shrink-0;
364
+ @apply border;
365
+ @apply cursor-pointer appearance-none;
366
+ @apply transition-shadow;
367
+
368
+ border-radius: var(--radio-radius, var(--pill-radius, calc(infinity * 1px)));
369
+ border-color: var(--input);
370
+ background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
371
+ box-shadow: var(--elevation-control, var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)));
372
+
373
+ /* Inner dot — overridable image, see the checkbox note above */
374
+ }
292
375
 
293
- /* ===================================================================
294
- RADIO
295
- Usage: <%= f.radio_button :plan, "basic", data: { component: "radio" } %>
296
- =================================================================== */
297
- [data-component="radio"] {
298
- @apply size-4 shrink-0;
299
- @apply rounded-full border;
300
- @apply cursor-pointer appearance-none;
301
- @apply transition-shadow;
302
-
303
- border-color: var(--input);
304
- background-color: transparent;
305
- box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));
306
- }
376
+ [data-component="radio"]:where([data-mark="dark"]) {
377
+ --radio-mark-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23000000' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
378
+ }
307
379
 
308
- [data-component="radio"]:focus,
309
- [data-component="radio"]:focus-visible {
310
- @apply outline-none;
311
- border-color: var(--ring);
312
- box-shadow:
313
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
314
- 0 0 0 3px color-mix(in oklch, var(--ring) 50%, transparent);
315
- }
380
+ /* ----- States ----- */
381
+ [data-component="radio"]:where(:checked) {
382
+ border-color: var(--primary);
383
+ background-color: var(--primary);
384
+
385
+ /* Inner dot */
386
+ background-image: var(--radio-mark-image,
387
+ url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23ffffff' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"));
388
+ background-position: center;
389
+ background-repeat: no-repeat;
390
+ background-size: 100% 100%;
391
+ }
316
392
 
317
- [data-component="radio"]:checked {
318
- border-color: var(--primary);
319
- background-color: var(--primary);
393
+ [data-component="radio"]:where(:focus-visible) {
394
+ 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));
397
+ outline-offset: var(--focus-ring-offset, 0px);
398
+ }
320
399
 
321
- /* Inner dot */
322
- background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
323
- background-position: center;
324
- background-repeat: no-repeat;
325
- background-size: 100% 100%;
326
- }
400
+ [data-component="radio"]:where(:disabled) {
401
+ @apply cursor-not-allowed opacity-50;
402
+ }
327
403
 
328
- [data-component="radio"]:disabled {
329
- @apply cursor-not-allowed opacity-50;
330
- }
404
+ /* ===================================================================
405
+ SWITCH (Toggle)
406
+ Usage: <%= f.check_box :notifications, data: { component: "switch" } %>
407
+ =================================================================== */
408
+ [data-component="switch"] {
409
+ @apply h-5 w-9 shrink-0;
410
+ @apply border-2 border-transparent;
411
+ @apply cursor-pointer appearance-none;
412
+ @apply transition-all duration-200;
413
+
414
+ border-radius: var(--switch-radius, var(--pill-radius, calc(infinity * 1px)));
415
+ background-color: var(--input);
416
+
417
+ /* Thumb — overridable image, see the checkbox note above.
418
+ The thumb slides via background-position, so these must stay longhands:
419
+ the background shorthand would reset the position on every state change. */
420
+ background-image: var(--switch-thumb-image,
421
+ url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='8' fill='%23ffffff'/%3e%3c/svg%3e"));
422
+ background-position: left 2px center;
423
+ background-repeat: no-repeat;
424
+ background-size: 16px 16px;
425
+ }
331
426
 
332
- /* Dark mode */
333
- .dark [data-component="radio"]:not(:checked) {
334
- background-color: color-mix(in oklch, var(--input) 30%, transparent);
335
- }
427
+ [data-component="switch"]:where([data-mark="dark"]) {
428
+ --switch-thumb-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='8' fill='%23000000'/%3e%3c/svg%3e");
429
+ }
336
430
 
337
- /* ===================================================================
338
- SWITCH (Toggle)
339
- Usage: <%= f.check_box :notifications, data: { component: "switch" } %>
340
- =================================================================== */
341
- [data-component="switch"] {
342
- @apply h-5 w-9 shrink-0;
343
- @apply rounded-full border-2 border-transparent;
344
- @apply cursor-pointer appearance-none;
345
- @apply transition-all duration-200;
346
-
347
- background-color: var(--input);
348
-
349
- /* Thumb */
350
- background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='8' fill='white'/%3e%3c/svg%3e");
351
- background-position: left 2px center;
352
- background-repeat: no-repeat;
353
- background-size: 16px 16px;
354
- }
431
+ /* ----- States ----- */
432
+ [data-component="switch"]:where(:checked) {
433
+ background-color: var(--primary);
434
+ background-position: right 2px center;
435
+ }
355
436
 
356
- [data-component="switch"]:focus,
357
- [data-component="switch"]:focus-visible {
358
- @apply outline-none;
359
- box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 50%, transparent);
360
- }
437
+ [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));
440
+ outline-offset: var(--focus-ring-offset, 0px);
441
+ }
361
442
 
362
- [data-component="switch"]:checked {
363
- background-color: var(--primary);
364
- background-position: right 2px center;
365
- }
443
+ [data-component="switch"]:where(:disabled) {
444
+ @apply cursor-not-allowed opacity-50;
445
+ }
366
446
 
367
- [data-component="switch"]:disabled {
368
- @apply cursor-not-allowed opacity-50;
369
- }
447
+ /* ===================================================================
448
+ BUTTON
449
+ Usage:
450
+ <%= f.submit "Save", data: { component: "button", variant: "primary" } %>
451
+ <%= button_to "Delete", path, data: { component: "button", variant: "destructive" } %>
452
+ <%= link_to "Cancel", path, data: { component: "button", variant: "outline" } %>
453
+ Matches: inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md
454
+ text-sm font-medium transition-colors outline-none
455
+ focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
456
+ disabled:pointer-events-none disabled:opacity-50
457
+ [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0
458
+ Shape, focus ring and elevation route through the engine tokens; see
459
+ app/assets/tailwind/maquina_components_engine/tokens.css.
460
+ =================================================================== */
461
+
462
+ /* ----- Base: every button, no exceptions -----
463
+ The un-varianted default treatment used to be folded into this selector,
464
+ which meant a theme's own `[data-component="button"]` rule at 0,1,0 beat
465
+ un-varianted buttons but lost to every explicitly-varianted one. The
466
+ default treatment now lives with the other variants, keyed off
467
+ :not([data-variant]). */
468
+ [data-component="button"] {
469
+ @apply inline-flex items-center justify-center gap-2;
470
+ @apply whitespace-nowrap;
471
+ border-radius: var(--button-radius, var(--control-radius, 0.375rem));
472
+ @apply text-sm;
473
+ font-weight: var(--label-weight, 500);
474
+ @apply h-9 px-4 py-2;
475
+ @apply cursor-default;
476
+ @apply outline-none;
477
+ @apply transition-colors;
478
+ @apply select-none;
479
+
480
+ /* SVG icons inside buttons */
481
+ & svg {
482
+ @apply pointer-events-none size-4 shrink-0;
483
+ }
484
+ }
370
485
 
371
- /* ===================================================================
372
- BUTTON
373
- Usage:
374
- <%= f.submit "Save", data: { component: "button", variant: "primary" } %>
375
- <%= button_to "Delete", path, data: { component: "button", variant: "destructive" } %>
376
- <%= link_to "Cancel", path, data: { component: "button", variant: "outline" } %>
377
- Matches: inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md
378
- text-sm font-medium transition-colors outline-none
379
- focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
380
- disabled:pointer-events-none disabled:opacity-50
381
- [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0
382
- =================================================================== */
486
+ /* ----- Sizes ----- */
383
487
 
384
- /* Base button styles */
385
- [data-component="button"] {
386
- @apply inline-flex items-center justify-center gap-2;
387
- @apply whitespace-nowrap rounded-md;
388
- @apply text-sm font-medium;
389
- @apply h-9 px-4 py-2;
390
- @apply cursor-default;
391
- @apply outline-none;
392
- @apply transition-colors;
393
- @apply select-none;
394
-
395
- /* SVG icons inside buttons */
396
- & svg {
397
- @apply pointer-events-none size-4 shrink-0;
488
+ [data-component="button"]:where([data-size="sm"]) {
489
+ @apply h-8 px-3 text-xs;
398
490
  }
399
- }
400
-
401
- /* Disabled state - applies to all variants */
402
- [data-component="button"]:disabled,
403
- [data-component="button"][aria-disabled="true"] {
404
- @apply pointer-events-none opacity-50;
405
- }
406
491
 
407
- /* Focus ring - applies to all variants */
408
- [data-component="button"]:focus-visible {
409
- box-shadow:
410
- 0 0 0 2px var(--background),
411
- 0 0 0 4px var(--ring);
412
- }
492
+ [data-component="button"]:where([data-size="lg"]) {
493
+ @apply h-10 px-8;
494
+ }
413
495
 
414
- /* ----- Variants ----- */
496
+ [data-component="button"]:where([data-size="icon"]) {
497
+ @apply size-9 p-0;
498
+ }
415
499
 
416
- /* Default (Primary) */
417
- [data-component="button"],
418
- [data-component="button"][data-variant="default"],
419
- [data-component="button"][data-variant="primary"] {
420
- background-color: var(--primary);
421
- color: var(--primary-foreground);
422
- box-shadow: var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05));
423
- }
500
+ [data-component="button"]:where([data-size="icon-sm"]) {
501
+ @apply size-8 p-0;
502
+ }
424
503
 
425
- [data-component="button"]:hover,
426
- [data-component="button"][data-variant="default"]:hover,
427
- [data-component="button"][data-variant="primary"]:hover {
428
- background-color: color-mix(in oklch, var(--primary) 90%, black);
429
- }
504
+ [data-component="button"]:where([data-size="icon-lg"]) {
505
+ @apply size-10 p-0;
506
+ }
430
507
 
431
- /* Destructive */
432
- [data-component="button"][data-variant="destructive"] {
433
- background-color: var(--destructive);
434
- color: var(--destructive-foreground);
435
- box-shadow: var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05));
436
- }
508
+ /* ----- Variants ----- */
437
509
 
438
- [data-component="button"][data-variant="destructive"]:hover {
439
- background-color: color-mix(in oklch, var(--destructive) 90%, black);
440
- }
510
+ /* Default (Primary) — :not([data-variant]) covers the un-varianted case */
511
+ [data-component="button"]:where(:not([data-variant]), [data-variant="default"], [data-variant="primary"]) {
512
+ background-color: var(--primary);
513
+ color: var(--primary-foreground);
514
+ box-shadow: var(--elevation-raised, var(--shadow-sm, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)));
515
+ }
441
516
 
442
- [data-component="button"][data-variant="destructive"]:focus-visible {
443
- box-shadow:
444
- 0 0 0 2px var(--background),
445
- 0 0 0 4px var(--destructive);
446
- }
517
+ /* Destructive */
518
+ [data-component="button"]:where([data-variant="destructive"]) {
519
+ background-color: var(--destructive);
520
+ color: var(--destructive-foreground);
521
+ box-shadow: var(--elevation-raised, var(--shadow-sm, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)));
522
+ }
447
523
 
448
- /* Outline */
449
- [data-component="button"][data-variant="outline"] {
450
- background-color: var(--background);
451
- color: var(--foreground);
452
- border: 1px solid var(--input);
453
- box-shadow: var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05));
454
- }
524
+ /* Outline */
525
+ [data-component="button"]:where([data-variant="outline"]) {
526
+ /* transparent, not --background: an outline button has to work on top of a
527
+ card or popover, where painting the page background makes it a light box */
528
+ background-color: transparent;
529
+ color: var(--foreground);
530
+ border: 1px solid var(--input);
531
+ box-shadow: var(--elevation-raised, var(--shadow-sm, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)));
532
+ }
455
533
 
456
- [data-component="button"][data-variant="outline"]:hover {
457
- background-color: var(--accent);
458
- color: var(--accent-foreground);
459
- }
534
+ /* Secondary */
535
+ [data-component="button"]:where([data-variant="secondary"]) {
536
+ background-color: var(--secondary);
537
+ color: var(--secondary-foreground);
538
+ box-shadow: var(--elevation-raised, var(--shadow-sm, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)));
539
+ }
460
540
 
461
- /* Secondary */
462
- [data-component="button"][data-variant="secondary"] {
463
- background-color: var(--secondary);
464
- color: var(--secondary-foreground);
465
- box-shadow: var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05));
466
- }
541
+ /* Ghost */
542
+ [data-component="button"]:where([data-variant="ghost"]) {
543
+ background-color: transparent;
544
+ color: var(--foreground);
545
+ box-shadow: var(--elevation-none, none);
546
+ }
467
547
 
468
- [data-component="button"][data-variant="secondary"]:hover {
469
- background-color: color-mix(in oklch, var(--secondary) 80%, black);
470
- }
548
+ /* Link — deliberately after the sizes: a link button has no box, so it must
549
+ win over the height and padding of whatever size it was given */
550
+ [data-component="button"]:where([data-variant="link"]) {
551
+ background-color: transparent;
552
+ color: var(--primary);
553
+ text-decoration-line: underline;
554
+ text-underline-offset: 4px;
555
+ box-shadow: var(--elevation-none, none);
556
+ @apply h-auto px-0 py-0;
557
+ }
471
558
 
472
- /* Ghost */
473
- [data-component="button"][data-variant="ghost"] {
474
- background-color: transparent;
475
- color: var(--foreground);
476
- box-shadow: none;
477
- }
559
+ /* ----- States ----- */
478
560
 
479
- [data-component="button"][data-variant="ghost"]:hover {
480
- background-color: var(--accent);
481
- color: var(--accent-foreground);
482
- }
561
+ [data-component="button"]:where(:not([data-variant]):hover, [data-variant="default"]:hover, [data-variant="primary"]:hover) {
562
+ /* Tokenize the outcome, not the recipe: darkening is wrong in dark mode,
563
+ where --primary is already light. */
564
+ background-color: var(--primary-hover, color-mix(in oklch, var(--primary) 90%, black));
565
+ }
483
566
 
484
- /* Link */
485
- [data-component="button"][data-variant="link"] {
486
- background-color: transparent;
487
- color: var(--primary);
488
- text-decoration-line: underline;
489
- text-underline-offset: 4px;
490
- box-shadow: none;
491
- @apply h-auto px-0 py-0;
492
- }
567
+ [data-component="button"]:where([data-variant="destructive"]:hover) {
568
+ background-color: var(--destructive-hover, color-mix(in oklch, var(--destructive) 90%, black));
569
+ }
493
570
 
494
- [data-component="button"][data-variant="link"]:hover {
495
- text-decoration-line: underline;
496
- }
571
+ [data-component="button"]:where([data-variant="outline"]:hover) {
572
+ background-color: var(--accent-hover, var(--accent));
573
+ color: var(--accent-foreground);
574
+ }
497
575
 
498
- /* ----- Sizes ----- */
576
+ [data-component="button"]:where([data-variant="secondary"]:hover) {
577
+ background-color: var(--secondary-hover, color-mix(in oklch, var(--secondary) 80%, black));
578
+ }
499
579
 
500
- [data-component="button"][data-size="sm"] {
501
- @apply h-8 px-3 text-xs rounded-md;
502
- }
580
+ [data-component="button"]:where([data-variant="ghost"]:hover) {
581
+ background-color: var(--accent-hover, var(--accent));
582
+ color: var(--accent-foreground);
583
+ }
503
584
 
504
- [data-component="button"][data-size="lg"] {
505
- @apply h-10 px-8 rounded-md;
506
- }
585
+ [data-component="button"]:where([data-variant="link"]:hover) {
586
+ text-decoration-line: underline;
587
+ }
507
588
 
508
- [data-component="button"][data-size="icon"] {
509
- @apply size-9 p-0;
510
- }
589
+ /* Focus ring.
590
+ * outline, not box-shadow. The ring no longer collides with the elevation each
591
+ * variant declares, so a single selector suffices and no variant can clobber
592
+ * it; it also survives forced-colors mode and overflow-hidden ancestors, and
593
+ * needs no knowledge of the backdrop color.
594
+ */
595
+ [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));
598
+ outline-offset: var(--focus-ring-offset, 0px);
599
+ }
511
600
 
512
- [data-component="button"][data-size="icon-sm"] {
513
- @apply size-8 p-0;
514
- }
601
+ /* Destructive keeps its own red ring. This only sets a custom property, so it
602
+ competes with nothing but other --focus-ring-color declarations; the shared
603
+ outline above resolves it at computed-value time. */
604
+ [data-component="button"]:where([data-variant="destructive"]:focus-visible) {
605
+ --focus-ring-color: var(--destructive);
606
+ }
515
607
 
516
- [data-component="button"][data-size="icon-lg"] {
517
- @apply size-10 p-0;
518
- }
608
+ /* Disabled - applies to all variants */
609
+ [data-component="button"]:where(:disabled, [aria-disabled="true"]) {
610
+ @apply pointer-events-none opacity-50;
611
+ }
519
612
 
520
- /* ===================================================================
521
- HELP TEXT / DESCRIPTION
522
- Usage: <p data-form-part="description">We'll never share your email.</p>
523
- =================================================================== */
524
- [data-form-part="description"] {
525
- @apply text-sm;
526
- color: var(--muted-foreground);
527
- }
613
+ /* ===================================================================
614
+ HELP TEXT / DESCRIPTION
615
+ Usage: <p data-form-part="description">We'll never share your email.</p>
616
+ =================================================================== */
617
+ [data-form-part="description"] {
618
+ @apply text-sm;
619
+ color: var(--muted-foreground);
620
+ }
528
621
 
529
- /* ===================================================================
530
- ERROR TEXT / MESSAGE
531
- Usage: <p data-form-part="error">Email is required</p>
532
- =================================================================== */
533
- [data-form-part="error"] {
534
- @apply text-sm font-medium;
535
- color: var(--destructive-foreground);
536
- }
622
+ /* ===================================================================
623
+ ERROR TEXT / MESSAGE
624
+ Usage: <p data-form-part="error">Email is required</p>
625
+ =================================================================== */
626
+ [data-form-part="error"] {
627
+ @apply text-sm;
628
+ font-weight: var(--label-weight, 500);
629
+ color: var(--destructive-foreground);
630
+ }
537
631
 
538
- /* Rails error wrapper compatibility */
539
- .field_with_errors {
540
- @apply contents;
541
- }
632
+ /* Rails error wrapper compatibility */
633
+ .field_with_errors {
634
+ @apply contents;
635
+ }
542
636
 
543
- .field_with_errors [data-component="input"],
544
- .field_with_errors [data-component="textarea"],
545
- .field_with_errors [data-component="select"] {
546
- border-color: var(--destructive);
547
- box-shadow:
548
- var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05)),
549
- 0 0 0 3px color-mix(in oklch, var(--destructive) 20%, transparent);
550
- }
637
+ :where(.field_with_errors) [data-component="input"],
638
+ :where(.field_with_errors) [data-component="textarea"],
639
+ :where(.field_with_errors) [data-component="select"] {
640
+ --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));
644
+ outline-offset: var(--focus-ring-offset, 0px);
645
+ }
551
646
 
552
- /* ===================================================================
553
- FORM ACTIONS (Submit area)
554
- Usage: <div data-form-part="actions">
555
- =================================================================== */
556
- [data-form-part="actions"] {
557
- @apply flex items-center gap-3 pt-4;
558
- }
647
+ /* ===================================================================
648
+ FORM ACTIONS (Submit area)
649
+ Usage: <div data-form-part="actions">
650
+ =================================================================== */
651
+ [data-form-part="actions"] {
652
+ @apply flex items-center gap-3 pt-4;
653
+ }
559
654
 
560
- [data-form-part="actions"][data-align="end"] {
561
- @apply justify-end;
562
- }
655
+ [data-form-part="actions"]:where([data-align="end"]) {
656
+ @apply justify-end;
657
+ }
563
658
 
564
- [data-form-part="actions"][data-align="between"] {
565
- @apply justify-between;
566
- }
659
+ [data-form-part="actions"]:where([data-align="between"]) {
660
+ @apply justify-between;
661
+ }
567
662
 
568
- /* ===================================================================
569
- FIELDSET & LEGEND
570
- =================================================================== */
571
- [data-component="fieldset"] {
572
- @apply space-y-4 rounded-lg border p-4;
573
- border-color: var(--border);
574
- }
663
+ /* ===================================================================
664
+ FIELDSET & LEGEND
665
+ =================================================================== */
666
+ [data-component="fieldset"] {
667
+ @apply space-y-4 border p-4;
668
+ border-radius: var(--fieldset-radius, var(--surface-radius, 0.5rem));
669
+ border-color: var(--border);
670
+ }
575
671
 
576
- [data-component="legend"],
577
- [data-component="fieldset"] > legend {
578
- @apply px-2 text-sm font-medium;
579
- color: var(--foreground);
580
- }
672
+ [data-component="legend"],
673
+ :where([data-component="fieldset"]) > legend {
674
+ @apply px-2 text-sm;
675
+ font-weight: var(--label-weight, 500);
676
+ color: var(--foreground);
677
+ }
581
678
 
582
- /* ===================================================================
583
- INPUT GROUP (Prefix/Suffix support)
584
- =================================================================== */
585
- [data-component="input-group"] {
586
- @apply relative flex items-center;
587
- }
679
+ /* ===================================================================
680
+ INPUT GROUP (Prefix/Suffix support)
681
+ Declared after the input sizes on purpose: the prefix/suffix padding has
682
+ to win over both the base px-3 and the data-size paddings.
683
+ =================================================================== */
684
+ [data-component="input-group"] {
685
+ @apply relative flex items-center;
686
+ }
588
687
 
589
- [data-component="input-group"] [data-component="input"] {
590
- @apply flex-1;
591
- }
688
+ :where([data-component="input-group"]) [data-component="input"] {
689
+ @apply flex-1;
690
+ }
592
691
 
593
- [data-input-group-part="prefix"],
594
- [data-input-group-part="suffix"] {
595
- @apply absolute flex items-center justify-center;
596
- @apply h-full px-3;
597
- @apply text-sm;
598
- color: var(--muted-foreground);
599
- pointer-events: none;
600
- }
692
+ [data-input-group-part="prefix"],
693
+ [data-input-group-part="suffix"] {
694
+ @apply absolute flex items-center justify-center;
695
+ @apply h-full px-3;
696
+ @apply text-sm;
697
+ color: var(--muted-foreground);
698
+ pointer-events: none;
699
+ }
601
700
 
602
- [data-input-group-part="prefix"] {
603
- @apply left-0;
604
- }
701
+ [data-input-group-part="prefix"] {
702
+ @apply left-0;
703
+ }
605
704
 
606
- [data-input-group-part="suffix"] {
607
- @apply right-0;
608
- }
705
+ [data-input-group-part="suffix"] {
706
+ @apply right-0;
707
+ }
609
708
 
610
- /* Adjust input padding when prefix/suffix present */
611
- [data-component="input-group"]:has([data-input-group-part="prefix"]) [data-component="input"] {
612
- @apply pl-9;
613
- }
709
+ /* Adjust input padding when prefix/suffix present */
710
+ :where([data-component="input-group"]:has([data-input-group-part="prefix"])) [data-component="input"] {
711
+ @apply pl-9;
712
+ }
614
713
 
615
- [data-component="input-group"]:has([data-input-group-part="suffix"]) [data-component="input"] {
616
- @apply pr-9;
714
+ :where([data-component="input-group"]:has([data-input-group-part="suffix"])) [data-component="input"] {
715
+ @apply pr-9;
716
+ }
617
717
  }