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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. 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