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 +4 -4
- data/README.md +2 -1
- data/app/assets/stylesheets/date_picker.css +3 -3
- data/app/assets/stylesheets/form.css +29 -8
- data/app/javascript/controllers/dropdown_menu_controller.js +46 -1
- data/app/javascript/controllers/menu_button_controller.js +38 -0
- data/app/views/components/alert/_description.html.erb +1 -1
- data/app/views/components/alert/_title.html.erb +1 -1
- data/app/views/components/card/_description.html.erb +1 -1
- data/app/views/components/card/_title.html.erb +1 -1
- data/app/views/components/combobox/_label.html.erb +1 -1
- data/app/views/components/drawer/_description.html.erb +1 -1
- data/app/views/components/drawer/_title.html.erb +1 -1
- data/app/views/components/sidebar/_menu_badge.html.erb +1 -1
- data/app/views/components/table/_caption.html.erb +1 -1
- data/app/views/components/table/_cell.html.erb +1 -1
- data/app/views/components/table/_head.html.erb +1 -1
- data/app/views/components/toast/_description.html.erb +1 -1
- data/app/views/components/toast/_title.html.erb +1 -1
- data/lib/maquina_components/doctor.rb +208 -14
- data/lib/maquina_components/version.rb +1 -1
- data/lib/tasks/maquina_components_tasks.rake +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca1b928bf464480638e3f7504164a10cfecab249eed3a970dbaaad5a3d5b311a
|
|
4
|
+
data.tar.gz: 27998f7d8cdf85c6e49af5b4db527086093149130af50a722105e8970c01838c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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() {
|
|
@@ -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,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
|
|
8
|
-
# suggested replacement for each. Advisory only: it never
|
|
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
|
|
18
|
-
review: "REVIEW - still works, but
|
|
19
|
-
cleanup: "CLEANUP - probably unnecessary
|
|
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|
|
|
69
|
-
|
|
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
|
|
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
|
|
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,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
|
|
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
|
|