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,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The page-architecture catalog: the data the build_page
6
+ # workflow's `plan` step matches an intent against. Each archetype is a
7
+ # page SHAPE - purpose, section order, the states a real screen must
8
+ # handle, the edge cases that bite, the components it draws on, and the
9
+ # vetted block to START from when one covers it. This is the plan step's
10
+ # answer to a measured failure: naming the design
11
+ # skill and the block catalog does not move composition on its own; the
12
+ # missing piece is directed retrieval at PLAN time, before any ERB.
13
+ #
14
+ # Product framing: the catalog exists for the person
15
+ # building the page - it is the answer to "what does a real <kind of>
16
+ # screen need", stated once, correctly.
17
+ #
18
+ # SEED: this ships the archetypes anchored to poetry's eight blocks plus
19
+ # the universal page types every app needs. It is deliberately a seed
20
+ # (the target is ~50); it grows as blocks and evidence
21
+ # accrue. A brief that matches nothing is told so and routed to compose
22
+ # + the five mechanics, never silently dropped.
23
+ #
24
+ # @api private
25
+ module PageArchitectures
26
+ # Each entry: name, title, purpose, keywords (matched, weighted x2),
27
+ # sections (ordered macrostructure), states (what a real screen shows
28
+ # beyond the happy path), edge_cases (what bites), components, and
29
+ # block (the vetted starting block, or nil when none covers it yet).
30
+ ALL = [
31
+ {
32
+ "name" => "records-index",
33
+ "title" => "Records index",
34
+ "purpose" => "A browsable, filterable list of many records - the workhorse admin screen.",
35
+ "keywords" => %w[index list listing table records directory browse catalog invoices orders
36
+ users customers management filter pagination],
37
+ "sections" => ["Page header (title + count + primary 'New' action)",
38
+ "Filter/search bar (the controls that narrow the set)",
39
+ "The table (sortable columns, row-level actions in the last column)",
40
+ "Pagination + result count footer"],
41
+ "states" => ["Loading: skeleton rows, not a spinner over a blank table",
42
+ "Empty - NO records yet: an empty state with the 'New' call to action",
43
+ "Empty - filter matched nothing: a distinct 'no matches, clear filters' message",
44
+ "Populated: a realistic distribution (mostly one status, a few exceptions)"],
45
+ "edge_cases" => ["Long cell text: truncate with a title, never wrap the row to three lines",
46
+ "One page of results: hide pagination, keep the count",
47
+ "Row actions on mobile: collapse the last column into a menu",
48
+ "Bulk selection turns this into the action-bar archetype"],
49
+ "components" => %w[table badge button button_group input_group native_select pagination label icon],
50
+ "block" => "data-index"
51
+ },
52
+ {
53
+ "name" => "bulk-actions-table",
54
+ "title" => "Table with bulk actions",
55
+ "purpose" => "A records table where rows are selectable and act on in batches.",
56
+ "keywords" => %w[bulk selection batch selected archive delete multi-select checkbox
57
+ actions action bar rows],
58
+ "sections" => ["The records table with a leading selection column",
59
+ "A contextual action bar that appears only when rows are selected",
60
+ "The batch actions (archive / delete / export) with a selected-count label"],
61
+ "states" => ["Nothing selected: the action bar is absent, the table reads normally",
62
+ "Some selected: the bar shows the count + the batch actions",
63
+ "All selected across pages: offer 'select all N', distinct from 'all on this page'",
64
+ "A destructive batch action confirms before running (AlertDialog)"],
65
+ "edge_cases" => ["The action bar must not cover the last row - it floats, it does not overlap",
66
+ "Selection survives sort but is cleared by a filter change (say so)",
67
+ "A single-row action still lives in the row, not only the bar"],
68
+ "components" => %w[data_table table button icon badge],
69
+ "block" => "action-bar"
70
+ },
71
+ {
72
+ "name" => "admin-dashboard",
73
+ "title" => "Admin dashboard / app shell",
74
+ "purpose" => "The signed-in workspace: persistent chrome around a content area of stats and panels.",
75
+ "keywords" => %w[dashboard shell layout sidebar workspace admin console overview stats
76
+ chrome home analytics metrics],
77
+ "sections" => ["Sidebar (primary nav, current section marked)",
78
+ "Top bar (breadcrumb + user menu + global actions)",
79
+ "A KPI row (Stat cards, one number each)",
80
+ "Content panels (charts, recent activity, tables) on a grid"],
81
+ "states" => ["First run: zero data - each panel shows its own empty state, not a blank grid",
82
+ "Loading: skeleton the panels independently so the shell stays put",
83
+ "Collapsed sidebar on narrow viewports (the shell owns the breakpoint)"],
84
+ "edge_cases" => ["The content area is already padded - blocks composed INTO it drop their outer wrapper",
85
+ "One primary action in the top bar, everything else outline/ghost",
86
+ "Deep nav: mark the active trail, do not just highlight the leaf"],
87
+ "components" => %w[sidebar breadcrumb avatar card separator button icon stat],
88
+ "block" => "app-shell"
89
+ },
90
+ {
91
+ "name" => "settings",
92
+ "title" => "Settings page",
93
+ "purpose" => "Grouped, independently-saved panels of preferences and account controls.",
94
+ "keywords" => %w[settings section panel account preferences profile configuration
95
+ notifications security billing],
96
+ "sections" => ["Page header (what these settings govern)",
97
+ "One section-card per group (Profile / Notifications / Security / Danger)",
98
+ "Each card: a heading, its fields, and its own save affordance",
99
+ "A danger zone LAST, visually separated"],
100
+ "states" => ["Pristine vs dirty: the save action enables only when something changed",
101
+ "Saving: the one button that submits shows progress, the rest stay usable",
102
+ "Saved: a quiet confirmation, not a modal"],
103
+ "edge_cases" => ["The danger zone is its own destructive-panel archetype, not a red button in a card",
104
+ "Never mix badge treatments across the cards (one status family)",
105
+ "Sensitive fields (API keys) use the reveal/copy pattern, masked at rest"],
106
+ "components" => %w[card badge link icon label input switch button separator],
107
+ "block" => "section-card"
108
+ },
109
+ {
110
+ "name" => "record-detail",
111
+ "title" => "Record detail page",
112
+ "purpose" => "Everything about one record: its facts, its related lists, its actions.",
113
+ "keywords" => %w[detail show record profile summary overview single view page],
114
+ "sections" => ["Page header with breadcrumb back to the index + record-level actions",
115
+ "A facts panel (MetadataList: label:value pairs, status as a Badge)",
116
+ "Related lists (line items, activity, comments) as their own sections",
117
+ "Destructive actions fenced at the bottom"],
118
+ "states" => ["Loading: skeleton the header + facts, not a full-page spinner",
119
+ "Missing/deleted record: a 404-style empty state, not a broken page",
120
+ "Partial data: show the field label with an em-dash placeholder, keep alignment"],
121
+ "edge_cases" => ["Status belongs in ONE place (the header badge), not repeated per section",
122
+ "Related lists that are empty say so inline, they do not vanish",
123
+ "The primary action reflects state (Publish vs Unpublish), it is not static"],
124
+ "components" => %w[breadcrumb metadata_list badge card button separator icon],
125
+ "block" => "page-header"
126
+ },
127
+ {
128
+ "name" => "create-edit-form",
129
+ "title" => "Create / edit form",
130
+ "purpose" => "A focused form that creates or updates one record, with real validation.",
131
+ "keywords" => %w[form create edit new update fields validation submit input save
132
+ create-edit],
133
+ "sections" => ["Page (or dialog) header naming the task",
134
+ "Fields grouped by meaning inside a section-card, in the order a human fills them",
135
+ "Inline help under complex fields",
136
+ "A sticky action row: primary Save + a Cancel that does not look primary"],
137
+ "states" => ["Pristine, dirty, submitting (button progress + disabled resubmit)",
138
+ "Field-level errors anchored to their field, with a summary if many",
139
+ "Server error on submit: a form-level alert, the entered values preserved",
140
+ "Success: navigate or confirm, do not silently reset"],
141
+ "edge_cases" => ["Required vs optional is marked once, consistently",
142
+ "Choosing that writes a value is a Select/Combobox, never a menu (see deciding)",
143
+ "Unsaved-changes guard on navigate-away for long forms"],
144
+ "components" => %w[card label input textarea select combobox switch button alert icon],
145
+ "block" => "section-card"
146
+ },
147
+ {
148
+ "name" => "wizard-checkout",
149
+ "title" => "Multi-step wizard / checkout",
150
+ "purpose" => "A long task broken into ordered steps with visible progress and a review.",
151
+ "keywords" => %w[stepper wizard steps checkout onboarding progress multi-step flow
152
+ cart payment review],
153
+ "sections" => ["The step indicator (where you are, what remains)",
154
+ "The current step's fields, one concern per step",
155
+ "A review step summarizing every prior choice before commit",
156
+ "Back / Continue controls; the final step's action is the commit"],
157
+ "states" => ["Per-step validation gates Continue - you cannot skip ahead past errors",
158
+ "Returning to a completed step preserves its answers",
159
+ "Submitting the final step: progress, and it cannot be double-fired",
160
+ "Payment/commit failure returns to the review step with the error, not step 1"],
161
+ "edge_cases" => ["Do not animate keyboard-initiated step changes (perception floor)",
162
+ "The indicator reflects real completion, not just the highest step visited",
163
+ "Mobile: the indicator collapses to 'Step 2 of 4', the fields get full width"],
164
+ "components" => %w[card button icon label input separator],
165
+ "block" => "stepper"
166
+ },
167
+ {
168
+ "name" => "marketing-landing",
169
+ "title" => "Marketing / landing page",
170
+ "purpose" => "A public page that explains and converts: hero, proof, call to action.",
171
+ "keywords" => %w[landing marketing hero home public site navbar features testimonials
172
+ cta convert homepage],
173
+ "sections" => ["Top nav (logo, a few links, one primary CTA)",
174
+ "Hero (one claim, one sub-line, one primary + one secondary action)",
175
+ "Feature/benefit sections (alternating, contained)",
176
+ "Social proof, then a closing CTA band, then a footer"],
177
+ "states" => ["Signed-out vs signed-in nav (the CTA changes to 'Dashboard')",
178
+ "Responsive: the nav collapses to a sheet, the hero stacks"],
179
+ "edge_cases" => ["One primary action per section - competing CTAs kill conversion and hierarchy",
180
+ "Marketing copy still respects the heading ladder for SEO + a11y",
181
+ "The theme carries the personality; do not reach for per-instance gradients"],
182
+ "components" => %w[navigation_menu button link icon card badge],
183
+ "block" => "top-nav"
184
+ },
185
+ {
186
+ "name" => "pricing",
187
+ "title" => "Pricing page",
188
+ "purpose" => "Comparable plan tiers with one recommended, driving a single choice.",
189
+ "keywords" => %w[pricing plans tiers subscription billing compare upgrade plan price],
190
+ "sections" => ["Top nav",
191
+ "A tier row (3-4 cards), the recommended one visually lifted",
192
+ "A feature comparison (per-card lists, or a comparison table below)",
193
+ "An FAQ / closing CTA"],
194
+ "states" => ["Monthly/annual toggle recomputes every card's price",
195
+ "The current plan is marked when signed in ('Your plan'), its CTA disabled"],
196
+ "edge_cases" => ["Exactly one card is emphasized - two 'most popular' badges is a hierarchy bug",
197
+ "Equal-height cards regardless of feature-list length",
198
+ "The recommended card's button is the only filled primary in the row"],
199
+ "components" => %w[card badge button separator icon link toggle_group],
200
+ "block" => "top-nav"
201
+ },
202
+ {
203
+ "name" => "auth",
204
+ "title" => "Sign-in / sign-up screen",
205
+ "purpose" => "A centered, low-chrome screen that authenticates and nothing else.",
206
+ "keywords" => %w[auth login signin signup register password authentication credentials
207
+ sign-in sign-up forgot],
208
+ "sections" => ["A centered card: brand mark, title, the fields",
209
+ "Primary submit, then secondary provider buttons (outline)",
210
+ "A footer link to the opposite flow (sign up <-> sign in) + forgot-password"],
211
+ "states" => ["Submitting: the primary button shows progress, inputs lock",
212
+ "Auth failure: a form-level alert ('check your details'), never per-field blame",
213
+ "Rate-limited / locked: a distinct message, not the generic error"],
214
+ "edge_cases" => ["No app chrome here - this is the one screen with no sidebar/top-nav",
215
+ "Password field carries a reveal toggle; caps-lock hint is a nice touch",
216
+ "Provider buttons never outrank the primary submit"],
217
+ "components" => %w[card label input button separator link alert icon],
218
+ "block" => nil
219
+ },
220
+ {
221
+ "name" => "empty-first-run",
222
+ "title" => "Empty state / first run",
223
+ "purpose" => "The zero-data screen that turns 'nothing here' into a first action.",
224
+ "keywords" => %w[empty first-run onboarding zero blank getting-started nothing new
225
+ empty-state placeholder],
226
+ "sections" => ["A centered empty state: an icon, a one-line explanation of what goes here",
227
+ "The single primary action that creates the first item",
228
+ "Optionally a secondary 'learn more' / import path"],
229
+ "states" => ["True empty (never had data) vs filtered-empty (had data, filter hid it) differ in copy",
230
+ "Loading-into-empty: skeleton first, resolve to the empty state, no flicker"],
231
+ "edge_cases" => ["This is a SECTION pattern as much as a page - the same shape sits inside a panel",
232
+ "One action - do not offer five ways to start",
233
+ "Do not mistake an error for empty; a failed load has its own state"],
234
+ "components" => %w[card button icon link],
235
+ "block" => nil
236
+ },
237
+ {
238
+ "name" => "confirm-destructive",
239
+ "title" => "Destructive confirmation",
240
+ "purpose" => "The surface that makes an irreversible action deliberate and clear.",
241
+ "keywords" => %w[delete destroy remove deactivate irreversible permanently danger
242
+ destructive confirm deletion],
243
+ "sections" => ["A tinted destructive boundary (panel or AlertDialog)",
244
+ "Icon + title only on the tint; consequences in AA-clean muted copy below",
245
+ "A confirm action (labeled with the verb: 'Delete project') + a plain Cancel"],
246
+ "states" => ["Requires intent: a type-to-confirm field for high-stakes deletes",
247
+ "Confirming: progress on the destructive button, Cancel stays available",
248
+ "Failure: the error shows in place, the record is untouched"],
249
+ "edge_cases" => ["Description copy on the destructive tint fails AA - keep prose off the tint",
250
+ "The default focus is Cancel, not Confirm, for irreversible acts",
251
+ "AlertDialog (no light-dismiss), never a plain Dialog, for destruction"],
252
+ "components" => %w[alert button icon],
253
+ "block" => "destructive-panel"
254
+ },
255
+ {
256
+ "name" => "profile-account",
257
+ "title" => "Profile / account overview",
258
+ "purpose" => "A read-first page about one person or org, with edit paths to settings.",
259
+ "keywords" => %w[profile account overview member team org organization bio details
260
+ user avatar],
261
+ "sections" => ["A header identity band (avatar, name, role, primary action)",
262
+ "A facts panel (MetadataList) of the account's key attributes",
263
+ "Section cards for related areas (team, activity, connected apps)"],
264
+ "states" => ["Own profile (edit affordances) vs someone else's (read-only)",
265
+ "Incomplete profile: prompt to finish, do not show blank fields as errors"],
266
+ "edge_cases" => ["Avatar fallback to initials when no image - never a broken img",
267
+ "Status/role shown once as a Badge, in the identity band",
268
+ "Editing routes to the settings archetype, it does not inline every field"],
269
+ "components" => %w[avatar metadata_list badge card button separator icon link],
270
+ "block" => "section-card"
271
+ }
272
+ ].freeze
273
+
274
+ module_function
275
+
276
+ def all
277
+ ALL
278
+ end
279
+
280
+ # Score every archetype against a pre-stemmed token set (the Server's
281
+ # brief_tokens output). Curated keywords count double; title/purpose
282
+ # tokens count once - the same weighting compose uses for blocks, so
283
+ # planning and routing rank intents the same way. Returns
284
+ # [entry, score] sorted best-first, ties broken by name.
285
+ def scored(tokens)
286
+ ALL.map { |entry| [entry, score(entry, tokens)] }
287
+ .sort_by { |entry, score| [-score, entry["name"]] }
288
+ end
289
+
290
+ def score(entry, tokens)
291
+ keywords = (entry["keywords"] || []).to_set { |word| word.delete_suffix("s") }
292
+ corpus = "#{entry["title"]} #{entry["purpose"]}".downcase
293
+ .scan(/[a-z0-9][a-z0-9_-]+/)
294
+ .to_set { |token| token.delete_suffix("s") }
295
+ tokens.sum do |token|
296
+ if keywords.include?(token) then 2
297
+ elsif corpus.include?(token) then 1
298
+ else 0
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Poetry
6
+ module Core
7
+ # The DOM verifier for the part contract: reconciles a
8
+ # component's declared parts (Concerns::Parts) against its rendered
9
+ # previews, in both directions. Type-checking declared part keys
10
+ # alone never proves the anatomy; this module
11
+ # checks the DOM, so the registry-published contract cannot state a
12
+ # part, state attribute, or var seam the component doesn't render -
13
+ # nor omit one it does.
14
+ #
15
+ # Like DesignLint.lint_dom, verification is a pure function: the
16
+ # caller (poetry-ui/charts' part_contract_test) renders the previews
17
+ # and supplies the HTML; nothing here boots or renders.
18
+ #
19
+ # Ownership: a data-slot element belongs to the component whose
20
+ # data-component root is its nearest ancestor-or-self - embedded
21
+ # components (a Button inside a Card preview) attribute their parts
22
+ # to themselves, never to the composition they sit in.
23
+ #
24
+ # Rules (all errors):
25
+ # - missing-root: no rendered element carries data-component=<title>
26
+ # - slotless-component: previews render no owned data-slot at all -
27
+ # the anatomy is unstylable and undocumentable
28
+ # - undeclared-part: a rendered part the contract omits (the
29
+ # suggestion carries a paste-ready `part` line)
30
+ # - phantom-part: a declared part no preview renders
31
+ # - undeclared-state: a rendered state attribute the part omits
32
+ # - unverified-state: a declared state never rendered, outside the
33
+ # setState vocabulary, absent from the sources
34
+ # - unknown-state-value: a rendered value outside the declared values
35
+ # - undeclared-var: an inline custom property the part omits
36
+ # - unverified-var: a declared var never rendered inline and
37
+ # absent from the sources
38
+ # - unnamed-stateful: an owned element carrying state attributes or
39
+ # var seams without a data-slot name
40
+ #
41
+ # @api private
42
+ module PartContract
43
+ # Ruby mirror of app/javascript/poetry/core/helpers/state.js
44
+ # VOCABULARY (the attributes setState can write). JS-toggled states
45
+ # (data-open flips at runtime) verify against this list when no
46
+ # preview renders them server-side; a sync test asserts the mirror
47
+ # matches the JS source.
48
+ STATE_VOCABULARY = %w[
49
+ data-open data-closed data-popup-open data-panel-open
50
+ data-checked data-unchecked data-indeterminate
51
+ data-pressed data-active data-selected
52
+ ].freeze
53
+
54
+ # Wiring and identity attributes - not styleable state: the two
55
+ # anatomy markers, the Stimulus grammar, Turbo, and test plumbing.
56
+ INFRASTRUCTURE = %w[data-slot data-component data-controller data-action].freeze
57
+ INFRASTRUCTURE_PATTERNS = [
58
+ /\Adata-.+-(?:target|value|outlet|param)\z/, # Stimulus per-identifier grammar
59
+ /\Adata-poetry-/, # poetry's own wiring grammar (collection membership et al)
60
+ /\Adata-turbo/,
61
+ /\Adata-testid\z/
62
+ ].freeze
63
+
64
+ VAR_DECLARATION = /(--[a-z][a-z0-9-]*)\s*:/
65
+
66
+ module_function
67
+
68
+ # @param title [String] the component_title (its data-component value)
69
+ # @param parts [Array<Hash>] registry-shaped part entries
70
+ # @param docs [Array<String, #css>] every preview render (HTML or
71
+ # pre-parsed Nokogiri nodes)
72
+ # @param sources [String] the component's own source text plus the
73
+ # JS corpus - the second source for JS-applied states and vars
74
+ # @return [Array<Check::Finding>]
75
+ def verify(title:, parts:, docs:, sources: "")
76
+ observed = observe(title, docs, parts.map { |part| part["name"] })
77
+ findings = []
78
+ root_findings(findings, title, observed, parts)
79
+ dom_to_contract(findings, title, observed, parts)
80
+ contract_to_dom(findings, title, observed, parts, sources)
81
+ unnamed_findings(findings, title, observed)
82
+ findings
83
+ end
84
+
85
+ # The rendered truth: every owned part with its state attributes
86
+ # (attr => Set of rendered values) and inline vars, plus the owned
87
+ # elements that carry state without a part name.
88
+ def observe(title, docs, part_names = [])
89
+ parts = Hash.new do |hash, key|
90
+ hash[key] = { states: Hash.new { |states, attr| states[attr] = Set.new }, vars: Set.new }
91
+ end
92
+ observed = { parts: parts, root_seen: false, unnamed: [] }
93
+ docs.each do |doc|
94
+ doc = Nokogiri::HTML5.fragment(doc.to_s) unless doc.respond_to?(:css)
95
+ doc.css("[data-component]").each do |node|
96
+ observed[:root_seen] ||= node["data-component"] == title
97
+ end
98
+ doc.css("*").each { |node| observe_node(observed, title, node, part_names) }
99
+ end
100
+ observed
101
+ end
102
+
103
+ def observe_node(observed, title, node, part_names)
104
+ return unless owned?(node, title, part_names)
105
+
106
+ states = state_attributes(node)
107
+ vars = inline_vars(node)
108
+ if (slot = node["data-slot"])
109
+ entry = observed[:parts][slot]
110
+ states.each { |attr, value| entry[:states][attr] << value }
111
+ vars.each { |var| entry[:vars] << var }
112
+ elsif states.any? || vars.any?
113
+ observed[:unnamed] << "<#{node.name} #{(states.keys + vars.to_a).sort.join(" ")}>"
114
+ end
115
+ end
116
+
117
+ # Ownership climbs to the nearest data-component root. One exception:
118
+ # an embedded component's root that wears a part the outer component
119
+ # declares (an icon rendered AS the indicator glyph, the source's own
120
+ # shape) is the outer component's part - the climb starts above it.
121
+ def owned?(node, title, part_names = [])
122
+ current = node
123
+ current = node.parent if embedded_root_wearing_part?(node, title, part_names)
124
+ while current.respond_to?(:key?)
125
+ return current["data-component"] == title if current.key?("data-component")
126
+
127
+ current = current.parent
128
+ end
129
+ false
130
+ end
131
+
132
+ def embedded_root_wearing_part?(node, title, part_names)
133
+ node.key?("data-component") && node["data-component"] != title &&
134
+ part_names.include?(node["data-slot"])
135
+ end
136
+
137
+ def state_attributes(node)
138
+ node.attribute_nodes.each_with_object({}) do |attribute, states|
139
+ name = attribute.name
140
+ next unless name.start_with?("data-")
141
+ next if INFRASTRUCTURE.include?(name)
142
+ next if INFRASTRUCTURE_PATTERNS.any? { |pattern| name.match?(pattern) }
143
+
144
+ states[name] = attribute.value.to_s
145
+ end
146
+ end
147
+
148
+ def inline_vars(node)
149
+ (node["style"] || "").scan(VAR_DECLARATION).map(&:first)
150
+ end
151
+
152
+ def root_findings(findings, title, observed, parts)
153
+ unless observed[:root_seen]
154
+ findings << finding("missing-root",
155
+ "no preview renders data-component=#{title.inspect} - the component " \
156
+ "never identifies its root")
157
+ end
158
+ return unless observed[:parts].empty? && parts.empty? && observed[:root_seen]
159
+
160
+ findings << finding("slotless-component",
161
+ "#{title}: previews render no data-slot at all - name the anatomy " \
162
+ "(the root at minimum) so it can be styled and contracted")
163
+ end
164
+
165
+ def dom_to_contract(findings, title, observed, parts)
166
+ declared = parts.to_h { |part| [part["name"], part] }
167
+ observed[:parts].sort.each do |name, seen|
168
+ part = declared[name]
169
+ unless part
170
+ findings << finding("undeclared-part",
171
+ "#{title} renders data-slot=#{name.inspect} but the contract omits it",
172
+ suggestion: scaffold(name, seen))
173
+ next
174
+ end
175
+ state_reconciliation(findings, title, part, seen)
176
+ var_reconciliation(findings, title, part, seen)
177
+ end
178
+ end
179
+
180
+ def state_reconciliation(findings, title, part, seen)
181
+ declared = (part["states"] || []).to_h { |state| [state["attr"], state] }
182
+ seen[:states].sort.each do |attr, values|
183
+ state = declared[attr]
184
+ unless state
185
+ findings << finding("undeclared-state",
186
+ "#{title}/#{part["name"]} renders #{attr} but the contract omits it")
187
+ next
188
+ end
189
+ next unless state["values"]
190
+
191
+ (values.reject(&:empty?) - state["values"]).sort.each do |value|
192
+ findings << finding("unknown-state-value",
193
+ "#{title}/#{part["name"]} renders #{attr}=#{value.inspect}, outside " \
194
+ "the declared values #{state["values"].inspect}")
195
+ end
196
+ end
197
+ end
198
+
199
+ def var_reconciliation(findings, title, part, seen)
200
+ seen[:vars].sort.each do |var|
201
+ next if (part["vars"] || []).any? { |declared| var_match?(declared["name"], var) }
202
+
203
+ findings << finding("undeclared-var",
204
+ "#{title}/#{part["name"]} sets #{var} inline but the contract omits it")
205
+ end
206
+ end
207
+
208
+ def contract_to_dom(findings, title, observed, parts, sources)
209
+ parts.each do |part|
210
+ seen = observed[:parts][part["name"]] if observed[:parts].key?(part["name"])
211
+ unless seen
212
+ findings << finding("phantom-part",
213
+ "#{title} declares part #{part["name"].inspect} but no preview " \
214
+ "renders it - dead contract or missing preview coverage")
215
+ next
216
+ end
217
+ declared_states(findings, title, part, seen, sources)
218
+ declared_vars(findings, title, part, seen, sources)
219
+ end
220
+ end
221
+
222
+ def declared_states(findings, title, part, seen, sources)
223
+ (part["states"] || []).each do |state|
224
+ attr = state["attr"]
225
+ next if seen[:states].key?(attr)
226
+ next if STATE_VOCABULARY.include?(attr)
227
+ next if sources.include?(attr)
228
+
229
+ findings << finding("unverified-state",
230
+ "#{title}/#{part["name"]} declares #{attr} but no preview renders it, " \
231
+ "it is outside the setState vocabulary, and the sources never mention it")
232
+ end
233
+ end
234
+
235
+ def declared_vars(findings, title, part, seen, sources)
236
+ (part["vars"] || []).each do |var|
237
+ name = var["name"]
238
+ next if seen[:vars].any? { |rendered| var_match?(name, rendered) }
239
+ next if sources.include?(name.delete_suffix("*"))
240
+
241
+ findings << finding("unverified-var",
242
+ "#{title}/#{part["name"]} declares #{name} but no preview sets it " \
243
+ "inline and the sources never mention it")
244
+ end
245
+ end
246
+
247
+ def unnamed_findings(findings, title, observed)
248
+ observed[:unnamed].uniq.sort.each do |element|
249
+ findings << finding("unnamed-stateful",
250
+ "#{title} renders #{element} with state or var seams but no data-slot " \
251
+ "- name the part so the surface is stylable")
252
+ end
253
+ end
254
+
255
+ # A declared var matches exactly, or by prefix when it declares a
256
+ # dynamic family with a trailing * (charts' --color-*).
257
+ def var_match?(declared, rendered)
258
+ if declared.end_with?("*")
259
+ rendered.start_with?(declared.delete_suffix("*"))
260
+ else
261
+ declared == rendered
262
+ end
263
+ end
264
+
265
+ # A paste-ready `part` line carrying the rendered truth - the tier
266
+ # doubles as the roster scaffold generator.
267
+ def scaffold(name, seen)
268
+ line = "part #{name.inspect}, \"TODO\""
269
+ if seen[:states].any?
270
+ states = seen[:states].keys.sort.map { |attr| "#{attr.inspect} => \"TODO\"" }
271
+ line << ", states: { #{states.join(", ")} }"
272
+ end
273
+ if seen[:vars].any?
274
+ vars = seen[:vars].sort.map { |var| "#{var.inspect} => \"TODO\"" }
275
+ line << ", vars: { #{vars.join(", ")} }"
276
+ end
277
+ line
278
+ end
279
+
280
+ def finding(rule, message, suggestion: nil)
281
+ Check::Finding.new(rule: rule, severity: :error, message: message, suggestion: suggestion)
282
+ end
283
+ end
284
+ end
285
+ end