maquina-components 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce8aead4098ab0b2b83aed60c45ffbcca78f03f8e800f43259a6a51f32f36e5d
4
- data.tar.gz: e58baf4a3a6ce7dcd8c0b8cce1166b5afe9d21ced6751574195ab29d76329625
3
+ metadata.gz: ca1b928bf464480638e3f7504164a10cfecab249eed3a970dbaaad5a3d5b311a
4
+ data.tar.gz: 27998f7d8cdf85c6e49af5b4db527086093149130af50a722105e8970c01838c
5
5
  SHA512:
6
- metadata.gz: '03008f285333b7b4f7cc066a10f55366629b683c7518458ac3187ffff2e6e1fb93a8850f9225b16d98fc0e14bedcc8ccef5759ee628acc4865964b90daf54661'
7
- data.tar.gz: 9ad4bb8c6d2b9ed0d0595d9571bb7d471e1fe6aaa4c3c294e0b58b6e2550a5d6f42fdbc5b3a077e44fb10ead2394e75cf07350edb884dd229663c15961b75201
6
+ metadata.gz: 8105dbdafd0bbf1f1be19ff9ba7f920f42f79b74703098e4229131b5310fb50cbd7e3ecdbf2af2cecfcd7f2f241f15be247e8592d5534995ed3cba931de50e7e
7
+ data.tar.gz: 31286d951a265d58bb22021f625242ce36e612ce9d0fe6a8a8ac3c057d0f845d34ea240ba1d2cf15aa4a87a33be8592bee5e1c8483a26b4bc733b069528ba27e
data/README.md CHANGED
@@ -165,7 +165,8 @@ bin/rails maquina:doctor
165
165
  ```
166
166
 
167
167
  It scans your CSS, views and JavaScript and prints file:line for every pattern
168
- the new release changes, grouped `BREAKING` / `REVIEW` / `CLEANUP`. It is
168
+ the new release changes, grouped `BREAKING` / `REVIEW` / `CLEANUP`, each tagged
169
+ with the release it came from. It is
169
170
  advisory: it never edits anything and never fails a build. See
170
171
  [docs/upgrading.md](docs/upgrading.md) for what changed and how to keep the
171
172
  previous look.
@@ -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 %>
@@ -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 = [
@@ -55,18 +58,62 @@ module MaquinaComponents
55
58
  SHADOW_DECL = /(?:box-shadow\s*:|@apply[^;{}]*\b(?:shadow|ring)(?:-[a-z0-9\[\].\/-]+)?\b)/
56
59
  FOCUS_SELECTOR = /:focus(-visible|-within)?\b/
57
60
 
61
+ # 0.7.1 -----------------------------------------------------------------
62
+ # The destructive pair plus the surface it lands on, read out of a host
63
+ # theme. Lightness alone cannot separate the two shipped palette
64
+ # conventions -- a tint palette's dark block has the same shape as a
65
+ # shadcn palette's light block -- so the check measures the symptom
66
+ # (error text disappearing into its own card) instead. See docs/theming.md.
67
+ DESTRUCTIVE_DECL = /\A--(destructive|destructive-foreground|card|background)\s*:\s*(.+)\z/m
68
+ OKLCH_LIGHTNESS = /oklch\(\s*([0-9.]+)(%?)/
69
+ # Oklch lightness gap below which text is effectively invisible on its
70
+ # surface. A proxy for a contrast ratio, deliberately conservative: this
71
+ # rule is BREAKING, so it must not fire on a merely low-contrast palette.
72
+ CONTRAST_FLOOR = 0.25
73
+ # Only :root / .dark / @theme declare a palette; a component-level override
74
+ # of the same name is not a statement about the app's convention.
75
+ THEME_SCOPE = /\A(:root|html|:where\(:root\)|\.dark|html\.dark|\[data-theme[^\]]*\]|)\s*\z/
76
+
77
+ ERROR_PART = /data-form-part=["']error["']|form_part:\s*[:"']error/
78
+ FIELD_WITH_ERRORS = /field_with_errors/
79
+ ARIA_INVALID = /aria-invalid|aria:\s*\{[^}]*\binvalid\b|\baria_invalid\b/
80
+ HANDROLLED_ERROR_COLOR = /\btext-destructive\b|\btext-red-\d/
81
+
82
+ # A form field, raw or through a Rails helper. Matched against a bounded
83
+ # window rather than a line: helper calls routinely span five or six lines,
84
+ # and a per-line regex would silently miss almost every real one.
85
+ 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/
86
+ FIELD_COMPONENT = /data-component=["'](?:input|textarea)["']|component:\s*[:"'](?:input|textarea)["']?/
87
+ FIELD_REQUIRED = /\brequired\b/
88
+ FIELD_PLACEHOLDER = /\bplaceholder\b/
89
+
90
+ DROPDOWN_FLIP_HINT = /dataset\.side|["']data-side["']|setAttribute\(\s*["']data-side["']/
91
+
58
92
  attr_reader :root, :findings, :scanned_files
59
93
 
60
94
  def initialize(root)
61
95
  @root = File.expand_path(root.to_s)
62
96
  @findings = []
63
97
  @scanned_files = 0
98
+ # Cross-file state: some 0.7.1 rules are conclusions about the whole app,
99
+ # not about one line, so they are evaluated after the scan.
100
+ @destructive_tokens = {}
101
+ @aria_invalid_seen = false
102
+ @error_sites = []
64
103
  end
65
104
 
66
105
  def run
67
106
  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) }
107
+ each_file(VIEW_GLOBS) { |path|
108
+ scan_markup(path)
109
+ scan_form_fields(path)
110
+ }
111
+ each_file(JS_GLOBS) { |path|
112
+ scan_markup(path)
113
+ scan_javascript(path)
114
+ }
115
+ check_destructive_palette
116
+ check_invalid_without_aria
70
117
  self
71
118
  end
72
119
 
@@ -81,7 +128,7 @@ module MaquinaComponents
81
128
  out << ""
82
129
 
83
130
  if findings.empty?
84
- out << "No at-risk patterns found. Nothing to migrate for 0.6.0."
131
+ out << "No at-risk patterns found. Nothing to migrate."
85
132
  out << ""
86
133
  return out.join("\n")
87
134
  end
@@ -93,7 +140,7 @@ module MaquinaComponents
93
140
  out << "#{heading} (#{group.size})"
94
141
  out << "-" * 72
95
142
  group.each do |finding|
96
- out << " #{relative(finding.path)}:#{finding.line} [#{finding.rule}]"
143
+ out << " #{relative(finding.path)}:#{finding.line} [#{finding.rule}] (#{finding.version})"
97
144
  out << " #{finding.source}"
98
145
  finding.suggestion.each_line { |line| out << " -> #{line.chomp}" }
99
146
  out << ""
@@ -101,7 +148,7 @@ module MaquinaComponents
101
148
  end
102
149
 
103
150
  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."
151
+ out << "Advisory only - nothing was changed. See docs/upgrading.md for the release notes behind each rule."
105
152
  out << ""
106
153
  out.join("\n")
107
154
  end
@@ -124,8 +171,8 @@ module MaquinaComponents
124
171
  end
125
172
  end
126
173
 
127
- def add(path, index, source, severity, rule, suggestion)
128
- findings << Finding.new(path, index + 1, squash(source), severity, rule, suggestion)
174
+ def add(path, index, source, severity, rule, suggestion, version = "0.6.0")
175
+ findings << Finding.new(path, index + 1, squash(source), severity, rule, suggestion, version)
129
176
  end
130
177
 
131
178
  # Multi-line selectors read better as one line in a report.
@@ -178,6 +225,10 @@ module MaquinaComponents
178
225
  return if declaration.empty?
179
226
 
180
227
  chain = selector_chain(stack)
228
+ # Palette tokens live on :root / .dark, never on a component selector, so
229
+ # they have to be captured before the component guard below discards them.
230
+ capture_destructive_token(path, index, declaration, chain)
231
+
181
232
  return unless component_selector?(chain)
182
233
 
183
234
  if RADIUS_DECL.match?(declaration)
@@ -257,6 +308,22 @@ module MaquinaComponents
257
308
  read(path).each_with_index do |line, index|
258
309
  scan_shared(path, index, line)
259
310
 
311
+ # Cross-file signals for check_invalid_without_aria.
312
+ @aria_invalid_seen ||= ARIA_INVALID.match?(line)
313
+ if ERROR_PART.match?(line) || FIELD_WITH_ERRORS.match?(line)
314
+ @error_sites << {path: path, index: index, source: squash(line)}
315
+ end
316
+
317
+ if ERROR_PART.match?(line) && HANDROLLED_ERROR_COLOR.match?(line)
318
+ add(path, index, line, :cleanup, "destructive-error-workaround",
319
+ "This restates the error color by hand on top of the engine's part.\n" \
320
+ "0.7.1 gives that color its own token, so the part is legible under\n" \
321
+ "either palette convention:\n" \
322
+ " :root { --destructive-text: var(--destructive); }\n" \
323
+ "Then drop the utility and let [data-form-part=\"error\"] paint itself.",
324
+ "0.7.1")
325
+ end
326
+
260
327
  next unless line.include?("data-component=") || line =~ /component:\s*[:"']/
261
328
 
262
329
  if /\b(?:rounded|shadow)(?:-[a-z0-9\[\].\/-]+)?\b/.match?(line)
@@ -267,6 +334,133 @@ module MaquinaComponents
267
334
  end
268
335
  end
269
336
 
337
+ # ----- 0.7.1 checks -------------------------------------------------
338
+
339
+ # Records --destructive / --destructive-foreground per theme scope. The
340
+ # engine ships two opposite conventions for this pair (a pale tint with a
341
+ # dark readable foreground, and a saturated fill with a near-white one),
342
+ # and only the app's own theme says which one its components are sitting in.
343
+ def capture_destructive_token(path, index, declaration, chain)
344
+ match = DESTRUCTIVE_DECL.match(declaration.strip)
345
+ return unless match
346
+ return unless THEME_SCOPE.match?(chain.to_s.strip)
347
+
348
+ name = match[1]
349
+ scope = /\.dark\b/.match?(chain.to_s) ? "dark" : "light"
350
+
351
+ @destructive_tokens[scope] ||= {}
352
+ @destructive_tokens[scope][name] ||= {value: match[2].strip, path: path, index: index}
353
+ end
354
+
355
+ # First component of an oklch() color, normalised to 0..1. Returns nil for
356
+ # any other color space -- an unparseable value must not be guessed at.
357
+ def oklch_lightness(value)
358
+ match = OKLCH_LIGHTNESS.match(value.to_s)
359
+ return nil unless match
360
+
361
+ lightness = match[1].to_f
362
+ (match[2] == "%") ? lightness / 100.0 : lightness
363
+ end
364
+
365
+ def check_destructive_palette
366
+ @destructive_tokens.each do |scope, tokens|
367
+ foreground = tokens["destructive-foreground"]
368
+ base = tokens["destructive"]
369
+ surface = tokens["card"] || tokens["background"]
370
+ next unless foreground && base && surface
371
+
372
+ foreground_lightness = oklch_lightness(foreground[:value])
373
+ surface_lightness = oklch_lightness(surface[:value])
374
+ next unless foreground_lightness && surface_lightness
375
+
376
+ # [data-form-part="error"] paints --destructive-foreground on the card.
377
+ # Under the installer's palette that is the dark readable red and this
378
+ # gap is wide; under a shadcn-style palette it is the near-white
379
+ # on-fill color and the text vanishes.
380
+ next unless (foreground_lightness - surface_lightness).abs < CONTRAST_FLOOR
381
+
382
+ add(foreground[:path], foreground[:index], "--destructive-foreground: #{foreground[:value]}",
383
+ :breaking, "destructive-error-invisible",
384
+ "In this #{scope} palette --destructive-foreground is the color that sits ON\n" \
385
+ "a destructive fill, not on the page. [data-form-part=\"error\"] paints it\n" \
386
+ "as body text on --#{tokens["card"] ? "card" : "background"}, where it is all but invisible\n" \
387
+ "(oklch lightness #{foreground_lightness.round(3)} against #{surface_lightness.round(3)}).\n" \
388
+ "0.7.1 routes that color through its own token. Add to your #{scope} block:\n" \
389
+ " --destructive-text: var(--destructive);\n" \
390
+ " --destructive-border: var(--destructive);",
391
+ "0.7.1")
392
+ end
393
+ end
394
+
395
+ # An app that never sets aria-invalid is getting its error border from the
396
+ # CSS :invalid fallback. 0.7.1 narrows that to :user-invalid so a pristine
397
+ # required field stops painting red, which also means an app relying on the
398
+ # old behaviour loses the border it was depending on.
399
+ def check_invalid_without_aria
400
+ return if @aria_invalid_seen || @error_sites.empty?
401
+
402
+ site = @error_sites.first
403
+ add(site[:path], site[:index], site[:source], :breaking, "invalid-styling-without-aria",
404
+ "This app renders form errors (#{@error_sites.size} site#{"s" unless @error_sites.size == 1})\n" \
405
+ "but never sets aria-invalid, so the destructive border is coming from the\n" \
406
+ "CSS :invalid fallback. 0.7.1 replaces that with :user-invalid, which only\n" \
407
+ "matches after the reader has interacted with the field -- a server-rendered\n" \
408
+ "error on an untouched form will no longer paint a border.\n" \
409
+ "Drive the state explicitly on the input:\n" \
410
+ " aria: { invalid: model.errors[:field].any? }",
411
+ "0.7.1")
412
+ end
413
+
414
+ # Scans a view for form fields across their whole tag, not line by line.
415
+ def scan_form_fields(path)
416
+ source = File.read(path)
417
+
418
+ source.to_enum(:scan, FIELD_OPENER).each do
419
+ match = Regexp.last_match
420
+ window = field_window(source, match.begin(0))
421
+ next unless FIELD_COMPONENT.match?(window)
422
+ next unless FIELD_REQUIRED.match?(window)
423
+ next if FIELD_PLACEHOLDER.match?(window)
424
+
425
+ index = source[0...match.begin(0)].count("\n")
426
+ add(path, index, window, :review, "required-without-placeholder",
427
+ "Before 0.7.1 a required field with no placeholder matched\n" \
428
+ ":invalid:not(:placeholder-shown) from first paint, so it rendered the\n" \
429
+ "error state before the reader touched it -- with no aria-invalid, so\n" \
430
+ "screen readers were told nothing. 0.7.1 keys the error state on\n" \
431
+ ":user-invalid instead, and the premature red goes away on upgrade.\n" \
432
+ "Nothing to change here unless you were relying on it; drive real errors\n" \
433
+ "with aria-invalid.",
434
+ "0.7.1")
435
+ end
436
+ rescue ArgumentError, Errno::ENOENT
437
+ nil
438
+ end
439
+
440
+ # From the opener to the end of the tag or ERB expression that contains it,
441
+ # bounded so a malformed template cannot swallow the rest of the file.
442
+ def field_window(source, start)
443
+ slice = source[start, 500].to_s
444
+ terminator = slice.index("%>") || slice.index(">") || slice.length
445
+ slice[0, terminator + 2]
446
+ end
447
+
448
+ def scan_javascript(path)
449
+ source = File.read(path)
450
+ return unless source.include?("getBoundingClientRect")
451
+ return unless DROPDOWN_FLIP_HINT.match?(source)
452
+
453
+ index = source[0...source.index("getBoundingClientRect")].count("\n")
454
+ add(path, index, "getBoundingClientRect + data-side", :cleanup, "app-level-dropdown-flip",
455
+ "This looks like an app-level dropdown collision/flip controller. 0.7.1\n" \
456
+ "measures on open and sets data-side itself, for both the dropdown menu\n" \
457
+ "and the menu button, so the workaround can go.\n" \
458
+ "Delete it and let the engine place the menu.",
459
+ "0.7.1")
460
+ rescue ArgumentError, Errno::ENOENT
461
+ nil
462
+ end
463
+
270
464
  def read(path)
271
465
  File.readlines(path)
272
466
  rescue ArgumentError, Errno::ENOENT
@@ -1,3 +1,3 @@
1
1
  module MaquinaComponents
2
- VERSION = "0.7.0"
2
+ VERSION = "0.7.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez