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,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # Generates the component-usage Claude Code skill from the registry -
6
+ # the same never-hand-maintained discipline as LlmsText, one delivery
7
+ # surface over: a lean SKILL.md menu (guardrails + family index) over
8
+ # references/ files an agent loads selectively, so a 65-component
9
+ # catalog stays inside the context budget.
10
+ # The family partition itself belongs to the UI gem (it knows its
11
+ # roster); this class only formats.
12
+ #
13
+ # @example The installable skill file map
14
+ # Poetry::Core::SkillText.new(registry: registry, families: families).files
15
+ #
16
+ # @api private
17
+ class SkillText < LlmsText
18
+ def initialize(registry:, families:, charts_registry: nil)
19
+ super(registry: registry)
20
+ @families = families
21
+ @charts_registry = charts_registry
22
+ end
23
+
24
+ # The installable file map, paths relative to .claude/skills/poetry/.
25
+ def files
26
+ files = { "SKILL.md" => skill_md }
27
+ @families.each_key { |family| files["references/#{family}.md"] = family_reference(family) }
28
+ files["references/blocks.md"] = blocks_reference
29
+ files["references/deciding.md"] = self.class.deciding_reference
30
+ files["references/charts.md"] = charts_reference if @charts_registry
31
+ files
32
+ end
33
+
34
+ # Contract sections for every entry, llms-full format - public here
35
+ # (unlike LlmsText's internals) so a charts-registry instance can
36
+ # lend its sections to the main skill's charts reference.
37
+ def sections(paths = nil)
38
+ entries = paths ? @registry.entries.slice(*paths) : @registry.entries
39
+ entries.map { |path, entry| component_section(path, entry) }.join("\n")
40
+ end
41
+
42
+ # The component decision tree: curated head-to-heads keyed on the
43
+ # INTERACTION MODEL. The
44
+ # roster facts it names are gate-checked (doc-prose scans installed
45
+ # skill prose), so keep every claim registry-true. A PUBLIC class
46
+ # method: the boot-free MCP agent serves it too (the guidance tool),
47
+ # without a registry in hand.
48
+ def self.deciding_reference
49
+ <<~MD
50
+ # deciding - which component
51
+
52
+ Match the INTERACTION MODEL first - what the user does - and the
53
+ visual treatment second. The look is the theme's job; the
54
+ component's job is behavior. When a whole screen is the brief,
55
+ the `compose` MCP tool routes to a vetted block BEFORE any of
56
+ this (see `references/blocks.md`).
57
+
58
+ ## Options are VALUES vs options DO things
59
+
60
+ - Choosing writes a form value: Select (closed pick),
61
+ NativeSelect (zero-JS forms), Combobox (filter to pick),
62
+ Combobox `multiple:` (pick several - chips), RadioGroup (few
63
+ always-visible exclusive choices), ToggleGroup `type: :single`
64
+ (exclusive UI state, no form machinery).
65
+ - Choosing runs an action: DropdownMenu (per-item actions),
66
+ ContextMenu (right-click/long-press), Menubar (app-wide command
67
+ strip), Command (searchable palette).
68
+ - The rule: if the choice submits, it is never a menu; if it
69
+ navigates or mutates, it is never a select.
70
+
71
+ ## Overlays
72
+
73
+ - Dialog: interrupt for a task; focus trapped; explicit close.
74
+ - AlertDialog: confirm a destructive or irreversible act - no
75
+ light dismiss, the cancel action is the default focus.
76
+ - Sheet: a side panel for secondary work while the page stays
77
+ visible; Drawer: the bottom-edge mobile-first surface.
78
+ - Popover: a light-dismiss micro-surface anchored to its trigger.
79
+ - HoverCard: a hover PREVIEW - never interactive controls.
80
+ - Tooltip: one line of labeling; never actions, never required
81
+ information.
82
+
83
+ ## Chips, toggles, badges
84
+
85
+ - Exists until removed (recipients, filters): TagGroup.
86
+ - On/off UI state: Toggle; exclusive set: ToggleGroup.
87
+ - Picked from options: Combobox `multiple:` (its chips remove
88
+ back into the option list; TagGroup chips are just gone).
89
+ - Static status: Badge - never clickable.
90
+
91
+ ## Quantity, progress, waiting
92
+
93
+ - A quantity within a known range (disk, seats, strength): Meter.
94
+ - An operation completing over time: Progress (determinate only).
95
+ - Unknown duration: Spinner; structure-shaped waits: Skeleton;
96
+ Turbo-loaded regions: Deferred.
97
+
98
+ ## Text and value entry
99
+
100
+ - One line: Input; multi-line: Textarea; search: SearchField
101
+ (Escape clears, the clear affordance rides it).
102
+ - Fixed format: Input `mask:`; numbers: NumberField; one-time
103
+ codes: InputOtp.
104
+ - Dates and times: DateField / TimeField (segmented editing);
105
+ DatePicker when a calendar aids the pick; Calendar alone for
106
+ in-page selection; Slider / range for magnitudes.
107
+ - File upload: FileInput (`variant: :dropzone` when dragging is
108
+ the point).
109
+
110
+ ## Structure and records
111
+
112
+ - Tabular records with sort/filter/page: DataTable
113
+ (server-driven URL state); plain semantics: Table.
114
+ - Label:value facts on a detail page: MetadataList; one KPI:
115
+ Stat; grouped content: Card; freeform rows: Item.
116
+ - Hierarchy that expands and collapses: Tree; app navigation:
117
+ Sidebar (shell) / NavigationMenu (site) / Tabs (views of one
118
+ thing); progressive disclosure: Accordion (a one-off:
119
+ Collapsible).
120
+ - Grouped controls in ONE Tab stop: Toolbar; visually fused
121
+ buttons: ButtonGroup.
122
+
123
+ ## When two still fit
124
+
125
+ Prefer the narrower component (Stat over a hand-built Card;
126
+ SearchField over Input-plus-button), and prefer the one whose
127
+ KEYBOARD contract matches what the user expects to press. If the
128
+ answer still is not obvious, the block catalog probably already
129
+ composed it - check `references/blocks.md` before building.
130
+ MD
131
+ end
132
+
133
+ private
134
+
135
+ def skill_md
136
+ <<~MD
137
+ ---
138
+ name: poetry
139
+ description: >-
140
+ Build Rails views with the poetry component library: helper
141
+ contracts, options, slots, blocks, and the check workflow. Use
142
+ whenever writing or editing ERB/UI in an app that has poetry
143
+ installed.
144
+ ---
145
+
146
+ # poetry - component usage
147
+
148
+ Generated from the poetry registry (#{census}). After updating
149
+ poetry gems, regenerate with `bin/rails g poetry:skill`.
150
+
151
+ ## Guardrails
152
+
153
+ - FIRST MOVE, for every brief: call the poetry MCP `compose` tool
154
+ with the task text, before writing any ERB. It routes you to the
155
+ matching vetted block (source included, adapt in place - the
156
+ known winning path for screens) or to the right components for
157
+ single-component work. No MCP? Open `references/blocks.md` and
158
+ `bin/rails g poetry:block --list`. Composing a screen from
159
+ scratch when a block matched is the known losing path.
160
+ - Compose with the `poetry_<name>` helpers; never hand-write `cn-*`
161
+ classes, raw hex/oklch colors, or off-scale arbitrary values -
162
+ tokens and variants carry the design.
163
+ - Options are keywords; content is the block. Helpers take at most
164
+ the positional arguments their contract lists - most take none.
165
+ - A typed slot renders another component: the call takes THAT
166
+ component's props, never a render block.
167
+ - Icon names are kebab-case symbols: `:"circle-check"`, never
168
+ `:circle_check`.
169
+ - Status reads as a set: one badge treatment family per surface -
170
+ never mix solid (default/destructive) and soft
171
+ (success/warning/info) pills in one table.
172
+ - Page framing: a section that IS the page's subject keeps its
173
+ container and breathing room (`mx-auto max-w-* p-6`); a bare
174
+ component at the viewport origin reads cramped. Drop the wrapper
175
+ when composing into an already-padded frame.
176
+ - One visual theme per app (chosen at install); components read
177
+ tokens, never restate them.
178
+ - Browser agents (WebMCP): opt a rendered component into the user's
179
+ own agent with `webmcp: "name"` on the call - only components that
180
+ declare tools (Combobox, Dialog, Sheet, Drawer, Tabs; `describe_component`
181
+ at `full` lists them); a form becomes a tool with
182
+ `poetry_webmcp_form(tool: { name:, description: })` (autosubmit is
183
+ GET-only). Needs the poetry-agent gem; check gates the opt-ins.
184
+ - Check comes LAST: run `bin/rails poetry:check` (or the poetry MCP
185
+ `check` tool - instant, no app boot) as the FINAL action, after
186
+ your last edit. An edit made after your last check is unverified
187
+ markup - re-run check before finishing, every time.
188
+
189
+ ## Find your component
190
+
191
+ Not sure WHICH component the job calls for? Open
192
+ `references/deciding.md` first - the decision tree matches the
193
+ INTERACTION MODEL (what the user does), never the visual look.
194
+
195
+ Load the reference for the family you are composing in - each file
196
+ carries the full contracts (options, variants, slots, wiring, RULE
197
+ lines) for its components:
198
+
199
+ #{family_index}
200
+
201
+ ## Composing a page? Load poetry-design
202
+
203
+ Building or restyling a full page, screen, or dashboard - not a
204
+ lone component? Load the `poetry-design` skill BEFORE composing:
205
+ theme fit, page macrostructure, hierarchy, status color, and the
206
+ finishing audit live there. Component contracts alone do not make
207
+ a composed page - and neither does guidance: start the page from
208
+ `compose`'s block match and adapt, don't rebuild its advice from
209
+ a blank file.
210
+
211
+ ## Authoring a component? Load poetry-component
212
+
213
+ Building a component of your own - one this catalog doesn't
214
+ cover? Load the `poetry-component` skill BEFORE writing the
215
+ class: the canonical anatomy (section order), the documentation
216
+ standard, and the audit checklist live there.
217
+ MD
218
+ end
219
+
220
+ def census
221
+ parts = ["#{@registry.entries.size} components"]
222
+ parts << "#{@charts_registry.entries.size} chart components" if @charts_registry
223
+ blocks = @registry.blocks
224
+ parts << "#{blocks.size} blocks" if blocks&.any?
225
+ parts.join(" + ")
226
+ end
227
+
228
+ def family_index
229
+ lines = @families.map do |family, members|
230
+ "- **#{family}** (`references/#{family}.md`): #{members.join(", ")}"
231
+ end
232
+ lines << "- **blocks** (`references/blocks.md`): #{(@registry.blocks || {}).keys.join(", ")}"
233
+ lines << "- **charts** (`references/charts.md`): #{chart_names.join(", ")}" if @charts_registry
234
+ lines.join("\n")
235
+ end
236
+
237
+ def chart_names
238
+ @charts_registry.entries.keys.map { |path| path.split("/").drop(2).join("_") }
239
+ end
240
+
241
+ def family_reference(family)
242
+ members = @families.fetch(family)
243
+ paths = @registry.entries.keys.select do |path|
244
+ members.include?(path.split("/").drop(2).join("_"))
245
+ end
246
+ <<~MD
247
+ # poetry #{family} components
248
+
249
+ Contracts generated from the registry. `RULE` lines are constraints,
250
+ not suggestions. Options are keywords; content is the block.
251
+
252
+ #{sections(paths)}
253
+ MD
254
+ end
255
+
256
+ # The blocks reference inlines every block's full source - the block
257
+ # WITH source is the load-bearing agent path.
258
+ def blocks_reference
259
+ <<~MD
260
+ # poetry blocks - vetted composed screens
261
+
262
+ Blocks are the DEFAULT starting point for a screen, not a
263
+ fallback: the MCP `compose` tool routes a brief to the right one
264
+ automatically (call it first); this file carries the same catalog
265
+ with full source. `bin/rails g poetry:block <name>` copies one
266
+ into app/views/blocks/ as source the app owns. Blocks carry the
267
+ composed patterns - containment, status color-coding, page
268
+ furniture, realistic content - so a screen starts composed, not
269
+ blank. The sample content is meant to be replaced.
270
+ #{blocks_full}
271
+ MD
272
+ end
273
+
274
+ def charts_reference
275
+ charts = self.class.new(registry: @charts_registry, families: {})
276
+ <<~MD
277
+ # poetry chart components
278
+
279
+ Contracts generated from the charts registry. Chart data is
280
+ server-rendered; the `poetry_chart(type, ...)` shorthand takes the
281
+ chart type as its one positional argument.
282
+
283
+ #{charts.sections}
284
+ MD
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view/record_identifier"
4
+ require "digest/md5"
5
+ require "random/formatter"
6
+
7
+ module Poetry
8
+ module Core
9
+ # Semantic identity for component DOM ids: a
10
+ # caller-supplied key: becomes a component-namespaced, render-stable
11
+ # token, so Turbo morph pairs the same logical component across
12
+ # renders and cached fragments stay composable.
13
+ #
14
+ # @example A render-stable id derived from a record
15
+ # Poetry::Core::StableId.key_token(article) # => "article_42"
16
+ #
17
+ # Derivation is deliberately dom_id-first, never slug-sniffing:
18
+ # records go through ActionView::RecordIdentifier.dom_id, which
19
+ # respects a host's `def to_key = [slug]` override - the one lever
20
+ # that keeps key:, turbo_frame_tag, and Turbo Stream broadcast
21
+ # targets emitting the SAME identity everywhere (turbo-rails calls
22
+ # RecordIdentifier internally). Plain values parameterize. Slugs
23
+ # read directly would be mutable and recyclable (a freed slug
24
+ # claimed by another record re-creates false identity), and poetry
25
+ # preferring them while the host's own dom_id stays pk-based would
26
+ # split one app into two id vocabularies.
27
+ #
28
+ # @api private
29
+ module StableId
30
+ # The thread-local sequence generator (the OPT-IN mode, default
31
+ # off). Same page -> same seed -> identical id sequence across
32
+ # renders, which is what byte-stable content pages need for Turbo
33
+ # morph and body-hash ETags. The hazards that keep it opt-in are
34
+ # documented where the mode is: same-path turbo-frames restart the
35
+ # sequence and collide deterministically, and reordered same-type
36
+ # collections get POSITIONAL false identity (state on the wrong
37
+ # record) - keyed identity (key:) is the general answer; this mode
38
+ # exists for static/content pages a host judges safe.
39
+ SEQUENCE_KEY = :poetry_stable_id_sequence
40
+
41
+ module_function
42
+
43
+ # Deterministic ids inside the block: with_seed is honored
44
+ # REGARDLESS of stable_id_mode (an explicit call is explicit
45
+ # intent - it is also the test API). The around_action is what the
46
+ # mode gates.
47
+ def with_seed(seed)
48
+ previous = Thread.current[SEQUENCE_KEY]
49
+ Thread.current[SEQUENCE_KEY] = sequence_for(seed)
50
+ yield
51
+ ensure
52
+ Thread.current[SEQUENCE_KEY] = previous
53
+ end
54
+
55
+ # The next deterministic token, or nil when no sequence is armed
56
+ # (callers fall through to the random rung).
57
+ def next_sequence_token
58
+ Thread.current[SEQUENCE_KEY]&.hex(8)
59
+ end
60
+
61
+ # Random::Formatter over a digest-seeded PRNG:
62
+ # hex(8) matches the random fallback's shape exactly, so the mode
63
+ # is invisible in the DOM.
64
+ def sequence_for(seed)
65
+ Random.new(Digest::MD5.hexdigest(seed.to_s).to_i(16))
66
+ end
67
+
68
+ # The opt-in request seeding (engine-installed on every controller,
69
+ # inert unless stable_id_mode == :sequence): seeds the thread-local
70
+ # sequence from the configured lambda (default: request.path -
71
+ # matching Turbo's own page-refresh pathname test) around each
72
+ # action, always restoring after.
73
+ module Controller
74
+ extend ActiveSupport::Concern
75
+
76
+ included do
77
+ around_action :poetry_stable_id_sequence
78
+ end
79
+
80
+ private
81
+
82
+ def poetry_stable_id_sequence(&)
83
+ config = Poetry::Core::Config.current
84
+ return yield unless config.stable_id_mode == :sequence
85
+
86
+ Poetry::Core::StableId.with_seed(config.stable_id_seed.call(request), &)
87
+ end
88
+ end
89
+
90
+ # The stable token for a key:, or nil when no usable token exists
91
+ # (callers fall back to random - fail toward correctness).
92
+ # Records (anything dom_id can address) -> dom_id; everything else
93
+ # -> to_s.parameterize. New records derive "new_<model>" - two of
94
+ # those on one page collide, so repeated new-record forms need
95
+ # explicit keys (documented).
96
+ #
97
+ # @param key [Object, nil] a record, a plain value, or nil
98
+ # @return [String, nil] the render-stable token, or nil
99
+ def key_token(key)
100
+ return nil if key.nil?
101
+
102
+ if key.respond_to?(:to_model) || key.respond_to?(:model_name)
103
+ ActionView::RecordIdentifier.dom_id(key)
104
+ else
105
+ key.to_s.parameterize.presence
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end