maquina-components 0.7.0 → 0.7.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce8aead4098ab0b2b83aed60c45ffbcca78f03f8e800f43259a6a51f32f36e5d
4
- data.tar.gz: e58baf4a3a6ce7dcd8c0b8cce1166b5afe9d21ced6751574195ab29d76329625
3
+ metadata.gz: 870cc289944a0b16196e5e259398cbdabf0ca1dd998bf2a178967898a3d1a57e
4
+ data.tar.gz: db3daba76341084ee6e096fd2186cebdcf594b6faee992be2c9e62e4b5781eed
5
5
  SHA512:
6
- metadata.gz: '03008f285333b7b4f7cc066a10f55366629b683c7518458ac3187ffff2e6e1fb93a8850f9225b16d98fc0e14bedcc8ccef5759ee628acc4865964b90daf54661'
7
- data.tar.gz: 9ad4bb8c6d2b9ed0d0595d9571bb7d471e1fe6aaa4c3c294e0b58b6e2550a5d6f42fdbc5b3a077e44fb10ead2394e75cf07350edb884dd229663c15961b75201
6
+ metadata.gz: a1934e3230c028e772cbaf0a0276a21fbb6f8ea406e76c355fdfc29587b445619a717a2d7f69a388a65afbcdef5236e59495072192845993a3079c944b0ca59c
7
+ data.tar.gz: 6c29f68c06d96157c4402ff03f1594aac41a748480d464c678bd995d10aa49ba055f759674786d666a1fb8b0f146fc76243a95171b146c88e82aa88d42df644c
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.
@@ -113,11 +113,11 @@
113
113
  }
114
114
 
115
115
  /* Error state — the root :has() drives the trigger */
116
- :where([data-component="date-picker"]:has(input:invalid), [data-component="date-picker"]:has(input[aria-invalid="true"])) [data-date-picker-part="trigger"] {
117
- 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));
118
118
  }
119
119
 
120
- :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) {
121
121
  --focus-ring-color: var(--destructive);
122
122
  }
123
123
 
@@ -154,9 +154,15 @@
154
154
 
155
155
  /* Invalid/error state — the destructive halo is themeable through
156
156
  --focus-ring-color, which the :focus-visible rule above also reads. */
157
- [data-component="input"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
157
+ [data-component="input"]:where([aria-invalid="true"], :user-invalid) {
158
158
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
159
- border-color: var(--destructive);
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) {
160
166
  outline-width: var(--focus-ring-width, 3px);
161
167
  outline-style: var(--focus-ring-style, solid);
162
168
  outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
@@ -206,9 +212,15 @@
206
212
  @apply cursor-not-allowed opacity-50;
207
213
  }
208
214
 
209
- [data-component="textarea"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
215
+ [data-component="textarea"]:where([aria-invalid="true"], :user-invalid) {
210
216
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
211
- border-color: var(--destructive);
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) {
212
224
  outline-width: var(--focus-ring-width, 3px);
213
225
  outline-style: var(--focus-ring-style, solid);
214
226
  outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
@@ -273,7 +285,7 @@
273
285
 
274
286
  [data-component="select"]:where([aria-invalid="true"]) {
275
287
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
276
- border-color: var(--destructive);
288
+ border-color: var(--destructive-border, var(--destructive));
277
289
  }
278
290
 
279
291
  /* ===================================================================
@@ -355,7 +367,7 @@
355
367
 
356
368
  [data-component="checkbox"]:where([aria-invalid="true"]) {
357
369
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
358
- border-color: var(--destructive);
370
+ border-color: var(--destructive-border, var(--destructive));
359
371
  outline-width: var(--focus-ring-width, 3px);
360
372
  outline-style: var(--focus-ring-style, solid);
361
373
  outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
@@ -653,7 +665,11 @@
653
665
  [data-form-part="error"] {
654
666
  @apply text-sm;
655
667
  font-weight: var(--label-weight, 500);
656
- 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));
657
673
  }
658
674
 
659
675
  /* Rails error wrapper compatibility */
@@ -665,7 +681,12 @@
665
681
  :where(.field_with_errors) [data-component="textarea"],
666
682
  :where(.field_with_errors) [data-component="select"] {
667
683
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
668
- border-color: var(--destructive);
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) {
669
690
  outline-width: var(--focus-ring-width, 3px);
670
691
  outline-style: var(--focus-ring-style, solid);
671
692
  outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
@@ -62,12 +62,57 @@ export default class extends Controller {
62
62
  // Add event listeners
63
63
  this.addEventListeners()
64
64
 
65
- // Focus first item after animation
65
+ // Measure after the paint, not here: dataset.state was just set and the
66
+ // content is still laid out at its pre-open box in this tick.
66
67
  requestAnimationFrame(() => {
68
+ this.positionContent()
67
69
  this.focusFirstItem()
68
70
  })
69
71
  }
70
72
 
73
+ // The placement the author asked for, captured before we ever overwrite it.
74
+ // Memoised on first read, which happens on the first open() -- i.e. while
75
+ // data-side is still whatever the partial rendered.
76
+ get authoredSide() {
77
+ if (this._authoredSide === undefined) {
78
+ this._authoredSide = (this.hasContentTarget && this.contentTarget.dataset.side) || "bottom"
79
+ }
80
+
81
+ return this._authoredSide
82
+ }
83
+
84
+ // Flip the menu above the trigger when it would otherwise open past the
85
+ // bottom of the viewport. CSS already styles every side; nothing was ever
86
+ // choosing one from geometry.
87
+ positionContent() {
88
+ if (!this.hasContentTarget) return
89
+
90
+ const content = this.contentTarget
91
+ const side = this.authoredSide
92
+
93
+ // Always re-measure from the authored placement. Without this reset a menu
94
+ // flipped once in a short window stays flipped after the window grows.
95
+ content.dataset.side = side
96
+
97
+ // Only the block axis flips; left/right placements are deliberate.
98
+ if (side !== "bottom" && side !== "top") return
99
+
100
+ const trigger = this.hasTriggerTarget ? this.triggerTarget : this.element
101
+ const triggerRect = trigger.getBoundingClientRect()
102
+ const gap = 4 // matches the mt-1 / mb-1 in dropdown_menu.css
103
+ const needed = content.getBoundingClientRect().height + gap
104
+ const roomBelow = window.innerHeight - triggerRect.bottom
105
+ const roomAbove = triggerRect.top
106
+
107
+ // Flip only when the other side genuinely fits. Flipping into a space that
108
+ // is also too small trades one clipped menu for another.
109
+ if (side === "bottom" && needed > roomBelow && needed <= roomAbove) {
110
+ content.dataset.side = "top"
111
+ } else if (side === "top" && needed > roomAbove && needed <= roomBelow) {
112
+ content.dataset.side = "bottom"
113
+ }
114
+ }
115
+
71
116
  close() {
72
117
  if (!this.openValue || !this.hasContentTarget) return
73
118
 
@@ -55,6 +55,44 @@ export default class extends Controller {
55
55
  this.contentTarget.hidden = false
56
56
 
57
57
  this.addEventListeners()
58
+
59
+ // Measure after the paint: the content is still at its pre-open box here.
60
+ requestAnimationFrame(() => this.positionContent())
61
+ }
62
+
63
+ // The placement the author asked for, read before we ever overwrite it.
64
+ get authoredSide() {
65
+ if (this._authoredSide === undefined) {
66
+ this._authoredSide = (this.hasContentTarget && this.contentTarget.dataset.side) || "bottom"
67
+ }
68
+
69
+ return this._authoredSide
70
+ }
71
+
72
+ // Flip above the button when the menu would open past the bottom of the
73
+ // viewport and there is room above. Mirrors dropdown_menu_controller.
74
+ positionContent() {
75
+ if (!this.hasContentTarget || !this.hasButtonTarget) return
76
+
77
+ const content = this.contentTarget
78
+ const side = this.authoredSide
79
+
80
+ // Reset first, so a menu flipped in a short window unflips once it grows.
81
+ content.dataset.side = side
82
+
83
+ if (side !== "bottom" && side !== "top") return
84
+
85
+ const buttonRect = this.buttonTarget.getBoundingClientRect()
86
+ const gap = 4 // matches the calc(100% + 4px) offsets in menu_button.css
87
+ const needed = content.getBoundingClientRect().height + gap
88
+ const roomBelow = window.innerHeight - buttonRect.bottom
89
+ const roomAbove = buttonRect.top
90
+
91
+ if (side === "bottom" && needed > roomBelow && needed <= roomAbove) {
92
+ content.dataset.side = "top"
93
+ } else if (side === "top" && needed > roomAbove && needed <= roomBelow) {
94
+ content.dataset.side = "bottom"
95
+ }
58
96
  }
59
97
 
60
98
  close() {
@@ -2,5 +2,5 @@
2
2
  <% merged_data = merge_component_data(html_options, alert_part: :description) %>
3
3
 
4
4
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5
- <%= text || content %>
5
+ <%= text.presence || content || yield %>
6
6
  <% end %>
@@ -2,5 +2,5 @@
2
2
  <% merged_data = merge_component_data(html_options, alert_part: :title) %>
3
3
 
4
4
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5
- <%= text || content %>
5
+ <%= text.presence || content || yield %>
6
6
  <% end %>
@@ -2,5 +2,5 @@
2
2
  <% merged_data = merge_component_data(html_options, card_part: :description) %>
3
3
 
4
4
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5
- <%= text || content %>
5
+ <%= text.presence || content || yield %>
6
6
  <% end %>
@@ -5,5 +5,5 @@
5
5
  ).compact %>
6
6
 
7
7
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
8
- <%= text || content %>
8
+ <%= text.presence || content || yield %>
9
9
  <% end %>
@@ -4,5 +4,5 @@
4
4
  ) %>
5
5
 
6
6
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
7
- <%= text || content %>
7
+ <%= text.presence || content || yield %>
8
8
  <% end %>
@@ -2,5 +2,5 @@
2
2
  <% merged_data = merge_component_data(html_options, drawer_part: :description) %>
3
3
 
4
4
  <%= content_tag tag, class: css_classes.presence, data: merged_data, **html_options do %>
5
- <%= text || content %>
5
+ <%= text.presence || content || yield %>
6
6
  <% end %>
@@ -2,5 +2,5 @@
2
2
  <% merged_data = merge_component_data(html_options, drawer_part: :title) %>
3
3
 
4
4
  <%= content_tag tag, class: css_classes.presence, data: merged_data, **html_options do %>
5
- <%= text || content %>
5
+ <%= text.presence || content || yield %>
6
6
  <% end %>
@@ -6,5 +6,5 @@
6
6
  sidebar_part: "menu-badge"
7
7
  ) %>
8
8
  <%= content_tag :span, class: css_classes.presence, data: merged_data, **html_options do %>
9
- <%= text || content || yield %>
9
+ <%= text.presence || content || yield %>
10
10
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <%# locals: (text: nil, content: nil, css_classes: "", **html_options) %>
2
2
  <% merged_data = merge_component_data(html_options, table_part: :caption) %>
3
3
  <%= content_tag :caption, class: css_classes.presence, data: merged_data, **html_options do %>
4
- <%= text || content || yield %>
4
+ <%= text.presence || content || yield %>
5
5
  <% end %>
@@ -1,5 +1,5 @@
1
1
  <%# locals: (text: nil, content: nil, css_classes: "", **html_options) %>
2
2
  <% merged_data = merge_component_data(html_options, table_part: :cell) %>
3
3
  <%= content_tag :td, class: css_classes.presence, data: merged_data, **html_options do %>
4
- <%= text || content || yield %>
4
+ <%= text.presence || content || yield %>
5
5
  <% end %>
@@ -4,5 +4,5 @@
4
4
  html_options[:scope] = scope if scope
5
5
  %>
6
6
  <%= content_tag :th, class: css_classes.presence, data: merged_data, **html_options do %>
7
- <%= text || content || yield %>
7
+ <%= text.presence || content || yield %>
8
8
  <% end %>
@@ -4,5 +4,5 @@
4
4
  ) %>
5
5
 
6
6
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
7
- <%= text || content %>
7
+ <%= text.presence || content || yield %>
8
8
  <% end %>
@@ -4,5 +4,5 @@
4
4
  ) %>
5
5
 
6
6
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
7
- <%= text || content %>
7
+ <%= text.presence || content || yield %>
8
8
  <% end %>
@@ -20,11 +20,11 @@
20
20
  accent. Set them to your own blue (or to var(--primary)) if you prefer. */
21
21
  --info: oklch(0.93 0.04 240);
22
22
  --info-foreground: oklch(0.4 0.12 250);
23
+ }
23
24
 
24
- .dark {
25
- --info: oklch(0.34 0.07 250);
26
- --info-foreground: oklch(0.88 0.06 240);
27
- }
25
+ .dark {
26
+ --info: oklch(0.34 0.07 250);
27
+ --info-foreground: oklch(0.88 0.06 240);
28
28
  }
29
29
 
30
30
  /* -------------------------------------------------------------------------
@@ -76,58 +76,61 @@
76
76
  --sidebar-accent-foreground: oklch(0.145 0 0);
77
77
  --sidebar-border: oklch(0.88 0 0);
78
78
  --sidebar-ring: oklch(0.645 0.246 16);
79
+ }
79
80
 
80
- /* Dark Mode */
81
- .dark {
82
- --background: oklch(0.145 0 0);
83
- --foreground: oklch(0.985 0 0);
81
+ /* Dark Mode
82
+ Put the `dark` class on <html>. This block must stay at the top level: nested
83
+ inside :root it compiles to `:root .dark`, which never matches <html> itself,
84
+ and the @theme bindings below resolve against <html>. */
85
+ .dark {
86
+ --background: oklch(0.145 0 0);
87
+ --foreground: oklch(0.985 0 0);
84
88
 
85
- --card: oklch(0.205 0 0);
86
- --card-foreground: oklch(0.985 0 0);
89
+ --card: oklch(0.205 0 0);
90
+ --card-foreground: oklch(0.985 0 0);
87
91
 
88
- --popover: oklch(0.269 0 0);
89
- --popover-foreground: oklch(0.985 0 0);
92
+ --popover: oklch(0.269 0 0);
93
+ --popover-foreground: oklch(0.985 0 0);
90
94
 
91
- --primary: oklch(0.712 0.194 13);
92
- --primary-foreground: oklch(0.15 0.052 13);
95
+ --primary: oklch(0.712 0.194 13);
96
+ --primary-foreground: oklch(0.15 0.052 13);
93
97
 
94
- --secondary: oklch(0.269 0 0);
95
- --secondary-foreground: oklch(0.985 0 0);
98
+ --secondary: oklch(0.269 0 0);
99
+ --secondary-foreground: oklch(0.985 0 0);
96
100
 
97
- --muted: oklch(0.269 0 0);
98
- --muted-foreground: oklch(0.708 0 0);
101
+ --muted: oklch(0.269 0 0);
102
+ --muted-foreground: oklch(0.708 0 0);
99
103
 
100
- --accent: oklch(0.371 0 0);
101
- --accent-foreground: oklch(0.985 0 0);
104
+ --accent: oklch(0.371 0 0);
105
+ --accent-foreground: oklch(0.985 0 0);
102
106
 
103
- --success: oklch(0.32 0.06 168);
104
- --success-foreground: oklch(0.82 0.06 168);
107
+ --success: oklch(0.32 0.06 168);
108
+ --success-foreground: oklch(0.82 0.06 168);
105
109
 
106
- --warning: oklch(0.36 0.06 55);
107
- --warning-foreground: oklch(0.86 0.06 55);
110
+ --warning: oklch(0.36 0.06 55);
111
+ --warning-foreground: oklch(0.86 0.06 55);
108
112
 
109
- --destructive: oklch(0.34 0.07 8);
110
- --destructive-foreground: oklch(0.88 0.06 8);
113
+ --destructive: oklch(0.34 0.07 8);
114
+ --destructive-foreground: oklch(0.88 0.06 8);
111
115
 
112
- --border: oklch(1 0 0 / 10%);
113
- --input: oklch(1 0 0 / 15%);
114
- --ring: oklch(0.455 0.188 13);
116
+ --border: oklch(1 0 0 / 10%);
117
+ --input: oklch(1 0 0 / 15%);
118
+ --ring: oklch(0.455 0.188 13);
115
119
 
116
- --chart-1: oklch(0.712 0.194 13);
117
- --chart-2: oklch(0.72 0.09 168);
118
- --chart-3: oklch(0.78 0.09 55);
119
- --chart-4: oklch(0.645 0.246 16);
120
- --chart-5: oklch(0.58 0.13 8);
120
+ --chart-1: oklch(0.712 0.194 13);
121
+ --chart-2: oklch(0.72 0.09 168);
122
+ --chart-3: oklch(0.78 0.09 55);
123
+ --chart-4: oklch(0.645 0.246 16);
124
+ --chart-5: oklch(0.58 0.13 8);
121
125
 
122
- --sidebar: oklch(0.14 0 0);
123
- --sidebar-foreground: oklch(0.9 0 0);
124
- --sidebar-primary: oklch(0.712 0.194 13);
125
- --sidebar-primary-foreground: oklch(0.15 0.052 13);
126
- --sidebar-accent: oklch(0.22 0 0);
127
- --sidebar-accent-foreground: oklch(0.95 0 0);
128
- --sidebar-border: oklch(1 0 0 / 12%);
129
- --sidebar-ring: oklch(0.455 0.188 13);
130
- }
126
+ --sidebar: oklch(0.14 0 0);
127
+ --sidebar-foreground: oklch(0.9 0 0);
128
+ --sidebar-primary: oklch(0.712 0.194 13);
129
+ --sidebar-primary-foreground: oklch(0.15 0.052 13);
130
+ --sidebar-accent: oklch(0.22 0 0);
131
+ --sidebar-accent-foreground: oklch(0.95 0 0);
132
+ --sidebar-border: oklch(1 0 0 / 12%);
133
+ --sidebar-ring: oklch(0.455 0.188 13);
131
134
  }
132
135
 
133
136
  /* Tailwind Theme Bindings
@@ -4,19 +4,22 @@ module MaquinaComponents
4
4
  # Migration scanner behind `rake maquina:doctor`.
5
5
  #
6
6
  # Run inside a consuming app, it looks for the app's own CSS/view/JS patterns
7
- # that 0.6.0 makes redundant or outright breaks, and prints file:line plus a
8
- # suggested replacement for each. Advisory only: it never edits anything and
9
- # never fails a build.
7
+ # that a maquina release makes redundant or outright breaks, and prints
8
+ # file:line plus a suggested replacement for each. Advisory only: it never
9
+ # edits anything and never fails a build.
10
+ #
11
+ # Findings carry the release that introduced them, so the report stays useful
12
+ # across upgrades rather than describing a single migration.
10
13
  #
11
14
  # Plain Ruby on purpose - no Rails, no extra gems - so it can also be run
12
15
  # against a directory from the engine's own repo.
13
16
  class Doctor
14
- Finding = Struct.new(:path, :line, :source, :severity, :rule, :suggestion)
17
+ Finding = Struct.new(:path, :line, :source, :severity, :rule, :suggestion, :version)
15
18
 
16
19
  SEVERITIES = {
17
- breaking: "BREAKING - stops working in 0.6.0",
18
- review: "REVIEW - still works, but 0.6.0 gives you a token for it",
19
- cleanup: "CLEANUP - probably unnecessary in 0.6.0"
20
+ breaking: "BREAKING - stops working after upgrading",
21
+ review: "REVIEW - still works, but there is now a token for it",
22
+ cleanup: "CLEANUP - probably unnecessary now"
20
23
  }.freeze
21
24
 
22
25
  CSS_GLOBS = [
@@ -54,6 +57,41 @@ module MaquinaComponents
54
57
  RADIUS_DECL = /(?:border-radius\s*:|@apply[^;{}]*\brounded(?:-[a-z0-9\[\].\/-]+)?\b)/
55
58
  SHADOW_DECL = /(?:box-shadow\s*:|@apply[^;{}]*\b(?:shadow|ring)(?:-[a-z0-9\[\].\/-]+)?\b)/
56
59
  FOCUS_SELECTOR = /:focus(-visible|-within)?\b/
60
+ # A bare `.dark` in a selector list; `&.dark` (which compiles to :root.dark)
61
+ # is fine and does not match.
62
+ BARE_DARK_SELECTOR = /(?:\A|,)\s*\.dark\s*(?:,|\z)/
63
+ ROOT_SELECTOR = /\A(?::root|html)\z/
64
+
65
+ # 0.7.1 -----------------------------------------------------------------
66
+ # The destructive pair plus the surface it lands on, read out of a host
67
+ # theme. Lightness alone cannot separate the two shipped palette
68
+ # conventions -- a tint palette's dark block has the same shape as a
69
+ # shadcn palette's light block -- so the check measures the symptom
70
+ # (error text disappearing into its own card) instead. See docs/theming.md.
71
+ DESTRUCTIVE_DECL = /\A--(destructive|destructive-foreground|card|background)\s*:\s*(.+)\z/m
72
+ OKLCH_LIGHTNESS = /oklch\(\s*([0-9.]+)(%?)/
73
+ # Oklch lightness gap below which text is effectively invisible on its
74
+ # surface. A proxy for a contrast ratio, deliberately conservative: this
75
+ # rule is BREAKING, so it must not fire on a merely low-contrast palette.
76
+ CONTRAST_FLOOR = 0.25
77
+ # Only :root / .dark / @theme declare a palette; a component-level override
78
+ # of the same name is not a statement about the app's convention.
79
+ THEME_SCOPE = /\A(:root|html|:where\(:root\)|\.dark|html\.dark|\[data-theme[^\]]*\]|)\s*\z/
80
+
81
+ ERROR_PART = /data-form-part=["']error["']|form_part:\s*[:"']error/
82
+ FIELD_WITH_ERRORS = /field_with_errors/
83
+ ARIA_INVALID = /aria-invalid|aria:\s*\{[^}]*\binvalid\b|\baria_invalid\b/
84
+ HANDROLLED_ERROR_COLOR = /\btext-destructive\b|\btext-red-\d/
85
+
86
+ # A form field, raw or through a Rails helper. Matched against a bounded
87
+ # window rather than a line: helper calls routinely span five or six lines,
88
+ # and a per-line regex would silently miss almost every real one.
89
+ FIELD_OPENER = /<(?:input|textarea)\b|\bf\.(?:text_field|text_area|email_field|password_field|number_field|url_field|telephone_field|phone_field|search_field|date_field)\b/
90
+ FIELD_COMPONENT = /data-component=["'](?:input|textarea)["']|component:\s*[:"'](?:input|textarea)["']?/
91
+ FIELD_REQUIRED = /\brequired\b/
92
+ FIELD_PLACEHOLDER = /\bplaceholder\b/
93
+
94
+ DROPDOWN_FLIP_HINT = /dataset\.side|["']data-side["']|setAttribute\(\s*["']data-side["']/
57
95
 
58
96
  attr_reader :root, :findings, :scanned_files
59
97
 
@@ -61,12 +99,25 @@ module MaquinaComponents
61
99
  @root = File.expand_path(root.to_s)
62
100
  @findings = []
63
101
  @scanned_files = 0
102
+ # Cross-file state: some 0.7.1 rules are conclusions about the whole app,
103
+ # not about one line, so they are evaluated after the scan.
104
+ @destructive_tokens = {}
105
+ @aria_invalid_seen = false
106
+ @error_sites = []
64
107
  end
65
108
 
66
109
  def run
67
110
  each_file(CSS_GLOBS) { |path| scan_css(path) }
68
- each_file(VIEW_GLOBS) { |path| scan_markup(path) }
69
- each_file(JS_GLOBS) { |path| scan_markup(path) }
111
+ each_file(VIEW_GLOBS) { |path|
112
+ scan_markup(path)
113
+ scan_form_fields(path)
114
+ }
115
+ each_file(JS_GLOBS) { |path|
116
+ scan_markup(path)
117
+ scan_javascript(path)
118
+ }
119
+ check_destructive_palette
120
+ check_invalid_without_aria
70
121
  self
71
122
  end
72
123
 
@@ -81,7 +132,7 @@ module MaquinaComponents
81
132
  out << ""
82
133
 
83
134
  if findings.empty?
84
- out << "No at-risk patterns found. Nothing to migrate for 0.6.0."
135
+ out << "No at-risk patterns found. Nothing to migrate."
85
136
  out << ""
86
137
  return out.join("\n")
87
138
  end
@@ -93,7 +144,7 @@ module MaquinaComponents
93
144
  out << "#{heading} (#{group.size})"
94
145
  out << "-" * 72
95
146
  group.each do |finding|
96
- out << " #{relative(finding.path)}:#{finding.line} [#{finding.rule}]"
147
+ out << " #{relative(finding.path)}:#{finding.line} [#{finding.rule}] (#{finding.version})"
97
148
  out << " #{finding.source}"
98
149
  finding.suggestion.each_line { |line| out << " -> #{line.chomp}" }
99
150
  out << ""
@@ -101,7 +152,7 @@ module MaquinaComponents
101
152
  end
102
153
 
103
154
  out << "Summary: " + SEVERITIES.keys.map { |s| "#{findings_for(s).size} #{s}" }.join(", ")
104
- out << "Advisory only - nothing was changed. See the 0.6.0 upgrade notes for the full token list."
155
+ out << "Advisory only - nothing was changed. See docs/upgrading.md for the release notes behind each rule."
105
156
  out << ""
106
157
  out.join("\n")
107
158
  end
@@ -124,8 +175,8 @@ module MaquinaComponents
124
175
  end
125
176
  end
126
177
 
127
- def add(path, index, source, severity, rule, suggestion)
128
- findings << Finding.new(path, index + 1, squash(source), severity, rule, suggestion)
178
+ def add(path, index, source, severity, rule, suggestion, version = "0.6.0")
179
+ findings << Finding.new(path, index + 1, squash(source), severity, rule, suggestion, version)
129
180
  end
130
181
 
131
182
  # Multi-line selectors read better as one line in a report.
@@ -149,7 +200,10 @@ module MaquinaComponents
149
200
  selector = buffer.strip
150
201
  buffer = +""
151
202
  at_rule = selector.start_with?("@")
152
- check_selector(path, index, selector, in_layer?(stack)) unless at_rule
203
+ unless at_rule
204
+ check_selector(path, index, selector, in_layer?(stack))
205
+ check_nested_dark(path, index, selector, stack)
206
+ end
153
207
  stack.push(selector)
154
208
  when "}"
155
209
  check_declaration(path, index, buffer.strip, stack)
@@ -178,6 +232,10 @@ module MaquinaComponents
178
232
  return if declaration.empty?
179
233
 
180
234
  chain = selector_chain(stack)
235
+ # Palette tokens live on :root / .dark, never on a component selector, so
236
+ # they have to be captured before the component guard below discards them.
237
+ capture_destructive_token(path, index, declaration, chain)
238
+
181
239
  return unless component_selector?(chain)
182
240
 
183
241
  if RADIUS_DECL.match?(declaration)
@@ -230,6 +288,24 @@ module MaquinaComponents
230
288
  end
231
289
  end
232
290
 
291
+ # `.dark` nested in `:root` compiles to `:root .dark`, a descendant of
292
+ # <html>, so `<html class="dark">` never matches it. And the @theme
293
+ # bindings resolve against <html>, so moving the class to <body> does not
294
+ # help either: dark mode silently never applies. The 0.7.1 installer
295
+ # shipped its palette this way.
296
+ def check_nested_dark(path, index, selector, stack)
297
+ return unless BARE_DARK_SELECTOR.match?(selector)
298
+ return unless stack.any? { |ancestor| ROOT_SELECTOR.match?(ancestor) }
299
+
300
+ add(path, index, selector, :breaking, "nested-dark-block",
301
+ "This .dark block is nested inside :root, so it compiles to `:root .dark` and\n" \
302
+ "never matches <html class=\"dark\">: dark mode does not apply. Move it out to\n" \
303
+ "the top level, after the closing brace of :root:\n" \
304
+ " :root { ... }\n" \
305
+ " .dark { ... }",
306
+ "0.7.2")
307
+ end
308
+
233
309
  # Checks that read the same in CSS, ERB and JS.
234
310
  def scan_shared(path, index, line)
235
311
  if PRESENCE_ACTIVE.match?(line)
@@ -257,6 +333,22 @@ module MaquinaComponents
257
333
  read(path).each_with_index do |line, index|
258
334
  scan_shared(path, index, line)
259
335
 
336
+ # Cross-file signals for check_invalid_without_aria.
337
+ @aria_invalid_seen ||= ARIA_INVALID.match?(line)
338
+ if ERROR_PART.match?(line) || FIELD_WITH_ERRORS.match?(line)
339
+ @error_sites << {path: path, index: index, source: squash(line)}
340
+ end
341
+
342
+ if ERROR_PART.match?(line) && HANDROLLED_ERROR_COLOR.match?(line)
343
+ add(path, index, line, :cleanup, "destructive-error-workaround",
344
+ "This restates the error color by hand on top of the engine's part.\n" \
345
+ "0.7.1 gives that color its own token, so the part is legible under\n" \
346
+ "either palette convention:\n" \
347
+ " :root { --destructive-text: var(--destructive); }\n" \
348
+ "Then drop the utility and let [data-form-part=\"error\"] paint itself.",
349
+ "0.7.1")
350
+ end
351
+
260
352
  next unless line.include?("data-component=") || line =~ /component:\s*[:"']/
261
353
 
262
354
  if /\b(?:rounded|shadow)(?:-[a-z0-9\[\].\/-]+)?\b/.match?(line)
@@ -267,6 +359,133 @@ module MaquinaComponents
267
359
  end
268
360
  end
269
361
 
362
+ # ----- 0.7.1 checks -------------------------------------------------
363
+
364
+ # Records --destructive / --destructive-foreground per theme scope. The
365
+ # engine ships two opposite conventions for this pair (a pale tint with a
366
+ # dark readable foreground, and a saturated fill with a near-white one),
367
+ # and only the app's own theme says which one its components are sitting in.
368
+ def capture_destructive_token(path, index, declaration, chain)
369
+ match = DESTRUCTIVE_DECL.match(declaration.strip)
370
+ return unless match
371
+ return unless THEME_SCOPE.match?(chain.to_s.strip)
372
+
373
+ name = match[1]
374
+ scope = /\.dark\b/.match?(chain.to_s) ? "dark" : "light"
375
+
376
+ @destructive_tokens[scope] ||= {}
377
+ @destructive_tokens[scope][name] ||= {value: match[2].strip, path: path, index: index}
378
+ end
379
+
380
+ # First component of an oklch() color, normalised to 0..1. Returns nil for
381
+ # any other color space -- an unparseable value must not be guessed at.
382
+ def oklch_lightness(value)
383
+ match = OKLCH_LIGHTNESS.match(value.to_s)
384
+ return nil unless match
385
+
386
+ lightness = match[1].to_f
387
+ (match[2] == "%") ? lightness / 100.0 : lightness
388
+ end
389
+
390
+ def check_destructive_palette
391
+ @destructive_tokens.each do |scope, tokens|
392
+ foreground = tokens["destructive-foreground"]
393
+ base = tokens["destructive"]
394
+ surface = tokens["card"] || tokens["background"]
395
+ next unless foreground && base && surface
396
+
397
+ foreground_lightness = oklch_lightness(foreground[:value])
398
+ surface_lightness = oklch_lightness(surface[:value])
399
+ next unless foreground_lightness && surface_lightness
400
+
401
+ # [data-form-part="error"] paints --destructive-foreground on the card.
402
+ # Under the installer's palette that is the dark readable red and this
403
+ # gap is wide; under a shadcn-style palette it is the near-white
404
+ # on-fill color and the text vanishes.
405
+ next unless (foreground_lightness - surface_lightness).abs < CONTRAST_FLOOR
406
+
407
+ add(foreground[:path], foreground[:index], "--destructive-foreground: #{foreground[:value]}",
408
+ :breaking, "destructive-error-invisible",
409
+ "In this #{scope} palette --destructive-foreground is the color that sits ON\n" \
410
+ "a destructive fill, not on the page. [data-form-part=\"error\"] paints it\n" \
411
+ "as body text on --#{tokens["card"] ? "card" : "background"}, where it is all but invisible\n" \
412
+ "(oklch lightness #{foreground_lightness.round(3)} against #{surface_lightness.round(3)}).\n" \
413
+ "0.7.1 routes that color through its own token. Add to your #{scope} block:\n" \
414
+ " --destructive-text: var(--destructive);\n" \
415
+ " --destructive-border: var(--destructive);",
416
+ "0.7.1")
417
+ end
418
+ end
419
+
420
+ # An app that never sets aria-invalid is getting its error border from the
421
+ # CSS :invalid fallback. 0.7.1 narrows that to :user-invalid so a pristine
422
+ # required field stops painting red, which also means an app relying on the
423
+ # old behaviour loses the border it was depending on.
424
+ def check_invalid_without_aria
425
+ return if @aria_invalid_seen || @error_sites.empty?
426
+
427
+ site = @error_sites.first
428
+ add(site[:path], site[:index], site[:source], :breaking, "invalid-styling-without-aria",
429
+ "This app renders form errors (#{@error_sites.size} site#{"s" unless @error_sites.size == 1})\n" \
430
+ "but never sets aria-invalid, so the destructive border is coming from the\n" \
431
+ "CSS :invalid fallback. 0.7.1 replaces that with :user-invalid, which only\n" \
432
+ "matches after the reader has interacted with the field -- a server-rendered\n" \
433
+ "error on an untouched form will no longer paint a border.\n" \
434
+ "Drive the state explicitly on the input:\n" \
435
+ " aria: { invalid: model.errors[:field].any? }",
436
+ "0.7.1")
437
+ end
438
+
439
+ # Scans a view for form fields across their whole tag, not line by line.
440
+ def scan_form_fields(path)
441
+ source = File.read(path)
442
+
443
+ source.to_enum(:scan, FIELD_OPENER).each do
444
+ match = Regexp.last_match
445
+ window = field_window(source, match.begin(0))
446
+ next unless FIELD_COMPONENT.match?(window)
447
+ next unless FIELD_REQUIRED.match?(window)
448
+ next if FIELD_PLACEHOLDER.match?(window)
449
+
450
+ index = source[0...match.begin(0)].count("\n")
451
+ add(path, index, window, :review, "required-without-placeholder",
452
+ "Before 0.7.1 a required field with no placeholder matched\n" \
453
+ ":invalid:not(:placeholder-shown) from first paint, so it rendered the\n" \
454
+ "error state before the reader touched it -- with no aria-invalid, so\n" \
455
+ "screen readers were told nothing. 0.7.1 keys the error state on\n" \
456
+ ":user-invalid instead, and the premature red goes away on upgrade.\n" \
457
+ "Nothing to change here unless you were relying on it; drive real errors\n" \
458
+ "with aria-invalid.",
459
+ "0.7.1")
460
+ end
461
+ rescue ArgumentError, Errno::ENOENT
462
+ nil
463
+ end
464
+
465
+ # From the opener to the end of the tag or ERB expression that contains it,
466
+ # bounded so a malformed template cannot swallow the rest of the file.
467
+ def field_window(source, start)
468
+ slice = source[start, 500].to_s
469
+ terminator = slice.index("%>") || slice.index(">") || slice.length
470
+ slice[0, terminator + 2]
471
+ end
472
+
473
+ def scan_javascript(path)
474
+ source = File.read(path)
475
+ return unless source.include?("getBoundingClientRect")
476
+ return unless DROPDOWN_FLIP_HINT.match?(source)
477
+
478
+ index = source[0...source.index("getBoundingClientRect")].count("\n")
479
+ add(path, index, "getBoundingClientRect + data-side", :cleanup, "app-level-dropdown-flip",
480
+ "This looks like an app-level dropdown collision/flip controller. 0.7.1\n" \
481
+ "measures on open and sets data-side itself, for both the dropdown menu\n" \
482
+ "and the menu button, so the workaround can go.\n" \
483
+ "Delete it and let the engine place the menu.",
484
+ "0.7.1")
485
+ rescue ArgumentError, Errno::ENOENT
486
+ nil
487
+ end
488
+
270
489
  def read(path)
271
490
  File.readlines(path)
272
491
  rescue ArgumentError, Errno::ENOENT
@@ -1,3 +1,3 @@
1
1
  module MaquinaComponents
2
- VERSION = "0.7.0"
2
+ VERSION = "0.7.2"
3
3
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :maquina do
4
- desc "Scan this app's CSS/views/JS for patterns that maquina-components 0.6.0 changes (advisory, always exits 0)"
4
+ desc "Scan this app's CSS/views/JS for patterns a maquina-components release changes (advisory, always exits 0)"
5
5
  task :doctor, [:path] do |_task, args|
6
6
  require_relative "../maquina_components/doctor"
7
7
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maquina-components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez