poetry-core 0.0.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 +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- metadata +267 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Core
|
|
5
|
+
# Design-slop detectors: deterministic rules for the checkable design
|
|
6
|
+
# axes, catalog-aware (the AST knows what a Card IS) and scoped to the
|
|
7
|
+
# on-distribution defaults generated UIs drift toward: cards in cards,
|
|
8
|
+
# the icon-tile-over-heading hero, walls of identical cards, off-scale
|
|
9
|
+
# values, token-less gradients, heading skips, center-everything,
|
|
10
|
+
# stacked shadows, type-scale monotony, invisible surface boundaries.
|
|
11
|
+
# No LLM scoring here - measured runs showed cold aesthetic judgment is
|
|
12
|
+
# near chance; deterministic rules + paired judges are the defensible
|
|
13
|
+
# modes.
|
|
14
|
+
#
|
|
15
|
+
# Two tiers, one Finding vocabulary (Check::Finding - file:line, message
|
|
16
|
+
# that names the fix, JSON/text via Check.to_json/to_text):
|
|
17
|
+
#
|
|
18
|
+
# - the AST tier rides the same herb walk as `poetry check`, and
|
|
19
|
+
# parses plain HTML just as well as ERB - so the eval harness runs
|
|
20
|
+
# the identical rules on every arm (scorer portability);
|
|
21
|
+
# - the DOM tier reads COMPUTED styles (the dommy tier in poetry-ui,
|
|
22
|
+
# no browser needed) for what markup cannot show: the painted type
|
|
23
|
+
# scale and surface boundaries.
|
|
24
|
+
#
|
|
25
|
+
# Every rule is a warning: this is the taste tier, `poetry check`'s
|
|
26
|
+
# mechanical errors stay the hard gate. rake design:lint (poetry-ui)
|
|
27
|
+
# exits non-zero on any finding - the dogfood surfaces stay clean.
|
|
28
|
+
#
|
|
29
|
+
# @api private
|
|
30
|
+
module DesignLint
|
|
31
|
+
# id => [tier, rationale] - the design discipline each rule enforces.
|
|
32
|
+
RULES = {
|
|
33
|
+
"card-in-card" => [:ast, "nesting discipline: a card never frames another card"],
|
|
34
|
+
"icon-tile-over-heading" => [:ast, "the stock AI hero tell (icon chip above heading)"],
|
|
35
|
+
"wall-of-cards" => [:ast, "macrostructure variety: identical cards repeated read as filler"],
|
|
36
|
+
"off-scale-arbitrary" => [:ast, "spacing scale discipline: arbitrary lengths break the scale"],
|
|
37
|
+
"gradient-off-token" => [:ast, "the purple-blue gradient tell; raw colors bypass the tokens"],
|
|
38
|
+
"heading-skip" => [:ast, "document outline audit (WCAG 1.3.1 adjacent)"],
|
|
39
|
+
"mixed-status-weight" => [:ast, "one status set, one treatment - " \
|
|
40
|
+
"solid and soft badge pills must not mix in one table"],
|
|
41
|
+
"center-everything" => [:ast, "centered-body-copy tell"],
|
|
42
|
+
"shadow-stack" => [:ast, "one elevation level per surface"],
|
|
43
|
+
"type-scale-monotony" => [:dom, "type hierarchy audit: a page painted in one size has no hierarchy"],
|
|
44
|
+
"adjacent-same-surface" => [:dom, "surface separation audit"],
|
|
45
|
+
"contrast-adjacent" => [:dom, "boundary legibility as a measurable axis"],
|
|
46
|
+
"stock-theme-nudge" => [:dom, "refuses the default look when a brand exists"],
|
|
47
|
+
# Copy/composition tells that CAN fire on poetry surfaces
|
|
48
|
+
# (off-token color/border tells cannot).
|
|
49
|
+
"em-dash-overuse" => [:ast, ">=5 em dashes in page copy - the LLM prose tell"],
|
|
50
|
+
"marketing-buzzword" => [:ast, "stock SaaS phrases in page copy"],
|
|
51
|
+
"aphoristic-cadence" => [:ast, "'Not a X. Y.' manufactured-contrast cadence"],
|
|
52
|
+
"numbered-section-markers" => [:ast, "sequential 01/02/03 section markers"],
|
|
53
|
+
"repeated-section-kickers" => [:ast, "tracked-caps kicker above every section heading"],
|
|
54
|
+
"hero-eyebrow-chip" => [:ast, "tracked-caps/accent text eyebrow above the h1"],
|
|
55
|
+
"oversized-h1" => [:ast, "72px+ display h1 carrying long copy"],
|
|
56
|
+
# Motion floor: perception-physics rules, theme-independent - they
|
|
57
|
+
# read the utility classes the same in host ERB (poetry check /
|
|
58
|
+
# design:lint) and in the extracted theme layer (design:motion
|
|
59
|
+
# self-audit). Only patterns poetry itself never ships are ENFORCED
|
|
60
|
+
# here, so the floor never false-positives on the gem's own rendered
|
|
61
|
+
# output. transition-all is pervasive in the upstream-ported theme
|
|
62
|
+
# layer, so it is a REPORT-ONLY advisory (transition_all_advisory /
|
|
63
|
+
# design:motion), NOT an enforced rule. The softer preferences
|
|
64
|
+
# (ease-in-out on enters) and timing TOKENS are the later
|
|
65
|
+
# tokenize/conformance tiers.
|
|
66
|
+
"motion-ease-in" => [:ast, "motion floor: ease-in decelerates wrong for UI enters"],
|
|
67
|
+
"motion-duration-ceiling" => [:ast, "motion floor: UI transition over ~500ms reads as laggy"],
|
|
68
|
+
"motion-scale-from-zero" => [:ast, "motion floor: scale-from-0 entries erupt from nothing"]
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
# Spacing/sizing/type utilities where an arbitrary length is off-scale.
|
|
72
|
+
SCALED_UTILITY = /\A-?(?:[mp][trblxyse]?|gap(?:-[xy])?|space-[xy]|text|leading|w|h|size|
|
|
73
|
+
min-w|min-h|max-w|max-h|top|right|bottom|left|inset(?:-[xy])?|
|
|
74
|
+
rounded(?:-[a-z]+)?)-\[(\d[\d.]*)(px|rem|em)\]\z/x
|
|
75
|
+
GRADIENT = /\Abg-(?:gradient-to-[a-z]+|linear-|radial|conic)/
|
|
76
|
+
SHADOW = /\Ashadow(?:\z|-(?!none)[a-z0-9]+\z)/
|
|
77
|
+
HEADING = /\Ah([1-6])\z/
|
|
78
|
+
|
|
79
|
+
# Motion floor: the floor governs TRANSITIONS - discrete state
|
|
80
|
+
# changes. An element is subject to it when it carries a transition-*
|
|
81
|
+
# utility (a bare `duration-`/`ease-`/`scale-0` is inert or ambiguous
|
|
82
|
+
# without one). Continuous keyframe loops (animate-spin, animate-pulse,
|
|
83
|
+
# animate-caret-blink) legitimately run long/linear and are EXEMPT -
|
|
84
|
+
# they are animations, not transitions. Durations are milliseconds in
|
|
85
|
+
# Tailwind (duration-700 = 700ms); the UI transition ceiling is ~500ms.
|
|
86
|
+
TRANSITION_DECLARED = /\Atransition(?:-|\z)/
|
|
87
|
+
DURATION_TOKEN = /\Aduration-(\d+)\z/
|
|
88
|
+
DURATION_ARBITRARY = /\Aduration-\[(\d+(?:\.\d+)?)(m?s)\]\z/
|
|
89
|
+
SCALE_ZERO = /\Ascale(?:-[xy])?-0\z/
|
|
90
|
+
UI_DURATION_CEILING_MS = 500
|
|
91
|
+
|
|
92
|
+
# The lint tree: HTML elements plus poetry_* call blocks as
|
|
93
|
+
# pseudo-nodes, so "Card inside Card" is checkable whether the card is
|
|
94
|
+
# a helper call (consumer ERB) or rendered markup (eval arms).
|
|
95
|
+
#
|
|
96
|
+
# @api private
|
|
97
|
+
Node = Struct.new(:kind, :tag, :classes, :attrs, :helper, :line, :children, :parent, :texts,
|
|
98
|
+
keyword_init: true) do
|
|
99
|
+
def element? = kind == :element
|
|
100
|
+
def heading_level = tag&.match(HEADING) && Regexp.last_match(1).to_i
|
|
101
|
+
|
|
102
|
+
def card?
|
|
103
|
+
return helper == "poetry_card" unless element?
|
|
104
|
+
|
|
105
|
+
attrs["data-slot"] == "card" || classes.include?("cn-card") ||
|
|
106
|
+
(classes.include?("border") && classes.any? { |c| c.start_with?("rounded") } &&
|
|
107
|
+
(classes.any?(SHADOW) || classes.include?("bg-card")))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def signature
|
|
111
|
+
element? ? "#{tag}##{classes.sort.join(".")}" : "call##{helper}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ancestors
|
|
115
|
+
list = []
|
|
116
|
+
node = parent
|
|
117
|
+
while node
|
|
118
|
+
list << node
|
|
119
|
+
node = node.parent
|
|
120
|
+
end
|
|
121
|
+
list
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
module_function
|
|
126
|
+
|
|
127
|
+
# AST tier over one ERB or HTML source string. Returns [Check::Finding].
|
|
128
|
+
def lint(source, file: nil)
|
|
129
|
+
require "herb"
|
|
130
|
+
root = build_tree(Herb.parse(source).value)
|
|
131
|
+
findings = []
|
|
132
|
+
walk_rules(root, findings)
|
|
133
|
+
heading_skips(root, findings)
|
|
134
|
+
center_everything(root, findings)
|
|
135
|
+
mixed_status_weight(root, findings)
|
|
136
|
+
copy_tells(root, findings)
|
|
137
|
+
repeated_section_kickers(root, findings)
|
|
138
|
+
findings.sort_by! { |finding| [finding.line || 0, finding.rule] }
|
|
139
|
+
findings.each { |finding| finding.file = file }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# DOM tier over a rendered page: `doc` is a Nokogiri node, `styles` a
|
|
143
|
+
# callable (element) -> computed-style hash with the CSS property names
|
|
144
|
+
# used below (the dommy tier provides it; nil skips style rules).
|
|
145
|
+
# `context` carries host facts for the nudge rule.
|
|
146
|
+
def lint_dom(doc:, styles: nil, context: {}, file: nil)
|
|
147
|
+
findings = []
|
|
148
|
+
if styles
|
|
149
|
+
type_scale_monotony(doc, styles, findings)
|
|
150
|
+
surface_boundaries(doc, styles, findings)
|
|
151
|
+
end
|
|
152
|
+
stock_theme_nudge(context, findings)
|
|
153
|
+
findings.each { |finding| finding.file = file }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# --- tree ----------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def build_tree(ast)
|
|
159
|
+
root = Node.new(kind: :root, classes: [], attrs: {}, children: [], texts: [])
|
|
160
|
+
append_children(ast, root)
|
|
161
|
+
root
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def append_children(ast_node, tree_parent)
|
|
165
|
+
ast_node.child_nodes.compact.each do |child|
|
|
166
|
+
case child.class.name.split("::").last
|
|
167
|
+
when "HTMLElementNode"
|
|
168
|
+
node = element_node(child, tree_parent)
|
|
169
|
+
tree_parent.children << node
|
|
170
|
+
append_children(child, node)
|
|
171
|
+
when "ERBBlockNode"
|
|
172
|
+
helper = child.content.value[/\bpoetry_[a-z_]+/] if child.respond_to?(:content)
|
|
173
|
+
node = Node.new(kind: helper ? :call : :block, helper: helper, classes: [], attrs: {},
|
|
174
|
+
line: child.location.start.line, children: [], texts: [], parent: tree_parent)
|
|
175
|
+
tree_parent.children << node
|
|
176
|
+
append_children(child, node)
|
|
177
|
+
when "HTMLTextNode"
|
|
178
|
+
tree_parent.texts << child.content if child.respond_to?(:content)
|
|
179
|
+
else
|
|
180
|
+
append_children(child, tree_parent) if child.respond_to?(:child_nodes)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def element_node(ast_el, parent)
|
|
186
|
+
attrs = {}
|
|
187
|
+
open_tag = ast_el.respond_to?(:open_tag) ? ast_el.open_tag : nil
|
|
188
|
+
Array(open_tag&.child_nodes).compact.each do |child|
|
|
189
|
+
next unless child.is_a?(Herb::AST::HTMLAttributeNode)
|
|
190
|
+
|
|
191
|
+
name = literal_content(child.name)
|
|
192
|
+
attrs[name] = attribute_literal(child) if name
|
|
193
|
+
end
|
|
194
|
+
Node.new(kind: :element, tag: ast_el.tag_name&.value.to_s.downcase, attrs: attrs,
|
|
195
|
+
classes: attrs.fetch("class", "").split, line: ast_el.location.start.line,
|
|
196
|
+
children: [], texts: [], parent: parent)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def literal_content(node)
|
|
200
|
+
first = Array(node&.child_nodes).compact.first
|
|
201
|
+
first.respond_to?(:content) ? first.content : nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def attribute_literal(attribute)
|
|
205
|
+
value = attribute.value
|
|
206
|
+
return "" unless value
|
|
207
|
+
|
|
208
|
+
value.child_nodes.compact.filter_map do |chunk|
|
|
209
|
+
chunk.content if chunk.is_a?(Herb::AST::LiteralNode)
|
|
210
|
+
end.join(" ").strip
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# --- AST rules -------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def walk_rules(node, findings, stack = [])
|
|
216
|
+
node.children.each_with_index do |child, index|
|
|
217
|
+
card_in_card(child, findings) if child.card? && stack.any?(&:card?)
|
|
218
|
+
icon_tile_over_heading(child, node.children[index + 1], findings)
|
|
219
|
+
hero_eyebrow_chip(child, node.children[index + 1], findings)
|
|
220
|
+
class_token_rules(child, findings) if child.element?
|
|
221
|
+
findings.concat(motion_class_findings(child.classes, child.line)) if child.element?
|
|
222
|
+
oversized_h1(child, findings)
|
|
223
|
+
shadow_stack(child, findings)
|
|
224
|
+
wall_of_cards(node, findings) if index.zero?
|
|
225
|
+
walk_rules(child, findings, stack + [child])
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def card_in_card(node, findings)
|
|
230
|
+
findings << finding("card-in-card", node.line,
|
|
231
|
+
"Card nested directly inside a Card - flatten, or use a plain " \
|
|
232
|
+
"bordered section (slop tell: cards-in-cards)")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def icon_tile_over_heading(node, next_sibling, findings)
|
|
236
|
+
return unless node.element? && next_sibling&.element? && next_sibling.heading_level
|
|
237
|
+
return unless node.classes.any? { |c| c.start_with?("rounded") } &&
|
|
238
|
+
node.classes.any? { |c| c.start_with?("bg-") }
|
|
239
|
+
return unless icon_only?(node)
|
|
240
|
+
|
|
241
|
+
findings << finding("icon-tile-over-heading", node.line,
|
|
242
|
+
"icon in a tinted tile directly above a heading - the stock AI hero " \
|
|
243
|
+
"pattern; put the icon inline with the heading or drop the tile")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def icon_only?(node)
|
|
247
|
+
meaningful = node.children.reject { |child| child.kind == :block }
|
|
248
|
+
return false unless meaningful.size == 1
|
|
249
|
+
|
|
250
|
+
child = meaningful.first
|
|
251
|
+
(child.element? && child.tag == "svg") || child.helper == "poetry_icon"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def class_token_rules(node, findings)
|
|
255
|
+
node.classes.each do |token|
|
|
256
|
+
if (match = token.match(SCALED_UTILITY))
|
|
257
|
+
value, unit = match.captures
|
|
258
|
+
next if unit != "px" && ((value.to_f / 0.125) % 1).zero? # on the half-step scale
|
|
259
|
+
|
|
260
|
+
findings << finding("off-scale-arbitrary", node.line, off_scale_message(token, value.to_f, unit))
|
|
261
|
+
elsif token.match?(GRADIENT)
|
|
262
|
+
findings << finding("gradient-off-token", node.line,
|
|
263
|
+
"#{token} paints a gradient outside the token surface - use a " \
|
|
264
|
+
"semantic background (bg-primary, bg-muted, ...) or theme the surface")
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def off_scale_message(token, value, unit)
|
|
270
|
+
px = unit == "px" ? value : value * 16
|
|
271
|
+
prefix = token[/\A-?[a-z-]+(?=-\[)/]
|
|
272
|
+
if (px % 1).zero? && (px % 2).zero? # an exact half-step: the scale already spells it
|
|
273
|
+
"#{token} is the scale value #{prefix}-#{format("%g", px / 4.0)} - use the scale spelling"
|
|
274
|
+
else
|
|
275
|
+
lower = (px / 2).floor * 2
|
|
276
|
+
steps = [lower, lower + 2].map { |step| "#{prefix}-#{format("%g", step / 4.0)} = #{step}px" }
|
|
277
|
+
"#{token} is off the spacing/type scale - use the nearest step (#{steps.join(" / ")}) or add a token"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The ENFORCED motion floor, PUBLIC so the
|
|
282
|
+
# theme-layer self-audit (design:motion) runs the identical rules over
|
|
283
|
+
# the @apply utilities it extracts. Only patterns poetry itself never
|
|
284
|
+
# ships are here, so the floor never false-positives on the gem's own
|
|
285
|
+
# rendered components. An over-ceiling duration is wrong whatever
|
|
286
|
+
# triggers it, so it reads the variant-stripped base (data-open:
|
|
287
|
+
# duration-700 still counts); ease-in and scale-0 are only wrong as the
|
|
288
|
+
# WHOLE transition or the rest state - a state-scoped data-closed:
|
|
289
|
+
# ease-in / data-closed:scale-0 is a legitimate exit target, so those
|
|
290
|
+
# match the bare, unprefixed token only.
|
|
291
|
+
def motion_class_findings(classes, line)
|
|
292
|
+
bases = classes.map { |token| token.split(":").last }
|
|
293
|
+
return [] unless bases.any? { |base| base.match?(TRANSITION_DECLARED) }
|
|
294
|
+
|
|
295
|
+
findings = []
|
|
296
|
+
if classes.include?("ease-in")
|
|
297
|
+
findings << finding("motion-ease-in", line,
|
|
298
|
+
"ease-in accelerates into the rest state - wrong for UI (enters feel " \
|
|
299
|
+
"sluggish, exits abrupt); use ease-out for enters, ease-in-out for moves")
|
|
300
|
+
end
|
|
301
|
+
if (over = motion_over_ceiling(bases))
|
|
302
|
+
findings << finding("motion-duration-ceiling", line,
|
|
303
|
+
"#{over}ms is above the UI motion ceiling (~#{UI_DURATION_CEILING_MS}ms) - UI " \
|
|
304
|
+
"transitions past ~300ms read as laggy; shorten it")
|
|
305
|
+
end
|
|
306
|
+
# A bare scale-0 rest state pops from a point - UNLESS it fades in
|
|
307
|
+
# too (a co-present opacity-0): at scale-0 the element is invisible,
|
|
308
|
+
# so the erupt is never seen. That is the standard icon crossfade
|
|
309
|
+
# (rest at scale-0 opacity-0, animate to scale-100 opacity-100), not
|
|
310
|
+
# slop; only a scale pop without a fade is flagged.
|
|
311
|
+
if classes.any? { |token| token.match?(SCALE_ZERO) } && !classes.include?("opacity-0")
|
|
312
|
+
findings << finding("motion-scale-from-zero", line,
|
|
313
|
+
"an animated element rests at scale-0 with no opacity fade - scaling up " \
|
|
314
|
+
"from nothing erupts into view; start from scale-95, or fade with opacity-0")
|
|
315
|
+
end
|
|
316
|
+
findings
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# The report-only transition-all advisory. transition-all is
|
|
320
|
+
# pervasive in the upstream-ported theme layer, so enforcing it would
|
|
321
|
+
# fail poetry's own rendered gates and force a nine-theme re-timing - a
|
|
322
|
+
# design decision, surfaced by design:motion rather than gated. NOT a
|
|
323
|
+
# registered RULE (lint never emits it); the message says so.
|
|
324
|
+
def transition_all_advisory(classes, line)
|
|
325
|
+
bases = classes.map { |token| token.split(":").last }
|
|
326
|
+
return [] unless bases.include?("transition-all")
|
|
327
|
+
|
|
328
|
+
[finding("motion-transition-all", line,
|
|
329
|
+
"transition-all animates every property, including layout; prefer the specific " \
|
|
330
|
+
"properties (transition-colors / transition-[color,box-shadow] / transition-transform). " \
|
|
331
|
+
"ADVISORY - poetry ships the upstream default here; re-timing is a design decision")]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def motion_over_ceiling(bases)
|
|
335
|
+
bases.each do |base|
|
|
336
|
+
if (match = base.match(DURATION_TOKEN))
|
|
337
|
+
ms = match[1].to_i
|
|
338
|
+
elsif (match = base.match(DURATION_ARBITRARY))
|
|
339
|
+
ms = match[2] == "s" ? (match[1].to_f * 1000).round : match[1].to_i
|
|
340
|
+
else
|
|
341
|
+
next
|
|
342
|
+
end
|
|
343
|
+
return ms if ms > UI_DURATION_CEILING_MS
|
|
344
|
+
end
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def shadow_stack(node, findings)
|
|
349
|
+
return unless node.element? && node.classes.any?(SHADOW)
|
|
350
|
+
return unless node.ancestors.any? { |ancestor| ancestor.element? && ancestor.classes.any?(SHADOW) }
|
|
351
|
+
|
|
352
|
+
findings << finding("shadow-stack", node.line,
|
|
353
|
+
"shadow on a child of an already-shadowed surface - keep one " \
|
|
354
|
+
"elevation level per surface (drop the inner shadow)")
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def wall_of_cards(parent, findings)
|
|
358
|
+
elements = parent.children.select(&:card?)
|
|
359
|
+
return if elements.size < 4
|
|
360
|
+
|
|
361
|
+
run = elements.each_cons(4).find { |group| group.map(&:signature).uniq.size == 1 }
|
|
362
|
+
return unless run
|
|
363
|
+
|
|
364
|
+
findings << finding("wall-of-cards", run.first.line,
|
|
365
|
+
"#{elements.size} identical cards in a row - vary the anatomy, or use " \
|
|
366
|
+
"a list/table for homogeneous records")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def heading_skips(root, findings)
|
|
370
|
+
levels = []
|
|
371
|
+
collect_headings(root, levels)
|
|
372
|
+
levels.each_cons(2) do |(prev, _), (level, line)|
|
|
373
|
+
next unless level > prev + 1
|
|
374
|
+
|
|
375
|
+
findings << finding("heading-skip", line,
|
|
376
|
+
"h#{prev} jumps to h#{level} - keep heading levels sequential " \
|
|
377
|
+
"(insert h#{prev + 1} or demote)")
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def collect_headings(node, levels)
|
|
382
|
+
levels << [node.heading_level, node.line] if node.element? && node.heading_level
|
|
383
|
+
node.children.each { |child| collect_headings(child, levels) }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# One status set, one treatment: the
|
|
387
|
+
# table arm mixed a solid destructive "Overdue" pill into a soft
|
|
388
|
+
# success/warning column and the judge called the inconsistency out.
|
|
389
|
+
# Detection rides the rendered badge markup (data-slot/data-variant) -
|
|
390
|
+
# a badge call's kwargs are invisible to the ERB pseudo-node, so the
|
|
391
|
+
# rule bites where the eval gate and design:lint read: rendered HTML.
|
|
392
|
+
SOLID_BADGE_VARIANTS = %w[default destructive].freeze
|
|
393
|
+
SOFT_BADGE_VARIANTS = %w[success warning info].freeze
|
|
394
|
+
|
|
395
|
+
def mixed_status_weight(root, findings)
|
|
396
|
+
each_table(root) do |table|
|
|
397
|
+
variants = []
|
|
398
|
+
collect_badge_variants(table, variants)
|
|
399
|
+
solid = (variants.map(&:first) & SOLID_BADGE_VARIANTS)
|
|
400
|
+
soft = (variants.map(&:first) & SOFT_BADGE_VARIANTS)
|
|
401
|
+
next unless solid.any? && soft.any?
|
|
402
|
+
|
|
403
|
+
line = variants.find { |variant, _line| solid.include?(variant) }&.last || table.line
|
|
404
|
+
findings << finding("mixed-status-weight", line,
|
|
405
|
+
"one table mixes solid (#{solid.join(", ")}) and soft " \
|
|
406
|
+
"(#{soft.join(", ")}) badge pills - status sets read as a set: " \
|
|
407
|
+
"keep one treatment family per surface")
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def each_table(node, &block)
|
|
412
|
+
yield node if node.element? && node.tag == "table"
|
|
413
|
+
node.children.each { |child| each_table(child, &block) }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def collect_badge_variants(node, variants)
|
|
417
|
+
if node.element? && node.attrs["data-slot"] == "badge" && node.attrs["data-variant"]
|
|
418
|
+
variants << [node.attrs["data-variant"], node.line]
|
|
419
|
+
end
|
|
420
|
+
node.children.each { |child| collect_badge_variants(child, variants) }
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# --- tranche 2: the copy/composition tells ----------------------------
|
|
424
|
+
|
|
425
|
+
# Page copy is the joined static text OUTSIDE code/kbd contexts - em
|
|
426
|
+
# dashes and `01` tokens inside code samples are content, not design.
|
|
427
|
+
COPY_EXEMPT_TAGS = %w[pre code kbd script style].freeze
|
|
428
|
+
|
|
429
|
+
# A curated cut of a 28-phrase stock-SaaS list: the phrases
|
|
430
|
+
# a model reaches for when no copy direction was given.
|
|
431
|
+
BUZZWORDS = [
|
|
432
|
+
"streamline your", "enterprise-grade", "harness the power", "unlock the power",
|
|
433
|
+
"supercharge", "seamlessly integrate", "revolutionize", "game-changing",
|
|
434
|
+
"next-generation", "cutting-edge", "empower your", "to the next level",
|
|
435
|
+
"all-in-one platform", "built for scale", "lightning-fast", "blazingly fast",
|
|
436
|
+
"future-proof", "world-class", "effortlessly", "in seconds, not"
|
|
437
|
+
].freeze
|
|
438
|
+
|
|
439
|
+
# "Not a X. Y." manufactured contrast + "X. No Y. Just Z." rebuttal.
|
|
440
|
+
# The lookbehind keeps the shared period unconsumed so back-to-back
|
|
441
|
+
# rebuttals ("No config. Just results.") each count.
|
|
442
|
+
APHORISM_PATTERNS = [
|
|
443
|
+
/\b[Nn]ot (?:a|an|just|your) [^.!?\n]{2,60}\.\s+[A-Z][^.!?\n]{1,40}\./,
|
|
444
|
+
/(?<=[.!?])\s+(?:no [a-z][a-z -]{1,30}\.|just [a-z][a-z -]{1,30}\.)/i
|
|
445
|
+
].freeze
|
|
446
|
+
|
|
447
|
+
def copy_tells(root, findings)
|
|
448
|
+
text = page_copy(root)
|
|
449
|
+
return if text.length < 80 # fragments carry no prose signal
|
|
450
|
+
|
|
451
|
+
em_dash_overuse(text, findings)
|
|
452
|
+
marketing_buzzword(text, findings)
|
|
453
|
+
aphoristic_cadence(text, findings)
|
|
454
|
+
numbered_section_markers(text, findings)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def page_copy(root)
|
|
458
|
+
chunks = []
|
|
459
|
+
collect_copy(root, chunks)
|
|
460
|
+
chunks.join(" ").squeeze(" ")
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def collect_copy(node, chunks)
|
|
464
|
+
return if node.element? && COPY_EXEMPT_TAGS.include?(node.tag)
|
|
465
|
+
|
|
466
|
+
chunks.concat(node.texts) if node.texts
|
|
467
|
+
node.children.each { |child| collect_copy(child, chunks) }
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def em_dash_overuse(text, findings)
|
|
471
|
+
count = text.count("—")
|
|
472
|
+
return if count < 5
|
|
473
|
+
|
|
474
|
+
findings << finding("em-dash-overuse", nil,
|
|
475
|
+
"#{count} em dashes in the page copy - the LLM prose tell; rewrite " \
|
|
476
|
+
"most as periods, commas, or parentheses")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def marketing_buzzword(text, findings)
|
|
480
|
+
down = text.downcase
|
|
481
|
+
hits = BUZZWORDS.select { |phrase| down.include?(phrase) }
|
|
482
|
+
return if hits.size < 3
|
|
483
|
+
|
|
484
|
+
findings << finding("marketing-buzzword", nil,
|
|
485
|
+
"stock SaaS copy: #{hits.first(4).map(&:inspect).join(", ")} - say what " \
|
|
486
|
+
"the product concretely does instead")
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def aphoristic_cadence(text, findings)
|
|
490
|
+
count = APHORISM_PATTERNS.sum { |pattern| text.scan(pattern).size }
|
|
491
|
+
return if count < 3
|
|
492
|
+
|
|
493
|
+
findings << finding("aphoristic-cadence", nil,
|
|
494
|
+
"#{count} manufactured-contrast constructions (\"Not a X. Y.\" / " \
|
|
495
|
+
"\"No X. Just Y.\") - one lands, #{count} read as generated copy")
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def numbered_section_markers(text, findings)
|
|
499
|
+
# Two-digit tokens only, and at least one zero-padded (01-09): "3
|
|
500
|
+
# steps" and prices must never count; "01 02 03" editorial markers do.
|
|
501
|
+
tokens = text.scan(/(?<![\d.,:$])(0[1-9]|1[0-2])(?![\d.,:%])/).flatten
|
|
502
|
+
return unless tokens.any? { |t| t.start_with?("0") }
|
|
503
|
+
|
|
504
|
+
numbers = tokens.map(&:to_i).uniq.sort
|
|
505
|
+
return if numbers.size < 3
|
|
506
|
+
return unless numbers.each_cons(2).count { |a, b| b == a + 1 } >= 2
|
|
507
|
+
|
|
508
|
+
findings << finding("numbered-section-markers", nil,
|
|
509
|
+
"sequential section markers (#{numbers.first(4).map { |n| format("%02d", n) }.join(" ")}" \
|
|
510
|
+
") - the numbered-editorial tell; cut the numbers or vary the anatomy")
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Tracked-caps kicker above section headings: one is an accent, one per
|
|
514
|
+
# section is the template tell. Nav/breadcrumb chrome never counts.
|
|
515
|
+
def repeated_section_kickers(root, findings)
|
|
516
|
+
kickers = []
|
|
517
|
+
collect_kickers(root, kickers)
|
|
518
|
+
return if kickers.size < 3
|
|
519
|
+
|
|
520
|
+
findings << finding("repeated-section-kickers", kickers[2].line,
|
|
521
|
+
"#{kickers.size} tracked-caps kickers above headings on one page - " \
|
|
522
|
+
"the sectioned-template tell; keep at most one, or vary section openers")
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def collect_kickers(node, kickers)
|
|
526
|
+
return if node.element? && (node.tag == "nav" || node.attrs["role"] == "navigation" ||
|
|
527
|
+
node.attrs["aria-label"].to_s.downcase.include?("breadcrumb"))
|
|
528
|
+
|
|
529
|
+
node.children.each_cons(2) do |candidate, heading|
|
|
530
|
+
next unless candidate.element? && heading.element? && heading.heading_level.to_i.between?(2, 4)
|
|
531
|
+
next unless kicker_classes?(candidate)
|
|
532
|
+
|
|
533
|
+
kickers << candidate
|
|
534
|
+
end
|
|
535
|
+
node.children.each { |child| collect_kickers(child, kickers) }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def kicker_classes?(node)
|
|
539
|
+
node.classes.include?("uppercase") &&
|
|
540
|
+
node.classes.any? { |c| c.start_with?("tracking-") } &&
|
|
541
|
+
node.attrs["data-slot"] != "badge"
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Text eyebrow directly above the h1 - the stock AI hero opener. The
|
|
545
|
+
# icon-tile variant has its own rule; badges (announcement pills) are a
|
|
546
|
+
# deliberate pattern and never count.
|
|
547
|
+
def hero_eyebrow_chip(node, next_sibling, findings)
|
|
548
|
+
return unless node.element? && next_sibling&.element? && next_sibling.heading_level == 1
|
|
549
|
+
return if node.attrs["data-slot"] == "badge" || node.texts.to_a.join.strip.empty?
|
|
550
|
+
return unless kicker_classes?(node) ||
|
|
551
|
+
(node.classes.any? { |c| c.match?(/\Afont-(semibold|bold)\z/) } &&
|
|
552
|
+
node.classes.include?("text-primary"))
|
|
553
|
+
|
|
554
|
+
findings << finding("hero-eyebrow-chip", node.line,
|
|
555
|
+
"text eyebrow directly above the h1 - the stock AI hero opener; fold " \
|
|
556
|
+
"it into the heading or cut it")
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# 72px+ display type carrying a full sentence: display sizes are for
|
|
560
|
+
# 2-6 word statements.
|
|
561
|
+
OVERSIZED_TEXT = %w[text-7xl text-8xl text-9xl].freeze
|
|
562
|
+
|
|
563
|
+
def oversized_h1(node, findings)
|
|
564
|
+
return unless node.element? && node.heading_level == 1
|
|
565
|
+
return unless node.classes.intersect?(OVERSIZED_TEXT)
|
|
566
|
+
return if node.texts.to_a.join(" ").strip.length < 40
|
|
567
|
+
|
|
568
|
+
findings << finding("oversized-h1", node.line,
|
|
569
|
+
"display-size h1 (#{(node.classes & OVERSIZED_TEXT).first}) carrying " \
|
|
570
|
+
"40+ characters - shorten the headline or step the size down")
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def center_everything(root, findings)
|
|
574
|
+
centered = []
|
|
575
|
+
collect_centered(root, centered)
|
|
576
|
+
return if centered.size < 3
|
|
577
|
+
|
|
578
|
+
findings << finding("center-everything", centered[2].line,
|
|
579
|
+
"text-center on #{centered.size} blocks in one template - center only " \
|
|
580
|
+
"heroes and empty states; left-align body copy")
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Centered CELLS are correct design (day grids, numeric columns,
|
|
584
|
+
# buttons) - the rule targets centered body COPY, so grid/table
|
|
585
|
+
# subtrees and cell/control elements never count.
|
|
586
|
+
CENTERED_EXEMPT_TAGS = %w[button td th table].freeze
|
|
587
|
+
CENTERED_EXEMPT_ROLES = %w[grid gridcell cell columnheader rowheader row table].freeze
|
|
588
|
+
|
|
589
|
+
def collect_centered(node, centered)
|
|
590
|
+
return if node.element? &&
|
|
591
|
+
(CENTERED_EXEMPT_TAGS.include?(node.tag) || CENTERED_EXEMPT_ROLES.include?(node.attrs["role"]))
|
|
592
|
+
|
|
593
|
+
centered << node if node.element? && node.classes.include?("text-center")
|
|
594
|
+
node.children.each { |child| collect_centered(child, centered) }
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# --- DOM rules -------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
# Content text only - controls (buttons, labels) and data cells are
|
|
600
|
+
# legitimately uniform; monotony is a CONTENT-hierarchy signal.
|
|
601
|
+
TEXT_ELEMENTS = "h1, h2, h3, h4, h5, h6, p, li, blockquote"
|
|
602
|
+
|
|
603
|
+
def type_scale_monotony(doc, styles, findings)
|
|
604
|
+
nodes = doc.css(TEXT_ELEMENTS).select { |el| el.text.strip.length.positive? }
|
|
605
|
+
# Navigation chrome (pagination, menus) is legitimately uniform.
|
|
606
|
+
nodes = nodes.reject do |el|
|
|
607
|
+
el.name == "li" && el.ancestors.any? { |a| a.name == "nav" || a["role"] == "navigation" }
|
|
608
|
+
end
|
|
609
|
+
# Typeset prose owns its hierarchy in the app-owned
|
|
610
|
+
# stylesheet's em-derived element rules - nested :where() CSS the
|
|
611
|
+
# static renderer cannot compute, so every element reads as the
|
|
612
|
+
# base size. Bare-element prose is the pattern, not the slop.
|
|
613
|
+
nodes = nodes.reject do |el|
|
|
614
|
+
el.ancestors.any? { |a| a["class"].to_s.split(/\s+/).include?("typeset") }
|
|
615
|
+
end
|
|
616
|
+
nodes = nodes.select { |el| rendered?(styles.call(el)) }
|
|
617
|
+
return if nodes.size < 6
|
|
618
|
+
|
|
619
|
+
sizes = nodes.filter_map { |el| styles.call(el)["font-size"] }.uniq
|
|
620
|
+
return if sizes.size >= 2
|
|
621
|
+
|
|
622
|
+
findings << finding("type-scale-monotony", nil,
|
|
623
|
+
"#{nodes.size} text elements all render at #{sizes.first} - give the " \
|
|
624
|
+
"page a type hierarchy (text-xl+ headings, text-sm support)")
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Boundary exemptions, each a deliberate design pattern, not slop:
|
|
628
|
+
# composite controls share surfaces (segmented buttons, toolbars);
|
|
629
|
+
# landmark regions may differ by a subtle tint (the sidebar rail);
|
|
630
|
+
# non-rendered elements (closed popups in the initial DOM) paint
|
|
631
|
+
# nothing.
|
|
632
|
+
INTERACTIVE_TAGS = %w[button a input select textarea].freeze
|
|
633
|
+
INTERACTIVE_ROLES = %w[button tab menuitem menuitemcheckbox menuitemradio option radio switch].freeze
|
|
634
|
+
LANDMARK_TAGS = %w[aside main nav header footer].freeze
|
|
635
|
+
LANDMARK_ROLES = %w[complementary main navigation banner contentinfo region].freeze
|
|
636
|
+
|
|
637
|
+
def surface_boundaries(doc, styles, findings)
|
|
638
|
+
doc.css("*").each do |parent|
|
|
639
|
+
children = parent.element_children
|
|
640
|
+
next if children.size < 2
|
|
641
|
+
|
|
642
|
+
gap = styles.call(parent)["gap"].to_s
|
|
643
|
+
next unless gap.empty? || gap == "0px" || gap == "normal"
|
|
644
|
+
|
|
645
|
+
children.each_cons(2) do |a, b|
|
|
646
|
+
check_boundary(a, b, styles, findings)
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def interactive?(element)
|
|
652
|
+
INTERACTIVE_TAGS.include?(element.name) || INTERACTIVE_ROLES.include?(element["role"])
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def landmark?(element)
|
|
656
|
+
LANDMARK_TAGS.include?(element.name) || LANDMARK_ROLES.include?(element["role"])
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def rendered?(computed)
|
|
660
|
+
computed["display"].to_s != "none" && computed["visibility"].to_s != "hidden"
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def check_boundary(first, second, styles, findings)
|
|
664
|
+
return if interactive?(first) && interactive?(second)
|
|
665
|
+
return if landmark?(first) || landmark?(second)
|
|
666
|
+
return unless rendered?(styles.call(first)) && rendered?(styles.call(second))
|
|
667
|
+
|
|
668
|
+
bg_a = surface_color(styles.call(first))
|
|
669
|
+
bg_b = surface_color(styles.call(second))
|
|
670
|
+
return unless bg_a && bg_b
|
|
671
|
+
return if bordered?(styles.call(first)) || bordered?(styles.call(second))
|
|
672
|
+
|
|
673
|
+
if bg_a.css == bg_b.css
|
|
674
|
+
findings << finding("adjacent-same-surface", nil,
|
|
675
|
+
"adjacent siblings paint the same surface (#{bg_a.css}) with no " \
|
|
676
|
+
"border, shadow, or gap - the boundary is invisible; separate or merge them")
|
|
677
|
+
elsif bg_a.contrast_ratio(bg_b) < 1.1
|
|
678
|
+
findings << finding("contrast-adjacent", nil,
|
|
679
|
+
"adjacent surfaces differ by #{format("%.2f", bg_a.contrast_ratio(bg_b))}:1 " \
|
|
680
|
+
"(#{bg_a.css} vs #{bg_b.css}) - indistinguishable; add a border or more contrast")
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def surface_color(computed)
|
|
685
|
+
color = Tokens::Color.parse(computed["background-color"].to_s)
|
|
686
|
+
return unless color&.alpha&.positive?
|
|
687
|
+
return if shadowed?(computed)
|
|
688
|
+
|
|
689
|
+
color
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Some computed-style engines (dommy) never aggregate the border-width
|
|
693
|
+
# shorthand - the per-side property is the reliable read.
|
|
694
|
+
def bordered?(computed)
|
|
695
|
+
%w[border-width border-top-width].any? do |property|
|
|
696
|
+
width = computed[property].to_s
|
|
697
|
+
!width.empty? && width != "0px"
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def shadowed?(computed)
|
|
702
|
+
shadow = computed["box-shadow"].to_s
|
|
703
|
+
!shadow.empty? && shadow != "none"
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def stock_theme_nudge(context, findings)
|
|
707
|
+
return unless context[:design_md_present] && !context[:overrides_present]
|
|
708
|
+
|
|
709
|
+
findings << finding("stock-theme-nudge", nil,
|
|
710
|
+
"a DESIGN.md exists but no design-overrides.css - the app paints the " \
|
|
711
|
+
"stock theme while a brand is on disk; run " \
|
|
712
|
+
"bin/rails 'poetry:design:import[DESIGN.md]'")
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def finding(rule, line, message)
|
|
716
|
+
Check::Finding.new(rule: rule, severity: :warning, message: message, line: line)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
end
|