Sidebar

Displays the mobile sidebar.

Command palette

Search for a command to run…

Docs
Installation
Theming
Typography
Testing
I18n
Stimulus
Form Builder
Pagination
Deferred Regions
Optimistic Forms
Recipes
Page Agent
Agent Skills
Editors
API Reference
Data Table
Caching
Stable IDs
Rails Engines
Accessibility
MCP Server
WebMCP
AG-UI Relay
A2UI Surfaces
Libraries
Core
UI
Charts
Agent
Simple Form
Extract
Icons
Lucide
Components
Accordion
Alert
Alert Dialog
Aspect Ratio
Attachment
Autocomplete
Avatar
Badge
Breadcrumb
Bubble
Button
Button Group
Calendar
Card
Carousel
Checkbox
Clipboard Text
Code Block
Collapsible
Combobox
Command
Command Dialog
Context Menu
Data Table
Date Field
Date Picker
Date Time Field
Deferred
Dialog
Drawer
Dropdown Menu
Empty
Field
Field Group
Field Separator
Fieldset
File Input
Hover Card
Icon
Input
Input Group
Input Otp
Item
Kbd
Label
Link
Marker
Menubar
Message
Message Scroller
Metadata List
Meter
Native Select
Navigation Menu
Number Field
Pagination
Popover
Progress
Questionnaire
Radio Group
Resizable
Scroll Area
Search Field
Select
Sensitive Input
Separator
Sheet
Sidebar
Skeleton
Slider
Spinner
Stat
Switch
Table
Tabs
Tag Group
Textarea
Time Field
Timeline
Toast
Toast Trigger
Toaster
Toggle
Toggle Group
Toolbar
Tooltip
Tree
Typeset
Charts
Adapter Chart
Area Chart
Bar Chart
Composed Chart
Line Chart
Pie Chart
Radar Chart
Radial Bar Chart
Scatter Chart
Demos
Chat Replay
AG-UI Relay
A2UI Surface
Interactive Filter
Live Streaming
Synced Tooltips
Brush & Zoom
Reference
Installation — Install
Installation — Upgrade an existing app
Installation — The three ownership tiers
Installation — Copy-ins: poetry:diff
Installation — Scaffold templates
Theming — The nine themes
Theming — Installing a theme
Theming — Owning the design outright
Theming — How this site renders all nine
Theming — The font pairing
Theming — Overriding the theme
Theming — DESIGN.md interop
Theming — What stays out of the theme
Theming — Consumer utilities
Theming — Color scheme (dark mode)
Theming — Portals and scoped themes
Typography — Default
Typography — Headings
Typography — Inline code
Typography — Large
Typography — Lead
Typography — Muted
Typography — Small
Testing — The doctrine
Testing — Lint first: Poetry check
Testing — Tier 1: wiring tests
Testing — Tier 2: behavior
Testing — Tier 3: the browser pass
Testing — The system-test helpers
Testing — What to assert, in one line
I18n — The shipped catalogue
I18n — Overriding keys and adding locales
I18n — Visible copy is yours
I18n — Forms localize through the model
I18n — Dates
Stimulus — The model
Stimulus — 1. Attach your controller to a component
Stimulus — 2. React to what a component does
Stimulus — 3. Drive a component from your markup
Stimulus — 4. Declare it in Ruby
Stimulus — 5. Change what a Poetry controller does
Stimulus — Checked at every rung
Form Builder — Coming from Simple Form?
Form Builder — Default
Form Builder — Errors
Form Builder — Inference
Form Builder — Layout
Pagination — Installation
Pagination — Wire your controller
Pagination — kaminari
Pagination — pagy
Pagination — will_paginate
Pagination — The window, labels, and one page
Pagination — Kaminari
Pagination — Pagy
Pagination — Will paginate
Deferred Regions — Error retry
Deferred Regions — In tabs
Deferred Regions — Lazy
Optimistic Forms — How it works
Optimistic Forms — Setup
Optimistic Forms — Usage
Optimistic Forms — The server contract
Optimistic Forms — The submitted value
Optimistic Forms — When the prediction can be wrong
Optimistic Forms — Authoring streams directly
Optimistic Forms — When to reach for it
Optimistic Forms — Default
Optimistic Forms — Reconciliation
Recipes — How it works
Recipes — In-page agent embed
Recipes — poetry scaffold templates
Recipes — Data index screen
Recipes — Settings screen
Recipes — poetry skill bundle
Recipes — poetry-component skill bundle
Recipes — poetry-design skill bundle
Page Agent — What this is
Page Agent — Or hand the agent tools
Page Agent — Activate
Page Agent — Task list
Page Agent — Sample implementation
Page Agent — Provenance & limits
Agent Skills — How it works
Agent Skills — poetry
Agent Skills — In a Rails app with Poetry
Editors — One generator
Editors — The MCP server
Editors — The check hook
Editors — Snippets
Editors — HTML+ERB tooling
Editors — Design tools
Data Table — The controller
Data Table — The view
Data Table — Sticky headers
Data Table — Row selection
Data Table — Scoped updates (optional)
Data Table — Filter as you type (optional)
Data Table — Accessibility
Data Table — Row mutations belong to another tier
Caching — The digest blind spot
Caching — The recipe: version-keyed cache keys
Caching — Rules for markup that can be cached at all
Caching — Components you copied in
Caching — Ids inside cached fragments
Caching — HTTP caching, ETags, and CSRF
Stable IDs — The identity ladder
Stable IDs — When you must pass identity
Stable IDs — Keeping primary keys out of the DOM
Stable IDs — What morph identity does and does not preserve
Stable IDs — The sequence mode (opt-in, experimental)
Stable IDs — ETags and CSRF: what ids do and don't buy
Rails Engines — Why this works
Rails Engines — The engine recipe
Rails Engines — Page-level utilities: the one wiring step
Rails Engines — Retheming the whole application
Rails Engines — Customizing a component from an engine
Rails Engines — Keep one theme contract
Accessibility — What Poetry guarantees
Accessibility — What automation cannot see
Accessibility — Set up a pass
Accessibility — The universal checklist
Accessibility — Pattern checklists
Accessibility — Form controls
Accessibility — Overlays
Accessibility — Menus and command palettes
Accessibility — Disclosure and navigation
Accessibility — Data, feedback, and streaming
Accessibility — Automated checks in your app
Accessibility — Recording findings
MCP Server — The server
MCP Server — The loop it closes
MCP Server — The ten tools
MCP Server — Over HTTP
MCP Server — Routing a brief
MCP Server — The check verdict
MCP Server — Skills and guidance at runtime
MCP Server — One source, every surface
WebMCP — What it is
WebMCP — Live: the tools on this page
WebMCP — Search the docs: a declarative tool
WebMCP — Two paths
WebMCP — Designing tools for your app
WebMCP — Declaring tools in your own components
WebMCP — Setup
WebMCP — Safety by construction
AG-UI Relay — What it is
AG-UI Relay — Run an agent
AG-UI Relay — Render the transcript
AG-UI Relay — Frontend tools
AG-UI Relay — Interrupts
AG-UI Relay — A2UI surfaces in the stream
AG-UI Relay — Deterministic development
AG-UI Relay — Setup
A2UI Surfaces — Two catalogs
A2UI Surfaces — Fold the envelope
A2UI Surfaces — Render a surface
A2UI Surfaces — Stream updates
A2UI Surfaces — Actions
A2UI Surfaces — Checks
A2UI Surfaces — Functions
A2UI Surfaces — State across updates
A2UI Surfaces — Transports
A2UI Surfaces — Setup
Accordion — Installation
Accordion — Default
Accordion — Borders
Accordion — Card
Accordion — Disabled
Accordion — Multiple
Accordion — Non collapsible
Accordion — Wiring
Alert — Installation
Alert — Default
Alert — Action
Alert — Custom colors
Alert — Destructive
Alert — Dismissible
Alert — With action
Alert — With icon
Alert Dialog — Installation
Alert Dialog — Default
Alert Dialog — Small
Alert Dialog — Small with media
Alert Dialog — With media
Alert Dialog — Wiring
Aspect Ratio — Installation
Aspect Ratio — Default
Aspect Ratio — Portrait
Aspect Ratio — Square
Aspect Ratio — With image
Attachment — Installation
Attachment — Default
Attachment — Group
Attachment — Idle dropzone
Attachment — Sizes
Attachment — States
Attachment — Vertical with actions
Attachment — With trigger
Autocomplete — Installation
Autocomplete — Default
Autocomplete — Value override
Autocomplete — Wiring
Avatar — Installation
Avatar — Default
Avatar — Badge
Avatar — Badge with icon
Avatar — Dropdown
Avatar — Group
Avatar — Group with icon
Avatar — Sizes
Avatar — With image
Badge — Installation
Badge — Default
Badge — Custom colors
Badge — Link
Badge — Status
Badge — Variants
Badge — With icon
Badge — With spinner
Breadcrumb — Installation
Breadcrumb — Default
Breadcrumb — Collapsed
Breadcrumb — Custom separator
Breadcrumb — Dropdown
Breadcrumb — Link component
Bubble — Installation
Bubble — Default
Bubble — Collapsible
Bubble — Group
Bubble — Links and buttons
Bubble — Popover
Bubble — Quick replies
Bubble — Reactions
Bubble — Tooltip
Bubble — Variants
Button — Installation
Button — Default
Button — As link
Button — Button group
Button — Icon buttons
Button — Link
Button — Loading
Button — Rounded
Button — Sizes
Button — With icon
Button Group — Installation
Button Group — Default
Button Group — Input action
Button Group — Input group
Button Group — Nested
Button Group — Popover
Button Group — Separator
Button Group — Sizes
Button Group — Split
Button Group — Text prefix
Button Group — Vertical
Button Group — With dropdown
Button Group — With select
Calendar — Installation
Calendar — Default
Calendar — Bounded
Calendar — Custom cell size
Calendar — Date and time
Calendar — Month and year selector
Calendar — Range
Calendar — Week numbers
Calendar — Wiring
Card — Installation
Card — Default
Card — Login form
Card — Terms of service
Card — With action and footer
Card — With media
Carousel — Installation
Carousel — Default
Carousel — Sizes
Carousel — Spacing
Carousel — Vertical
Carousel — Wiring
Checkbox — Installation
Checkbox — Default
Checkbox — Card
Checkbox — Disabled
Checkbox — Group
Checkbox — In a field
Checkbox — In a table
Checkbox — States
Checkbox — With description
Checkbox — Wiring
Clipboard Text — Installation
Clipboard Text — Default
Clipboard Text — Truncated copy
Clipboard Text — Wiring
Code Block — Installation
Code Block — Default
Code Block — Numbered and highlighted
Code Block — Wiring
Collapsible — Installation
Collapsible — Default
Collapsible — File tree
Collapsible — Initially open
Collapsible — Settings panel
Collapsible — With icon
Collapsible — Wiring
Combobox — Installation
Combobox — Default
Combobox — Auto highlight
Combobox — Clear button
Combobox — Custom items
Combobox — Disabled
Combobox — Disabled options
Combobox — Grouped
Combobox — Invalid
Combobox — Multiple
Combobox — Valued
Combobox — Wide
Combobox — With icon
Combobox — Agent tools
Combobox — Wiring
Command — Installation
Command — Default
Command — Close button
Command — Dialog
Command — Initial value
Command — Scrollable
Command — With icons
Command — Zero match
Command — Wiring
Command Dialog — Installation
Command Dialog — Default
Command Dialog — Empty state
Command Dialog — Hotkey
Command Dialog — Scrollable
Command Dialog — With icons
Command Dialog — Wiring
Context Menu — Installation
Context Menu — Default
Context Menu — Checkboxes
Context Menu — Destructive
Context Menu — Disabled surface
Context Menu — Focusable surface
Context Menu — Groups
Context Menu — Radio
Context Menu — Sides
Context Menu — Submenu
Context Menu — With icon
Context Menu — Wiring
Data Table — Installation
Data Table — Default
Data Table — Cell formatting
Data Table — Empty
Data Table — Filtered and paginated
Data Table — Row actions
Data Table — Selectable
Data Table — Wiring
Date Field — Installation
Date Field — Default
Date Field — With range
Date Field — Year first
Date Field — Wiring
Date Picker — Installation
Date Picker — Default
Date Picker — Date of birth
Date Picker — Input
Date Picker — Preselected
Date Picker — Range
Date Picker — With time
Date Picker — Wiring
Date Time Field — Installation
Date Time Field — Default
Date Time Field — With seconds
Date Time Field — Wiring
Deferred — Installation
Deferred — Default
Deferred — Wiring
Dialog — Installation
Dialog — Default
Dialog — Confirmation
Dialog — No close button
Dialog — Scrollable content
Dialog — Sticky footer
Dialog — Agent tools
Dialog — Wiring
Drawer — Installation
Drawer — Default
Drawer — Edit form
Drawer — Nested
Drawer — Non modal
Drawer — Position
Drawer — Responsive
Drawer — Snap points
Drawer — Swipe handle
Drawer — Agent tools
Drawer — Wiring
Dropdown Menu — Installation
Dropdown Menu — Default
Dropdown Menu — Avatar
Dropdown Menu — Checkbox with icons
Dropdown Menu — Checkboxes
Dropdown Menu — Complex
Dropdown Menu — Inset and disabled
Dropdown Menu — Radio group
Dropdown Menu — Radio with icons
Dropdown Menu — With icons
Dropdown Menu — Wiring
Empty — Installation
Empty — Default
Empty — Background
Empty — Outline
Empty — With avatar
Empty — With avatar group
Empty — With icon and actions
Empty — With input
Field — Installation
Field — Default
Field — Checkbox
Field — Choice card
Field — Fieldset
Field — Group
Field — Horizontal switch
Field — Responsive
Field — With error
Field — With hint
Field — With radio group
Field — With select
Field — With slider
Field — With textarea
Field Group — Installation
Field Group — Default
Field Group — Choices
Field Separator — Installation
Field Separator — Default
Field Separator — With caption
Fieldset — Installation
Fieldset — Default
Fieldset — Label legend
File Input — Installation
File Input — Default
File Input — Dropzone
File Input — Wiring
Hover Card — Installation
Hover Card — Default
Hover Card — Custom delays
Hover Card — Sides
Hover Card — Wiring
Icon — Installation
Icon — Default
Icon — Animated
Icon — Colors
Icon — Labeled
Icon — Sizes
Icon — Stroke width
Icon — With tooltip
Input — Installation
Input — Default
Input — Button group
Input — Disabled
Input — Field
Input — File
Input — Form
Input — Grid
Input — Inline
Input — Invalid
Input — Required
Input — With badge
Input — With value
Input Group — Installation
Input Group — Default
Input Group — Align
Input Group — Button
Input Group — Custom
Input Group — Dropdown
Input Group — Icon
Input Group — Kbd
Input Group — Spinner
Input Group — Text
Input Group — Textarea block
Input Group — Textarea header
Input Otp — Installation
Input Otp — Default
Input Otp — Alphanumeric
Input Otp — Form
Input Otp — Four digits
Input Otp — Groupings
Input Otp — In a field
Input Otp — States
Input Otp — Wiring
Item — Installation
Item — Default
Item — Avatar
Item — Dropdown
Item — Group
Item — Image
Item — Link
Item — Outline with icon and actions
Item — Sizes
Item — Variants
Item — With header and footer
Kbd — Installation
Kbd — Default
Kbd — Chord
Kbd — Command
Kbd — Group
Kbd — In button
Kbd — In tooltip
Kbd — Search hint
Label — Installation
Label — Default
Label — Disabled
Label — Form
Label — With checkbox
Label — With description
Link — Installation
Link — Default
Link — Current
Link — External
Link — Underline
Marker — Installation
Marker — Default
Marker — Border
Marker — Links and buttons
Marker — Separator
Marker — Shimmer
Marker — Status
Marker — Variants
Marker — With icon
Menubar — Installation
Menubar — Default
Menubar — Checkbox
Menubar — Disabled menu
Menubar — Icons
Menubar — Radio
Menubar — Submenu
Menubar — Wiring
Message — Installation
Message — Default
Message — Actions
Message — Align end
Message — Attachment
Message — Conversation
Message — Grouped
Message — Header and footer
Message — Markdown
Message Scroller — Installation
Message Scroller — Default
Message Scroller — Anchored turns
Message Scroller — Commands
Message Scroller — Group chat
Message Scroller — Open at start
Message Scroller — Scroll state
Message Scroller — Streaming
Message Scroller — Without jump button
Message Scroller — Wiring
Metadata List — Installation
Metadata List — Default
Metadata List — Two columns
Meter — Installation
Meter — Default
Meter — With value text
Native Select — Installation
Native Select — Default
Native Select — Grouped
Native Select — Invalid
Native Select — Small disabled
Navigation Menu — Installation
Navigation Menu — Default
Navigation Menu — Viewport
Navigation Menu — With descriptions
Navigation Menu — With icon
Navigation Menu — Wiring
Number Field — Installation
Number Field — Default
Number Field — Currency
Number Field — Decimal steps
Number Field — Wiring
Pagination — Installation
Pagination — Adapters
Pagination — Default
Pagination — First page
Pagination — Icons only
Pagination — Long range
Pagination — Short range
Pagination — Simple
Popover — Installation
Popover — Default
Popover — Align
Popover — Basic
Popover — Modal
Popover — Open
Popover — Side top
Popover — With anchor
Popover — With form
Popover — Wiring
Progress — Installation
Progress — Default
Progress — Complete
Progress — Controlled
Progress — Custom max without value
Progress — Label
Questionnaire — Installation
Questionnaire — Default
Questionnaire — Animated
Questionnaire — Card
Questionnaire — Custom progress
Questionnaire — Dialog
Questionnaire — Shortcuts
Questionnaire — Skip
Questionnaire — Validation
Questionnaire — Wiring
Radio Group — Installation
Radio Group — Default
Radio Group — Cards
Radio Group — Disabled
Radio Group — Fieldset
Radio Group — Horizontal layout
Radio Group — In a field
Radio Group — Invalid
Radio Group — With description
Radio Group — Wiring
Resizable — Installation
Resizable — Default
Resizable — Handle
Resizable — Nested
Resizable — Three panels
Resizable — Vertical
Resizable — Wiring
Scroll Area — Installation
Scroll Area — Default
Scroll Area — Horizontal
Search Field — Installation
Search Field — Default
Search Field — With value
Search Field — Wiring
Select — Installation
Select — Default
Select — Align item with trigger
Select — Disabled
Select — Groups
Select — Invalid
Select — Scrollable
Select — Small
Select — Valued
Select — Wiring
Sensitive Input — Installation
Sensitive Input — Default
Sensitive Input — With copy
Sensitive Input — With copy and tooltip
Sensitive Input — Wiring
Separator — Installation
Separator — Default
Separator — List
Separator — Menu
Separator — Vertical semantic
Sheet — Installation
Sheet — Default
Sheet — Form
Sheet — No close button
Sheet — Sides
Sheet — Agent tools
Sheet — Wiring
Sidebar — Installation
Sidebar — Default
Sidebar — Loading
Sidebar — Offcanvas collapsed
Sidebar — Submenu
Sidebar — User menu
Sidebar — Workspace switcher
Sidebar — Wiring
Skeleton — Installation
Skeleton — Default
Skeleton — Avatar
Skeleton — Block
Skeleton — Card
Skeleton — Composed
Skeleton — Form
Skeleton — Table
Skeleton — Text
Slider — Installation
Slider — Default
Slider — Controlled
Slider — Disabled
Slider — Multiple thumbs
Slider — Range
Slider — Range with gap
Slider — Vertical
Slider — Wiring
Spinner — Installation
Spinner — Default
Spinner — Button
Spinner — Custom
Spinner — Empty state
Spinner — In badge
Spinner — In input group
Spinner — Size
Spinner — With context label
Stat — Installation
Stat — Default
Stat — With description
Switch — Installation
Switch — Default
Switch — Card
Switch — Description
Switch — In a field
Switch — Invalid
Switch — Sizes
Switch — States
Switch — Wiring
Table — Installation
Table — Default
Table — Actions
Table — Footer
Table — Minimal
Table — Sticky
Tabs — Installation
Tabs — Default
Tabs — Disabled
Tabs — Line
Tabs — Vertical
Tabs — With icon
Tabs — Agent tools
Tabs — Wiring
Tag Group — Installation
Tag Group — Default
Tag Group — As form value
Tag Group — Wiring
Textarea — Installation
Textarea — Default
Textarea — Auto grown
Textarea — Button
Textarea — Disabled
Textarea — Field
Textarea — Invalid
Textarea — With hint
Textarea — With label
Time Field — Installation
Time Field — Default
Time Field — Twenty four hour
Time Field — Wiring
Timeline — Installation
Timeline — Default
Timeline — Horizontal
Timeline — With icons
Toast — Installation
Toast — Default
Toast — Promise
Toast — Variants
Toast — With action
Toast — Wiring
Toast Trigger — Installation
Toast Trigger — Default
Toast Trigger — Wiring
Toaster — Installation
Toaster — Default
Toaster — Positions
Toaster — Wiring
Toggle — Installation
Toggle — Default
Toggle — Outline
Toggle — Sizes
Toggle — States
Toggle — With text
Toggle — Wiring
Toggle Group — Installation
Toggle Group — Default
Toggle Group — Custom
Toggle Group — Disabled
Toggle Group — Outline
Toggle Group — Single
Toggle Group — Sizes
Toggle Group — Spaced
Toggle Group — Vertical
Toggle Group — Wiring
Toolbar — Installation
Toolbar — Default
Tooltip — Installation
Tooltip — Default
Tooltip — Disabled trigger
Tooltip — Keyboard
Tooltip — Open
Tooltip — Provider row
Tooltip — Side bottom
Tooltip — Sides
Tooltip — With label
Tooltip — Wiring
Tree — Installation
Tree — Default
Tree — With links
Tree — Wiring
Typeset — Installation
Typeset — Default
Typeset — Chat preset
Typeset — Docs preset
Typeset — Not typeset
Typeset — Overrides
Typeset — Responsive table
Adapter Chart — Installation
Adapter Chart — Bring your own engine
Adapter Chart — Default
Adapter Chart — Wiring
Area Chart — Installation
Area Chart — Default
Area Chart — Legend toggle
Area Chart — Wiring
Bar Chart — Installation
Bar Chart — Default
Bar Chart — Active
Bar Chart — Horizontal
Bar Chart — Label
Bar Chart — Mixed
Bar Chart — Multiple
Bar Chart — Negative
Bar Chart — Stacked
Bar Chart — Wiring
Composed Chart — Installation
Composed Chart — Default
Composed Chart — Full mix
Composed Chart — Wiring
Line Chart — Installation
Line Chart — Default
Line Chart — Dots
Line Chart — Dots colors
Line Chart — Label
Line Chart — Linear
Line Chart — Multiple
Line Chart — References and errors
Line Chart — Step
Line Chart — Wiring
Pie Chart — Installation
Pie Chart — Default
Pie Chart — Donut
Pie Chart — Donut active
Pie Chart — Donut text
Pie Chart — Label list
Pie Chart — Legend
Pie Chart — Stacked
Pie Chart — Wiring
Radar Chart — Installation
Radar Chart — Default
Radar Chart — Dots
Radar Chart — Grid circle
Radar Chart — Grid fill
Radar Chart — Grid none
Radar Chart — Legend
Radar Chart — Lines only
Radar Chart — Multiple
Radar Chart — Wiring
Radial Bar Chart — Installation
Radial Bar Chart — Default
Radial Bar Chart — Grid
Radial Bar Chart — Label
Radial Bar Chart — Shape
Radial Bar Chart — Stacked
Radial Bar Chart — Text
Radial Bar Chart — Wiring
Scatter Chart — Installation
Scatter Chart — Default
Scatter Chart — Bubbles
Scatter Chart — Multiple
Scatter Chart — Reference zones
Scatter Chart — Wiring
Action bar — Installation
Action bar — Default
Action bar — Styling
App shell — Installation
App shell — Default
App shell — Styling
Data index — Installation
Data index — Default
Data index — Styling
Destructive panel — Installation
Destructive panel — Default
Destructive panel — Revoke deploy token
Destructive panel — Styling
Page header — Installation
Page header — Default
Page header — Styling
Section card — Installation
Section card — Default
Section card — Styling
Stepper — Installation
Stepper — Default
Stepper — Styling
Top nav — Installation
Top nav — Default
Top nav — Styling
Chat Replay — Default
AG-UI Relay — Default
A2UI Surface — Default
Interactive Filter — Default
Live Streaming — Default
Synced Tooltips — Default
Brush & Zoom — Default
poetry-core — ActiveModel
poetry-core — ActiveModel::Type
poetry-core — ActiveModel::Type::List
poetry-core — ActiveModel::Type::Symbol
poetry-core — Poetry
@poetry/controllers — IncompleteDate
poetry-ui — Poetry
poetry-charts — Poetry
poetry-agent — Poetry
poetry-simple_form — Poetry
poetry-extract — Poetry
Core — What it is
Core — Install
Core — Build your own component library
Core — The component class
Core — The style dictionary
Core — Behavior from the primitives
Core — The registry
Core — Two styling paths: Tailwind or BEM
Core — Design tokens
Core — The check
Core — Box and Wrapper
Core — Box, the polymorphic element
Core — Wrapper, conditional outer HTML
UI — What it is
UI — Install and first components
UI — The form builder handoff
UI — Nine themes ship in the gem
UI — Own the code when you want to
UI — Blocks and recipes
UI — The agent surface
UI — Testing helpers
Charts — What it is
Charts — Install
Charts — Basic usage
Charts — Works wherever HTML renders
Charts — Live updates
Charts — Bring your own engine
Charts — The theme palette
Agent — What it is
Agent — Install
Agent — The MCP server
Agent — The WebMCP runtime
Agent — The AG-UI relay
Agent — The A2UI catalog and renderer
Agent — What's in the gem
Simple Form — How it works
Simple Form — Install
Simple Form — What restyles
Simple Form — The end state
Extract — Install and run
Extract — The pipeline
Extract — What it emits
Extract — From extract to theme
Lucide — Install
Lucide — Names, misses, and the fallback
Lucide — The pin

@poetry/controllers API

The JavaScript surface: the 53 Stimulus controllers and the DOM helper modules, one source over two channels (the engine's importmap pins and the npm package) - generated from the source JSDoc and the controllers manifest.

Generated from the source JSDoc plus the introspected controllers manifest. What each component wires is on its gallery page - this page documents the JS surface itself: identifiers, values, events, actions, and the helper modules.

@poetry/controllers

module · app/javascript/poetry/core/index.js

@poetry/controllers - poetry's Stimulus controllers + DOM helpers, one source shipped over two channels: importmap-first (the engine pins this tree; zero build) and this same tree as the npm package for esbuild / Vite / jsbundling hosts. Never requires a bundler.

Constant Description
controllers identifier -> controller class, for every sidecar controller poetry ships. The manifest (config/controllers_manifest.json) is introspected from these classes; registerPoetryControllers registers them all.

.registerPoetryControllers(application)

The bundler-host one-liner: registers every poetry controller on the host's Stimulus application (importmap hosts get the same registrations via the engine's pins + this same call in their controllers/index.js), arms the portal event bridge, and installs the unregistered-controller warning.

  • application (import("@hotwired/stimulus").Application)

Returns (import("@hotwired/stimulus").Application) — the application, for chaining

poetry--core--state

controller · app/javascript/poetry/core/state_controller.js

The controllable-state controller: seeds the data-open/ data-closed pair from a Value default when no other layer owns it, and exposes toggle/open/close actions. "Controlled vs uncontrolled" is just which layer wrote the attribute - a server re-render, the URL, an Outlet, or this default - the controller code is identical either way.

Optional reflection targets (the Collapsible contract): a trigger target mirrors aria-expanded; a content target rides the presence helper (the pair flip deferred through animationend, hidden applied only after the exit animation finishes).

Targets: content, trigger

Values: state (String, default: "closed")

#connect()

Seeds the pair from the Value default when no other layer wrote it, then reflects the current state onto the optional targets.

#disconnect()

Abandons any exit animation still holding the content.

#toggle()

The toggle action: open when closed, close when open.

#open()

Opens: flips the pair, unhides the content target (when present) and runs its entry presence, mirrors aria-expanded onto the trigger.

#close()

Closes: flips the pair and holds the content target through its exit animation before hiding it (the presence contract).

poetry--core--dialog

controller · app/javascript/poetry/core/dialog_controller.js

The native-dialog primitive: borrow the PLATFORM overlay - showModal() gives the focus trap, Esc handling, top-layer stacking, and focus return for free; this controller adds what the platform doesn't: the data-open/data-closed pair for CSS variants, backdrop-click dismissal, a body scroll-lock, and the presence-hold close - exit flips the pair to data-closed and HOLDS the dialog through its CSS exit animation before the native close() (synchronous when no exit animation applies, so reduced-motion and unthemed hosts close instantly). Consumed by Dialog (and AlertDialog / CommandDialog / Sheet).

Targets: dialog

Values: dismissible (Boolean, default: true); hotkey (String, default: "")

#connect()

Heals a restored zombie snapshot, subscribes the before-cache close, and arms the opt-in global hotkey.

#disconnect()

Balances the scroll lock and unwires the hotkey and before-cache subscriptions.

#toggle()

The toggle action (and the hotkey's landing): open <-> close.

#open()

Opens modally: showModal() (the platform trap, top-layer and focus return), the pair flip, and the body scroll lock. Returns the resulting state (what an agent tool reports back).

Returns ({ open: boolean, changed: boolean ) — }

#close(event)

Closes with the presence hold: flips the pair to data-closed and holds the dialog through its CSS exit animation before the native close() and the unlock (synchronous when no exit animation applies). The native cancel event (Esc) routes through here so state stays in sync.

  • [event] (Event) — the native cancel event, when Esc drove it

Returns ({ open: boolean, changed: boolean ) — } the resulting state (what an agent tool reports back)

#backdropClose(event)

The click action discriminating backdrop presses: a backdrop click targets the <dialog> element itself AND lands outside its bounding rect (the backdrop is rendered by the dialog). The target check alone is not enough: clicks on the dialog's own padding / grid gaps also target the element (found in a live browser pass - they were incorrectly dismissing).

  • event (MouseEvent)

#lockScroll()

Takes the shared refcounted body scroll lock (scrollbar-width compensated) - subclasses (sheet/drawer/sidebar) inherit these entry points unchanged; the instance flag keeps double-unlocks (disconnect after close) balanced.

#unlockScroll()

Balances lockScroll; safe when already unlocked.

poetry--core--popper

controller · app/javascript/poetry/core/popper_controller.js

The anchored-positioning primitive: one controller over the VENDORED @floating-ui/dom. A fixed middleware pipeline - offset -> shift -> flip (both gated on avoidCollisions) -> size -> arrow (when an arrow target exists) -> hide - and a fixed output contract: data-side / data-align mirror the RESOLVED placement (after flip), and the content gets the sizing CSS vars (--transform-origin / -available-width / -available-height / -anchor-width / -anchor-height) so ported Tailwind classes like data-[side=top]:slide-in-from-bottom-2 and max-h-[var(--available-height)] work unchanged.

The Arrow stays a markup primitive: any [data-slot=popper-arrow] marked as the arrow target is positioned + given data-side here; the SVG itself is poetry-ui's business.

VIRTUAL-ANCHOR mode (the ContextMenu contract): a non-empty anchorPoint value - canonically the data-poetry--core--popper-anchor-point="x,y" attribute on the controller root (the DOM is the store) - floats against a floating-ui VirtualElement: a zero-size rect at (x,y) client coords. The resolved ELEMENT anchor rides along as contextElement so autoUpdate still tracks ancestor scroll/resize. setAnchor(x, y) is the ergonomics wrapper ContextMenu calls from its contextmenu / long-press handlers; clearing the value returns to plain element anchoring.

Consumers: Popover, Dropdown/Context Menu, Tooltip, HoverCard, Select, Combobox, Menubar.

Targets: anchor, arrow, content

Values: anchor (String, default: ""); anchorPoint (String, default: ""); side (String, default: "bottom"); align (String, default: "center"); sideOffset (Number, default: 0); alignOffset (Number, default: 0); avoidCollisions (Boolean, default: true); strategy (String, default: "fixed")

#connect()

Arms autoUpdate (which fires one immediate position pass).

#disconnect()

Disarms, stranding any in-flight update (the generation bump).

#anchorPointValueChanged()

Stimulus value callback - reactive re-anchor: switching between element and virtual anchoring (or moving the point) re-arms autoUpdate against the new reference. Fires during initialization too, before connect - the connected guard skips that first call.

#strategyValueChanged()

Stimulus value callback - reactive strategy (the portal seam): a consumer flips fixed <-> absolute when it portals/restores content; re-arming repositions in the new coordinate space immediately.

#reposition()

Manual re-run for consumers whose geometry changed outside autoUpdate's sensors (e.g. content swapped by a Turbo Stream).

Returns (Promise<void>) — the update promise, so callers can await settlement

#setAnchor(x, y)

The ContextMenu entry point: stores the pointer coords (the ATTRIBUTE is canonical - inspectable, server-settable) and repositions immediately; the value-changed callback then re-arms autoUpdate's tracking.

  • x (number) — client coords
  • y (number)

#setAnchorElement(element)

The NavigationMenu viewport entry point: floats against a caller-supplied element (the active trigger) instead of a target/selector. Re-arms autoUpdate so the new reference's ancestors are tracked, then repositions - the positioner's inset transition turns that write into the position morph.

  • element (Element)

poetry--core--dismissable

controller · app/javascript/poetry/core/dismissable_controller.js

The dismissal layer: Escape + pointerdown-outside for every overlay. This controller NEVER removes DOM - it dispatches "dismiss" and the consumer closes itself (removes the node, collapses a disclosure, navigates a frame). The class-level stack makes Esc topmost-only, so stacked overlays peel one at a time.

Values: disableOutsidePointerEvents (Boolean, default: false)

Events: poetry--core--dismissable:dismiss, poetry--core--dismissable:interact-outside

#connect()

Registers on the layer stack, wires the capture-phase Escape and pointerdown listeners, subscribes the before-cache dismissal, and takes the body scrim when configured.

#disconnect()

Leaves the stack and unwires everything, releasing the scrim when held.

poetry--core--focus-scope

controller · app/javascript/poetry/core/focus_scope_controller.js

The overlay focus scope: traps Tab/Shift+Tab within the subtree, loops at the edges, and - the part to get exact - snapshots document.activeElement on connect and RESTORES it on disconnect (focus return). Backs Dialog-family overlays, Popover, Menus, Select, Command.

Listeners are wired here, not as data-actions: pause/resume must attach and detach them dynamically as scopes stack, which data-action cannot do.

Values: trapped (Boolean, default: true); loop (Boolean, default: true)

Events: poetry--core--focus-scope:mount-auto-focus, poetry--core--focus-scope:unmount-auto-focus

#connect()

Snapshots the focus-return element BEFORE any focus moves, pauses the previous scope and takes the top of the stack, takes the guard refcount when trapped, then runs mount auto-focus (cancelable - a consumer owns initial focus by vetoing it).

#disconnect()

Leaves the stack (resuming the scope below when this was the top), releases the guards, and restores focus to the snapshot (cancelable - a consumer sends focus elsewhere by vetoing it).

poetry--core--roving-focus

controller · app/javascript/poetry/core/roving_focus_controller.js

The roving-tabindex engine: the group is ONE Tab stop - exactly one item holds tabindex=0, the rest -1, and arrow keys move focus among the collection items in DOM order. Orientation gates which arrows are live (horizontal flips Left/Right under RTL via the direction helper); Home/End jump to the edges; loop wraps. Backs Tabs, Toolbar, RadioGroup, Menus, ToggleGroup, and the Select/Command list.

Dynamic items: NAVIGATION recomputes the collection on every keydown (the DOM is the registry - always fresh, zero bookkeeping), but that alone is not correct: an item appended between keystrokes would sit at its natural tabindex and grow the group a SECOND Tab stop before any arrow is pressed. The MutationObserver exists for that one job - re-stamping the roving tabindex the moment items enter or leave.

Values: orientation (String, default: "vertical"); loop (Boolean, default: true); manageTabindex (Boolean, default: true)

Events: poetry--core--roving-focus:entry

#connect()

Stamps the initial tab stops and starts the item-mutation re-stamping (the header's second-Tab-stop hazard).

#disconnect()

Stops the mutation observer.

#keydown(event)

The group root's keydown action: arrows/Home/End move the roving focus - orientation-gated, RTL-aware, loop-wrapping. The caret guard leaves text-entry controls their keys until the caret reaches the travel-direction boundary.

  • event (KeyboardEvent)

@poetry/controllers/helpers/state

module · app/javascript/poetry/core/helpers/state.js

The controllable-state convention (the DOM is the store): runtime state is a set of presence-boolean data attributes - written here, styled by CSS variants (data-open:..., via the vendored bridge variants that also match the older data-state="open|closed" value form), owned by whichever layer set it (a Stimulus Value default, the DOM, the URL, or a server re-render).

Keys are PAIRS (or triples): setting one member writes its attribute and removes its counterparts. The negative popup/panel/pressed/selected keys only remove, deliberately: there is no data-popup-closed - absence IS the state.

Constant Description
VOCABULARY Vocabulary key -> its writes: { add: attribute | null, remove: [attributes] }.

.stateOf(element)

The vocabulary key element currently wears, per the derivation order above. Attribute-only negative states (the popup/panel/pressed/selected negatives) have no positive attribute to find and derive as undefined - callers that care test the attribute directly.

  • element (Element)

.setState(element, key)

Writes vocabulary key key onto element: sets its add-attribute (when the key has one), removes its counterparts, and announces the flip as a bubbling poetry:state-change CustomEvent carrying { state: key }.

Raises Error - on a key outside the vocabulary

  • element (Element)
  • key (string) — a VOCABULARY key

Returns (string) — the key that was written

@poetry/controllers/helpers/presence

module · app/javascript/poetry/core/helpers/presence.js

Presence: the mount/unmount animation convention - a helper, not a controller (no element ownership here). Exit flips the pair to data-closed and HOLDS the node in the DOM until its CSS exit animation / transition finishes, then hands removal back to the caller via onRemove (this module never removes DOM); enter just flips the pair to data-open so the data-open: animation runs. State writes go through setState so poetry:state-change fires like every other state flip.

.flushPendingExits()

Settles every exit still waiting on its CSS animation, synchronously (each runs its onRemove). Wired to turbo:before-cache at module load; the dismissable layer also calls it directly (see above).

.measurePresence(element, { property = "--poetry-presence-height" } = {})

The measured-entry/exit hook (the Accordion contract's height mechanism): height keyframes cannot animate to auto, so the keyframe chain reads a CSS var instead. This measures the settled box - temporarily unhiding the element and suppressing its animations so scrollHeight reports the real content height - writes "<n>px" to the custom property, then restores exactly what it changed.

  • element (HTMLElement)
  • [options] (Object)
  • [options.property="--poetry-presence-height"] (string) — overridable because the vendored keyframes read --accordion-panel-height

Returns (number) — the measured height (px)

.enterPresence(element, { measure = false, property } = {})

Runs the entry: flips the pair to data-open (through setState, so the state-change event fires) wearing data-starting-style for exactly one painted frame after the flip (the two-frame trick below), so CSS transitions can animate FROM the starting declarations. No poetry class consumes the attribute yet - it ships so a future theme layer can adopt the transition idiom without touching JS.

  • element (HTMLElement)
  • [options] (Object)
  • [options.measure=false] (boolean) — measure BEFORE the pair flips, so the entry keyframe can consume the var from its first frame
  • [options.property] (string) — forwarded to measurePresence

Returns (HTMLElement) — the element

.exitPresence(element, { onRemove, measure = false, property } = {})

Runs the exit: flips the pair to data-closed and HOLDS the node in the DOM until its CSS exit animation/transition finishes, then hands removal back to the caller (this module never removes DOM). onRemove runs at most once, on the first of animationend / transitionend (on the element itself, not a child) or the safety timeout - or synchronously when no exit animation exists. data-ending-style rides the whole exit.

  • element (HTMLElement)
  • [options] (Object)
  • [options.onRemove] (() => void) — the caller's removal step
  • [options.measure=false] (boolean) — measure while the element is STILL VISIBLE, before the flip to "closed" starts the exit keyframe
  • [options.property] (string) — forwarded to measurePresence

Returns (() => void) — cancel() - abandons the wait WITHOUT calling onRemove (an exit interrupted by a re-open)

@poetry/controllers/helpers/portal

module · app/javascript/poetry/core/helpers/portal.js

The portal-on-open mechanism: move popper content to a stable container (body by default) while open so it can position absolute - static under compositor scroll, immune to transformed ancestors - and return it HOME on close, exactly where a placeholder comment marks the origin.

THE EVENT BRIDGE (the logical-tree trap): a component-tree portal re-bubbles events from its logical position; a DOM portal does not. A host's data-action on the component ROOT would go deaf to events rising out of portaled content. The bridge restores logical-tree bubbling for poetry's OWN

.registerBridgeEvents(names)

Adds event names to the bridge list. index.js registers the union of every controller's declared static events at boot - the bridge list stays honest against the manifest surface without portal.js importing the controllers (no cycle).

  • names (Iterable<string>) — full event names as emitted

.resolvePortalContainer(root)

The portal-container seam, attribute-shaped: a host scoping themes to a subtree points its overlays at a container inside that scope via data-poetry-portal-container="<element id>".

  • root (Element | null) — the element carrying the attribute

Returns (Element) — the named container, or document.body

.isPortaled(content)

Whether content is currently portaled out (has a live placeholder).

  • content (Element)

.logicallyContains(container, node)

Containment that follows portals HOME: a node inside portaled content counts as inside container when the content's home position does. Focus-scope's trap keys on this - a portaled sub level is outside the root content's subtree but logically inside its tree, and the trap must follow the logical tree or every portaled level reads as outside.

  • container (Element)
  • node (Node | null)

.portalContent(content, { container = document.body } = {})

Moves content to container, leaving a placeholder comment at home and wiring the event bridge for the registered list. No-op (false) when already portaled or parentless. The home-effective dir is stamped onto undeclared content for the trip, so direction-dependent behavior survives the move (un-stamped on restore).

  • content (Element)
  • [options] (Object)
  • [options.container=document.body] (Element)

Returns (boolean) — true when the move happened

.restoreContent(content)

Returns portaled content to its placeholder and unwires the bridge. When the origin is gone (a morph replaced it) the content is DROPPED, never stranded at the container.

  • content (Element)

Returns (boolean) — true when the content went home; false when it was not portaled, or had to be dropped

@poetry/controllers/helpers/announce

module · app/javascript/poetry/core/helpers/announce.js

The announce SINGLETON: shared screen-reader live regions - a plain module, not a controller. Injecting a node that IS a live region is unreliably announced across SR/browser pairs (the region must exist BEFORE its text changes), so consumers keep their own nodes aria-live=off and route announcements through here: TWO lazily-created sr-only regions on body (polite + assertive), refcounted via acquire()/release() (regions removed when the last consumer releases; consumers: Toast, async form status, Combobox result counts - API changes after Toast ships are breaking, so the surface stays exactly this).

Announcement mechanics: clear-then-set on a microtask (identical consecutive messages re-announce), a per-region message queue with a small gap between messages, textContent ONLY (live-region injection is a real sink - never innerHTML).

Tab-visibility muting: while the tab is hidden both regions flip aria-live=off (muted); announcements made while hidden keep at most the LAST message, flushed on return (no backlog flood).

.acquire()

Takes a refcount on the shared live regions (created on the first acquire): a consumer acquires while it is connected and releases on teardown.

.release()

Releases one refcount; the last release removes the regions and their visibility listener.

.announce(message, politeness = "polite")

The announcement surface: queues message on the shared region for politeness. While the tab is hidden only the LAST message is kept, flushed on return.

  • message (string) — stringified; injected as textContent only
  • [politeness="polite"] ("polite" | "assertive") — unknown values fall back to polite

@poetry/controllers/helpers/breakpoint

module · app/javascript/poetry/core/helpers/breakpoint.js

The mobile breakpoint: matchMedia below Tailwind's md (768px), with a change listener. The first poetry consumer is the Sidebar's mobile-Sheet mode. Environments without matchMedia (the dommy QuickJS engine, bare jsdom) report DESKTOP - the server-rendered desktop shell is the safe default.

Constant Description
MOBILE_BREAKPOINT The mobile cutoff (px): viewports strictly narrower are mobile.

.watchMobile(onChange)

Watches the mobile breakpoint: calls onChange(isMobile) immediately with the current state and again on every crossing. Environments without matchMedia report desktop once and never call again.

  • onChange ((isMobile: boolean) => void)

Returns (() => void) — unwatch

@poetry/controllers/helpers/collection

module · app/javascript/poetry/core/helpers/collection.js

The DOM is the registry: collection items are read from the document in DOM order - no client-side bookkeeping, no registration step; a membership question is always a fresh query.

Constant Description
COLLECTION_ITEM_SELECTOR The attribute marking an element as a collection item.

.collectionItems(root, selector = COLLECTION_ITEM_SELECTOR)

The collection items under root, in DOM order.

  • root (ParentNode) — the element (or document) to query
  • [selector] (string) — override for consumers with their own item marker (defaults to COLLECTION_ITEM_SELECTOR)

@poetry/controllers/helpers/direction

module · app/javascript/poetry/core/helpers/direction.js

Reading direction: the platform mechanism - the closest [dir] ancestor - consumed by roving-focus (Left/Right flip) and popper (side flip).

.directionOf(element)

The reading direction in effect at element: the closest [dir] ancestor's value. Only an explicit rtl flips - dir="auto", dir="ltr" and no [dir] ancestor at all resolve "ltr".

  • element (Element)

@poetry/controllers/helpers/escape

module · app/javascript/poetry/core/helpers/escape.js

Capture-phase Escape handling: the primitive under dismissable's topmost-only Esc behavior.

.isImeKeydown(event)

True when a keydown belongs to an IME composition. An Escape that cancels IME composition must never reach dismissal: CJK users press Escape to drop an in-progress composition, and closing the overlay under them destroys the field they were typing into. Chromium reports isComposing on the cancel keydown; some engines only mark it with the legacy 229 keyCode - check both. Every Escape consumer (dismissable via onEscapeKeydown, plus controllers with their own Escape branches) gates through this predicate.

  • event (KeyboardEvent)

.onEscapeKeydown(callback, { capture = true, target = window } = {})

Subscribes callback to Escape keydowns, with IME-cancel presses filtered out via isImeKeydown.

  • callback ((event: KeyboardEvent) => void)
  • [options] (Object)
  • [options.capture=true] (boolean) — capture phase, so dismissal sees the key before bubble-phase consumers can swallow it
  • [options.target=window] (EventTarget)

Returns (() => void) — unsubscribe

@poetry/controllers/helpers/filter_rank

module · app/javascript/poetry/core/helpers/filter_rank.js

The Command filter spec: deterministic substring + a 5-band rank - deliberately NOT a fuzzy command-score. Pure string functions so the CI spec table pins the contract: any scoring change is a reviewed table change, never a silent reorder of every palette. The score's ONLY job is picking the auto-highlighted first match - the controller hides score-0 items and NEVER reorders the DOM (DOM order is the ranking authority within a band).

Bands: prefix 4 > word-boundary 3 > substring 2 > keyword 1 > hidden 0; an empty query scores 1 (everything visible). Matching is diacritic-folded ("creme" matches "Crème") and case-insensitive.

Constant Description
SCORE_EMPTY_QUERY An empty query: everything stays visible.
SCORE_PREFIX The label starts with the query.
SCORE_WORD A word inside the label starts with the query.
SCORE_SUBSTRING The query appears anywhere in the label.
SCORE_KEYWORD Only a keyword starts with the query.
SCORE_HIDDEN No match - the controller hides the item.

.normalize(value)

trim + lowercase + NFKD-fold combining marks: diacritic-insensitive matching for free in every locale that marks are decorative in (folding is always-on - the contract's documented call).

  • value (*) — stringified; null/undefined normalize to ""

.scoreText(label, query, keywords = [])

The pure scorer. label/query arrive RAW (normalization is this function's business); keywords is an array of raw strings.

  • label (string)
  • query (string)
  • [keywords=[]] (string[])

Returns (number) — a SCORE_* band

.filterLabel(item)

The label an item filters against: data-filter-value overrides (icon-rich content), else the item-text part, else the item's own text.

  • item (Element)

.filterKeywords(item)

data-keywords: whitespace-separated extra filter terms an item may carry beyond its visible label.

  • item (Element)

.scoreItem(item, query)

Scores one collection item against the query via its filter label and keywords.

  • item (Element)
  • query (string)

Returns (number) — a SCORE_* band

@poetry/controllers/helpers/focus_guards

module · app/javascript/poetry/core/helpers/focus_guards.js

Focus guards: two visually-hidden tabindex=0 sentinels at the body edges so focusin/focusout fire predictably at the document boundary while any trapped overlay is open - a Tab out of the last real element lands on a guard (which focus-scope yanks back), never on nothing. Refcounted module state: one pair per page no matter how many overlays.

Constant Description
FOCUS_GUARD_SELECTOR Selector matching the two page-edge focus-guard sentinels.

.ensureFocusGuards()

Takes a refcount on the page-edge guard pair, creating the two sentinels on the first acquire. Pair every call with removeFocusGuards on teardown.

.removeFocusGuards()

Releases one refcount on the guard pair; the last release removes both sentinels from the document.

@poetry/controllers/helpers/hotkey

module · app/javascript/poetry/core/helpers/hotkey.js

The hotkey descriptor grammar, extracted from the dialog controller so any surface can speak it (the generic hotkey controller, future data-hotkey affordances). "meta+k" / "ctrl+shift+p": '+'-separated modifiers plus one final key token, matched exactly - unlisted modifiers must be UP, so plain typing never triggers. "meta" matches metaKey OR ctrlKey (⌘K on mac, ^K elsewhere - the command-palette convention).

.matchesHotkey(event, descriptor)

Whether a keydown satisfies a hotkey descriptor (the grammar above).

  • event (KeyboardEvent)
  • descriptor (string) — e.g. "meta+k", "ctrl+shift+p", "?"

.isEditingTarget(event)

True when the event originates in a text-editing context - unmodified single-key shortcuts ("/", "?") must stay inert while the user types (the standard hotkey ignore list: form fields and contenteditable).

  • event (KeyboardEvent)

@poetry/controllers/helpers/id_integrity

module · app/javascript/poetry/core/helpers/id_integrity.js

The composed-DOM duplicate-id tripwire: the ONLY check that sees the page as actually composed - static lints can't see across templates, frames, streams, or cached fragments, so this scans the live document for duplicate [id] values after every composition event and reports what it finds. Development tooling: install via poetry_id_integrity_script (dev layouts); never wired in production.

Duplicate ids are always a bug regardless of source (ARIA IDREFs resolve to the first match only), and under StableId they are the signature of the two documented hazards: the same key rendered twice, or sequence-mode collisions across frames/cached fragments.

.scanForDuplicateIds(root = document)

Scans the live (composed) document for duplicate [id] values.

  • [root=document] (ParentNode)

Returns (string[]) — the duplicated ids, each listed once ("" ignored)

.installPoetryIdIntegrityCheck({ report } = {})

Installs the scanner on the page's composition events (initial load, Turbo loads, frame loads, morphs, stream insertions - each checked one frame after the event so the DOM has settled).

  • [options] (Object)
  • [options.report] ((dups: string[]) => void) — overrides the console warning

Returns (() => string[]) — the scan trigger, for manual use

@poetry/controllers/helpers/incomplete_date

module · app/javascript/poetry/core/helpers/incomplete_date.js

The nullable-segment date/time value (the IncompleteDate model, adapted from an Apache-2.0-licensed source - source and license in THIRD_PARTY_NOTICES.md): segments are stored RAW so a user can edit day before month - the object can hold February 31st and only commit constrains. Hour is stored in the LOCALE'S HOUR CYCLE with a separate dayPeriod bit (0 = AM, 1 = PM), so am/pm edits are independent and "12 means 0" lives in exactly one place. Gregorian-only by design (the calendar seam is clean if that ever changes - limits() is the only calendar knowledge).

Constant Description
PAGE_STEP PageUp/PageDown step per segment type.

.daysInMonth(year, month)

Days in a Gregorian month, leap Februaries included.

  • year (number)
  • month (number) — 1-12 (out-of-range months report 31)

.resolveHourCycle(locale, override = null)

The resolved hour cycle for a locale, with two known Intl bug detections built in: Chrome resolves hour12: false to the buggy h24 per the ECMA-402 spec bug, and WebKit misreports resolvedOptions().hourCycle in some locales - so the cycle is INFERRED by formatting hour 0 and hour 23 and reading what comes out.

  • locale (string | undefined)
  • [override=null] ("h11" | "h12" | "h23" | "h24" | null) — wins outright when present

@poetry/controllers/helpers/mask

module · app/javascript/poetry/core/helpers/mask.js

The mask engine, adapted from an MIT-licensed source (source and license in THIRD_PARTY_NOTICES.md). Pure functions over a parsed slot list, zero DOM. A mask is a sequence of slots: token slots validate ONE character against a pattern, literal slots are fixed chrome ("/", "-", " ") the engine inserts and the user never types. Every value decision lives here so it stays exhaustively unit-testable; the caret math and events live in mask_controller.js.

Constant Description
DEFAULT_TOKENS The default token map: mask char -> the pattern one input char must match.

.parseMask(mask, tokens = {})

Parses a mask into a slot list. String grammar: token chars from the (custom-over-default merged) token map, "\" escapes the next char to a literal, "?" is consumed and makes every LATER slot optional - the flag is STICKY, it never resets, so "(999) 999-9999? x9999" is complete without the extension. Array grammar: RegExp item = token slot, string item = literal.

  • mask (string | Array<RegExp | string>)
  • [tokens={}] (Object<string, RegExp>) — custom tokens, merged over DEFAULT_TOKENS

Returns (Array<Object>) — slots: { type: "token" | "literal", char, pattern?, optional? }

.applyMaskToRaw(raw, slots, transform)

Raw chars -> masked string. Literals append EAGERLY (raw "12" under "99/99" is "12/" - the separator paints the moment it is reachable); a token slot consumes the next raw char when it matches, else silently DROPS it and retries the SAME slot with the following char; transform (poetry's upcase knob) runs before validation.

  • raw (string)
  • slots (Array<Object>) — from parseMask
  • [transform] ((char: string) => string)

Returns (string) — the masked value

.processInput(text, slots)

Re-parses arbitrary display text (autofill, a server-rendered value, IME output): literals self-match or are inserted, token slots scan forward discarding non-matching chars, and the walk stops at the first slot the remaining text cannot fill.

  • text (string)
  • slots (Array<Object>)

Returns (string) — the re-masked value

.extractRaw(masked, slots)

Chars at token positions - the value the form actually means.

  • masked (string)
  • slots (Array<Object>)

.buildDisplayValue(value, slots, slotChar = "_", showSlots = true)

Pads the masked value with the mask skeleton. slotChar "_" by default; a multi-char slotChar indexes per position ("dd/mm/yyyy" under a date mask) falling back to "_" past its end; null/"" disables padding (the display stops at the first empty token slot).

  • value (string) — the masked value so far
  • slots (Array<Object>)
  • [slotChar="_"] (string | null)
  • [showSlots=true] (boolean) — false returns value untouched

.checkComplete(masked, slots)

Complete = every non-optional token position is filled and pattern-valid.

  • masked (string)
  • slots (Array<Object>)

.generatePattern(slots, kind = "full")

Regex source for the HTML pattern attribute: "full" wraps each token in a capture group, "full-inexact" doesn't; optional tokens get a trailing "?"; literals are regex-escaped.

  • slots (Array<Object>)
  • [kind="full"] ("full" | "full-inexact")

.findNextEditablePosition(pos, slots, filledLength)

Skips a literal run rightward from pos, bounded by the filled region

  • the caret never lands inside chrome or out in the skeleton.
  • pos (number)
  • slots (Array<Object>)
  • filledLength (number)

.nextTokenPosition(slots, from = 0)

First token position at or after from.

  • slots (Array<Object>)
  • [from=0] (number)

Returns (number) — slots.length when none

.prevTokenPosition(slots, from)

Last token position at or before from.

  • slots (Array<Object>)
  • from (number)

Returns (number) — -1 when none

@poetry/controllers/helpers/registration_guard

module · app/javascript/poetry/core/helpers/registration_guard.js

The registration guard: Stimulus never errors on a data-controller identifier nothing registered - the element simply stays inert - and one failed import in the host's controllers graph silently takes every poetry controller down with it. After the page is ready (and again on every Turbo navigation) this compares the poetry-prefixed identifiers on the page with the application's registry and warns ONCE per identifier. Host controllers are never inspected (they may lazy-load); poetry's cannot.

.unregisteredPoetryControllers(application, root = document)

The poetry identifiers on the page that application has not registered.

  • application (import("@hotwired/stimulus").Application)
  • [root=document] (ParentNode) — the subtree to inspect

Returns (string[]) — sorted, deduplicated identifiers

.checkPoetryRegistration(application, root = document)

Warns once per newly-seen unregistered poetry identifier.

  • application (import("@hotwired/stimulus").Application)
  • [root=document] (ParentNode)

Returns (string[]) — the identifiers warned about in this call

.guardPoetryRegistration(application)

Runs the check once the DOM is parsed (registration usually happens while the document is still loading) and after every Turbo navigation. Idempotent: the first registrar to call it wires the listeners.

  • application (import("@hotwired/stimulus").Application)

.resetPoetryRegistrationGuard()

Test seam: forget what has been warned about and re-arm the scheduler.

@poetry/controllers/helpers/scroll_lock

module · app/javascript/poetry/core/helpers/scroll_lock.js

Body scroll-lock with scrollbar-width compensation: bare overflow: hidden shifts the whole layout by the scrollbar width the moment an overlay opens on a scrollable page. The gap is measured BEFORE locking and paid back as body padding-right. Refcounted so stacked overlays (a dialog opened from a sheet) lock once and restore only when the LAST one closes - per-instance saved values break on out-of-order closes.

Why not scrollbar-gutter: stable (this helper's original primary)? Measured live with classic scrollbars: Chrome drops the viewport's rail AND its reserved gutter the moment the viewport's used overflow computes to hidden - whether the pair sits on the root, propagates from body, or the gutter was set permanently - so the page shifted by the scrollbar width anyway (the exact wiggle the strategy existed to stop). The body-padding payback is the only compensation the viewport honors; its known cost is that position:fixed elements aren't compensated - accepted deliberately, not an oversight.

.lockScroll()

Locks body scrolling, paying the measured scrollbar gap back as body padding-right so the layout never shifts. Refcounted: stacked overlays lock once; only the first call writes styles.

.unlockScroll()

Releases one scroll lock; the LAST release restores the body's saved overflow and padding-right.

.resetScrollLock()

Test seam: vitest suites run many overlays in one document.

@poetry/controllers/helpers/scroller_geometry

module · app/javascript/poetry/core/helpers/scroller_geometry.js

Scroll geometry for the message-scroller, adapted from an MIT-licensed source (source and license in THIRD_PARTY_NOTICES.md): pure functions of {viewport, content, spacer, rects} - no Stimulus, no state. This is the jsdom-testable half; the controller owns the policy that decides when to call these. The constants encode deliberate fixes - treat them as pinned values, not tunables.

Constant Description
SCROLL_POSITION_EPSILON Two fractional scrollTop values within this range are treated as equal, to absorb zoom and HiDPI rounding drift.
EMPTY_MESSAGE_SCROLLER_SCROLLABLE Initial scrollable snapshot before measurement.
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE Initial visibility snapshot: nothing tracked, no current anchor.

.getMessageScrollerScrollable({ content, scrollEdgeThreshold, spacer, viewport })

Whether the scroller has scroll room past each edge, beyond the configured threshold.

  • args (Object)
  • args.content (HTMLElement | null)
  • args.scrollEdgeThreshold (number) — px of slack that still counts as "at the edge"
  • args.spacer (HTMLElement | null)
  • args.viewport (HTMLElement | null)

Returns ({ start: boolean, end: boolean ) — }

.getMessageScrollerVisibilityState({ content, scrollMargin, scrollPreviousItemPeek, spacer, viewport, visibleMessageIds })

The visible message ids (top-to-bottom) and the current anchor - the last anchor row to have reached the reading line (the comments inside hold the line rules).

  • args (Object)
  • args.content (HTMLElement | null)
  • args.scrollMargin (number)
  • args.scrollPreviousItemPeek (number)
  • args.spacer (HTMLElement | null)
  • args.viewport (HTMLElement | null)
  • args.visibleMessageIds (Set<string>) — the observer's current set (consulted when IntersectionObserver exists)

Returns ({ currentAnchorId: string | null, visibleMessageIds: string[] ) — }

.getMessageScrollerItems(content, spacer)

The collection is DOM order: rows are content's element children minus the tail spacer - membership is a filter over live children, never a registration step.

  • content (HTMLElement)
  • spacer (HTMLElement | null)

.getNewScrollAnchor(items, previousItemCount)

The first anchor row appended after previousItemCount items existed.

  • items (HTMLElement[])
  • previousItemCount (number)

.getUnanchoredScrollAnchor(items, handledAnchors)

The first anchor row not yet in handledAnchors.

  • items (HTMLElement[])
  • handledAnchors (Set<HTMLElement>)

.hasMultipleNewScrollAnchors(items, previousItemCount)

Whether more than one anchor row arrived after previousItemCount items existed (a multi-turn batch).

  • items (HTMLElement[])
  • previousItemCount (number)

.getLastScrollAnchor(items)

The last anchor row in the collection.

  • items (HTMLElement[])

.getFirstVisibleMessageItem({ content, spacer, viewport })

The topmost message row intersecting the viewport.

  • args (Object)
  • args.content (HTMLElement)
  • args.spacer (HTMLElement | null)
  • args.viewport (HTMLElement)

.getElementScrollTop({ align, element, scrollMargin, spacer, viewport })

Target scrollTop that aligns element to the viewport inset (content block padding respected).

  • args (Object)
  • args.align ("start" | "center" | "end" | "nearest")
  • args.element (HTMLElement)
  • args.scrollMargin (number)
  • args.spacer (HTMLElement | null)
  • args.viewport (HTMLElement)

Returns (number) — the target scrollTop (the caller clamps)

.getElementTop(element, viewport)

element's top in the viewport's content space (independent of the current scroll position).

  • element (HTMLElement)
  • viewport (HTMLElement)

Returns (number) — px

.getElementViewportTop(element, viewport)

element's top relative to the viewport's current visual top.

  • element (HTMLElement)
  • viewport (HTMLElement)

Returns (number) — px

.getTailSpacerHeight({ content, scrollTop, spacer, viewport })

Scroll room the tail spacer must fake below the last row so the requested scrollTop is reachable. Caller clamps/ceils (see the controller).

  • args (Object)
  • args.content (HTMLElement)
  • args.scrollTop (number) — the position being made reachable
  • args.spacer (HTMLElement | null)
  • args.viewport (HTMLElement)

Returns (number) — px (possibly negative - the caller clamps)

.getContentBottom({ content, spacer, viewport })

Lowest row bottom in content space plus block padding, EXCLUDING the tail spacer - the spacer must never make the jump button appear.

  • args (Object)
  • args.content (HTMLElement)
  • args.spacer (HTMLElement | null)
  • args.viewport (HTMLElement)

Returns (number) — px

.getMaxScrollTop(viewport)

The viewport's maximum scrollTop (never negative).

  • viewport (HTMLElement)

Returns (number) — px

.getContentBlockPadding(spacer)

Block padding of the spacer's parent (the content element); zeros without a spacer.

  • spacer (HTMLElement | null)

Returns ({ start: number, end: number ) — } px

.getFlexGap(element)

The element's effective row gap (0 for "normal" or no element).

  • element (HTMLElement | null)

Returns (number) — px

.areScrollStatesEqual(current, next)

Value equality for two scrollable snapshots.

  • } ({ start: boolean, end: boolean ) — current
  • } ({ start: boolean, end: boolean ) — next

.areVisibilityStatesEqual(current, next)

Value equality for two visibility snapshots (anchor + ordered ids).

  • } ({ currentAnchorId: string | null, visibleMessageIds: string[] ) — current
  • } ({ currentAnchorId: string | null, visibleMessageIds: string[] ) — next

@poetry/controllers/helpers/tabbable

module · app/javascript/poetry/core/helpers/tabbable.js

Tabbable-candidate walk: the shared filter behind focus-scope and the Dialog trap. Candidates in DOM order, minus disabled / hidden / tabindex=-1 / inert-subtree elements.

.tabbableWithin(container)

The tabbable elements under container, in DOM order: candidate tags/tabindexes minus disabled / hidden / tabindex=-1 / inert-subtree / type=hidden elements, with each radio group collapsed to its single real tab stop (see below).

  • container (Element)

@poetry/controllers/helpers/turbo_cache

module · app/javascript/poetry/core/helpers/turbo_cache.js

Turbo snapshots the page BEFORE caching it (turbo:before-cache), and an overlay still open at that moment serializes INTO the snapshot: the restoration visit then renders a de-modalized zombie dialog (the open attribute survives HTML serialization, top-layer/modal state does not) over a body whose inline scroll-lock / pointer-events-scrim styles came back frozen - a page that can never scroll, or never be clicked, again (both reproduced live against the docs app). Overlay controllers subscribe their synchronous teardown here; no-op when Turbo is absent.

.onBeforeCache(callback)

Subscribes callback to Turbo's before-cache moment - the last synchronous chance to tear an overlay down before the snapshot is taken. A page without Turbo simply never fires it.

  • callback ((event: Event) => void) — must finish synchronously; the snapshot is taken right after the event dispatch completes

Returns (() => void) — unsubscribe

@poetry/controllers/helpers/typeahead

module · app/javascript/poetry/core/helpers/typeahead.js

The APG typeahead buffer, shared: printable keys accumulate into a search buffer that resets after a timeout (1s default), matching wraps from the current item, and a repeated same-letter buffer cycles matches. Extracted VERBATIM from menu_controller.js so the menu family and the Select listbox run the identical algorithm - the buffer/timer state lives in the instance this factory returns, one per consuming controller.

.typeaheadLabel(item)

The label an item types against: data-text-value overrides textContent (icon-rich content declares its typed label explicitly).

  • item (Element)

Returns (string) — the trimmed label ("" when neither source exists)

.createTypeahead()

One typeahead instance for a consuming controller - the buffer/timer state lives in the returned object (the module header holds the algorithm).

Returns ({ pending: () => boolean, reset: () => void, search: (key: string, items: Element[], options?: Object) => Element | null ) — }

IncompleteDate

class · app/javascript/poetry/core/helpers/incomplete_date.js

A date/time value whose segments may each be null (the module header holds the storage rules). One instance backs one date-field.

Exported from @poetry/controllers/helpers/incomplete_date.

#constructor(hourCycle = "h23")

  • [hourCycle="h23"] ("h11" | "h12" | "h23" | "h24") — the field's RESOLVED cycle (resolveHourCycle)

#twelveHour()

Returns (boolean) — whether the cycle carries a dayPeriod (h11/h12)

#limits(type)

Segment limits in DISPLAY terms. Day deliberately allows the calendar maximum (31) regardless of the month segment - editing order must not trap the user; constrain() clamps at commit.

  • type (string) — "year" | "month" | "day" | "hour" | "minute" | "second" | "dayPeriod"

Returns ({ min: number, max: number ) — }

#cycle(type, amount, placeholderValue, { round = false } = {})

Arrow/page steps a segment. The first press on an EMPTY segment lands on the placeholder value, the second one moves it (the cycle contract); values wrap within the segment's limits (the spinbutton contract).

  • type (string) — the segment
  • amount (number) — signed step
  • placeholderValue (number) — what an empty segment lands on
  • [options] (Object)
  • [options.round=false] (boolean) — PageUp/Down snap to the next multiple of amount instead of adding it

#set(type, value)

Sets a segment, clamped to its limits.

  • type (string)
  • value (number | null) — null clears

#clear(type)

Empties a segment.

  • type (string)

#isComplete(types)

Whether every listed segment holds a value.

  • types (string[])

#isValidDate()

A complete date can still be invalid (February 31st): valid means the day exists in the month.

Returns (boolean) — false while year/month/day are incomplete

#constrain(types)

Commit-time clamp (blur): the raw day is pulled into the real month.

  • types (string[]) — the field's segments; day is only clamped when listed

#hourInH23()

The stored hour converted to h23. Under h11/h12 an unset dayPeriod reads as AM.

Returns (number | null) — null while the hour is unset

#setFromH23(h23)

Sets hour (and dayPeriod under h11/h12) from an h23 hour.

  • h23 (number) — 0-23

#toISODate()

Returns (string | null) — "YYYY-MM-DD", or null while incomplete

#toISOTime(withSeconds = false)

  • [withSeconds=false] (boolean)

Returns (string | null) — "HH:MM" or "HH:MM:SS" (h23 on the wire), or null while incomplete (a twelve-hour cycle needs dayPeriod too)

#setFromISODate(iso)

Fills year/month/day from "YYYY-MM-DD".

  • iso (string | null)

Returns (boolean) — false (nothing touched) on a non-matching string

#setFromISOTime(iso)

Fills hour/minute(/second) from "HH:MM(:SS)" (h23 on the wire).

  • iso (string | null)

Returns (boolean) — false (nothing touched) on a non-matching string

#setFromISODateTime(iso)

Fills every field from "YYYY-MM-DDTHH:MM(:SS)" - the datetime-local wire format (no zone, local wall time).

  • iso (string | null)

Returns (boolean) — false (nothing touched) unless both halves parse

#toISODateTime(withSeconds = false)

  • [withSeconds=false] (boolean)

Returns (string | null) — "YYYY-MM-DDTHH:MM(:SS)", or null while either half is incomplete

poetry--core--accordion

controller · app/javascript/poetry/core/accordion_controller.js

The accordion open-set machine: single (optionally collapsible) or multiple. Composes with poetry--core--roving-focus (manageTabindex: false - APG keeps every trigger tabbable) attached separately on the same root. Panels ride the presence helper; the measured --accordion-panel-height var feeds the vendored accordion-down/up keyframes.

Values: type (String, default: "single"); collapsible (Boolean, default: false)

Events: poetry:accordion:change

#toggle(event)

Each trigger's click action: toggles its item - single mode closes the others first; single non-collapsible keeps the open item open. The change event reports the open values.

  • event (MouseEvent)

#connect()

Reflects the locked-open trigger (aria-disabled) and adopts data-panel-open onto server-open triggers.

#disconnect()

Flushes any in-flight transition window so its timer/listener doesn't outlive the controller (Turbo teardown, or a test between cases).

poetry--core--action-bar

controller · app/javascript/poetry/core/action_bar_controller.js

The floating bulk-actions bar (the ActionBar contract): shows while its table's selection is non-empty, and holds the contract rules - focus NEVER moves in on show; if focus was inside when the bar hides, it returns to where it was before entering (the FocusScope restoreFocus equivalent, scoped small); "Actions available." is announced ONCE per appearance; the visible count RETAINS its last non-zero value while the bar animates out (never a "None selected" flash); Escape anywhere inside clears the selection (the table's engine listens for

Targets: count

Values: label (String, default: "%{count} selected")

Events: poetry:data-table:clear-selection

#connect()

Subscribes to the table's selection-change on the shared wrapper and starts the focus bookkeeping the hide-restore needs.

#disconnect()

Unwires both listeners.

#keydown(event)

The keydown action: Escape anywhere inside clears the selection.

  • event (KeyboardEvent)

#clear()

The clear affordance (and Escape): asks the table's engine - which owns the state - to clear the selection.

poetry--core--autocomplete

controller · app/javascript/poetry/core/autocomplete_controller.js

The Autocomplete: a REAL text input that IS the form value, suggesting from a server-rendered list that filters as you type. This is the input-is-the-value semantic - the sibling of Combobox, whose value is a SELECTED ITEM behind a native <select>. Selecting a suggestion writes the input and closes; the text always submits as ordinary params.

The contract's fixed behaviors: locale-aware filtering (toLocaleLowerCase), list scroll reset on every filter pass, and change-reason details on the commit event (item-press | enter-key). Positioning rides the shared popper controller (input = anchor).

Targets: content, empty, input, list

Values: open (Boolean, default: false); openOnFocus (Boolean, default: true)

Events: poetry:autocomplete:closed, poetry:autocomplete:commit, poetry:autocomplete:open

#disconnect()

Closes an open popup so its state never outlives the controller.

#input()

The input action: re-filters the list and opens.

#focus()

The focus action: opens pre-typing when openOnFocus allows.

#blurred(event)

The focusout action: closes, unless focus moved into the popup (an item click commits first - the body comment).

  • event (FocusEvent)

#keydown(event)

The input's keydown action: arrows open / move the highlight, Enter commits it, Escape closes (consumed - the next press reaches the layer above), Tab closes and passes through. IME keydowns are ignored.

  • event (KeyboardEvent)

#itemPress(event)

Each item's pointerdown action (not click): commits BEFORE the input's blur closes the popup.

  • event (PointerEvent)

#itemEnter(event)

Each item's pointerenter action: moves the highlight under the pointer.

  • event (PointerEvent)

poetry--core--calendar

controller · app/javascript/poetry/core/calendar_controller.js

The Calendar engine, decided OWN-THE-ENGINE: no date-picker library (a heavy JS dep). The month grid is SERVER-RENDERED (Ruby Date math) so a no-JS page shows a valid calendar; this controller adds month navigation (regenerating the 42-cell grid from plain Date math - reusing the day button DOM, no innerHTML churn), single-date selection (writing the hidden input + the data-selected/aria vocabulary), and roving arrow-key focus over the days. Selection is a real form value; the DatePicker composes this inside a Popover.

Range mode: mode="range" swaps the click path for a transcription of an MIT-licensed range-selection algorithm (source and license in THIRD_PARTY_NOTICES.md) and the reflection for the range vocabulary (data-range-start/middle/end - dictionary classes that shipped inert until range mode). The form value is TWO hidden inputs (name[start]/name[end] - the wire shape is poetry's). The transcribed semantics hold exactly: an incomplete start-only pick renders as a plain selected single day; the range vocabulary appears only once the range completes.

Dropdown caption (caption_layout: :dropdown), the invisible-select overlay pattern: each [data-calendar-unit=month|year] wrapper holds a visible text label (calendar-dropdown-value) with the real select stretched invisibly over it - #jump reads the selects, #render reflects navigation back into both select values AND label text (no caption target in that mode). Week numbers: one role=rowheader per week; each row's Thursday decides the ISO number (matches Ruby Date#cweek under any week_start).

Still deferred: multiple months.

Targets: caption, day, endInput, grid, input, startInput

Values: month (String); selected (String); mode (String, default: "single"); rangeStart (String); rangeEnd (String); weekStart (Number, default: 0); min (String); max (String); monthNames (Array, default: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"])

Events: poetry:calendar:change

#connect()

Syncs the selected/today vocabulary over the server-rendered grid.

#previousMonth()

The previous-month button's click action.

#nextMonth()

The next-month button's click action.

#jump()

The dropdown caption's change action (the month/year select wrappers): navigates to the selects' month.

#select(event)

Each day button's click action: single mode writes the selection; range mode runs the addToRange transcription. The change event carries value (single) or start/end (range) as ISO strings.

  • event (MouseEvent)

#keydown(event)

The grid's keydown action: arrows move focus by day/week, PageUp/Down by month, Home/End to the week edges.

  • event (KeyboardEvent)

#selectedValueChanged()

Stimulus value callback. The DOM is the store (the anchor-point precedent): external writes to selected/month - the DatePicker input variant syncs typed dates this way - re-render on change. The booted gate skips the init echo (the server's own paint - Stimulus fires initial value callbacks BEFORE connect, and a connect-era render would restamp data-today with the client clock over the server's pinned one). Internal writes re-render once more; the in-place cell diff makes that free.

#monthValueChanged()

Stimulus value callback: re-renders on external month writes (see above).

controller · app/javascript/poetry/core/carousel_controller.js

The Carousel engine, decided NATIVE: no carousel library - the platform's scroll-snap owns the physics (touch, momentum, snapping, overscroll), and this controller adds only what CSS can't: prev/next paging, button state, and arrow keys. Navigation scrolls the VIEWPORT by a bounding- rect delta (RTL- and transform-safe: never scrollLeft sign conventions) - not scrollIntoView, whose alignment bubbles to scrollable ancestors and whose "nearest" no-ops when several slides fit the viewport at once (the vertical stack). Deferred with the library-grade machinery: loop (it clones slides), autoplay, and a plugin API.

Targets: next, previous, viewport

Values: orientation (String, default: "horizontal")

Events: poetry:carousel:select

#connect()

Syncs button state to the initial scroll position.

#disconnect()

Cancels any pending sync frame.

#scrolled()

The viewport's scroll action - button state follows the SCROLL (finger, wheel, keyboard PageDown), not just our own paging. rAF-coalesced: scroll fires in bursts.

#previous()

The previous button's click action: pages one slide back.

#next()

The next button's click action: pages one slide forward.

#keydown(event)

The region root's keydown action: arrows page the carousel, orientation-aware.

  • event (KeyboardEvent)

#scrollTo(index)

The programmatic surface: scrolls to a slide by index (clamped).

  • index (number | string) — stringified numbers accepted

poetry--core--checkbox-group

controller · app/javascript/poetry/core/checkbox_group_controller.js

The select-all recipe (a parent checkbox running the APG mixed-state pattern) over the checked family. Wrap the parent and its rows, route the bubbling per-checkbox observe event at #changed, and mark the parent (target: all) plus each row (target: item):

<div data-controller="poetry--core--checkbox-group" data-action="poetry:checkbox:change->poetry--core--checkbox-group#changed">

A parent toggle fans its new state out to every enabled row; a row toggle re-derives the parent (all -> checked, none -> unchecked, some -> indeterminate, re-entered via the checked controller's set() - the only path back to mixed). Every state write goes THROUGH each box's checked controller (input first, real change event, aria-checked + the checked triple together) - this controller never touches attributes itself. Disabled rows are skipped by fan-out and excluded from the derivation (the DataTable disabledBehavior 'selection' doctrine; table_selection_controller is this same recipe for NATIVE checkboxes).

Targets: all, item

Events: poetry:checkbox-group:change

#changed(event)

The wrapper's data-action route for every bubbling checkbox change: a parent toggle fans out, a row toggle re-derives the parent, and the group's own change event reports the enabled count/total. The applying flag mutes the echoes our own set() calls dispatch (a fan-out must not re-derive per row; a parent reflection must not fan out).

  • event (CustomEvent) — the per-checkbox change

poetry--core--checked

controller · app/javascript/poetry/core/checked_controller.js

The toggle family's checked-state owner (Checkbox introduces it; Switch reuses it VERBATIM - zero fork, CI-asserted). The architecture is the STORE INVERSION: the hidden native <input type=checkbox> is the form participant AND the store - the visual button[role=checkbox|switch] only REFLECTS it (aria-checked incl. "mixed" + the data-checked/data-unchecked/ data-indeterminate attributes on the control and every part that carries them: the checkbox indicator, the switch thumb). Every transition writes the input FIRST, dispatches a REAL bubbling change event (no synthetic prototype-setter dance - the input LEADS and the visual follows), then reflects attributes.

The three states: checked / unchecked / indeterminate. Indeterminate is server/programmatic only (aria-checked=mixed, input.indeterminate - a JS-only property re-derived from the checked attributes on connect); the first user toggle resolves it to CHECKED - contractual, do not loosen. A Switch never renders it (the Ruby component raises), so that branch is simply dormant there.

Enter is suppressed on role=checkbox ONLY (WAI-ARIA: checkboxes activate on Space alone); role=switch has no keydown handler, so Enter toggles via the native button click (a deliberate asymmetry, keyed off the role - no controller fork).

No inputId value -> pure visual mode: state lives on the button's checked attributes alone (controlled-UI cases like DataTable row selection). The component-flavored prefixes the dynamic dispatch below emits under (data-component on the host: checkbox / switch, with the bare fallback) - the events declaration, the portal bridge, and the manifest enumerate these REAL names; the identifier-default name never fires here.

Values: inputId (String, default: "")

Events: poetry:checkbox:change, poetry:checked:change, poetry:switch:change

#connect()

Reconcile-on-connect: derives input.checked/indeterminate from the checked attributes (the server truth), arms the form-reset restore, and installs the role-keyed Enter suppression.

#disconnect()

Unwires the keydown and form-reset listeners.

#toggle()

Control activation (click / Space / label-for): indeterminate resolves to checked (contractual), else flip.

#check()

Programmatically checks (see set).

#uncheck()

Programmatically unchecks (see set).

#set(state)

The programmatic controllable-state surface: set(true | false | "indeterminate") reaches all three states (the select-all recipe - checkbox_group_controller - re-enters indeterminate this way).

  • state (boolean | "indeterminate")

poetry--core--clipboard-text

controller · app/javascript/poetry/core/clipboard_text_controller.js

ClipboardText, adapted from an MIT-licensed source (source and license in THIRD_PARTY_NOTICES.md): a read-only value with one copy affordance. navigator.clipboard is the primary path; the execCommand fallback SAVES AND RESTORES the user's own selection and focus (copying a field must never eat a selection made elsewhere on the page). Success stamps data-copied on the root for a beat (CSS swaps the copy/check glyphs off it), announces through the live-region singleton, and dispatches the copied event.

Targets: input, source

Values: text (String); message (String)

Events: poetry:clipboard-text:copied

#disconnect()

Clears the copied-beat timer.

#copy()

The copy action: writes the text (navigator.clipboard first, then the selection-preserving execCommand fallback), stamps data-copied for a beat, announces, and dispatches the copied event with the text in the detail. A failed write changes nothing.

poetry--core--combobox

controller · app/javascript/poetry/core/combobox_controller.js

The Combobox ORCHESTRATOR: Select's shell x Command's engine, composed VIA THE EVENT CONTRACT ONLY - this thin controller owns open/close + the commit pipeline + autofill adoption and listens for the embedded engine's poetry:command:select; it contains NO filter/highlight/scoring code (Command owns those) and Command gained no combobox code (engine purity, fenced both directions by the conformance greps). Zero code is shared with SelectController - the PATTERNS are (the layer mechanism, the native-first 5-step pipeline, the adoption path), re-instantiated here because a facade extraction was explicitly declined (the Select contract's open question, answered by this build).

THE THREE DELIBERATE DELTAS vs Select, each pinned by tests so neither sibling's rules leak:

  • open focuses the COMMAND INPUT for every reason (a combobox session is

a TYPING session - APG editable combobox); the selected option gets the HIGHLIGHT (activedescendant, via the Command controller) + scroll, not DOM focus;

  • Tab while open CLOSES WITHOUT COMMIT and lets focus proceed (Popover

semantics, modal:false default; modal:true restores the trap) - Select is Tab-inert;

  • a printable key on the CLOSED trigger OPENS and SEEDS the filter

(typing filters, never blind-commits) - Select's closed-trigger typeahead-commit does NOT port.

TWO MEANINGS, TWO ATTRIBUTES, ONE LIST: data-highlighted + aria-activedescendant = position (Command's, never aria-selected); aria-selected + data-selected + the indicator = the COMMITTED value (Select's twin-write, written only here, in the pipeline).

THE SYNC INVARIANT (Select's, inherited): native_select.value, the value Value, the twin-write, and the display never diverge - every write path funnels through #apply, NATIVE FIRST (serialization truth is never behind the facade), with real bubbling change/input on the native so Turbo auto-submit and form listeners work unmodified.

MULTIPLE (the input-inside layout): the value is a LIST (the value Value carries a JSON array over the same String seam), the native is a <select multiple> posting name[], and the trigger is replaced by the chips FIELD - one chip per committed value IN VALUE ORDER with the filter input inline after them (data-slot=combobox-chip-input, which the engine resolves too; the engine itself rides the ROOT). Selection TOGGLES and the popup STAYS OPEN; chips take REAL DOM focus (:focus-visible styles it - never data-highlighted) and focusing a chip closes the popup; Backspace on the empty input removes the last chip; Escape on the CLOSED popup clears the query and wipes the selection. The input NEVER mirrors selection text; single-mode paths are behavior-identical.

Values: open (Boolean, default: false); value (String, default: ""); modal (Boolean, default: false); multiple (Boolean, default: false)

Events: poetry:combobox:change, poetry:combobox:closed, poetry:combobox:open, poetry:combobox:select

#connect()

Wires the content (and chips-field) listeners, captures the placeholder, reconciles silently from the native select / the Value (multiple parses the JSON-array seam), and catches a server-open popup up (layers + the late portal).

#disconnect()

Restores portaled content (drop-never-strand) and unwires everything.

#openValueChanged(value)

Stimulus value callback - controllable open state.

  • value (boolean)

#valueValueChanged(value)

Stimulus value callback - controllable value (multiple parses the JSON-array seam; both modes no-op on the applied echo).

  • value (string)

#toggle()

The trigger's click action: open <-> close.

#triggerKeydown(event)

The trigger's keydown action: Enter / Space / ArrowDown / ArrowUp open (reason: list-navigation). A PRINTABLE key opens AND seeds the filter input with the char (reason: keyboard + the char as data-open-seed, a poetry extension - the char is never lost, the filter pass runs immediately). There is deliberately NO closed-trigger typeahead-commit here.

  • event (KeyboardEvent)

#chipsPointerdown(event)

The chips frame's pointerdown action (multiple): a press anywhere in the FRAME focuses the input and opens the popup (the whole frame IS the field) - except a chip-remove press, which is a removal, never a chips-area press.

  • event (PointerEvent)

#inputKeydown(event)

The inline input's OWN keyboard map (multiple; the engine's map rides the same keydown): Backspace on an empty input removes the LAST chip (focus stays here), ArrowLeft at caret 0 walks into the chips, ArrowDown/Up reopen the popup, Enter with no highlight closes, and Escape on the CLOSED popup clears the query AND wipes the selection to [] (contractual; readOnly blocks every mutation).

  • event (KeyboardEvent)

#chipKeydown(event)

A focused chip's keyboard map: Left/Right walk the chips (off either end -> back to the input), Backspace/Delete remove (next highlight: same index, step back at the tail, the input once emptied), Enter/Space are no-ops returning to the input, ArrowDown/Up reopen the popup, and a printable char resumes the typing session.

  • event (KeyboardEvent)

#removeChip(event)

The chip-remove button's click action: removes the chip's value; focus returns to the input WITHOUT opening (not a chips-area press).

  • event (MouseEvent)

#clear()

The show_clear X's click action - the trigger-side deselection surface: commits the blank value through the pipeline, then hands focus to the trigger (the X hides itself once the value empties, and a focused hidden button would drop focus to body). Single mode only (the component raises on multiple; the guard here is belt and braces).

#open(reason = "trigger-press", { seed = "" } = {})

Programmatically opens.

  • [reason="trigger-press"] (string | Event) — a family reason string; an Event reads as trigger-press
  • [options] (Object)
  • [options.seed=""] (string) — a printable char pre-seeding the filter input

#close(reason = "none")

Programmatically closes.

  • [reason="none"] (string | Event)

#setValue(value)

The programmatic controllable-state surface (multiple accepts an array). Returns the resulting state - what an agent tool reports back - so an unknown value (not committed) reads as changed: false.

  • value (string | string[] | null)

Returns ({ value: string | string[], changed: boolean ) — }

#nativeChanged()

The hidden native's change action: browser autofill (or any programmatic write) adopts - multiple compares MEMBERSHIP, not order (the body comment holds the reorder rule).

poetry--core--command

controller · app/javascript/poetry/core/command_controller.js

The Command palette engine - the suite's first ACTIVEDESCENDANT component (the APG editable-combobox pattern, the deliberate delta vs the menus/Select family's roving focus): real DOM focus stays pinned to the input for the whole session; the highlighted option carries data-highlighted + its server-stable id in the input's aria-activedescendant (the twin-write, activedescendant-flavored). Options never get tabindex; roving-focus is NOT attached; aria-selected is NEVER written here (reserved for committed values - Combobox's twin-write; a bare palette has no committed value).

THE FILTER is the deterministic helpers/filter_rank spec (prefix 4 > word-boundary 3 > substring 2 > keyword 1 > hidden 0, diacritic-folded) and it is HIDE-ONLY: score-0 items get hidden + data-hidden, matches get both removed, and children are NEVER reordered - DOM order is the ranking authority within a band; the score's only job is seating the auto-highlight. filter:false is the server-driven mode: steps 1-2 (hiding) are skipped entirely and only highlight/activation/announcement run over whatever the server rendered

  • the Turbo-frame async seam Combobox's recipe plugs into.

ACTIVATION IS AN EVENT, NOT AN ACTION: Enter/click dispatches cancelable poetry:command:select and this controller does nothing further - no navigation, no close, no value write. The listener (host data-action, Combobox's commit pipeline) owns the consequences - the engine-purity Combobox's composition depends on (no popper/commit/native-select code lives here, fenced by the conformance greps).

The DOM is the store, filtering included: query in the input, visibility as hidden + data-hidden, highlight as data-highlighted + aria-activedescendant, group visibility derived - a Turbo Stream can append items mid-session and the next keystroke ranks them. Each part matches both families' vocabularies: Command's own names and the combobox's (two names for the same parts; the engine rides both).

Values: filter (Boolean, default: true); loop (Boolean, default: false); debounce (Number, default: 0)

Events: poetry:command:filter, poetry:command:highlight, poetry:command:select

#connect()

Reconciles (a non-empty input re-derives visibility + highlight silently; an empty one just seats the highlight) and wires the portal delegation on the list node (the body comments hold the rules).

#disconnect()

Clears the timers and unwires the list delegation.

#filterInput()

The input's input action: runs the filter pass (debounced when configured).

#keydown(event)

The input's keydown action: ArrowUp/Down move the highlight (Meta/Ctrl jumps first/last), Enter activates it. Home/End/ArrowLeft/ArrowRight fall through to the input (CARET movement - APG-correct for an editable field; a Home/End list-jump is deliberately not offered). Space TYPES a space, never activates (a text field - the family delta vs Select/menus). Esc/Tab are NOT handled - the hosting layer owns them (Dialog dismiss / Combobox close / natural tab-out).

  • event (KeyboardEvent)

#activate(event)

Each in-scope item's click action: dispatches the cancelable select event - and nothing further (the engine-purity contract).

  • event (MouseEvent)

#pointerHighlight(event)

Each in-scope item's pointermove action: hovering highlights (no scroll chasing); pointerleave does NOT clear - the keyboard position survives mouse exit (contractual).

  • event (PointerEvent)

#highlightItem(item, { scroll = true } = {})

The composition surface (Combobox calls it): moves the highlight to a given option (visible + enabled required).

  • item (Element)
  • [options] (Object)
  • [options.scroll=true] (boolean) — scroll the option into view

#reset()

Clears the query and silently re-derives visibility + highlight (the clean-reopen reset an overlay host runs on close).

poetry--core--context-menu

controller · app/javascript/poetry/core/context_menu_controller.js

The ContextMenu DELTA layer: trigger acquisition + pointer-point capture ONLY. Everything menu-shaped (open state machine, items, typeahead, submenus, dismissal, the layer stack) stays with poetry--core--menu on the same root; positioning stays with poetry--core--popper, driven here through its VIRTUAL-ANCHOR attribute (data-poetry--core--popper-anchor-point-value="x,y" - the DOM is the store, so the write works whether or not popper has connected yet).

Input paths (all contractual):

  • contextmenu (mouse right-click, or Shift+F10 / the ContextMenu key on a

focused surface): preventDefault, capture the point, open. A keyboard-synthesized event carries no usable point (0,0) - the anchor attribute is CLEARED so popper falls back to the trigger surface rect (deliberately better than a 0,0 fallback).

  • long-press (touch/pen ONLY - mouse never long-presses): pointerdown

starts a longPressDelay timer (700ms default, exposed as a Value); ANY pointermove/pointerup/pointercancel cancels (no slop radius

  • the platform's own touch slop absorbs jitter); a second pointerdown

restarts (multi-touch guard); the contextmenu handler itself clears the timer (Android synthesizes contextmenu from long-press - clearing there prevents double-open). data-pressing on the surface is the styleable long-press feedback window (poetry addition).

  • disabled STANDS THE HANDLERS DOWN (no preventDefault): the

browser-native context menu returns - never a dead right-click.

Values: longPressDelay (Number, default: 700); disabled (Boolean, default: false)

Events: poetry:context-menu:open

#disconnect()

Clears any armed long-press and stops the native-menu suppression.

#disabledValueChanged(disabled)

Stimulus value callback: disabling mid-press clears the armed long-press.

  • disabled (boolean)

#open(event)

The trigger surface's contextmenu action. Re-invoking on an already-open menu re-captures the point and repositions (popper's anchorPoint value is reactive; autoUpdate re-reads the stored point).

  • event (MouseEvent)

#pressStart(event)

The surface's pointerdown action - touch/pen long-press. While open, a press on the surface closes first (press-again-to-dismiss), then the timer arms for a fresh open.

  • event (PointerEvent)

#pressCancel()

pointermove / pointerup / pointercancel - any of them cancels the armed press.

poetry--core--date-field

controller · app/javascript/poetry/core/date_field_controller.js

The segmented date/time editor. The component renders a real native <input type=date|time> that IS the form value (no JS = native pickers; its ISO value format is exactly the wire contract), plus an empty group. This controller builds per-segment role=spinbutton spans from Intl.DateTimeFormat.formatToParts - so the LOCALE decides segment order and literals - and syncs segments -> native input eagerly whenever the value is complete and valid, constraining on blur (February 31st is representable mid-edit; commit clamps).

Hour is stored in the locale's hour cycle with a separate dayPeriod segment; the cycle itself is INFERRED by formatting (two Intl bugs make resolvedOptions() untrustworthy - helpers/incomplete_date.js).

Latin-digit typing only (the NumberField documented divergence); the DISPLAY uses Intl.NumberFormat, so locales that render non-latin digits still see their own numerals.

Targets: group, input

Values: locale (String, default: ""); hourCycle (String, default: ""); seconds (Boolean, default: false); placeholder (String, default: ""); labels (Object, default: {}); placeholders (Object, default: {})

Events: poetry:date-field:change

#connect()

Builds the locale-driven segment row from formatToParts, marks the field enhanced, retires the native input from the tab order (it stays the form value and the constraint-validation carrier), and routes native invalid to the first empty segment.

#disconnect()

Restores the native input's tab order / AT visibility and unwires the document listeners.

#focusGap(event)

The group's click action: clicking blank space lands on the earliest unfilled segment (the focusLast heuristic, simplified - segments themselves stop propagation by matching first).

  • event (MouseEvent)

#settle(event)

The group's focusout action: leaving the whole group constrains (Feb 31 -> Feb 29) and commits.

  • event (FocusEvent)

poetry--core--date-picker

controller · app/javascript/poetry/core/date_picker_controller.js

The DatePicker glue: the thin coordinator between a Popover and the Calendar it wraps. The Calendar owns selection + the form value (its name: hidden input); this controller only reacts to the calendar's change event to (a) write the human-formatted date into the trigger label and (b) close the popover so the pick feels complete. Everything hard is already done by poetry--core--calendar and poetry--core--popover.

Targets: input, label

Values: placeholder (String, default: "Pick a date"); mode (String, default: "single"); locale (String, default: "en-US")

#picked(event)

The calendar-change action on the wrapper: writes the formatted date into the trigger label (and input, when present) and closes the popover so the pick feels complete. Range mode: the label joins the pair, a start-only pick shows one date, and the popover closes only once the range COMPLETES - never mid-range.

  • event (CustomEvent) — detail carries value (single) or start/end (range) as ISO strings

#inputChanged()

The input's change action: a parseable date re-selects and re-months the calendar through its reactive values (the DOM is the store), and the calendar's own render refreshes the hidden ISO form value. Unparseable text changes nothing.

#inputKeydown(event)

The input's keydown action: ArrowDown opens the calendar.

  • event (KeyboardEvent)

poetry--core--deferred

controller · app/javascript/poetry/core/deferred_controller.js

Error isolation for deferred turbo-frames. Turbo owns the loading physics (loading="lazy" fetches on visibility, "eager" after paint) but leaves failure INVISIBLE: a missing frame logs "Content missing" or - Turbo 8 - promotes the error response to a full-page visit, and a network error leaves the placeholder spinning forever. This controller makes failure a state: data-error reflected on the frame (a bespoke boolean, deliberately not the shared open/closed vocabulary), the placeholder hidden, and the slotted <template> error content stamped so the region shows a real, retryable message.

THE BOOT CONTRACT: the frame renders with NO src - the URL rides srcValue and connect() arms it. A visible lazy frame with src in markup can complete its fetch before the controllers module graph finishes loading (caught live: a local 404 beat Stimulus and Turbo 8 navigated the whole page to the error response); with src armed at connect, no fetch - and so no failure - can predate the instance.

retry() prefers Turbo's FrameElement#reload(); environments without the Turbo runtime (jsdom, dommy) fall back to re-setting src, the same signal Turbo reacts to.

Targets: error, placeholder

Values: src (String)

#connect()

Wires the three failure listeners, subscribes the before-cache reset, and arms src LAST (the boot contract above: no fetch can predate the instance).

#disconnect()

Unwires the listeners and the before-cache subscription.

#retry()

The retry action: back to the placeholder posture, then reload - Turbo's FrameElement#reload when the runtime is present, else the re-set-src fallback (the same signal Turbo reacts to).

poetry--core--drawer

controller < poetry--core--dialog · app/javascript/poetry/core/drawer_controller.js

The Drawer: the dialog machinery + the swipe-to-dismiss gesture. Everything hard about the OVERLAY is inherited (native <dialog> platform trap, backdrop-click discrimination, scroll lock, hotkey); this subclass adds the two things a drawer is:

* ANIMATED presence - the first consumer of the presence helpers: enter rides the data-starting-style two-frame trick (a transition from --closed-transform), exit HOLDS the dialog through the data-ending-style transition before the native close() (the presence-hold close the sheet styling depends on). * The SWIPE - pointer-captured drag along the dismiss direction, writing the swipe CSS-var contract onto the <dialog> (::backdrop inherits from its originating element, so the overlay fade rides the same vars): --drawer-swipe-movement-x/y (px toward dismissal), --drawer-swipe-progress (0..1), data-swiping while tracking (duration-0 - the drawer follows the finger), and on release either a snap-back or a dismissal whose exit duration scales with --drawer-swipe-strength (mostly-swiped closes fast).

Deferred with the rest of the stack machinery (see the Drawer style): snap points, nested-drawer stacking, and bleed.

Targets: dialog

Values: dismissible (Boolean, default: true); hotkey (String, default: ""); direction (String, default: "down"); modal (Boolean, default: true); snapPoints (Array, default: [])

#open()

Opens per the modal value: showModal() plus the scroll lock, or the non-modal show() (no top layer - the component pins the panel; the body comment). The first snap offset lands BEFORE the enter transition starts, then the animated entry runs.

#escapeClose(event)

The non-modal Escape exit (the component wires this keydown action only when modal is false): a non-modal dialog never fires cancel, so Esc needs its own path while focus is inside; modal drawers ride the native cancel.

  • event (KeyboardEvent)

#close(event)

Closes through the animated exit (data-ending-style), then the native close(), the unlock, and the swipe-var reset. The native cancel event (Esc) routes through here so state stays in sync.

  • [event] (Event) — the native cancel event, when Esc drove it

#swipeStart(event)

The <dialog>'s pointerdown action: arms the swipe - always from the handle; elsewhere only when the press is not on a control and no scrollable child still scrolls toward the gesture.

  • event (PointerEvent)

#swipeMove(event)

The captured pointermove action: past the slop, tracks the drag - writing the swipe CSS vars (the finger-following, duration-0 window) and the release velocity.

  • event (PointerEvent)

#swipeEnd(event)

The pointerup action - release physics: dismiss past half the travel or on a flick (exit duration scaled by the remaining travel), else snap back; snap-pointed sheets settle between their points instead.

  • event (PointerEvent)

#swipeCancel(event)

The pointercancel action: abandons the drag and snaps home.

  • event (PointerEvent)

poetry--core--file-input

controller · app/javascript/poetry/core/file_input_controller.js

The FileInput dropzone engine: the component renders a <label> wrapping a visually-hidden native <input type=file>, so click-to-browse and keyboard access are the PLATFORM's - this controller adds only what HTML cannot: drag-and-drop onto the label, the selected-file list, and the clear affordance. The native input stays the single source of truth (its FileList is the form value; ActiveStorage direct upload rides it untouched).

Drag state uses an enter/leave COUNTER: dragenter/dragleave fire per descendant crossed, so a boolean flickers over child elements - the counter nets out to zero only when the pointer truly leaves the zone.

Targets: clear, input, list

Values: multiple (Boolean, default: false)

Events: poetry:file-input:change

#connect()

Reflects the input's current FileList (list, clear, data-populated).

#dragenter(event)

File drags flip the zone into its accepting state - depth-counted (the header explains the counter).

  • event (DragEvent)

#dragover(event)

The required preventDefault - without it the browser navigates to the dropped file.

  • event (DragEvent)

#dragleave()

Nets the depth counter down; at zero the drag styling clears.

#drop(event)

Assigns the dropped files to the native input (single-file zones take the FIRST - the native picker's rule) and dispatches a real change so every listener sees a picker-identical selection.

  • event (DragEvent)

#changed()

The native input's change action (picker AND drop land here): reflects the selection and reports the file names.

#clear(event)

The clear button's click action: empties the input and dispatches a real change.

  • event (MouseEvent)

poetry--core--hotkey

controller · app/javascript/poetry/core/hotkey_controller.js

Declarative global shortcut: put the controller on any clickable element and declare the descriptor -

<a href="/inbox" data-controller="poetry--core--hotkey" data-poetry--core--hotkey-keys-value="g+i">

On match the controller dispatches a cancelable poetry--core--hotkey:pressed event, then clicks the host element (buttons, links, summary - anything click-activatable). Unmodified single-key descriptors stay inert while the user is typing in an input/textarea/select/contentEditable; combos carrying meta/ctrl/alt fire everywhere (the ⌘K convention).

Values: keys (String, default: "")

Events: poetry--core--hotkey:pressed

#connect()

Arms the window keydown listener when a descriptor is declared (an empty keys value stays inert).

#disconnect()

Disarms the listener.

poetry--core--hover-card

controller · app/javascript/poetry/core/hover_card_controller.js

The HoverCard controller (the popper-consumer trio's thinnest machine): pointer-only enrichment behind a LINK. Two timers (open 600 / close 300, over the trigger+content pair, re-enter cancels - the grace window, no polygon), the touch double-guard (pointerType 'touch' no-ops AND touchstart preventDefaults so a tap can never synthesize a focus-open - a tap just navigates the link), the focus mirror (trigger focus opens immediately / blur closes - a keyboard user SEES the card), the per-open TABINDEX STRIP (every tabbable inside is forced tabindex=-1: keyboard and touch users never reach inside, intentional and contractual - the reachable-elsewhere rule), and the SELECTION HOLD (text selection started in the card keeps it open and suppresses body user-select while dragging, so previews are copyable).

NO focus-scope anywhere in the lifecycle: focus never moves in, so there is nothing to trap or restore - the trio's simplest teardown. The token-activated dismissable delivers Esc (topmost-only) + outside press while open. No aria surface is added (no haspopup/expanded/describedby): advertising a keyboard-unreachable surface to AT is worse than silence.

Values: open (Boolean, default: false); openDelay (Number, default: 600); closeDelay (Number, default: 300)

Events: poetry:hover-card:closed, poetry:hover-card:open

#connect()

Wires the content listeners (portal-safe) and reconciles a server-pinned card (layer + tabindex strip catch up; the DOM wins).

#disconnect()

Clears every timer, restores suppressed selection and portaled content (drop-never-strand), and unwires the listeners.

#openValueChanged(value)

Stimulus value callback - controllable state: pinned previews (Turbo Stream / Outlet ownership).

  • value (boolean)

#pointerEnter(event)

The trigger's pointerenter action: arms the open timer; touch pointerType is EXCLUDED - a tap navigates the link instead.

  • event (PointerEvent)

#pointerLeave(event)

The trigger's pointerleave action: cancels a pending open; leaving toward the content keeps the card, anywhere else arms the close grace.

  • event (PointerEvent)

#focusOpen()

The trigger's focus action: opens IMMEDIATELY, skipping the timers - a keyboard user sees the preview even though they cannot enter it.

#blurClose()

The trigger's blur action: closes immediately.

#touchGuard(event)

The trigger's touchstart action - the touch guard: preventDefault so a tap can never synthesize a focus event (a focus-opened card on touch would be unreachable). The tap still navigates the link.

  • event (TouchEvent)

poetry--core--mask

controller · app/javascript/poetry/core/mask_controller.js

The input-mask machine (the engine is adapted from an MIT-licensed source - source and license in THIRD_PARTY_NOTICES.md) on a bare native <input> - this.element IS the input. Three layers:

1. THE PRIMARY PATH is keydown with preventDefault: a printable char skips forward over literals from the caret and pattern-tests (a failing char is a NO-OP, it never reaches the field), a selection is replaced via a raw rebuild, and the caret lands past the next literal run. Backspace scans BACKWARD over literals (the caret lands at the deleted token), Delete scans FORWARD (the caret stays), Cmd/Ctrl+Backspace kills to start, arrows hop literal runs. Because preventDefault kills the native undo stack, a custom {raw, selectionStart} history (max 100, deduped) backs Cmd/Ctrl+Z / Shift+Cmd/Ctrl+Z / Ctrl+Y.

2. THE FALLBACK is the input-event diff (IME, mobile keyboards, autofill, password managers - paths that never emit clean keydowns): the longest common prefix+suffix against the previous display isolates the inserted text and the removed span, the value is rebuilt from raw pieces, re-masked, and the caret lands after the insertion.

3. THE DISPLAY: skeleton padding ("__/__") shows on focus (showMaskOnFocus, default) or permanently (alwaysShowMask); blur strips it back to the filled region - or clears the field entirely when autoClear is set and the mask is incomplete. A COLLAPSED caret is clamped into [first token, end of filled region] on focus (rAF), mousedown (rAF) and mouseup; selections are never touched.

A deliberate divergence from the adapted source: every programmatic value write dispatches a native bubbling input event (#painting guards the controller against its own echo) so Rails/Turbo listeners stay live. The raw value mirrors to data-raw after every change; pattern is set on connect from generatePattern("full-inexact") unless the input already carries one.

Values: mask (String); slotChar (String, default: "_"); alwaysShowMask (Boolean); showMaskOnFocus (Boolean, default: true); autoClear (Boolean); upcase (Boolean)

Events: poetry:mask:change, poetry:mask:complete

#connect()

Parses the mask, stamps the pattern attribute (unless the input carries one), wires the listener set, and processes a server-rendered value silently.

#disconnect()

Unwires the listener set.

poetry--core--menu

controller · app/javascript/poetry/core/menu_controller.js

The menus-family controller (the DropdownMenu contract's ANCHOR - ONE menu engine behind DropdownMenu / ContextMenu / Menubar). This owns ONLY what is menu-specific: open/close with the data-open-reason initial-focus contract, item activation (the cancelable poetry:menu:select), checkbox/radio state (poetry:menu:change), the APG typeahead buffer, submenu open/close with hover intent + sibling exclusivity, and the cancelable poetry:menu:edge-navigate seam a Menubar coordinator consumes. Everything else is composed BY REFERENCE: focus-scope (trap + focus return to the trigger), dismissable (topmost-only Esc + outside press, arriving here as its "dismiss" event), roving-focus (arrows/Home/End per menu level), popper (positioning - markup-owned, untouched here), presence (data-open/data-closed flip -> animationend -> hidden).

STRUCTURAL RESOLUTION, no targets: the content is found via the trigger's aria-controls id (portal-safe - a Stimulus target cannot cross a portal move), items via the collection helper over the family's data-slot suffix selectors ([data-slot$=menu-item] etc., so dropdown-menu-*, context-menu-* and menubar-* anatomies all resolve), subs via their own aria-controls pairs. Content-level listeners are wired programmatically in connect for the same portal-safety reason.

The layer stack is ACTIVATED on open: the focus-scope / dismissable / roving-focus identifiers are appended to the content's data-controller (a statically-connected trap or dismiss layer on a hidden menu would steal focus at page load and swallow topmost-Esc). Close reverses: presence exit -> hidden -> tokens removed -> focus-scope's disconnect restores focus to the trigger. Each open sub level adds its own dismissable layer (the close-one-level-at-a-time Esc chain for free) + its own roving group; subs join the ROOT focus scope (no nested traps, contractual). Both family spellings resolve: dropdown-menu-* / context-menu-* share the "menu-" suffix; menubar-* is its own word (menubar-item does NOT end with "menu-item"), so every part selector carries the pair.

Values: open (Boolean, default: false); modal (Boolean, default: true); loop (Boolean, default: false); typeaheadTimeout (Number, default: 1000); closeOnSelect (Boolean, default: true)

Events: poetry:menu:change, poetry:menu:closed, poetry:menu:edge-navigate, poetry:menu:open, poetry:menu:select

#connect()

Wires the content listeners (portal-safe) and reconciles: server-open content adopts the layer stack and re-portals one frame late.

#disconnect()

Restores portaled subs then the content (drop-never-strand), unwires everything, and clears the sub hover-intent timers.

#openValueChanged(value)

Stimulus value callback - controllable state: a host (outlet / Turbo Stream / URL param) may own the open value; flipping the attribute drives the same machine.

  • value (boolean)

#toggle()

The trigger's click action: open <-> close.

#triggerKeydown(event)

The trigger's keydown action: Enter / Space / ArrowDown open + focus the FIRST enabled item; ArrowUp opens + focuses the LAST (the APG menu-button map). preventDefault also suppresses the button's synthetic click, so toggle cannot double-fire.

  • event (KeyboardEvent)

#open(reason = "trigger-press", { focus = true, seed = null } = {})

Programmatically opens (the family surface Menubar's coordinator calls).

  • [reason="trigger-press"] (string | Event) — a family reason string; an Event (data-action use) reads as trigger-press
  • [options] (Object)
  • [options.focus=true] (boolean) — false is the coordinator's pointer-toggle contract (a menubar pointer-open leaves focus on the trigger; keyboard-open focuses an item)
  • [options.seed=null] ("first" | "last" | null) — the initial-focus seed

#close(reason = "none", { restoreFocus = true } = {})

Programmatically closes.

  • [reason="none"] (string | Event)
  • [options] (Object)
  • [options.restoreFocus=true] (boolean) — false is the hover-slide/edge-navigate contract (the outgoing menu must not yank focus back mid-swap)

#activate(event)

Item activation - click/Enter/Space unified. Kept as a public action for markup-declared data-action; the delegated content listener claims the event first, so both paths never double-activate.

  • event (Event)

#keydown(event)

The content's keydown route (also wired programmatically): Tab closes and lets focus move on, Enter/Space activate (Space defers to a live typeahead; native link/submit items act through the element itself), the direction-aware horizontal pair opens/closes submenus or fires the Menubar edge seam, and printable keys run typeahead per level.

  • event (KeyboardEvent)

#subEnter(event)

Each sub-trigger's pointerenter action: hover intent toward opening its sub level.

  • event (PointerEvent)

#subLeave(event)

Each sub-trigger's pointerleave action: hover intent toward closing (entering the sub pair within the grace cancels it).

  • event (PointerEvent)

#openSub(event)

The sub-trigger's click action: opens its sub level (the delegated click claims first).

  • event (MouseEvent)

#closeSub(event)

Closes a sub-trigger's whole subtree (markup-declared affordances).

  • event (Event)

poetry--core--menubar

controller · app/javascript/poetry/core/menubar_controller.js

The Menubar cross-menu COORDINATOR - kept deliberately anorexic: ONE piece of state (value, which menu is open) plus the three behaviors no other layer can own. The ownership split:

  • roving-focus (horizontal, manageTabindex TRUE) owns which trigger is

tabbable/focused on the bar - it knows nothing about menus.

  • poetry--core--menu (one instance per menu, modal: false) owns

everything inside an open menu - it knows nothing about siblings.

  • this controller owns:

1. TOGGLE - pointerdown opens (closing any sibling) / closes the open menu. Keyboard open (ArrowDown/Enter/Space -> first item; ArrowUp -> last item) rides the family's open-reason contract; pointer-open leaves focus on the trigger (pointer users keep their context). 2. HOVER-SLIDE - pointerenter on a sibling trigger is a no-op from cold; once ANY menu is open it swaps to the hovered menu (the gated-hover rule) and focus moves to the new trigger. 3. EDGE-NAVIGATE - the family menu controller fires a cancelable poetry:menu:edge-navigate when ArrowLeft/Right has no submenu meaning; this coordinator consumes it (loop-aware, RTL-aware, disabled-skipping) and opens the adjacent menu with its FIRST item focused (both directions - APG menubar). Standalone DropdownMenu leaves the event unconsumed. Dismiss (Esc/outside) and select arrive as the family's poetry:menu:closed

  • the coordinator nulls value; focus return to the trigger is the family

focus-scope's job (its connect snapshot IS the trigger, because every open path here puts focus there first).

A press on a sibling trigger must be TOGGLE's, not the open menu's dismissable layer's: the coordinator vetoes interact-outside for presses landing on this bar's triggers (otherwise dismiss-then-toggle would close-and-reopen in the same pointerdown).

Values: value (String, default: ""); loop (Boolean, default: false)

Events: poetry:menubar:value-changed

#connect()

Wires the sibling-trigger interact-outside veto and reconciles one microtask behind (the per-menu controllers connect after the bar).

#disconnect()

Unwires the veto listener.

#valueValueChanged(value, previous)

Stimulus value callback - controllable state: a host (outlet / Turbo Stream / URL param) may own the value; flipping the attribute drives the same machine.

  • value (string)
  • previous (string)

#toggle(event)

Each trigger's pointerdown action: opens (closing any sibling) or closes the open menu - left button only, ctrl-click passes (the macOS context menu); pointer-open leaves focus on the trigger.

  • event (PointerEvent | MouseEvent)

#hoverSlide(event)

Each trigger's pointerenter action - the gated hover: a no-op from cold; once ANY menu is open it swaps to the hovered menu.

  • event (PointerEvent)

#triggerKeydown(event)

Each trigger's keydown action: Enter/Space/ArrowDown open with the FIRST item focused, ArrowUp with the last (the family's open-reason contract).

  • event (KeyboardEvent)

#slideAdjacent(event)

The edge-navigate action (cancelable, bubbling from the open menu's ROOT content). detail.direction is the PHYSICAL arrow; RTL maps it here. ALWAYS consumed once the menu belongs to this bar - an unconsumed edge would fall through to the bar's roving-focus and move trigger focus while the menu stays open (bar arrows are inert while open, by rule). At a no-loop boundary that means: consumed, no move.

  • event (CustomEvent)

#onMenuClosed(event)

The family's closed event, from any of the bar's menus: nulls the value unless the close is one half of an in-flight swap.

  • event (CustomEvent)

poetry--core--message-scroller

controller · app/javascript/poetry/core/message_scroller_controller.js

How long (ms) data-autoscrolling stays set during a programmatic scroll before clearing. While set, the follow-bottom RELEASE is suppressed so the auto-scroll animation cannot release itself.

Targets: button, content, spacer, viewport

Values: autoScroll (Boolean, default: false); defaultScrollPosition (String, default: "end"); preserveScrollOnPrepend (Boolean, default: true); trackVisibility (Boolean, default: false); scrollEdgeThreshold (Number, default: 8); scrollPreviousItemPeek (Number, default: 64); scrollMargin (Number, default: 0)

Events: poetry:message-scroller:mode, poetry:message-scroller:pinned, poetry:message-scroller:scrollable, poetry:message-scroller:unpinned, poetry:message-scroller:visibility

#connect()

Builds the ref bag, wires the viewport listeners and the three observers, runs the mount pass (rows counted, defaultScrollPosition applied once), and arms visibility tracking when opted in.

#disconnect()

Cancels every frame/timer, disconnects the observers, and unwires the viewport listeners (the body comment holds the stale-id rule).

#defaultScrollPositionValueChanged()

Stimulus value callback: a defaultScrollPosition change re-arms the one-shot apply.

#autoScrollValueChanged()

Stimulus value callback: re-pin if we were following.

#syncAfterScroll()

The viewport's scroll handler: commits scrollable state, schedules a visibility sync, and re-captures the prepend anchor.

#userScrollIntent()

The wheel/touchmove handler: a deliberate gesture releases auto-follow, turn-anchoring, AND an in-flight programmatic jump so re-pinning never fights the reader.

#keydownIntent(event)

The viewport's keydown handler: scroll keys count as deliberate intent.

  • event (KeyboardEvent)

#jump(event)

The jump button's click action. No-op while inactive; blurs so focus is not stranded on a control about to inert itself.

  • event (MouseEvent)

#scrollToEnd(eventOrOptions = {})

Scrolls to the end - callable as a Stimulus action (options via params) or directly with an options object (outlet callers).

  • [eventOrOptions] (Event | Object) — behavior: "auto" | "smooth"

Returns (boolean) — whether a scroll was issued

#scrollToStart(eventOrOptions = {})

Scrolls to the start (scrollToEnd's calling conventions).

  • [eventOrOptions] (Event | Object)

Returns (boolean) — whether a scroll was issued

#scrollToMessage(eventOrId, options)

scrollToMessage("id", options) programmatically, or as an action with data-...-message-id-param (align/behavior/scrollMargin params pass through).

  • eventOrId (Event | string)
  • [options] (Object) — align / behavior / scrollMargin

Returns (boolean) — true when scrolled - or queued (a miss on an empty transcript queues the request for the next content change)

poetry--core--navigation-menu

controller · app/javascript/poetry/core/navigation_menu_controller.js

The NavigationMenu coordinator (the viewport=false mode): a DISCLOSURE BAR, not a menu - Tab moves through triggers and links normally, arrows are convenience navigation, nothing traps. Each item's panel is its own popup positioned under the item; this controller owns what per-item popovers can't: ONE panel open at a time, hover intent (open/close delays so diagonal travel into a panel doesn't flicker), Esc + focus-out + outside-press closing, and the vocabulary writes (data-popup-open on the trigger - the chevron's rotation hook - and the presence-driven open/closed pair on the panel).

THE VIEWPORT MODE: when the markup ships the shared positioner > popup > viewport shell (viewport: true), panels are lazily ADOPTED into the viewport on first activation (the Rails stand-in for a portal - server-rendered content stays in place until JS activates) and the composite MORPHS: the popper re-anchors to the active trigger (full floating-ui) while CSS transitions the positioner's insets, the popup's --popup-width/height pin old -> new across two frames so width/height transition, panels slide by data-activation-direction (new trigger vs old - travel direction drives the slide), and data-instant suppresses transitions on cold opens. Vars reset to auto after the animations finish (the auto-size reset via getAnimations().finished).

Values: openDelay (Number, default: 50); closeDelay (Number, default: 150)

#connect()

Subscribes the before-cache close (the body comment holds the zombie rules).

#disconnect()

Clears the timer and unwires the outside-press and before-cache listeners.

#toggle(event)

Each trigger's click action - immediate, cancels hover intent.

  • event (MouseEvent)

#scheduleOpen(event)

Each ITEM's pointerenter action (the panel lives inside its item, so moving into the panel never schedules a close): arms hover intent - instant switch while the bar is already open, the open delay on cold entry. Touch is click's job.

  • event (PointerEvent)

#scheduleClose(event)

Each ITEM's pointerleave action: arms the close grace.

  • event (PointerEvent)

#cancelClose()

The shared positioner's pointerenter action - in viewport mode the panel no longer lives inside its item, so entering the popup must cancel a pending close.

#keydown(event)

The root's keydown action: Escape closes and refocuses the trigger; ArrowLeft/Right move between the bar's stops (triggers and top-level links).

  • event (KeyboardEvent)

#focusLeft(event)

The root's focusout action - a disclosure closes when focus leaves it entirely (never traps).

  • event (FocusEvent)

poetry--core--number-field

controller · app/javascript/poetry/core/number_field_controller.js

NumberField: a formatted visible <input type=text> over a hidden <input type=number> that is the form/validation truth - the spinbutton ARIA pattern is deliberately not used. The controller owns stepping (arrows, steppers with press-and-hold, opt-in wheel), parse/clamp/format, and the two-input sync. Number | null value model: empty is null, never NaN.

Deliberate v1 boundaries (documented in the component): no scrub area, Latin-digit parsing only (locale separators and currency/percent symbols ARE handled via Intl.formatToParts), and the server renders the raw number - the display formats on connect.

Targets: decrement, hidden, increment, input

Values: min (Number); max (Number); step (Number, default: 1); largeStep (Number, default: 10); smallStep (Number, default: 0.1); snap (Boolean); wheel (Boolean); format (Object); locale (String)

Events: poetry:number-field:change, poetry:number-field:commit

#connect()

Adopts the hidden value, paints the formatted display, reflects the boundaries, and wires the opt-in wheel plus the window-level release listeners.

#disconnect()

Unwires the wheel/release listeners and stops any repeat.

#keydown(event)

The visible input's keydown action: ArrowUp/Down step (Shift = large step, Alt = small), Home/End jump to the bounds, and printable keys pass the character gate.

  • event (KeyboardEvent)

#input()

The input action: text updates freely; the value goes live only while parseable. The display is NEVER rewritten mid-typing (dirty-text authority).

#focus()

The focus action: the first focus parks the caret at the end.

#blur()

The blur action - the text commit point: empty clears, unparseable text is left as typed with no commit, parseable text clamps and normalizes to the canonical formatted display (never snapped to step - blur is not a correction gesture).

#press(event)

Each stepper's pointerdown action: steps once, then repeats after the hold delay (mouse focuses the input; touch would pop the software keyboard).

  • event (PointerEvent)

#tap(event)

The stepper's click action: quick touch taps synthesize click without a held pointer - step once, but never double-step after a handled pointerdown.

  • event (MouseEvent)

#leave()

The stepper's pointerleave action: stops the repeat.

#hiddenChanged()

The hidden input's change action: browser autofill lands there - adopt, clamp, commit.

poetry--core--optimistic-form

controller · app/javascript/poetry/core/optimistic_form_controller.js

Optimistic UI for Turbo forms, adapted from an MIT-licensed source (source and license in THIRD_PARTY_NOTICES.md). The optimistic update is AUTHORED AS A TURBO STREAM inside a <template> target - the same vocabulary the server answers in, so prediction and truth share one mental model and there is no bespoke DOM patching. On turbo:submit-start the template contents are cloned into the document and Turbo paints the predicted state immediately; on turbo:submit-end the controller reconciles ONLY when the submission failed, by appending <turbo-stream action="refresh"> - which must morph (the helper documents the turbo-refresh-method=morph meta) so the correction is seamless.

THE SERVER CONTRACT (enforced nowhere, so stated everywhere): success answers 204 (or a targeted stream for authoritative correction under contention) - NEVER a redirect, because a redirect under morph refreshes is itself a full reload and defeats the optimism. Failure answers 4xx so event.detail.success is false and the refresh restores authoritative truth. Full story: the Optimistic Forms guide on the poetry docs site.

Targets: template

#apply()

Applies every template's stream(s) - the turbo:submit-start action. Rapid resubmits inside the window cannot stack duplicate clones; the first paint is still immediate.

#reconcile(event)

The turbo:submit-end action: appends the authoritative refresh stream ONLY when the submission failed (the body comment holds the rule).

  • event (CustomEvent) — detail.success decides

poetry--core--otp

controller · app/javascript/poetry/core/otp_controller.js

The InputOTP projection (the single-input architecture, poetry's own build - no npm dependency): ONE real native <input> holds the whole value, stretched invisibly over the slot row, so paste, SMS autofill, IME, constraint validation and form serialization are all native and AT sees ONE text field. The n slot cells are an aria-hidden MIRROR painted here - this controller contains ZERO editing logic:

  • #sync filters input.value per-character through the pattern + truncates

to length (writing back only when filtering changed it - that one line IS paste splitting: "123-456" under digits becomes "123456"), paints slot[i] with value[i], and projects the caret: the active cell is min(selectionStart, length - 1) while focused (data-active), with the fake blinking caret element visible only on the active EMPTY cell.

  • Auto-advance and backspace-retreat are not features: typing moves the

native caret forward, Backspace moves it back - the active cell is a PROJECTION of selectionStart. Arrows/Home/End/IME: native, re-projected via the document-level selectionchange listener (bound on focus, unbound on blur - n OTP fields must not all re-project on every caret move anywhere).

  • poetry:otp:change fires per accepted mutation; poetry:otp:complete

fires once when the value reaches length and re-arms below it (the enable-the-submit-button hook - it never submits).

Targets: input, slot

Values: length (Number, default: 6); pattern (String, default: "\\d")

Events: poetry:otp:change, poetry:otp:complete

#connect()

Compiles the pattern, adopts the input's value, and paints the initial projection (no events dispatched).

#disconnect()

Unbinds the document-level selectionchange listener.

#sync(event)

The native input's input / focus / blur action (one handler - the projection is idempotent; blur clears the active cell; focus binds the selectionchange re-projection).

  • [event] (Event)

#paste(event)

The paste action. Paste is the one native path maxlength breaks: the browser truncates the RAW clipboard text to maxlength before any input event, so "123-456" loses its tail before the sync pass could filter the dashes (real Chrome; jsdom doesn't enforce maxlength, which is why the unit tier never saw it). Intercept, filter FIRST, splice at the selection.

  • event (ClipboardEvent)

#focusInput()

The container's click action (gaps, separators - the input already covers the cells at z-20): focuses the real control.

poetry--core--popover

controller · app/javascript/poetry/core/popover_controller.js

The Popover controller (the popper-consumer trio's click-open member): the menu controller's #show/#hide + token-activated layer skeleton with ALL item machinery deleted - no typeahead, no roving, no subs, no collection. What remains is exactly the APG dialog-pattern-lite: the trigger toggles a role=dialog panel, focus MOVES INTO the content on open (focus-scope's mount default - deliberately NOT vetoed, the contrast with the menu family's data-open-reason contract) and RETURNS to the trigger on close; the trap is enforced only when modal (the default is modal: FALSE - the deliberate contrast with the menu family's true).

STRUCTURAL RESOLUTION, no targets: the content is found via the trigger's aria-controls id (portal-safe - the menu-controller pattern verbatim); content-level listeners are wired programmatically in connect for the same reason.

The layer stack is ACTIVATED on open: focus-scope + dismissable are appended to the content's data-controller (a statically-connected trap on hidden content would steal focus at page load; a static dismissable would swallow topmost-Esc), with trapped / disable-outside-pointer-events both set from modal. Close reverses: presence exit -> hidden -> tokens removed -> focus-scope's disconnect restores focus to the trigger (suppressed for outside-press when non-modal: focus follows the click).

Values: open (Boolean, default: false); modal (Boolean, default: false)

Events: poetry:popover:closed, poetry:popover:open

#connect()

Wires the content listeners (programmatic - portal-safe) and reconciles: server-open content adopts the layer stack and re-portals one frame late (the body comments hold the rules).

#disconnect()

Restores portaled content (drop-never-strand) and unwires the content listeners.

#openValueChanged(value)

Stimulus value callback - controllable state: a host (outlet / Turbo Stream / URL param) may own the open value; flipping the attribute drives the same machine.

  • value (boolean)

#toggle()

The trigger's click action (native button Enter/Space arrive as click - no custom keydown map, the deliberate contrast with the menu trigger).

#open()

Programmatically opens.

#close(reason = "none")

Programmatically closes.

  • [reason="none"] (string | Event) — a family reason string; an Event (data-action use) reads as "none"

poetry--core--pressed

controller · app/javascript/poetry/core/pressed_controller.js

The Toggle micro-machine (the smallest controller in the suite): flip aria-pressed and mirror the bare data-pressed presence boolean - WRITTEN TOGETHER, never separately. The DOM is the store: no Values, no internal state. This is deliberately NOT poetry--core--checked (different ARIA vocabulary, no input to sync) and not poetry--core--state (that owns aria-expanded disclosure) - three micro-controllers, three vocabularies.

No keydown code: Space AND Enter both activate a native button (click) - the platform already implements the keyboard.

poetry:toggle:change is CANCELABLE and fires BEFORE the flip renders: preventDefault vetoes it (hosts that must confirm). The pressed value in the detail is the state the toggle is ABOUT to enter.

Events: poetry:toggle:change

#toggle()

The click action: flips to the opposite pressed state. No-op while disabled; a listener that vetoes the (cancelable) change event stops the flip.

#press()

Programmatically presses the toggle (see set).

#unpress()

Programmatically releases the toggle (see set).

#set(pressed)

Programmatically writes a pressed state through the same veto path the click takes. No-op while disabled.

  • pressed (boolean)

poetry--core--questionnaire

controller · app/javascript/poetry/core/questionnaire_controller.js

The Questionnaire machine, adapted from an MIT-licensed source (source and license in THIRD_PARTY_NOTICES.md): a native <form> of fieldset items shown ONE at a time. The server renders the complete initial state (active item, statuses, shortcuts, button visibility); this controller owns the runtime transitions - navigation (validate-gated Next, Skip for optional items, submit validates every item and jumps to the first invalid), answer tracking (choice change / text input -> status), the keyboard map (Cmd/Ctrl+Enter confirm, ArrowUp/Down answer focus, ArrowLeft/Right item navigation, Enter-on-filled-answer confirm, letter/number shortcuts), and the data-attribute stamps the styling contract reads. Answers are native radio/checkbox/text inputs - the form serializes with zero JS.

Targets: next, previous, progress, skip, submit

Values: shortcuts (String, default: "")

Events: poetry:questionnaire:item-change, poetry:questionnaire:status-change

#connect()

Derives every stamp (checked/filled/status/progress/buttons) from the server-rendered DOM.

#previous()

The previous button's click action: back one item (no validation).

#next()

The next button's click action: validate-gated advance - an invalid active item takes focus instead.

#skip()

The skip button's click action (optional items only): stamps skipped, then advances - or submits from the last item.

#submit(event)

The form's submit action: every enabled item must validate; the first invalid one becomes active with its error focused.

  • event (SubmitEvent)

#reset()

The form's reset action: native reset restores the inputs; re-derive every stamp from the restored DOM and return to the first item.

#change(event)

The choice inputs' change action: re-stamps the item's choices, status, and validity.

  • event (Event)

#input(event)

The text inputs' input action: re-stamps filled/status/validity.

  • event (Event)

#keydown(event)

The form's keydown action (the primitive's full map): Cmd/Ctrl+Enter confirms, ArrowUp/Down move answer focus, ArrowLeft/Right navigate items (outside text entry and radios), Enter on a filled answer confirms, and letter/number shortcuts pick choices.

  • event (KeyboardEvent)

poetry--core--radio-group

controller · app/javascript/poetry/core/radio_group_controller.js

The RadioGroup checked-value machine (the Accordion composition shape): this controller owns ONLY the value + attribute/input writes - ZERO keyboard code. The shared poetry--core--roving-focus runs on the same root in its DEFAULT tabindex-managing mode (one Tab stop) with the orientation: "both" extension (all four arrows, APG radio), and this controller consumes its cancelable entry event to implement SELECTION FOLLOWS FOCUS: entry fires only on arrow/Home/End navigation - never on Tab or click-focus - so checking on entry is exactly the APG contract (Tab into the group never changes the value; contractual).

The form story is the hidden-native-input rule: one <input type=radio> per item, shared name (aria-hidden, tabindex=-1) - native radio serialization, byte-identical to collection_radio_buttons. check() writes every item's aria-checked/checked pair/indicator, sets the hidden input's .checked (the native group unchecks siblings; written explicitly anyway - belt and braces), moves the roving tab stop to the checked item, and dispatches poetry:radio-group:change + native input/change on the newly checked hidden input (Rails autosave/change-tracking listeners fire).

Targets: input

Values: value (String, default: "")

Events: poetry:radio-group:change

#connect()

Reconcile-on-connect: adopts the server-checked item into the Value when none was given, else normalizes the DOM to the Value (the body comment holds the rules).

#check(event)

Each item's click action (Space arrives here too, via native button activation): checks the pressed item.

  • event (MouseEvent)

#entryCheck(event)

The roving-focus entry action - the entry event fires ONLY on arrow/Home/End navigation (never on Tab), so this IS selection-follows-focus (the APG contract).

  • event (CustomEvent) — detail.item is the entered radio

#setValue(value)

The programmatic controllable-state surface: checks the item carrying value. Unknown values log a debug line and change nothing.

  • value (string) — an item's data-value

#valueValueChanged(value, previous)

Stimulus value callback: re-writes the DOM when the Value changes after connect (an Outlet or a host writing the attribute directly).

  • value (string)
  • previous (string)

poetry--core--resizable

controller · app/javascript/poetry/core/resizable_controller.js

The Resizable engine, decided NATIVE: no panel library - panels are flex children whose flex-grow IS the percentage, and this controller implements the APG window-splitter on the handles: pointer drag redistributes the two adjacent panels (clamped to each panel's min/max), arrows step, Home/End jump the range, and every move writes aria-valuenow (the preceding panel's size). Deferred with the library's machinery: persistence (autoSaveId), collapsible panels, and the imperative API.

Values: orientation (String, default: "horizontal")

Events: poetry:resizable:resize

#connect()

Writes the initial aria-value* reflection onto every handle.

#dragStart(event)

Each handle's pointerdown action: begins a drag between the two adjacent panels (pointer capture holds the gesture).

  • event (PointerEvent)

#dragMove(event)

The captured pointermove action: redistributes the two panels by the drag delta, clamped to both panels' min/max.

  • event (PointerEvent)

#dragEnd(event)

The pointerup / pointercancel action: ends the drag.

  • event (PointerEvent)

#keydown(event)

Each handle's keydown action (the APG window splitter): arrows step by 5%, Home/End jump the range.

  • event (KeyboardEvent)

poetry--core--scroll-spy

controller · app/javascript/poetry/core/scroll_spy_controller.js

Scroll-spy: marks the nav link whose section is currently active while the page scrolls - the docs-TOC pattern. Put the controller on the nav; every link target's href="#id" names its section:

<nav data-controller="poetry--core--scroll-spy"> <a href="#usage" data-poetry--core--scroll-spy-target="link">Usage</a>

The active section is the LAST one whose top sits above the offset line (a closest-heading reduce); its link gains data-active and a poetry--core--scroll-spy:changed event carries the id. rAF-coalesced passive scroll + resize listeners; call refresh() after content changes.

#connect()

Resolves the sections and wires the rAF-coalesced passive scroll/resize listeners.

#disconnect()

Unwires the listeners and cancels any pending frame.

#refresh()

Re-resolves sections from the links' hashes (content changed, Turbo morph, tab switch) and recomputes immediately.

poetry--core--search-field

controller · app/javascript/poetry/core/search_field_controller.js

The SearchField seams: Escape CLEARS a non-empty field and is consumed - the NEXT press reaches the dismissal layer and closes a parent overlay; an already-empty field lets Escape propagate untouched. Emptiness is checked against the RAW input value (autofill and scripts poke the DOM directly). The clear button keeps focus in the input by preventing the press's focus steal at pointerdown - on mobile that is what keeps the virtual keyboard up. Enter is never intercepted: native form submission is the Rails path.

Targets: clear, input

Events: poetry:search-field:clear

#connect()

Reflects the initial emptiness (data-empty + the clear affordance).

#changed()

The input action: keeps data-empty and the clear affordance honest.

#keydown(event)

The input's keydown action: Escape CLEARS a non-empty field and is consumed; an already-empty field lets it propagate to the dismissal layer (the seam the header documents).

  • event (KeyboardEvent)

#holdFocus(event)

The clear button's pointerdown action: keeps focus (and the mobile keyboard) in the input - the later click acts.

  • event (PointerEvent)

#clear(event)

The clear button's click action: empties the field (dispatching a REAL input event so live-search listeners react) and refocuses it. No-op while disabled or readonly.

  • event (MouseEvent)

poetry--core--select

controller · app/javascript/poetry/core/select_controller.js

The Select listbox controller - the APG select-only combobox on the menus MACHINERY (popper markup-owned + focus-scope/dismissable/roving-focus activated as layers + the shared typeahead helper) but deliberately NOT a mode of poetry--core--menu: everything menu-specific (submenus, close_on_select, checkbox/radio aria-checked, edge-navigate, Tab-closes, pointer-open-no-focus) is wrong for a listbox, and everything here is dead weight for menus.

THE SYNC INVARIANT (the component): native_select.value, the value Value, aria-selected/data-selected on options, and the display text never diverge - every write path (commit, closed-trigger typeahead, autofill adoption, programmatic setValue) funnels through #apply, which writes the NATIVE SELECT FIRST (serialization truth is never behind the facade), dispatches real bubbling change/input on it (Turbo auto-submit and friends work unmodified), flips aria-selected + data-selected TOGETHER on every option, syncs the value display from the option's item-text (data-text-value override), toggles trigger[data-placeholder], then fires poetry:select:change.

Three deltas vs the menu family, all deliberate and contractual:

  • Tab while open is INERT (a value picker resolves by commit or Esc);
  • open focuses the SELECTED option for EVERY reason, pointer included;
  • typeahead on the CLOSED trigger COMMITS the match without opening

(native <select> behavior), while open typeahead only moves focus.

Values: open (Boolean, default: false); value (String, default: ""); modal (Boolean, default: true); loop (Boolean, default: false); typeaheadTimeout (Number, default: 1000); alignItemWithTrigger (Boolean, default: false)

Events: poetry:select:change, poetry:select:closed, poetry:select:open, poetry:select:select

#connect()

Captures the placeholder, reconciles silently from the native select (the serialization truth) or the given Value, wires the content listeners, and catches a server-open listbox up (layers + the late portal).

#disconnect()

Restores portaled content (drop-never-strand), unwires the content listeners, resets typeahead and the scroll hold, and abandons any exit.

#openValueChanged(value)

Stimulus value callback - controllable open state.

  • value (boolean)

#valueValueChanged(value)

Stimulus value callback - controllable value (no-ops on the applied echo).

  • value (string)

#toggle()

The trigger's click action: open <-> close.

#triggerKeydown(event)

The trigger's keydown action: Enter / Space / ArrowDown / ArrowUp open and focus the SELECTED option (closed arrows never step the value). A printable key on the CLOSED trigger commits the typeahead match WITHOUT opening (native <select> behavior - the full commit pipeline minus open/close).

  • event (KeyboardEvent)

#open(reason = "trigger-press", { seed = null } = {})

Programmatically opens.

  • [reason="trigger-press"] (string | Event) — a family reason string (an Event, from data-action use, reads as trigger-press)
  • [options] (Object)
  • [options.seed=null] ("first" | "selected" | null) — the initial-focus seed stamped onto the content for this open

#close(reason = "none")

Programmatically closes.

  • [reason="none"] (string | Event) — a family reason string; an Event reads as "none"

#setValue(value)

The programmatic controllable-state surface: applies a value through the full sync pipeline.

  • value (string) — stringified; "" clears to the placeholder

#commit(event)

Each option's click action (markup-declared); the delegated content click claims first, so both paths never double-commit.

  • event (MouseEvent)

#keydown(event)

The content's keydown route (also wired programmatically): Enter and Space commit the focused option (Space defers to a live typeahead), Tab is INERT while open (a value picker resolves by commit or Esc - contractual), Left/Right no-op (flat listbox), and printable keys move focus via typeahead - selection never follows focus.

  • event (KeyboardEvent)

#nativeChanged()

The hidden native select's change action: browser autofill (or any programmatic write) fires change there - the UI adopts it without re-writing the native (no loop).

#syncScrollButtons()

The viewport's scroll action (also run on open): shows each scroll button only while scroll room remains on its side.

#scrollHoldStart(event)

The scroll buttons' pointerenter action: scrolls the viewport a few px per frame while hovered (hold-to-scroll).

  • event (PointerEvent)

#scrollHoldStop()

The scroll buttons' pointerleave action: stops the held scroll.

poetry--core--sensitive-input

controller · app/javascript/poetry/core/sensitive_input_controller.js

The SensitiveInput machine, adapted from an MIT-licensed source (source and license in THIRD_PARTY_NOTICES.md): a secret field in three states - masked | revealed | empty - where data-state on the root carries the truth and CSS renders it. Masked-with-value turns the MASK OVERLAY into the reveal affordance (role=button + label + sr-hint; only text spans inside - a role on the surrounding group would trip axe nested-interactive around the inert input) while the real input stays rendered for layout but goes inert (aria-hidden, tabindex -1, readonly, transparent). Reveal: click anywhere on the group (mask clicks bubble) or Enter/Space on the mask, focus moves into the input. Re-mask: Escape (focus returns to the mask

  • the input just lost its tab stop), leaving the component, or the eye.

Typing into an empty field auto-reveals so composition happens in type=text. The no-JS story is a plain password input.

Targets: hint, input, mask, toggle

Values: maskedLabel (String); hiddenMessage (String); readOnly (Boolean)

Events: poetry:sensitive-input:mask, poetry:sensitive-input:reveal

#connect()

Adopts the server-rendered data-state and re-derives the reflection.

#reveal(event)

The group's click action: click anywhere on the bordered group reveals (the mask button's own clicks bubble here too). Addon-cell clicks and synthetic label clicks are filtered (the body comments).

  • event (MouseEvent)

#maskKeydown(event)

The mask overlay's keydown action: Enter/Space reveals (the overlay is the reveal affordance while masked).

  • event (KeyboardEvent)

#inputKeydown(event)

The input's keydown action: Escape re-masks and is consumed - the NEXT press reaches the dismissal layer.

  • event (KeyboardEvent)

#blurred(event)

The root's focusout action: leaving the component with a value re-masks.

  • event (FocusEvent)

#changed()

The input action: emptiness drives the state; the first character typed into an empty field reveals (composition belongs in type=text).

#toggle(event)

The eye's click action: re-masks and hands focus to the mask button - the eye is about to hide (it only exists while revealed).

  • event (MouseEvent)

poetry--core--sheet

controller < poetry--core--dialog · app/javascript/poetry/core/sheet_controller.js

The Sheet: the dialog machinery, animated end to end. Everything hard about the overlay is inherited (native <dialog> platform trap, backdrop-click discrimination, scroll lock, hotkey) - including the presence-hold close, which the base controller owns (exit flips the pair to data-closed and holds the dialog through the slide-out before the native close()). This subclass only upgrades the ENTER: open rides enterPresence so the data-starting-style hook fires like every other presence consumer.

Targets: dialog

Values: dismissible (Boolean, default: true); hotkey (String, default: "")

#open()

Opens with the animated entry: showModal(), then the presence enter so the data-starting-style hook fires like every other presence consumer (the exit half is inherited - the base close() already holds through the slide-out).

poetry--core--sidebar

controller · app/javascript/poetry/core/sidebar_controller.js

The Sidebar state machine (desktop plus the mobile mode): expand/collapse coordination for the app shell. The COLLAPSE itself is pure CSS - the peer sidebar carries data-state=expanded|collapsed and the dictionary's group-data-[state=collapsed] classes do all the width/transform work; this controller only flips that attribute (plus data-collapsible, which the source sets to the mode WHILE collapsed and "" while expanded), persists the choice to a cookie (so the SERVER can read it and render the right initial state - poetry's server-first angle), and binds the Cmd/Ctrl+B shortcut.

MOBILE (DOM-move): below md the trigger routes to a separate never-persisted openMobile state (only desktop toggles write the cookie). Opening ADOPTS the server-rendered nav children from the desktop inner into the mobile <dialog> (one render, no duplicate ids - the render-twice rejection) and shows it through the sheet presence path; closing holds through the slide-out, then moves the children back. Crossing to desktop while open restores INSTANTLY. The component-facing event namespace (the poetry:<component> rule).

Targets: inner, mobileDialog, mobileInner, sidebar

Values: open (Boolean, default: true); collapsible (String, default: "offcanvas"); cookieName (String, default: "sidebar_state"); cookieMaxAge (Number, default: 604800); shortcut (String, default: "b")

Events: poetry:sidebar:mobile-toggle, poetry:sidebar:toggle

#connect()

Heals a restored zombie snapshot, reflects the server value once (the body comment explains why not openValueChanged), starts the breakpoint watcher, subscribes the before-cache close, and binds the shortcut.

#disconnect()

Unwires the shortcut / watcher / before-cache subscriptions and balances the scroll lock.

#toggle()

The trigger's (and rail's) click action - also the shortcut's landing. On mobile the SAME trigger routes to the sheet; on desktop it flips the collapse (inert when collapsible is "none").

#closeMobile(event)

The mobile dialog's cancel action (and the close affordances): closes through the sheet exit, then moves the nav children home.

  • [event] (Event) — the native cancel event, when Esc drove it

#mobileBackdropClose(event)

The mobile dialog's click action - the dialog's coordinate discrimination (a backdrop click targets the <dialog> itself AND lands outside its bounding rect).

  • event (MouseEvent)

#open()

Programmatically expands the desktop sidebar (persisted).

#close()

Programmatically collapses the desktop sidebar (persisted).

poetry--core--slider

controller · app/javascript/poetry/core/slider_controller.js

The Slider machine - the only form control with real math. Three concerns, nothing else:

1. THE VALUE MATH (the pure core, unit-tested exhaustively): snap to the step grid with decimal-precision rounding (a 0.1 grid lands on 0.3, never 0.30000000000000004), clamp to [min, max], and clamp against neighbor thumbs +- the min gap (minStepsBetweenThumbs * step) so range thumbs can never cross.

2. TWO INPUT PATHS feeding it: the APG keyboard map per thumb (arrows +-step, Shift+Arrow / PageUp / PageDown +-step*10, Home/End to the thumb's EFFECTIVE min/max) with the orientation x RTL x inverted resolution - horizontal RTL swaps Left/Right only, inverted flips the whole axis, both compose (rtl + inverted = ltr math); and pointer capture on the root - pointerdown jumps the NEAREST thumb (ties to the later index, so stacked thumbs stay separable), the track box is read ONCE at pointerdown (no per-frame layout), pointermove projects the value ABSOLUTELY from the pointer position (overshoot past a neighbor clamps, never swaps), pointerup commits.

3. THE DOM PROJECTION: aria-valuemin/max/now per role=slider thumb - the range bounds are DYNAMIC (neighbor-clamped, rewritten on every neighbor move: APG multithumb), the --slider-start/--slider-end geometry vars consumed by the calc() rules, and one hidden native input per thumb. poetry:slider:change fires per mutation; commit (pointerup / each keydown) syncs the hidden inputs + dispatches native input/change so Rails listeners get one event per gesture, not per frame.

Targets: input, range, thumb, track

Values: min (Number, default: 0); max (Number, default: 100); step (Number, default: 1); value (Array, default: []); minStepsBetweenThumbs (Number, default: 0); orientation (String, default: "horizontal"); inverted (Boolean, default: false)

Events: poetry:slider:change, poetry:slider:commit

#connect()

Adopts the value (the Value, else the server-rendered aria-valuenow), projects, and primes the hidden inputs silently.

#disconnect()

Ends any in-flight gesture without committing.

#valueValueChanged(value)

Stimulus value callback - controllable state: a host (outlet / Turbo Stream) may own the value.

  • value (number[])

#keydown(event)

Each thumb's keydown action: the APG map (arrows step, Shift/Page = large step, Home/End to the thumb's EFFECTIVE bounds) under the orientation x RTL x inverted resolution; every keyboard change commits.

  • event (KeyboardEvent)

#pointerdown(event)

The root's pointerdown action: jumps the NEAREST thumb to the pointer (ties to the later index), reads the track box once, and starts the window-level drag; pointerup commits.

  • event (PointerEvent)

#setValue(value)

The programmatic controllable-state surface: must keep the thumb count; changes commit.

  • value (number | number[])

poetry--core--table-selection

controller · app/javascript/poetry/core/table_selection_controller.js

The table row-selection engine (the SelectionManager contract, checkbox-flavored): per-row checkboxes are the form value (selected_ids[] - no JS means plain checkboxes in a form, the honest fallback), this controller adds what HTML cannot:

  • select-all with a real INDETERMINATE middle state (a JS property,

never an attribute), computed over ENABLED rows only (disabled rows are skipped by select-all and ranges - disabledBehavior 'selection').

  • Shift-click range selection off the ANCHOR model: the

anchor is the last plainly-toggled row; Shift sets every row in anchor..target to the ANCHOR row's state (the checkbox idiom).

  • aria-selected + data-selected mirrored onto rows, count announcements

through the announce singleton, and a bubbling selection-change event the ActionBar block feeds on.

  • poetry:data-table:clear-selection (dispatched by the ActionBar's

Escape) clears everything from anywhere inside the wrapper.

Targets: all

Values: label (String, default: "%{count} selected")

Events: poetry:data-table:selection-change

#connect()

Wires the ActionBar's clear-selection listener and reflects silently.

#disconnect()

Unwires the clear-selection listener.

#toggled(event)

Each row checkbox's change action. Shift ranges ride the click that produced the change (change events carry no modifiers - press captures the gesture): a shift-toggle sets anchor..target to the ANCHOR row's state; a plain toggle re-anchors.

  • event (Event)

#press(event)

The pointerdown/keydown seam: remembers whether the NEXT change was a shift-gesture (change events themselves carry no modifiers).

  • event (PointerEvent | KeyboardEvent)

#toggleAll(event)

The select-all checkbox's change action: fans its new state out to every ENABLED row and clears the anchor.

  • event (Event)

poetry--core--tabs

controller · app/javascript/poetry/core/tabs_controller.js

The Tabs activation machine: this controller owns ONLY the active-value state + attribute writes; the shared poetry--core--roving-focus on the tablist owns the keyboard (default tabindex-managing mode - one Tab stop). Triggers are DUMB buttons (click -> tabs#activate; with automatic activation - the APG default for tabs - focusin activates too, so arrow keys both move focus AND switch panels).

The state vocabulary: the active trigger carries data-active (the styled token) + aria-selected; inactive panels carry the hidden property + data-hidden. data-activation-direction is deliberately NOT emitted - no shipped class consumes it (add it with the animated indicator, when something does).

Panels are scoped to THIS root (a nested Tabs inside a panel owns its own triggers/panels - the DOM is the registry, same rule as roving-focus).

Values: activateOnFocus (Boolean, default: true)

Events: poetry:tabs:change

#connect()

Reconcile-on-connect: derives the full vocabulary from the server-rendered data-active truth (the body comment holds the rules).

#activate(event)

Each trigger's click action: activates the pressed trigger's value.

  • event (MouseEvent)

#focusActivate(event)

The tablist's roving-focus entry action - automatic activation follows the roving focus via the roving controller's entry event (deterministic: never depends on the platform firing focusin for a programmatic .focus()). A raw focusin routes here too, so hand-wired hosts get the same behavior.

  • event (CustomEvent | FocusEvent)

#setValue(value)

The programmatic controllable-state surface: setValue("account"). Unknown values are ignored (the contract's guard). Returns the resulting state - what an agent tool reports back - so a no-op is visible as changed: false.

  • value (string) — a trigger's data-value (stringified)

Returns ({ value: string, changed: boolean ) — } the active value after the call

poetry--core--tag-group

controller · app/javascript/poetry/core/tag_group_controller.js

The TagGroup removal engine (the tag-group contract): navigation itself rides the roving-focus controller (the toolbar precedent) - this controller owns what tags add on top:

  • Delete/Backspace on a focused tag removes it (row-origin keys only;

keys from a tag's inner remove button must not drive the grid).

  • The remove button removes exactly its own tag.
  • Focus recovery after removal is the reference walk: FORWARD through

the pre-removal order to the first surviving enabled tag, then backward; when the last tag goes, the CONTAINER takes focus, flips role grid->group, and becomes the tab stop.

  • The container is a live region ONLY while focus is within (polite,

additions) - SRs hear tags added while working in the group without spam from elsewhere.

Removal is CANCELABLE (poetry:tag-group:remove): a Turbo-driven host preventDefault()s and re-renders; otherwise this controller removes the row (and the hidden input riding it - form mode serializes name[] per tag).

Events: poetry:tag-group:remove

#connect()

Wires the focus-scoped live-region toggling and reflects emptiness (role, tab stop, data-empty).

#disconnect()

Unwires the focus listeners.

#keydown(event)

The container's keydown action: Delete/Backspace on a focused tag ROW removes it - row-origin keys only (keys from a tag's inner remove button must not drive the grid).

  • event (KeyboardEvent)

#remove(event)

The remove button's click action: removes exactly its own tag.

  • event (MouseEvent)

poetry--core--toast

controller · app/javascript/poetry/core/toast_controller.js

One toast item (poetry's own Toast - the stacked-toaster genre with strict a11y semantics). The item is role=status aria-live=off: it never announces itself - on connect it speaks ONCE through the announce singleton at its politeness (destructive -> assertive, wired server-side via the politeness value). The auto-dismiss timer follows APG/WCAG 2.2.1 timing: it PAUSES on hover, focus-within, window blur and tab-hidden (reasons are refcounted so overlapping pauses cannot resume early), and duration <= 0 means persistent (required for undo/action toasts). Dismiss flips data-open -> data-closed, dispatches poetry:toast:dismiss {id, reason} (the toaster's reflow + focus-return seam), then presence holds the node until its exit animation finishes before removal.

Swipe-to-dismiss is a browser-verification-GATED enhancement (contract) - it does not ship in this pass; the swipe reason is reserved.

Targets: action, close

Values: duration (Number, default: 5000); politeness (String, default: "polite")

Events: poetry:toast:dismiss, poetry:toast:show

#connect()

Assigns an id when missing, adopts the open pair, announces ONCE through the singleton, wires the pause sources and the toast-local Escape, and starts the auto-dismiss timer (queued/hidden toasts hold theirs).

#disconnect()

Stops the timer and unwires every listener.

#pause(eventOrReason)

Holds the auto-dismiss timer under a reason (hover/focus derive from events; the toaster and window paths pass strings). Reasons pool in a set, so overlapping pauses cannot resume early.

  • [eventOrReason] (Event | string)

#resume(eventOrReason)

Releases one pause reason; the timer restarts when none remain.

  • [eventOrReason] (Event | string)

#dismiss(eventOrReason)

Dismisses the toast. Reasons: timeout | close-press | action | swipe(reserved) | manual - the family reason vocabulary plus poetry's own timeout/action/queued/manual extensions. A click on the action slot reports "action"; the close button "close-press". The dismiss event goes out BEFORE removal (the toaster's reflow + focus-return seam); presence then holds the node through its exit animation.

  • [eventOrReason] (Event | string) — the triggering event (the reason derives from its origin) or an explicit reason string

poetry--core--toast-trigger

controller · app/javascript/poetry/core/toast_trigger_controller.js

The client-side toast delivery trigger (poetry's no-round-trip path - what a toast() JS factory does elsewhere, done with server-rendered markup): press -> dispatch poetry:toaster:stamp, and the toaster clones the addressed <template>'s toast into its region. The toast inside the template is byte-for-byte what a Turbo Stream would deliver.

Values: template (String); toaster (String)

Events: poetry:toaster:stamp

#fire()

The click action: stamps the addressed template's toast into the toaster region (the no-round-trip delivery the header describes).

poetry--core--toaster

controller · app/javascript/poetry/core/toaster_controller.js

The toast viewport (one per page): the labeled role=region <ol> that is the Turbo Stream append target (id=poetry-toaster, data-turbo-permanent). It ACQUIRES the announce singleton for its lifetime (items announce through it on connect), owns the F8 hotkey (focus moves to the most recent toast; the prior focus is remembered so dismissal returns it), enforces the visible LIMIT (default 3 - the oldest overflow toasts queue hidden with their timers held, promoted as newer ones dismiss), and owns the stack reflow (--poetry-toast-index, newest = 0, for the offset/scale stack styling).

Items arrive by server render, Turbo Stream append, or a stamp (the poetry:toaster:stamp window event - poetry_toast_trigger's no-round-trip path, cloning a <template> toast into the region); a childList MutationObserver reconciles limit + reflow on every change, so no append path needs to know about the toaster.

Targets: item

Values: hotkey (String, default: "F8"); limit (Number, default: 3); position (String)

#connect()

Acquires the announce singleton, wires the hotkey / stamp / dismiss listeners, and starts the childList reconciler.

#disconnect()

Unwires everything and releases the announce singleton.

#focusRegion(event)

The hotkey's landing (F8 default, configurable): moves focus into the region - onto the most recent visible toast, else the region itself. The prior focus is remembered for the dismiss return.

  • [event] (Event) — the window keydown when the hotkey drove it; programmatic calls may omit it

#reflow()

Stack reflow: newest toast = index 0 (the front of the stack), written as the offset/scale custom property.

poetry--core--toggle-group

controller · app/javascript/poetry/core/toggle_group_controller.js

The ToggleGroup value-set machine (the Accordion composition, second consumer): this controller owns ONLY the pressed-values set + attribute writes; the shared poetry--core--roving-focus on the same root owns the keyboard (default tabindex-managing mode - one Tab stop). Items are DUMB buttons (data-action -> group#toggle; poetry--core--pressed is NOT attached in group context - one owner, no event soup).

The role/vocabulary split, enforced here: type=single is RADIO semantics - items carry aria-checked (aria-pressed stripped) and re-pressing the sole pressed item deselects to EMPTY; type=multiple is toolbar semantics - independent aria-pressed items. The controller reads type once and never mixes vocabularies; the bare data-pressed presence boolean styles both types identically (Toggle's classes just work).

After every transition the PRESSED item becomes the roving tab stop (re-entering the group lands on the selection) - written directly as the tabindex stamp roving-focus adopts.

Values: type (String, default: "single")

Events: poetry:toggle-group:change

#connect()

Reconcile-on-connect: re-derives the type-correct aria vocabulary from the server-rendered data-pressed truth, then prefers the pressed tab stop.

#toggle(event)

Each item's click action (Space/Enter arrive via native button activation): single mode is radio semantics with deselect-to-empty; multiple XORs the value in and out.

  • event (MouseEvent)

#setValue(value)

The programmatic controllable-state surface: setValue("b") in single mode ("" / null clears); setValue(["a", "b"]) in multiple mode. Validated against type; unknown values are ignored (logged in dev - the contract's guard).

  • value (string | string[] | null)

poetry--core--tooltip

controller · app/javascript/poetry/core/tooltip_controller.js

The Tooltip controller (the popper-consumer trio's timing machine): open DELAYS (provider delay_duration, default 0), the provider-scoped WARM grace (one tooltip open - or closed less than skip_delay_duration ms ago - lets siblings in the same provider scope open instantly), ONE OPEN GLOBALLY (the document-level will-open event), close-on-scroll, and the pointer-vs-focus open paths with their latches.

A11y is the strictest of the trio: the content is role=tooltip and the trigger's aria-describedby is set on open and REMOVED on close (describedby must never reference hidden content); focus-scope is NOT composed at all - focus never enters a tooltip; touch NEVER opens one (no long-press path, deliberately). Esc rides a token-activated dismissable layer while open, so a tooltip above a Dialog peels first.

THE WARM REGISTRY (the provider mechanism): a module-level WeakMap keyed by the [data-slot=tooltip-provider] ancestor (document fallback) holding {openCount, warmUntil}. The DOM ancestor IS the provider scope; the WeakMap lets morphed/replaced providers garbage-collect (no Turbo leaks).

Values: open (Boolean, default: false); delayDuration (Number, default: -1); disableHoverableContent (Boolean, default: false)

Events: poetry:tooltip:closed, poetry:tooltip:open

#connect()

Wires the content listeners plus the document-level will-open listener, and reconciles a server-pinned tooltip (describedby, layer, scope count catch up; the DOM wins).

#disconnect()

Clears the timers, balances the warm-scope count for an open tooltip, drops the scroll listener, restores portaled content (drop-never-strand), and unwires everything.

#openValueChanged(value)

Stimulus value callback - controllable state: pinned tooltips (Turbo Stream / Outlet ownership).

  • value (boolean)

#pointerMove(event)

The trigger's pointermove action (not pointerenter, deliberately): opens once per hover via the latch - instantly when the provider scope is warm, else after the delay; touch pointerType is EXCLUDED entirely.

  • event (PointerEvent)

#pointerLeave(event)

The trigger's pointerleave action: cancels a pending open; with hoverable content the close waits its grace, else it closes now.

  • [event] (PointerEvent)

#pointerDown()

The trigger's pointerdown action: activating the control dismisses its hint; the latch also suppresses the focus-open until pointerup (pointer users never get a focus-opened tooltip - contractual).

#clickClose()

The trigger's click action: an activation dismisses its hint.

#focusOpen()

The trigger's focus action: opens INSTANTLY (skipping all delays) - unless the focus was caused by a pointerdown (the latch).

#blurClose()

The trigger's blur action: closes (the focus interaction's exit).

poetry--core--tree

controller · app/javascript/poetry/core/tree_controller.js

The Tree engine (the flat-treegrid contract): the server renders a FLAT list of role=row siblings - hierarchy lives entirely in aria-level/posinset/setsize (static per render) - so this controller owns only what HTML cannot: roving focus over VISIBLE rows, the four-branch ArrowLeft/Right expansion logic (including focus-to-parent), Enter-toggles-expandable (the no-action default), typeahead, and subtree show/hide that preserves nested collapsed state (a row is visible iff every ancestor is expanded).

Expansion state IS the DOM (aria-expanded + hidden); the host persists it by listening for poetry:tree:toggle. Selection modes are deferred (the TagGroup reasoning).

Events: poetry:tree:toggle

#connect()

Applies ancestor-expansion visibility and settles the single tab stop.

#keydown(event)

The treegrid container's keydown action (row-origin keys only): arrows rove the visible rows, the direction-aware expand/collapse pair runs the four-branch logic (collapse on a leaf focuses the parent), Home/End jump the edges, Enter toggles expandables, and printable keys run typeahead.

  • event (KeyboardEvent)

#press(event)

Each row's click action: toggles when expandable (the default press behavior with no action/link/selection); clicks on inner controls (links, the chevron) stay their own.

  • event (MouseEvent)

#toggle(event)

The chevron's click action (the chevron rides tabindex -1; its pointerdown is preventDefault'd via pressStart so focus never leaves the row): toggles, then refocuses the row.

  • event (MouseEvent)

#pressStart(event)

The chevron's pointerdown action: never steal focus from the row.

  • event (PointerEvent)