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,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
|