keystone_ui 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,488 +1,111 @@
1
1
  ---
2
2
  name: keystone_ui-develop
3
- description: Use PROACTIVELY for building or editing screens in a Rails app that has Keystone UI — pages, forms, tables, navigation, action menus for a record's Edit and Delete, dashboards, charts, amounts shown in green or red as a gain or loss, marketing sections, a light/dark theme switch — MUST BE USED instead of hand-writing ERB and Tailwind for UI.
4
- tools: Read, Write, Edit, Grep
3
+ description: Use PROACTIVELY for building or editing screens in a Rails app that has keystone_ui — page shells with Back links and breadcrumbs, forms and fields, data tables with saved columns, desktop navigation, sidebars and bottom tabs, action menus for a record's Edit and Delete, dashboards with stat cards, charts, funnels and goal buckets, amounts shown in green or red as a gain or loss, info buttons and breakdowns, marketing sections, a light/dark theme switch — MUST BE USED instead of hand-writing ERB and Tailwind for UI.
4
+ tools: Bash, Read, Write, Edit, Grep
5
5
  scope: UI — pages, forms, tables, navigation, dashboards
6
6
  ---
7
7
 
8
- This local builds screens by composing Keystone UI's `ui_*` view helpers in ERB.
8
+ This local builds screens by composing keystone_ui's `ui_*` view helpers in ERB.
9
9
  It always works the same way: pick the page shell, fill it with the helpers that
10
10
  match the content, and write no Tailwind classes of its own.
11
11
 
12
- ## What Keystone UI is
12
+ ## What keystone_ui is
13
13
 
14
- Keystone UI is a Rails engine that supplies an app's visual layer as a library of
15
- view helpers. Each helper renders one named piece — a page shell, a section, a
16
- form field, a data table, a navigation bar, a stat card, a chart — with all
17
- styling and dark-mode treatment owned inside the gem. Two screens built from the
18
- same helpers cannot drift apart, and a change to a piece updates every screen at
19
- once. It is mobile-first: several helpers ship distinct mobile and desktop
20
- treatments, which matters because these apps are often viewed in a native
21
- webview.
14
+ keystone_ui is a Rails engine that supplies an app's UI as view helpers. Each
15
+ helper renders one named piece — a page shell, a section, a form field, a data
16
+ table, navigation, a stat card, a chart — with its styling and dark-mode
17
+ treatment owned by the gem, so two screens built from the same helpers cannot
18
+ drift apart. It is mobile-first, because these apps are often shown in a native
19
+ web view: several helpers render only below or only from the `lg:` breakpoint.
22
20
 
23
- Fire on any request to build or change a screen, view, form, table, navigation,
24
- or dashboard in an app that has Keystone UI installed. If the `ui_*` helpers are
25
- not available in the app yet, that is the `keystone_ui-install` local's job, not
26
- this one's.
21
+ Fire on any request to build or change a screen, view, form, table, navigation
22
+ or dashboard in an app that has keystone_ui installed. If the `ui_*` helpers are
23
+ not available in the app yet, that is `keystone_ui-install`'s job.
27
24
 
28
25
  ## Interface
29
26
 
30
- Every entry point is a view helper called from ERB. Keywords with defaults are
31
- optional; the rest are required. Symbol options are validated — an unrecognized
32
- one raises at render time. Two exceptions: `ui_page`'s `padding:` treats any
33
- value other than `:none` as `:standard`, and `ui_pipeline`'s box `accent:` falls
34
- back to `:muted`.
35
-
36
- Six helpers — `ui_page`, `ui_section`, `ui_page_header`, `ui_card`, `ui_alert`,
37
- `ui_badge` — also accept `class:`, a string of classes appended to the helper's
38
- outer element. See Conventions before using it.
39
-
40
- ### Page shells and layout
41
-
42
- - `ui_page(max_width: :full, padding: :standard, top_offset: nil, class: nil)` —
43
- takes a block. The outer wrapper for a screen. `max_width:` `:sm` `:md` `:lg`
44
- `:xl` `:full`; `padding:` `:standard` or `:none`; `top_offset:` `:sm` `:md`
45
- `:lg` `:xl` to clear a fixed navbar. When `ui_form_page` or `ui_show_page` was
46
- called earlier on the same screen, `ui_page` renders their "Back" link, their
47
- breadcrumbs, and the form page's title, in that order, at its top, above the
48
- block.
49
- - `ui_section(title: nil, subtitle: nil, action: nil, menu: [], spacing: :md, class: nil)`
50
- — takes a block. A titled block of content with an optional right-aligned
51
- link. `action:` is `{ label:, href: }`; `spacing:` `:sm` `:md` `:lg`.
52
- `menu:` is `[{ label:, href:, method: }, ...]`, each hash taking the keywords
53
- of `ui_action_menu_item`, and renders an action menu at the right of the
54
- header. The header, and with it `action:` and `menu:`, renders only when
55
- `title:` is given.
56
- - `ui_panel(padding: :md, radius: :lg, shadow: true)` — takes a block. A bordered
57
- card surface. `padding:` `:sm` `:md` `:lg`; `radius:` `:md` `:lg` `:xl`.
58
- - `ui_grid(cols: { default: 1 }, gap: :md, gap_x: nil, gap_y: nil)` — takes a
59
- block. A responsive grid. `cols:` maps breakpoints `:default` `:sm` `:md` `:lg`
60
- to a column count 1–12, e.g. `{ default: 1, md: 3 }`. `gap:` `:sm` `:md` `:lg`
61
- `:xl`; passing `gap_x:`/`gap_y:` replaces `gap:` entirely.
62
- - `ui_card_link(href:, padding: :md, shadow: true)` — takes a block. A whole
63
- panel that is one link. `padding:` `:sm` `:md` `:lg`.
64
- - `ui_card(title:, summary:, link:, cta: "Read more", edge_to_edge: false, class: nil)`
65
- — a fixed title/summary/CTA card. `edge_to_edge: true` drops the side border and
66
- corner rounding below `sm:` so it spans the full width on mobile.
67
- - `ui_page_header(title:, subtitle: nil, action_url: nil, action_label: "Add new", class: nil)`
68
- — takes a block yielding the header. Desktop-only page title (hidden below
69
- `sm:`). Call `header.action { ... }` in the block to place a custom control on
70
- the right; only what `action` receives is rendered. Passing `action_url:`
71
- publishes that URL and label for a mobile navbar to pick up.
72
- - `ui_form_page(title:, back_url: nil, subtitle: nil, trail: nil)` — the shell
73
- marker for a form screen. It renders nothing where it is called. It publishes
74
- the title and back URL so the navbar can render mobile header context, and
75
- hands `ui_page` a "Back" link to `back_url` shown from `lg:` up, then the
76
- title and subtitle shown from `md:` up. Call it before `ui_page`, outside
77
- `ui_page`'s block, or none of that appears. Passing a non-empty `trail:` adds
78
- breadcrumbs under the "Back" link: the trail's links followed by `title`
79
- unlinked, as `ui_breadcrumbs` renders them, shown from `lg:` up. With no
80
- `trail:`, it uses the trail the app supplies for the current request, if the
81
- app supplies one. With no `back_url:`, the back URL is the `href` of the
82
- trail's last link. A `trail:` or `back_url:` passed here always wins over the
83
- supplied ones. `trail: []` marks the page a navigation tab opens directly: it
84
- shows no "Back" link and no breadcrumbs, publishes no back URL when no
85
- `back_url:` is passed, so the mobile header shows no back arrow, and raises
86
- nothing. With no `back_url:` and no trail from either place, or a trail whose
87
- last link has no `href`, rendering raises `KeystoneUi::MissingBackLink`,
88
- naming the page's title. A trail with any link whose label or `href` is `nil`
89
- or blank raises `KeystoneUi::IncompleteTrail`, naming the page's title, even
90
- when `back_url:` is passed.
91
- - `ui_show_page(title:, back_url: nil, subtitle: nil, trail: nil)` — the shell
92
- marker for a detail screen. It renders nothing where it is called. It
93
- publishes the title, subtitle, and back URL for the navbar, and hands
94
- `ui_page` a "Back" link to `back_url` shown from `lg:` up. Call it before
95
- `ui_page`, outside `ui_page`'s block. It shows no title, so put
96
- `ui_page_header` inside the `ui_page` block for the desktop title. `trail:`
97
- and `back_url:` work as they do on `ui_form_page`, including the breadcrumbs
98
- under the "Back" link, the supplied trail, the fallback to the trail's last
99
- link, `trail: []` for a navigation tab's own page, and the
100
- `KeystoneUi::MissingBackLink` and `KeystoneUi::IncompleteTrail` errors, and
101
- the breadcrumbs end with `title` unlinked.
102
- - `ui_breadcrumbs(trail:, current: nil)` — a line of links shown only from `lg:`
103
- up. `trail:` is `[[label, href], ...]`, from the top level down, and each pair
104
- renders as a link, separated by `›`. `current:` is the page being shown,
105
- rendered last, unlinked, and marked as the current page. On a form or detail
106
- screen pass `trail:` to the shell instead of calling this helper.
107
-
108
- ### Navigation
109
-
110
- - `ui_navbar(sticky: true)` — takes a block yielding the navbar. Fill named slots
111
- on it: `logo`, `desktop_links`, `desktop_right`, `mobile_left`,
112
- `mobile_center`, `mobile_right`. Desktop slots are hidden below `lg:` and the
113
- mobile slots above it. `desktop_right` renders only when `desktop_links` is
114
- also filled.
115
- - `ui_navigation` — no keywords, takes a block holding the page content. Placed
116
- in the layout around `yield`, it draws the tabs the app declares with
117
- `config.navigation_group` as a top bar of menus from `lg:` up, then the
118
- content. It leaves out tabs whose permission check fails and groups with no
119
- tab left, and below `lg:` it draws only the content. Inside the block, a
120
- `navigation.with_logo do ... end` block puts the app's logo at the left end
121
- of the top bar, and a `navigation.with_menus do ... end` block puts menus
122
- such as the account menu and the user menu at its right end, so the layout
123
- draws no second bar for them. When the preference supplied for
124
- `:navigation` holds `{ "placement" => "left" }` or `"right"`, it draws a
125
- sidebar on that side of the content from `lg:` up instead, with each group's
126
- label above its tabs, the logo at its top and the menus at its bottom, and
127
- below `lg:` the content takes the full width. Any other placement draws the
128
- top bar. When that value holds an `"order"` list of
129
- `{ "group" => label, "tabs" => [tab key strings] }` entries, both placements
130
- draw the named groups and tabs first in that order, then the rest in
131
- declared order, ignoring names no longer declared. When the app sets `config.current_tab_supplier`, the tab whose key
132
- it returns for the page and that tab's group are shown as active in either
133
- placement, and nothing is marked when it returns `nil`, a key no declared
134
- tab has, or is not set. Check whether the app's Keystone UI
135
- initializer declares navigation groups before adding desktop tabs by hand;
136
- declaring them is `keystone_ui-install`'s job.
137
- - `ui_nav_item(label:, href:, active: false)` — one desktop navigation link.
138
- - `ui_nav_dropdown(title:, area:, active: false)` — takes a block. A navbar
139
- dropdown; the block holds the menu links.
140
- - `ui_bottom_nav` — no keywords, takes a block. The mobile bottom tab bar; hidden
141
- above `lg:` and inside a Hotwire Native webview.
142
- - `ui_bottom_nav_item(label:, href:, icon:, active: false)` — one bottom tab.
143
- `icon:` is a raw SVG string.
144
- - `ui_mobile_header(title:, back_url:, subtitle: nil)` — a back chevron plus
145
- centered title for mobile; hidden above `lg:`. Place it in the navbar's
146
- `mobile_left` slot. `back_url:` must be passed, and `nil` renders the title
147
- with no back chevron.
148
- - `ui_action_menu` — no keywords, takes a block. An ellipsis (⋯) button that
149
- opens a dropdown of actions, shown at every screen size. Fill the block with
150
- `ui_action_menu_item` calls.
151
- - `ui_action_menu_item(label:, href:, method: :get, confirm: nil)` — one entry
152
- in an action menu. With `method: :get` it is a link to `href`. Any other
153
- method, such as `:post`, `:patch` or `:delete`, renders a button inside its
154
- own small form that sends that method to `href`, so never place such an item
155
- inside a `ui_form` block, since a form cannot contain another form.
156
- `confirm:` is a question Turbo asks before the item sends. With no
157
- `confirm:`, a `method: :delete` item asks "<label> this? This cannot be
158
- undone.", and every other method sends without asking. A `method: :get` item
159
- is a plain link and ignores `confirm:`.
160
- - `ui_mobile_actions` — no keywords, takes a block. An ellipsis dropdown for
161
- mobile actions; the block holds the menu items. Hidden above `lg:`. Use
162
- `ui_action_menu` instead when the actions must also be reachable on desktop.
163
- - `ui_settings_link(label:, href:)` — a full-width settings row with a chevron.
164
- - `ui_theme_toggle` — no keywords, no block. A row of three buttons, Light, Dark
165
- and System, that switches the page's theme at once and remembers the choice in
166
- the `keystone_theme` cookie for a year. System follows the operating system.
167
- The button for the page's current mode renders pressed. When the current mode
168
- is a custom palette supplied by another gem, no button is pressed, because the
169
- toggle does not offer custom.
170
-
171
- ### Forms
172
-
173
- - `ui_form(action:, method: :post, multipart: false, data: nil)` — takes a block.
174
- The `<form>` wrapper. `method:` may be `:patch`/`:put`/`:delete` and is
175
- translated for Rails. Set `multipart: true` when the form contains a file
176
- upload.
177
- - `ui_form_field(attribute:, label: nil, type: :text, required: false, hint: nil, placeholder: nil, min: nil, max: nil, step: nil, value: nil, options: [], errors: [], include_blank: nil, disabled: false, suggestions: [])`
178
- — a labeled field with hint and error text. This is the default way to render
179
- an input. `type:` `:text` `:number` `:email` `:password` `:date` `:textarea`
180
- `:checkbox` `:select`. `attribute:` is used verbatim as the input's `name`, so
181
- pass the full param name the controller expects, e.g. `"quote[title]"`, and
182
- pass `label:` whenever the attribute is a nested name. `label:` defaults to the
183
- attribute with underscores turned to spaces and the first letter capitalized.
184
- `options:` is for `:select` and takes `[[label, value], ...]`; `value:` picks
185
- the selected option, and a non-required select gets a leading empty option,
186
- worded by `include_blank:` (for example `"Not set yet"`) or left blank without
187
- it. Never add your own empty choice to `options:` as well. A
188
- `:checkbox` renders its label beside the box, submits `"0"` when unchecked and
189
- `"1"` when checked, and pre-checks when `value:` is `"1"`. `errors:` is an
190
- array of message strings. `suggestions:` is an array of strings the browser
191
- offers while the field's text is typed, and the user can still type a value
192
- that is not in it. It applies to `:text`, `:number`, `:email`, `:password`
193
- and `:date` fields, and a `:textarea`, `:checkbox` or `:select` ignores it.
194
- The suggestion list's `id` is built from `attribute:`, so two fields with
195
- suggestions on one screen need different `attribute:` values.
196
- - `ui_input(name:, type: :text, value: nil, placeholder: nil, disabled: false, min: nil, max: nil, step: nil)`
197
- — a bare styled input with no label. `type:` `:text` `:number` `:email`
198
- `:password` `:date`.
199
- - `ui_textarea(name:, value: nil, rows: 3, placeholder: nil, disabled: false)` —
200
- a bare styled textarea.
201
- - `ui_select(name:, options: [], selected: nil, include_blank: nil, disabled: false)`
202
- — a bare styled select. `options:` is `[[label, value], ...]`;
203
- `include_blank:` is the text of a leading empty option.
204
- - `ui_multi_select(name:, label:, options:, selected: [])` — a dropdown of
205
- checkboxes all posting under `name`, used verbatim; pass an array name such as
206
- `"status[]"` so every checked value arrives. `options:` is
207
- `[[label, value], ...]`; `selected:` is the values to pre-check. The trigger
208
- reads "All <label>" when nothing is checked and "N selected" otherwise.
209
- - `ui_file_upload(name:, label: nil, accept: nil, multiple: false, hint: nil)` —
210
- a drop zone with drag-and-drop and selected-file feedback. Requires the
211
- enclosing form to be multipart.
212
- - `ui_color_picker(name:, value: "#000000", label: nil)` — a swatch that opens a
213
- hue/saturation panel and writes the hex into a hidden input named `name`.
214
- - `ui_radio_card(name:, value:, label:, hint: nil, info: nil, checked: false)`
215
- — a selectable card backed by a real radio input; selection styling is pure
216
- CSS. `hint:` is a line of text always shown under the label. `info:` is
217
- longer text about the option, kept hidden: passing it adds an info button on
218
- the row with the label, named "About <label>" for screen readers, and the
219
- text shows in a panel floating below the card while the button is hovered,
220
- and tapping the button toggles it. With no `info:` the card has no info
221
- button.
222
- - `ui_checkbox_row(name:, value:, label:, hint: nil, info: nil, checked: false)`
223
- — a real checkbox with its label and optional hint inside one `<label>`, so a
224
- tap anywhere on the row toggles the box. A checked row submits `value` under
225
- `name`; give several rows the same array name, e.g. `"shown[]"`, to submit the
226
- checked values as a list. An unchecked row submits nothing. `hint:` is a line
227
- of text always shown under the label. `info:` works as it does on
228
- `ui_radio_card`: passing it adds an info button on the line with the label,
229
- named "About <label>" for screen readers, and the text shows in a panel
230
- floating below the row while the button is hovered, and tapping the button
231
- toggles it. With no `info:` the row has no info button.
232
- - `ui_option_card(name:, value:, selected: false, input_data: {}, label_data: {})`
233
- — takes a block. A radio whose visible body is whatever the block renders.
234
- `input_data:`/`label_data:` become `data-*` attributes on the input and label.
235
-
236
- ### Tables
237
-
238
- - `ui_data_table(items:, columns:, empty_message: nil, sort: nil, sort_direction: nil, sort_url: nil, hidden_columns: [], key: nil)`
239
- — takes a block yielding the table. `items:` are records or hashes; each cell
240
- value is read by calling the column key on the item, falling back to `item[key]`.
241
- `columns:` accepts plain `{ key: "Label" }` hashes or `Keystone::Ui::Column`
242
- objects. In the block, `table.link(:column_key) { |item| url }` turns that
243
- column's cells into links and `table.actions { |item| ... }` appends a
244
- right-aligned actions column. With no actions column, the last data
245
- column's header and cells are both right-aligned, so put the column of
246
- amounts last to line them up under their header. Each row's actions render inside an action
247
- menu, so the `actions` block holds `ui_action_menu_item` calls and nothing
248
- else, never buttons or bare links. Sorting requires all three of `sort:` (the
249
- current column key), `sort_direction:` (`:asc`/`:desc`), and `sort_url:` (a
250
- lambda taking `(column_key, direction)` and returning a URL); headers then
251
- render as links that flip direction. `hidden_columns:` drops columns
252
- server-side and only affects columns declared `hideable: true`. `key:` (a
253
- symbol or string) names the table so it looks up a saved layout through the
254
- app's preference supplier. When the supplier returns a saved value for the
255
- key that lists `"hidden_columns"`, the hideable columns in that list replace
256
- the ones passed in `hidden_columns:`, and a saved empty list shows every
257
- column. When the saved value is `nil` or lists no `"hidden_columns"`, the
258
- table keeps the columns `hidden_columns:` hides. When the saved value lists
259
- `"column_order"`, an array of column keys, the hideable columns render in
260
- that order, followed by any hideable columns the list leaves out in the order
261
- they were declared. Columns that are not hideable keep their declared place,
262
- and the hideable ones fill the remaining places. With no `"column_order"` the
263
- columns keep their declared order. Whenever the supplier
264
- returns a save address, the table renders a "Columns" menu in a row above
265
- itself, aligned right, that saves to it as `ui_column_picker` does and shows
266
- the same message when a save fails, including for a person with nothing saved
267
- yet. That menu lists the hideable columns in
268
- the order the table shows them and leaves ticked exactly the ones the table
269
- shows. When the
270
- supplier returns nothing, when no supplier is set, or when no `key:` is
271
- passed, the table renders from `hidden_columns:` with no Columns menu. In
272
- every case `hidden_columns:` is the table's default layout.
273
- - `Keystone::Ui::Column.new(key, header_text, mobile_hidden: false, sortable: false, hideable: false, locked: false)`
274
- — a column with per-column options, for when a `{ key: "Label" }` hash is not
275
- enough. `mobile_hidden:` hides the column below `sm:`; `sortable:` opts it into
276
- sort headers; `hideable:` lets the Columns menu hide it and move it, and a
277
- saved `"column_order"` place it. `locked: true` on the table's first column
278
- keeps its header and cells in view while the rest of the table scrolls
279
- sideways. A locked column that is not first renders as an ordinary column. A
280
- locked column is never hideable, even with `hideable: true`: it is left out of
281
- the Columns menu, `hidden_columns:` and a saved layout cannot hide it, and a
282
- saved `"column_order"` cannot move it.
283
- - `ui_column_picker(columns:, hidden_columns: [], save_url: nil)` — a "Columns"
284
- dropdown with one row per `hideable` column, in the order `columns:` lists
285
- them. Each row has a checkbox and an up and a down button that move the
286
- column one place; the first row's up button and the last row's down button
287
- are disabled, and after a move the disabled buttons follow the new first and
288
- last rows. A hidden column's name renders greyed, and unticking a box greys
289
- its name at once. Pass it the same columns, in the order the table shows
290
- them, and the same hidden keys as the table. Ticking, unticking and moving
291
- change only the open menu and send nothing. When the menu closes, by its
292
- Columns button or by a click outside it, it sends one `PATCH save_url` with
293
- JSON `{ "hidden_columns": ["key", ...], "column_order": ["key", ...] }` and a
294
- `X-CSRF-Token` header, then reloads the page. A menu closed with nothing
295
- changed sends nothing. `column_order` lists every hideable column's key in
296
- the menu's order when it closes. With no `save_url:` it sends nothing. The app must provide that endpoint and persist
297
- both lists. The endpoint must answer with a success status when it has saved
298
- them. When it answers with an error status, or the request cannot reach the
299
- server, the menu does not reload the page: it shows "Your column changes were
300
- not saved." under the Columns button, and puts its boxes and its order back
301
- to what the table shows. The picker does not reorder the table: beside a table without
302
- `key:`, the app must pass the table and the picker its columns in the saved
303
- order itself. A table
304
- given `key:` renders its own Columns menu when the supplier gives a save
305
- address, so never add `ui_column_picker` beside such a table.
306
-
307
- ### Content and status
308
-
309
- - `ui_button(label:, href: nil, variant: :primary, size: :md, type: :submit, data: nil)`
310
- — renders an `<a>` when `href:` is given and a `<button>` otherwise. `variant:`
311
- `:primary` `:secondary` `:danger`; `size:` `:sm` `:md` `:lg`; `type:` applies
312
- only to the button form.
313
- - `ui_badge(label:, variant: :neutral, class: nil)` — a pill. `variant:`
314
- `:neutral` `:success` `:danger` `:warning` `:info`.
315
- - `ui_figure(text:, tone: :neutral)` — no block. One figure, such as an amount,
316
- as inline text with no pill or box around it. `tone:` `:neutral` prints it in
317
- the surrounding text colour, `:success` in green and `:danger` in red; any
318
- other symbol raises `KeyError`. `text:` is printed as given, so format
319
- numbers, currency and any minus sign before passing it. Its output can be
320
- passed anywhere a string is shown, such as a `ui_data_table` cell value or
321
- column label.
322
- - `ui_alert(message:, type: :info, title: nil, dismissible: false, class: nil)` —
323
- a banner.
324
- `type:` `:info` `:success` `:warning` `:error`. `dismissible: true` adds a
325
- close control.
326
- - `ui_progress(value:, max:, label: nil)` — a labeled progress bar. The percent
327
- is `value / max`, rounded and clamped at 100.
328
- - `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil)`
329
- — a single metric tile. `variant:` `:neutral` `:success` `:danger` `:warning`
330
- `:info` colors the value. `change:` is a signed number rendered as `▲ 4.2%` in
331
- green when positive, `▼` in red when negative, plain when zero. `href:` makes
332
- the value a link to its drill-down screen; only the value is linked, never the
333
- whole card. Passing `definition:` and/or `calculation:` adds an info button
334
- whose details show in a panel floating below the card while the button is
335
- hovered or focused, and tapping the button toggles it.
336
- - `ui_copy_button(text:, label: "Copy", success_message: "Copied!", error_message: "Failed!")`
337
- — copies `text:` to the clipboard.
338
- - `ui_code(language: nil, caption: nil)` — takes a block holding the code. The
339
- caption is a header strip above the block; `language:` sets the `language-*`
340
- class for a highlighter.
341
- - `ui_disclosure(open: false)` — takes a block yielding the component. Fill its
342
- `summary` slot with the clickable header; the rest of the block is the body.
343
- Native `<details>` — no JavaScript.
344
- - `ui_calculation(groups:, summary: "How this is worked out")` — no block. Shows
345
- how a figure was reached, closed by default under a row reading `summary:`.
346
- `groups:` is `[{ title:, lines: [{ label:, working:, result: }, ...] }, ...]`;
347
- `title:` is optional, `lines:` is required and a group without it raises
348
- `KeyError`, and a line with any key other than those three raises
349
- `ArgumentError`. Each line renders as three columns — label, working, result
350
- — with the result right-aligned. Every value is printed as given, so format
351
- numbers and currency before passing them. Place it directly under the figure
352
- it explains, such as a `ui_stat_card`.
353
- - `ui_info(summary:)` — an info button, named "More about this" for screen
354
- readers, placed inline beside the thing it explains, and sits level with the
355
- middle of the text beside it. `summary:` is a short
356
- line of text shown in a floating panel while the button is hovered. The block
357
- is optional and holds the full detail. With a block, tapping or clicking the
358
- button toggles a second floating panel holding the block's content, and the
359
- summary stays hover-only. With no block, tapping the button toggles the
360
- summary itself. A second tap closes what the first opened, a click anywhere
361
- else on the page closes the detail panel, and scrolling the page closes both
362
- panels. Each panel opens just under the button with its right edge on the
363
- button's right edge, the first time it opens as well as every later time,
364
- and stays at least 8 pixels from the left edge of the screen. The detail
365
- panel is wider than the summary panel, and the text in both wraps, so pass
366
- plain sentences and add no line breaks or widths of your own. The panels are
367
- placed against the screen, so the button can sit
368
- inside a table or any other container that clips its contents without the
369
- panels being cut off. Both panels render inside a `<span>`, so the block may
370
- hold text and inline elements only, such as a `ui_breakdown`, and never a
371
- `<div>`, list or table. Hovering and tapping both need the gem's Stimulus
372
- controllers registered, and without them the button shows nothing.
373
- - `ui_breakdown(lines:, total:)` — no block. A list of amounts ending in their
374
- total. `lines:` is `[{ amount:, label: }, ...]` and `total:` is one
375
- `{ amount:, label: }`, both required. Each line renders its amount with its
376
- label beside it, and the total renders last, set apart from the lines above
377
- it. Every value is printed as given and nothing is added up, so compute the
378
- total and format numbers and currency before passing them. How the amounts
379
- and labels line up, and how the total is set apart, come from the
380
- `ks-breakdown` classes in the keystone_ui-styles gem, and the helper sets no
381
- widths or spacing of its own. It renders inline elements only, so it can sit
382
- inside a `ui_info` block.
383
- - `ui_accordion(items: [])` — a stack of independently expandable rows. `items:`
384
- is `[{ question:, answer: }, ...]`.
385
- - `ui_tab_switcher(tabs:)` — takes a block. `tabs:` is an array of label strings;
386
- the first is active on load. The block renders below the tab bar. Selecting a
387
- tab dispatches a `tab-switcher:change` event carrying the clicked index —
388
- showing and hiding the matching panels is the app's job.
389
- - `ui_modal(title:, size: :md)` — takes a block holding the body. `size:` `:sm`
390
- `:md` `:lg` `:xl`. Renders hidden, and closes on its own close button and on a
391
- backdrop click. Nothing in the gem opens it: the modal's outermost element
392
- carries the `hidden` class, and the app's own code must remove that class to
393
- show it. A `data-action="modal#open"` placed outside the modal does nothing.
394
- - `ui_swipe_deck(items:, empty_title: "All done!", empty_subtitle: nil)` — takes
395
- a block yielding the deck. Call `deck.item { |item| ... }` in the block to
396
- render one card's face. Accepting a card (button or swipe right) dispatches a
397
- bubbling `swipe-deck:complete` event and rejecting (button or swipe left)
398
- dispatches `swipe-deck:skip`. Both carry `detail.itemId` — the item's `id`, or
399
- its position in `items:` when it has none — and `detail.card`; `complete` also
400
- carries `detail.value`, read from an input inside the card's face marked
401
- `data-swipe-deck-value`, or `null`. The app must listen and persist the
402
- outcome.
403
-
404
- ### Charts and analytics
405
-
406
- - `ui_chart_card(title:, height: :md)` — takes a block holding a chart. `height:`
407
- `:sm` `:md` `:lg`.
408
- - `ui_line_chart(series:, labels: nil, dates: nil, height: :md)` — a line chart
409
- with one line per series. `series:` is
410
- `[{ name:, data:, color:, dashed: }, ...]` where `data:` is the array of
411
- values, and `color:` (a CSS color string for the line) and `dashed: true` are
412
- optional. `height:` `:sm` `:md` `:lg`. Pass exactly one of `labels:` and
413
- `dates:` for the horizontal axis: passing both, or neither, raises
414
- `ArgumentError`. `labels:` is an array of strings, one per value, spaced
415
- evenly and shown as written. `dates:` is an array of `Date`, `Time` or
416
- `DateTime` values, one per value, matched to each series' `data:` by
417
- position. A dated chart places each point by its day, so a gap of a week is
418
- seven times as wide as a gap of a day. Its axis runs from the first day to
419
- the last, marks only whole days, and reads each day as a date such as
420
- "Oct 2, 2026", which is also the heading a hovered point shows. The time of
421
- day is dropped, so pass one value per day.
422
- - `ui_funnel(steps:, shape: :bars)` — a conversion funnel. `steps:` is
423
- `[{ label:, value:, color: }, ...]` in order, with `color:` optional. Each
424
- step's width is its value as a share of the first step's value, and the
425
- percent shown between two steps is the second step's value divided by the
426
- first's. Each step takes the next colour in the order `:accent` `:sky`
427
- `:violet` `:amber` `:rose`, starting again after `:rose`; a step passing one
428
- of those symbols as `color:` uses it instead, and any other symbol raises.
429
- Values are printed as given. Divide-by-zero safe, no JavaScript. `shape:`
430
- `:bars` draws one bar per step, left-aligned, with the label and value on a
431
- row above it and a `↓ N%` caption between two bars. `shape: :joined` draws
432
- one shape: each step's value over its label in a column on the left, each
433
- step's block centred at its width, and between two blocks a neutral band
434
- that narrows from the block above to the block below, with the percent on it.
435
- Any other `shape:` raises `ArgumentError`.
436
- - `ui_bucket(goal:, actual:, label: nil, over: :success)` — an upright container
437
- for one target, filled from the bottom toward `goal:`. It shows the optional
438
- `label` on top, then `goal`, the container, `actual`, and the percent reached.
439
- `goal` and `actual` must be numbers: a string such as `"9,000"`, or `nil`,
440
- raises `ArgumentError` at render time. They are printed as Ruby prints them,
441
- with no thousands separators, so `9000` shows as `9000` and `9000.0` as
442
- `9000.0`. A value read from a decimal column is a `BigDecimal`, which prints
443
- as `0.9e4`, so convert it with `to_i` or `to_f` first. The percent is
444
- `actual / goal * 100` rounded and **not**
445
- clamped (so 150% shows as 150%), and it is 0 when `goal` is zero. The fill
446
- stops at the top once the goal is reached. Within the goal the fill uses the
447
- accent colour; over it the fill turns green with `over: :success` or amber with
448
- `over: :warning`, and any other symbol raises. No JavaScript.
449
- - `ui_bucket_series(buckets:)` — a row of buckets that wraps onto more rows on
450
- narrow screens. `buckets:` is an array of hashes, each taking the same keywords
451
- as `ui_bucket`, e.g. `[{ goal: 10, actual: 7, label: "Mon" }, ...]`.
452
- - `ui_pipeline(title:, boxes:, links:, subtitle: nil)` — a staged flow diagram
453
- for event flows, approval chains, or state machines. `boxes:` is
454
- `[{ label:, count:, accent:, action: }, ...]` where `count:` and `accent:`
455
- (`:amber` `:emerald` `:danger` `:muted`) are optional, and `action:` is
456
- `{ url:, label:, params:, variant: }` rendering a button that POSTs `params`
457
- to `url`. `links:` has exactly one fewer entry than `boxes:`, each
458
- `{ url:, params:, broken: }`, rendering a ✓/✗ toggle between two boxes that
459
- POSTs to flip its state. The app owns every endpoint these post to.
460
-
461
- ### Marketing sections
462
-
463
- - `ui_hero(title:, subtitle: nil, badge: nil, layout: :split)` — takes a block
464
- holding the call-to-action buttons, and yields the component so its `aside`
465
- slot can hold an image or panel. `layout:` `:split` (content beside the aside)
466
- or `:centered`.
467
- - `ui_cta_banner(title:, subtitle: nil)` — takes a block holding the
468
- call-to-action buttons.
469
- - `ui_feature_grid(title:, features:, subtitle: nil)` — a responsive grid of
470
- feature cards. `features:` is `[{ icon:, title:, description: }, ...]` where
471
- `icon:` is a raw SVG or HTML string.
27
+ - `ui_page` — the outer wrapper of a screen, holding the page's content.
28
+ - `ui_form_page` — marks a screen as a form screen and gives it its title, Back link and breadcrumbs.
29
+ - `ui_show_page` — marks a screen as a detail screen and gives it its Back link and breadcrumbs.
30
+ - `ui_page_header` — the desktop title of a page, with an optional control on its right.
31
+ - `ui_breadcrumbs` — a line of links to the pages above the current one, shown from `lg:` up.
32
+ - `ui_section` — a titled block of content with an optional link and action menu in its header.
33
+ - `ui_grid` — a responsive grid with a column count per breakpoint.
34
+ - `ui_panel` — a bordered card surface holding any content.
35
+ - `ui_card_link` — a panel that is one link as a whole.
36
+ - `ui_card` — a fixed card of title, summary and call-to-action link.
37
+ - `ui_navbar` — a navigation bar with named slots for desktop and mobile.
38
+ - `ui_navigation` — draws the app's declared navigation groups as a top bar or a sidebar around the page content.
39
+ - `ui_nav_item` — one desktop navigation link.
40
+ - `ui_nav_dropdown` — a navbar dropdown menu.
41
+ - `ui_bottom_nav` — the mobile bottom tab bar.
42
+ - `ui_bottom_nav_item` — one tab in the bottom tab bar.
43
+ - `ui_mobile_header` — a mobile back arrow and centred title.
44
+ - `ui_mobile_actions` — an ellipsis menu of actions shown only below `lg:`.
45
+ - `ui_action_menu` — an ellipsis menu of actions shown at every screen size.
46
+ - `ui_action_menu_item` — one action in an action menu, as a link or a button sending a method.
47
+ - `ui_settings_link` — a full-width settings row with a chevron.
48
+ - `ui_theme_toggle` — Light, Dark and System buttons that switch and remember the page's theme.
49
+ - `ui_form` — the form element wrapping a form's fields.
50
+ - `ui_form_field` — a labelled input, textarea, checkbox or select with hint and error text.
51
+ - `ui_input` — a bare styled input with no label.
52
+ - `ui_textarea` — a bare styled textarea with no label.
53
+ - `ui_select` — a bare styled select with no label.
54
+ - `ui_multi_select` — a dropdown of checkboxes posting every checked value.
55
+ - `ui_file_upload` — a file drop zone with selected-file feedback.
56
+ - `ui_color_picker` — a colour swatch that writes a hex value into a hidden input.
57
+ - `ui_radio_card` — a selectable card backed by a radio input.
58
+ - `ui_checkbox_row` — a checkbox with its label and hint, toggled by a tap anywhere on the row.
59
+ - `ui_option_card` — a radio whose visible body is whatever its block renders.
60
+ - `ui_data_table` — a table of records with links, row actions, sorting and hideable columns.
61
+ - `Keystone::Ui::Column` — a table column with options a `{ key: "Label" }` hash cannot carry.
62
+ - `ui_column_picker` — a Columns menu that saves which columns are hidden and their order to an app endpoint.
63
+ - `ui_button` — a link or a button styled as a button.
64
+ - `ui_badge` — a coloured pill holding a short label.
65
+ - `ui_figure` — one amount as inline text, coloured as a gain or a loss.
66
+ - `ui_alert` — a banner message, optionally dismissible.
67
+ - `ui_progress` — a labelled progress bar.
68
+ - `ui_stat_card` — a single metric tile with optional change, link and info button.
69
+ - `ui_copy_button` — a button that copies text to the clipboard.
70
+ - `ui_code` — a block of code with an optional caption.
71
+ - `ui_disclosure` — a native expandable section with a clickable summary.
72
+ - `ui_calculation` — the steps behind a figure, closed under a summary row.
73
+ - `ui_info` — an inline info button showing a summary, and optional detail, in floating panels.
74
+ - `ui_breakdown` — a list of amounts ending in their total.
75
+ - `ui_accordion` — a stack of question-and-answer rows that expand independently.
76
+ - `ui_tab_switcher` — a row of tabs that reports which one was selected.
77
+ - `ui_modal` — a hidden dialog the app's own code opens.
78
+ - `ui_swipe_deck` — a stack of cards accepted or rejected by button or swipe.
79
+ - `ui_chart_card` — a titled card holding a chart.
80
+ - `ui_line_chart` — a line chart with one line per series, over labels or dates.
81
+ - `ui_funnel` — a conversion funnel drawn as bars or one joined shape.
82
+ - `ui_bucket` — an upright container filled toward a goal.
83
+ - `ui_bucket_series` — a wrapping row of buckets.
84
+ - `ui_pipeline` — a staged flow diagram with buttons and breakable links that post to the app.
85
+ - `ui_hero` — a marketing hero with title, call-to-action buttons and an aside.
86
+ - `ui_cta_banner` — a marketing banner holding call-to-action buttons.
87
+ - `ui_feature_grid` — a responsive grid of feature cards.
472
88
 
473
89
  ## How to use it
474
90
 
91
+ Every entry point is a view helper called from ERB, apart from
92
+ `Keystone::Ui::Column`. Keywords with defaults are optional and the rest are
93
+ required. A symbol option outside the listed values raises at render time, with
94
+ two exceptions: `ui_page`'s `padding:` treats any value other than `:none` as
95
+ `:standard`, and a `ui_pipeline` box's `accent:` falls back to `:muted`.
96
+
475
97
  1. Confirm the helpers are available in the app. If they are not, stop and hand
476
- off to `keystone_ui-install` — do not hand-roll the markup in the meantime.
98
+ off to `keystone_ui-install`, and do not hand-write the markup meanwhile.
477
99
 
478
100
  2. Find the closest existing screen in `app/views/` and read it. Match its
479
- composition before inventing one; that screen is the house style.
101
+ composition before inventing one, since that screen is the house style.
102
+
103
+ 3. Pick the page shell.
480
104
 
481
- 3. Pick the page shell for what you are building:
482
- - a form screen → `ui_form_page`, then `ui_page` holding the form
483
- - a detail screen → `ui_show_page`, then `ui_page` holding `ui_page_header`
484
- and the details
485
- - anything else → `ui_page`, with `ui_page_header` for the desktop title.
105
+ - A form screen → `ui_form_page`, then `ui_page` holding the form.
106
+ - A detail screen → `ui_show_page`, then `ui_page` holding `ui_page_header`
107
+ and the details.
108
+ - Anything else → `ui_page`, with `ui_page_header` for the desktop title.
486
109
 
487
110
  ```erb
488
111
  <%= ui_form_page(title: "New quote", back_url: quotes_path) %>
@@ -493,169 +116,422 @@ outer element. See Conventions before using it.
493
116
  <% end %>
494
117
  ```
495
118
 
496
- The shell call always comes first and sits outside `ui_page`'s block,
497
- because `ui_page` renders what the shell handed it at its own top. A form or
498
- detail screen without `ui_page` shows no desktop "Back" link or form title.
499
- Those shells supply the desktop "Back" link themselves, so never add a
500
- second back link or button to those screens. Whether a screen
501
- shows breadcrumbs under its "Back" link, and which parent screens the trail
502
- names, is the developer's choice, so ask before passing `trail:`. A screen
503
- a navigation tab opens directly, such as a bottom tab's own page, passes
504
- `trail: []` and no `back_url:`, so it shows no "Back" link and no back
505
- arrow; which screens those are is the developer's choice, so ask. Check
506
- first whether the app's Keystone UI initializer sets a `trail_supplier`,
507
- which supplies a trail for every request: if it
508
- does, a screen whose supplied trail is right passes neither `trail:` nor
509
- `back_url:`, and passes its own only where the supplied one is wrong. If the
510
- app supplies no trail, every form and detail screen passes `back_url:` or
511
- `trail:`, since a screen with neither raises `KeystoneUi::MissingBackLink`
512
- when it renders. Every link in a trail needs both a label and an `href`, or
513
- the screen raises `KeystoneUi::IncompleteTrail`. Setting up a supplied trail is `keystone_ui-install`'s job. Below `lg:` the
514
- back link comes from `ui_mobile_header`, which the navbar renders from the
515
- title and back URL these shells publish. Check the app's layout: if it does not
516
- already render `ui_mobile_header` from that published context, ask the
517
- developer whether to wire it before adding more screens that depend on it.
518
-
519
- 4. Lay out the body with `ui_section` for each titled group, `ui_grid` for
520
- multi-column arrangements, and `ui_panel` or `ui_card_link` for card
521
- surfaces. Nest them; each takes a block.
522
-
523
- 5. Fill the body with the leaf helpers from the Interface above. Reach for the
524
- most specific one that fits — `ui_form_field` over `ui_input`,
525
- `ui_data_table` over a hand-built `<table>`, `ui_stat_card` over a panel with
526
- text in it. To make a stat card clickable, pass `href:` — never wrap it in
527
- `ui_card_link`, because a tap on the info button would then follow the link.
528
- To show the arithmetic behind a figure, put `ui_calculation` under it rather
529
- than a hand-built list. Which lines and groups to show is the app's own
530
- calculation, so ask the developer which steps a reader needs to see.
531
- To explain a figure or label that is not a stat card, put `ui_info` beside
532
- it, with the one-line explanation as `summary:` and, when the figure is a
533
- sum of parts, a `ui_breakdown` in its block. Which amounts the breakdown
534
- lists, and the wording of the summary, are the app's own, so ask the
535
- developer rather than pick. If an info button shows nothing when hovered or
536
- tapped, the gem's Stimulus controllers are not registered, so stop and hand
537
- that part to `keystone_ui-install`. If a breakdown renders as one run of
538
- text with no columns and no separate total, the app's keystone_ui-styles
539
- version does not define the `ks-breakdown` classes, so stop and hand that
540
- part to `keystone_ui-install` as well, and add no classes to fix it.
541
- To show an amount as a gain or a loss, use `ui_figure` with `tone:` rather
542
- than a `ui_badge` or a colour class. Which amounts are coloured, and whether
543
- a value counts as a gain or a loss, is the app's own rule, so ask the
544
- developer rather than pick. If a figure with `:success` or `:danger` shows in
545
- the plain text colour, the app's keystone_ui-styles version does not define
546
- the `ks-figure` and `ks-tone-*` classes, so stop and hand that part to
547
- `keystone_ui-install`, and add no classes to fix it.
548
- A field whose value must be one of a fixed list is a `:select` with
549
- `options:`, and a field that accepts any text and offers common values is a
550
- text field with `suggestions:`. Which one a field is, and which values it
551
- suggests, is the app's own rule, so ask the developer rather than pick.
552
- On a radio card or a checkbox row, text every reader needs to choose goes in `hint:` and
553
- text only some will want goes in `info:`. Which is which is the app's own
554
- wording, so ask the developer rather than pick.
555
- For a line chart, pass `dates:` when each value belongs to a calendar day
556
- and `labels:` when the horizontal axis is anything else, such as week names
557
- or categories. With `dates:`, a day with no value leaves a wider gap between
558
- its neighbours rather than a point at zero. Whether a missing day is left
559
- out or passed as a zero is the app's own rule, so ask the developer rather
560
- than pick. Never format dates into strings and pass them as `labels:`.
561
- For a funnel, whether it is drawn as separate bars or as one joined shape
562
- is the developer's choice, so ask before passing `shape:`. If a joined
563
- funnel shows its blocks but no band between them, the app's
564
- keystone_ui-styles version does not define the `ks-funnel-band` classes, so
565
- stop and hand that part to `keystone_ui-install`, and add no classes to fix
566
- it.
567
- Put the actions on a record, such as Edit and Delete, in an action menu
568
- rather than in a row of buttons: `menu:` on the `ui_section` that shows the
569
- record, `table.actions` for a table row, or `ui_action_menu` anywhere else.
570
- A delete item asks for confirmation on its own, so pass `confirm:` only
571
- when the developer wants different wording. A `:post`, `:patch` or `:put`
572
- item sends without asking, so ask the developer whether that action needs
573
- a question, and pass it as `confirm:` if it does.
574
-
575
- 6. For a table, decide how columns are declared. Use `{ key: "Label" }` hashes
576
- when every column is plain. Switch the whole set to `Keystone::Ui::Column`
577
- objects as soon as one column needs `mobile_hidden:`, `sortable:`,
578
- `hideable:`, or `locked:`.
579
-
580
- To keep a wide table's first column, such as a name, in view while the rest
581
- scrolls sideways, declare it first with `locked: true`. Whether a table locks
582
- its first column is the developer's choice, so ask rather than pick. If the
583
- locked column stays in place but the scrolled columns show through its
584
- cells, the app's keystone_ui-styles version does not define the
585
- `ks-table-header-locked` and `ks-table-cell-locked` classes, so stop and hand
586
- that part to `keystone_ui-install`, and add no classes to fix it.
587
-
588
- For a table whose hideable columns a user should be able to choose and keep,
589
- check whether the app's Keystone UI initializer sets a
590
- `preference_supplier`. If it does, pass `key:` and the default
591
- `hidden_columns:`, and add no `ui_column_picker`. The order the columns are
592
- declared in is the default order, and only columns declared
593
- `hideable: true` can be hidden or moved from the Columns menu. Which key
594
- names the table, which columns are hideable, and which it hides by default
595
- are the developer's choice, so ask rather than pick. If the app sets no supplier, either use `ui_column_picker`
596
- with an endpoint the app owns, as in step 7, or hand setting up a supplier to
597
- `keystone_ui-install`, and ask the developer which. If a Columns menu shows
598
- no greyed name for a hidden column, the app's keystone_ui-styles version is
599
- older than 0.11.0, so stop and hand that part to `keystone_ui-install`, and
600
- add no classes to fix it.
601
-
602
- 7. Wire up anything that posts back. Several helpers render controls whose
603
- endpoints the app must own — the column picker's save URL, the pipeline's box
604
- and link URLs, the swipe deck's outcome events, the modal's open trigger, the
605
- tab switcher's panel visibility. Each is named in the Interface. These are
606
- real decisions about the app's domain: where a preference is persisted, what
607
- a stage's action does, what happens when a card is accepted. Do not invent
608
- routes or a persistence strategy — put the choice to the developer, then
609
- implement what they pick.
610
-
611
- 8. To let users pick light or dark, place `ui_theme_toggle`. Where it goes — a
612
- settings screen, the navbar's `desktop_right` or a mobile menu — is the
613
- developer's choice, so ask before placing it. It only stays correct across
614
- page loads and Turbo visits when the app's layout already writes the theme
615
- onto its `html` tag and the gem's Stimulus controllers are registered. If the
616
- layout's `<html` tag carries nothing for the theme, stop and hand that part to
617
- `keystone_ui-install`.
618
-
619
- 9. Read back what you wrote and delete every Tailwind class and inline `style`
620
- you added. If the result still needs one, that is a signal the wrong helper
621
- was chosen — go back to step 5. Bring it to the developer only if no helper
622
- fits.
119
+ - `ui_page(max_width: :full, padding: :standard, top_offset: nil, class: nil)`
120
+ takes a block. `max_width:` is `:sm` `:md` `:lg` `:xl` `:full`,
121
+ `padding:` is `:standard` or `:none`, and `top_offset:` is `:sm` `:md`
122
+ `:lg` `:xl` to clear a fixed navbar. At its top, above the block, it
123
+ renders what an earlier `ui_form_page` or `ui_show_page` on the same screen
124
+ handed it: the "Back" link, then the breadcrumbs, then the form page's
125
+ title.
126
+ - `ui_form_page(title:, back_url: nil, subtitle: nil, trail: nil)` renders
127
+ nothing where it is called. It publishes the title and back URL for the
128
+ navbar's mobile header, and hands `ui_page` a "Back" link to `back_url`
129
+ shown from `lg:` up, then the title and subtitle shown from `md:` up.
130
+ - `ui_show_page(title:, back_url: nil, subtitle: nil, trail: nil)` renders
131
+ nothing where it is called. It publishes the title, subtitle and back URL
132
+ for the navbar, and hands `ui_page` the "Back" link. It shows no title, so
133
+ put `ui_page_header` inside the `ui_page` block.
134
+ - Call the shell before `ui_page` and outside its block, or the "Back" link,
135
+ breadcrumbs and title do not appear. Never add a second back link or
136
+ button to a shell's screen.
137
+ - `trail:` is `[[label, href], ...]` from the top level down. A non-empty
138
+ trail adds breadcrumbs under the "Back" link, ending with `title`
139
+ unlinked, shown from `lg:` up. With no `trail:`, the shell uses the trail
140
+ the app supplies for the request, if it supplies one. With no
141
+ `back_url:`, the back URL is the `href` of the trail's last link. A
142
+ `trail:` or `back_url:` passed to the shell always wins over the supplied
143
+ one.
144
+ - `trail: []` marks a page a navigation tab opens directly. It shows no
145
+ "Back" link and no breadcrumbs, and with no `back_url:` it publishes no
146
+ back URL, so the mobile header shows no back arrow.
147
+ - With no `back_url:`, no trail from either place, or a trail whose last
148
+ link has no `href`, rendering raises `KeystoneUi::MissingBackLink` naming
149
+ the page's title. A trail with any link whose label or `href` is `nil` or
150
+ blank raises `KeystoneUi::IncompleteTrail` naming the page's title, even
151
+ when `back_url:` is passed.
152
+ - Check whether the app's keystone_ui initializer sets a `trail_supplier`.
153
+ If it does, a screen whose supplied trail is right passes neither `trail:`
154
+ nor `back_url:`. If it does not, every form and detail screen passes
155
+ `back_url:` or `trail:`. Setting up a supplier is `keystone_ui-install`'s
156
+ job.
157
+ - Ask the developer whether a screen shows breadcrumbs and which parent
158
+ screens its trail names. Ask which screens a navigation tab opens
159
+ directly, since those pass `trail: []`.
160
+ - `ui_page_header(title:, subtitle: nil, action_url: nil, action_label: "Add new", class: nil)`
161
+ takes a block yielding the header and is hidden below `sm:`. Call
162
+ `header.action { ... }` in the block to place a control on its right, and
163
+ only what `action` receives is rendered. `action_url:` publishes that URL
164
+ and label for a mobile navbar to pick up.
165
+ - `ui_breadcrumbs(trail:, current: nil)` renders `trail:` as links separated
166
+ by `›`, then `current:` unlinked and marked as the current page, shown
167
+ only from `lg:` up. On a form or detail screen pass `trail:` to the shell
168
+ instead of calling it.
169
+ - Below `lg:` the back link comes from `ui_mobile_header`, rendered by the
170
+ navbar from the title and back URL the shells publish. If the layout does
171
+ not render `ui_mobile_header` from that context, ask the developer whether
172
+ to wire it before adding more screens that depend on it.
173
+
174
+ 4. Lay out the body. Each of these takes a block and they nest.
175
+
176
+ - `ui_section(title: nil, subtitle: nil, action: nil, menu: [], spacing: :md, class: nil)`
177
+ — `action:` is `{ label:, href: }` and renders a right-aligned link.
178
+ `menu:` is `[{ label:, href:, method: }, ...]`, each hash taking the
179
+ keywords of `ui_action_menu_item`, and renders an action menu at the right
180
+ of the header. `spacing:` is `:sm` `:md` `:lg`. The header, with its
181
+ `action:` and `menu:`, renders only when `title:` is given.
182
+ - `ui_grid(cols: { default: 1 }, gap: :md, gap_x: nil, gap_y: nil)` —
183
+ `cols:` maps `:default` `:sm` `:md` `:lg` to a count from 1 to 12, such as
184
+ `{ default: 1, md: 3 }`. `gap:` is `:sm` `:md` `:lg` `:xl`, and passing
185
+ `gap_x:` or `gap_y:` replaces `gap:` entirely.
186
+ - `ui_panel(padding: :md, radius: :lg, shadow: true)` — `padding:` is `:sm`
187
+ `:md` `:lg` and `radius:` is `:md` `:lg` `:xl`.
188
+ - `ui_card_link(href:, padding: :md, shadow: true)` — `padding:` is `:sm`
189
+ `:md` `:lg`.
190
+ - `ui_card(title:, summary:, link:, cta: "Read more", edge_to_edge: false, class: nil)`
191
+ takes no block. `edge_to_edge: true` drops the side border and corner
192
+ rounding below `sm:` so the card spans the full width on mobile.
193
+
194
+ 5. Wire navigation in the layout.
195
+
196
+ - `ui_navigation` takes no keywords and a block holding the page content, so
197
+ it goes around `yield`. It draws the tabs the app declares with
198
+ `config.navigation_group` as a top bar of menus from `lg:` up, then the
199
+ content, and below `lg:` it draws only the content. It leaves out tabs
200
+ whose permission check fails and groups with no tab left.
201
+ - Inside its block, `navigation.with_logo do ... end` puts the app's logo at
202
+ the left of the top bar, and `navigation.with_menus do ... end` puts menus
203
+ such as the account menu at its right, so the layout draws no second bar.
204
+ With no group left, it draws only the content unless a logo or menus are
205
+ set, in which case the top bar holds just those.
206
+ - When the preference supplied for `:navigation` holds
207
+ `{ "placement" => "left" }` or `"right"`, it draws a sidebar on that side
208
+ from `lg:` up instead, with each group's label above its tabs, the logo at
209
+ its top and the menus at its bottom. Any other placement draws the top
210
+ bar. An `"order"` list of `{ "group" => label, "tabs" => [tab key strings] }`
211
+ entries draws the named groups and tabs first, in that order, then the
212
+ rest in declared order, ignoring names no longer declared.
213
+ - When the app sets `config.current_tab_supplier`, the tab whose key it
214
+ returns and that tab's group show as active in either placement. A `nil`
215
+ key, a key no declared tab has, or no supplier marks nothing.
216
+ - Check whether the app's initializer declares navigation groups before
217
+ adding desktop tabs by hand. Declaring groups, the placement and order
218
+ preference, and the current tab supplier are `keystone_ui-install`'s job.
219
+ - `ui_navbar(sticky: true)` takes a block yielding the navbar. Fill its
220
+ slots: `logo`, `desktop_links`, `desktop_right`, `mobile_left`,
221
+ `mobile_center`, `mobile_right`. Desktop slots are hidden below `lg:` and
222
+ mobile slots from `lg:` up. `desktop_right` renders only when
223
+ `desktop_links` is also filled.
224
+ - `ui_nav_item(label:, href:, active: false)` is one desktop link.
225
+ `ui_nav_dropdown(title:, area:, active: false)` takes a block holding the
226
+ menu links.
227
+ - `ui_bottom_nav` takes no keywords and a block of
228
+ `ui_bottom_nav_item(label:, href:, icon:, active: false)` calls, where
229
+ `icon:` is a raw SVG string. The bar is hidden from `lg:` up and inside a
230
+ Hotwire Native web view.
231
+ - `ui_mobile_header(title:, back_url:, subtitle: nil)` is hidden from `lg:`
232
+ up and goes in the navbar's `mobile_left` slot. `back_url:` must be
233
+ passed, and `nil` renders the title with no back arrow.
234
+ - `ui_settings_link(label:, href:)` renders one settings row.
235
+ - `ui_theme_toggle` takes no keywords and no block. Its buttons switch the
236
+ theme at once and store the choice in the `keystone_theme` cookie for a
237
+ year, and System follows the operating system. The button for the current
238
+ mode renders pressed, and none is pressed when the mode is a custom
239
+ palette supplied by another gem. Ask the developer where it goes, such as
240
+ a settings screen, the navbar's `desktop_right` or a mobile menu. It stays
241
+ correct across page loads and Turbo visits only when the layout's `<html`
242
+ tag carries the theme attributes and the gem's Stimulus controllers are
243
+ registered. If the tag carries nothing for the theme, stop and hand that
244
+ part to `keystone_ui-install`.
245
+
246
+ 6. Build forms.
247
+
248
+ - `ui_form(action:, method: :post, multipart: false, data: nil)` takes a
249
+ block. `method:` may be `:patch`, `:put` or `:delete`, sent as Rails
250
+ expects. Set `multipart: true` when the form holds a file upload.
251
+ - `ui_form_field(attribute:, label: nil, type: :text, required: false, hint: nil, placeholder: nil, min: nil, max: nil, step: nil, value: nil, options: [], errors: [], include_blank: nil, disabled: false, suggestions: [])`
252
+ is the default way to render a field. `type:` is `:text` `:number`
253
+ `:email` `:password` `:date` `:textarea` `:checkbox` `:select`.
254
+ - `attribute:` is used as the input's `name` as given, so pass the full
255
+ param name, such as `"quote[title]"`, and pass `label:` whenever it is a
256
+ nested name. `label:` defaults to the attribute with underscores turned to
257
+ spaces and the first letter capitalized. `errors:` is an array of message
258
+ strings.
259
+ - A `:select` takes `options:` as `[[label, value], ...]`, and `value:`
260
+ picks the selected option. A select that is not required gets a leading
261
+ empty option, worded by `include_blank:` or left blank without it, so
262
+ never add an empty choice to `options:` as well.
263
+ - A `:checkbox` renders its label beside the box, submits `"0"` unchecked
264
+ and `"1"` checked, and is checked when `value:` is `"1"`.
265
+ - `suggestions:` is an array of strings the browser offers while typing, and
266
+ the user can still type any value. It applies to `:text` `:number`
267
+ `:email` `:password` `:date` and is ignored by the other types. Its list
268
+ is identified by `attribute:`, so two fields with suggestions on one
269
+ screen need different `attribute:` values.
270
+ - A value that must be one of a fixed list is a `:select`, and a free value
271
+ with common choices is a text field with `suggestions:`. Ask the developer
272
+ which a field is and which values it offers.
273
+ - `ui_input(name:, type: :text, value: nil, placeholder: nil, disabled: false, min: nil, max: nil, step: nil)`
274
+ takes `:text` `:number` `:email` `:password` `:date`.
275
+ `ui_textarea(name:, value: nil, rows: 3, placeholder: nil, disabled: false)`
276
+ and `ui_select(name:, options: [], selected: nil, include_blank: nil, disabled: false)`
277
+ are the bare forms of the other two. Use them only where a label does not
278
+ belong.
279
+ - `ui_multi_select(name:, label:, options:, selected: [])` posts every
280
+ checked value under `name` as given, so pass an array name such as
281
+ `"status[]"`. `options:` is `[[label, value], ...]` and `selected:` is the
282
+ values to check. Its button reads "All <label>" with nothing checked and
283
+ "N selected" otherwise.
284
+ - `ui_file_upload(name:, label: nil, accept: nil, multiple: false, hint: nil)`
285
+ needs the enclosing form to be multipart.
286
+ - `ui_color_picker(name:, value: "#000000", label: nil)` writes the chosen
287
+ hex value into a hidden input named `name`.
288
+ - `ui_radio_card(name:, value:, label:, hint: nil, info: nil, checked: false)`
289
+ and `ui_checkbox_row(name:, value:, label:, hint: nil, info: nil, checked: false)`
290
+ share `hint:` and `info:`. `hint:` is a line always shown under the label.
291
+ `info:` adds an info button beside the label, named "About <label>" for
292
+ screen readers, that shows the text in a panel below while hovered and
293
+ toggles it when tapped. Text every reader needs goes in `hint:` and text
294
+ only some want goes in `info:`, so ask the developer which is which.
295
+ - A checked `ui_checkbox_row` submits `value` under `name`, an unchecked one
296
+ submits nothing, and rows sharing an array name such as `"shown[]"`
297
+ submit the checked values as a list.
298
+ - `ui_option_card(name:, value:, selected: false, input_data: {}, label_data: {})`
299
+ takes a block rendering the card's body. `input_data:` and `label_data:`
300
+ become `data-*` attributes on the input and the label.
301
+
302
+ 7. Put the actions on a record, such as Edit and Delete, in an action menu
303
+ rather than a row of buttons: `menu:` on the `ui_section` showing the record,
304
+ `table.actions` for a table row, or `ui_action_menu` anywhere else.
305
+
306
+ - `ui_action_menu` takes no keywords and a block of `ui_action_menu_item`
307
+ calls, and shows at every screen size. `ui_mobile_actions` takes the same
308
+ block and is hidden from `lg:` up, so use it only for actions that
309
+ desktop reaches some other way.
310
+ - `ui_action_menu_item(label:, href:, method: :get, confirm: nil)` is a link
311
+ with `method: :get`. Any other method renders a button in its own small
312
+ form sending that method, so never place such an item inside a `ui_form`
313
+ block.
314
+ - `confirm:` is a question Turbo asks before the item sends. With no
315
+ `confirm:`, a `:delete` item asks "<label> this? This cannot be undone."
316
+ and every other method sends without asking. A `:get` item ignores
317
+ `confirm:`. Ask the developer whether a `:post`, `:patch` or `:put` action
318
+ needs a question.
319
+
320
+ 8. Build tables.
321
+
322
+ - `ui_data_table(items:, columns:, empty_message: nil, sort: nil, sort_direction: nil, sort_url: nil, hidden_columns: [], key: nil)`
323
+ takes a block yielding the table. `items:` are records or hashes, and a
324
+ cell's value is the column key called on the item, or `item[key]`.
325
+ - `columns:` takes `{ key: "Label" }` hashes when every column is plain.
326
+ Switch the whole set to `Keystone::Ui::Column` objects once one column
327
+ needs an option.
328
+ - `Keystone::Ui::Column.new(key, header_text, mobile_hidden: false, sortable: false, hideable: false, locked: false)`
329
+ — `mobile_hidden:` hides the column below `sm:`, `sortable:` gives it a
330
+ sort header, and `hideable:` lets it be hidden and moved.
331
+ `locked: true` on the first column keeps it in view while the rest scrolls
332
+ sideways, a locked column that is not first renders as an ordinary one,
333
+ and a locked column is never hideable. Ask the developer whether a wide
334
+ table locks its first column.
335
+ - In the block, `table.link(:column_key) { |item| url }` turns a column's
336
+ cells into links, and `table.actions { |item| ... }` adds a right-aligned
337
+ actions column whose block holds only `ui_action_menu_item` calls. With no
338
+ actions column, the last column is right-aligned, so put a column of
339
+ amounts last.
340
+ - Sorting needs all three of `sort:` (the current column key),
341
+ `sort_direction:` (`:asc` or `:desc`) and `sort_url:` (a lambda taking
342
+ `(column_key, direction)` and returning a URL). Headers then render as
343
+ links that flip direction.
344
+ - `hidden_columns:` is the table's default layout and drops only hideable
345
+ columns. The declared order is the default order.
346
+ - For a table whose columns a user chooses and keeps, check whether the
347
+ app's initializer sets a `preference_supplier`. If it does, pass `key:`
348
+ and the default `hidden_columns:`, and add no `ui_column_picker`. A saved
349
+ `"hidden_columns"` list replaces `hidden_columns:`, an empty saved list
350
+ shows every column, and a saved `"column_order"` places the hideable
351
+ columns in that order while other columns keep their place. When the
352
+ supplier returns a save address, the table renders its own Columns menu
353
+ above it. Ask the developer which key names the table, which columns are
354
+ hideable, and which are hidden by default.
355
+ - If the app sets no supplier, ask the developer whether to use
356
+ `ui_column_picker` with an endpoint the app owns, or to hand setting up a
357
+ supplier to `keystone_ui-install`.
358
+ - `ui_column_picker(columns:, hidden_columns: [], save_url: nil)` lists the
359
+ hideable columns in the order given, each with a checkbox and up and down
360
+ buttons. Pass it the columns in the order the table shows them and the
361
+ same hidden keys. When the menu closes with a change, it sends one
362
+ `PATCH save_url` with a CSRF token and the JSON
363
+ `{ "hidden_columns": [...], "column_order": [...] }`, then reloads.
364
+ `column_order` lists every hideable key. The app's endpoint must store both
365
+ lists and answer with a success status. On an error status or no
366
+ connection the menu shows "Your column changes were not saved." and puts
367
+ its boxes and order back. It does not reorder the table, so the app passes
368
+ both the table and the picker their columns in the saved order.
369
+
370
+ 9. Show content and status.
371
+
372
+ - `ui_button(label:, href: nil, variant: :primary, size: :md, type: :submit, data: nil)`
373
+ renders a link with `href:` and a button without it. `variant:` is
374
+ `:primary` `:secondary` `:danger`, `size:` is `:sm` `:md` `:lg`, and
375
+ `type:` applies only to the button.
376
+ - `ui_badge(label:, variant: :neutral, class: nil)` — `variant:` is
377
+ `:neutral` `:success` `:danger` `:warning` `:info`.
378
+ - `ui_alert(message:, type: :info, title: nil, dismissible: false, class: nil)`
379
+ — `type:` is `:info` `:success` `:warning` `:error`, and `dismissible: true`
380
+ adds a close button.
381
+ - `ui_progress(value:, max:, label: nil)` shows `value / max` as a percent,
382
+ rounded and capped at 100.
383
+ - `ui_copy_button(text:, label: "Copy", success_message: "Copied!", error_message: "Failed!")`.
384
+ - `ui_code(language: nil, caption: nil)` takes a block holding the code.
385
+ `caption:` is a strip above it and `language:` sets the `language-*`
386
+ class for a highlighter.
387
+ - `ui_disclosure(open: false)` takes a block yielding the component. Fill
388
+ its `summary` slot with the header, and the rest of the block is the body.
389
+ - `ui_accordion(items: [])` — `items:` is `[{ question:, answer: }, ...]`.
390
+
391
+ 10. Explain figures.
392
+
393
+ - `ui_stat_card(label:, value:, variant: :neutral, suffix: nil, definition: nil, calculation: nil, change: nil, href: nil)`
394
+ — `variant:` is `:neutral` `:success` `:danger` `:warning` `:info` and
395
+ colours the value. `change:` is a signed number shown as `▲ 4.2%` in
396
+ green, `▼` in red, or plain at zero. `href:` links the value only, so
397
+ never wrap a stat card in `ui_card_link`. `definition:` and
398
+ `calculation:` add an info button whose panel shows while hovered or
399
+ focused and toggles when tapped.
400
+ - `ui_figure(text:, tone: :neutral)` prints one amount inline. `tone:`
401
+ `:neutral` uses the surrounding text colour, `:success` green and
402
+ `:danger` red, and any other symbol raises `KeyError`. Its output can go
403
+ anywhere a string is shown, such as a table cell. Use it, not a badge or
404
+ a colour class, for a gain or a loss, and ask the developer which amounts
405
+ are coloured and what counts as a gain.
406
+ - `ui_calculation(groups:, summary: "How this is worked out")` shows the
407
+ steps behind a figure, closed under a row reading `summary:`. `groups:`
408
+ is `[{ title:, lines: [{ label:, working:, result: }, ...] }, ...]`.
409
+ `title:` is optional, a group without `lines:` raises `KeyError`, and a
410
+ line with another key raises `ArgumentError`. Put it directly under the
411
+ figure it explains, and ask the developer which steps a reader needs.
412
+ - `ui_info(summary:)` is an info button beside the thing it explains,
413
+ named "More about this" for screen readers. `summary:` is a short line
414
+ shown while hovered. With a block, a tap toggles a wider panel holding
415
+ the block, and the summary stays hover-only. With no block, a tap
416
+ toggles the summary. A click elsewhere closes the detail and scrolling
417
+ closes both. The panels are not cut off by a table or other clipping
418
+ container. The block may hold only text and inline elements, such as a
419
+ `ui_breakdown`, never a `<div>`, list or table. Pass plain sentences with
420
+ no line breaks.
421
+ - `ui_breakdown(lines:, total:)` — `lines:` is `[{ amount:, label: }, ...]`
422
+ and `total:` is one `{ amount:, label: }`. It adds nothing up, so
423
+ compute the total. It renders inline, so it can sit in a `ui_info` block.
424
+ Ask the developer which amounts it lists and how the summary is worded.
425
+ - Every value these print is shown as given, so format numbers, currency
426
+ and minus signs before passing them.
427
+
428
+ 11. Draw charts and analytics.
429
+
430
+ - `ui_chart_card(title:, height: :md)` takes a block holding a chart, with
431
+ `height:` `:sm` `:md` `:lg`.
432
+ - `ui_line_chart(series:, labels: nil, dates: nil, height: :md)` —
433
+ `series:` is `[{ name:, data:, color:, dashed: }, ...]`, where `color:`
434
+ is a CSS colour and `dashed: true` is optional. Pass exactly one of
435
+ `labels:` and `dates:`, or it raises `ArgumentError`. `labels:` are
436
+ strings spaced evenly. `dates:` are `Date`, `Time` or `DateTime` values,
437
+ one per value, placed by day and read as dates such as "Oct 2, 2026",
438
+ with the time of day dropped. Use `dates:` for calendar days and never
439
+ format dates into `labels:`. A day with no value leaves a wider gap, so
440
+ ask the developer whether a missing day is left out or passed as zero.
441
+ - `ui_funnel(steps:, shape: :bars)` — `steps:` is
442
+ `[{ label:, value:, color: }, ...]` in order. Each width is the value's
443
+ share of the first step's value, and the percent between two steps is
444
+ the second divided by the first. Colours run `:accent` `:sky` `:violet`
445
+ `:amber` `:rose` and repeat, and `color:` picks one of those or raises.
446
+ `:bars` draws one bar per step with a `↓ N%` caption between bars.
447
+ `:joined` draws one shape with a narrowing band holding the percent
448
+ between blocks. Any other `shape:` raises `ArgumentError`. Ask the
449
+ developer which shape they want.
450
+ - `ui_bucket(goal:, actual:, label: nil, over: :success)` shows the label,
451
+ `goal`, the container, `actual` and the percent reached. `goal` and
452
+ `actual` must be numbers, so a string or `nil` raises `ArgumentError`.
453
+ They print as Ruby prints them, so convert a `BigDecimal` with `to_i` or
454
+ `to_f`. The percent is not capped and is 0 for a zero goal. Over the goal
455
+ the fill turns green with `over: :success` or amber with `over: :warning`.
456
+ - `ui_bucket_series(buckets:)` takes an array of hashes with the keywords
457
+ of `ui_bucket`, such as `[{ goal: 10, actual: 7, label: "Mon" }, ...]`.
458
+ - `ui_pipeline(title:, boxes:, links:, subtitle: nil)` — `boxes:` is
459
+ `[{ label:, count:, accent:, action: }, ...]`, with `accent:` one of
460
+ `:amber` `:emerald` `:danger` `:muted`, and `action:` is
461
+ `{ url:, label:, params:, variant: }`, a button that POSTs `params` to
462
+ `url`. `links:` has one fewer entry than `boxes:`, each
463
+ `{ url:, params:, broken: }`, a toggle between two boxes that POSTs to
464
+ flip its state.
465
+
466
+ 12. Add interactive pieces whose outcome the app owns.
467
+
468
+ - `ui_modal(title:, size: :md)` takes a block holding the body, with
469
+ `size:` `:sm` `:md` `:lg` `:xl`. It renders with the `hidden` class and
470
+ closes on its close button and on a backdrop click. Nothing in the gem
471
+ opens it, so the app's code removes `hidden`.
472
+ - `ui_tab_switcher(tabs:)` takes an array of label strings and a block
473
+ rendered below the tabs. The first tab is active on load, and a click
474
+ sends a `tab-switcher:change` event with the clicked index. Showing and
475
+ hiding the panels is the app's code.
476
+ - `ui_swipe_deck(items:, empty_title: "All done!", empty_subtitle: nil)`
477
+ takes a block yielding the deck, where `deck.item { |item| ... }` renders
478
+ one card's face. Accepting sends a bubbling `swipe-deck:complete` event
479
+ and rejecting sends `swipe-deck:skip`. Both carry `detail.itemId` (the
480
+ item's `id`, or its position) and `detail.card`, and `complete` carries
481
+ `detail.value` from an input marked `data-swipe-deck-value`, or `null`.
482
+ - For these, the pipeline's URLs and the column picker's save URL, do not
483
+ invent routes or how the outcome is stored. Ask the developer, then
484
+ build what they pick.
485
+
486
+ 13. Build marketing sections.
487
+
488
+ - `ui_hero(title:, subtitle: nil, badge: nil, layout: :split)` takes a
489
+ block holding the call-to-action buttons and yields the component, so
490
+ its `aside` slot can hold an image or panel. `layout:` is `:split` or
491
+ `:centered`.
492
+ - `ui_cta_banner(title:, subtitle: nil)` takes a block holding the
493
+ buttons.
494
+ - `ui_feature_grid(title:, features:, subtitle: nil)` — `features:` is
495
+ `[{ icon:, title:, description: }, ...]`, where `icon:` is a raw SVG or
496
+ HTML string.
497
+
498
+ 14. Read back what you wrote and delete every Tailwind class and inline `style`
499
+ you added. If the result still needs one, a different helper fits, so go
500
+ back to the steps above. Bring it to the developer only if no helper fits.
501
+
502
+ If a piece renders unstyled where it should not — an info button that
503
+ shows nothing, a breakdown in one run of text, a `:success` or `:danger`
504
+ figure in plain colour, a joined funnel with no band, a locked column the
505
+ others show through, or a Columns menu with no greyed name for a hidden
506
+ column — the app's Stimulus controllers or keystone_ui-styles version are
507
+ out of date. Stop and hand that part to `keystone_ui-install`, and add no
508
+ classes to fix it.
623
509
 
624
510
  ## Conventions
625
511
 
626
- - **Helpers only.** Call `ui_*` helpers from ERB. Never name a component class
627
- directly in an app. `Keystone::Ui::Column` is the one exception — it is a value
628
- object passed as an argument, not a thing that renders.
629
- - **Never hand-write Tailwind for something a helper covers.** Utility classes
630
- layered onto a helper's output fight the component and drift the moment the gem
631
- updates. The gem owns spacing, color, borders, radius, shadow, and dark mode.
632
- - **Never restyle a helper from the outside** — no wrapper div that overrides its
633
- padding or width, no CSS targeting its markup. Choose a different option
634
- symbol instead, or say the helper does not fit.
635
- - **`class:` needs the developer's approval.** The six helpers that accept it
636
- append whatever it holds to their outer element, so it can override anything
637
- the gem sets. Before passing it, name the class and the reason to the
638
- developer and wait for a yes; if the same class keeps being needed, it is a
639
- missing option in the gem.
640
- - **Semantic color only.** Themed color is `accent-*` (the brand hue) and
641
- `surface-*` (the neutral family). Never write a literal color into a view;
642
- changing the palette is install-local territory.
643
- - **Options are per-helper.** `variant:`, `size:`, `padding:`, and `spacing:` do
644
- not share one vocabulary — a button's `variant:` and a badge's `variant:`
645
- accept different symbols. Use the values listed above; a wrong symbol raises at
646
- render time rather than degrading quietly.
647
- - **Containers take blocks, leaves take keywords.** Helpers that wrap content
648
- yield; helpers that render one thing are configured entirely by keywords.
649
- Composite helpers (the navbar, the hero, the disclosure) yield the component so
650
- named slots can be filled. Helpers that yield a receiver for registration —
651
- the data table, page header, and swipe deck — only render what was registered
652
- through it, so anything else emitted inside their block is discarded.
653
- - **Mobile is not an afterthought.** Several helpers render only on one side of
654
- the `lg:` (or `sm:`) breakpoint — page headers, mobile headers, mobile actions,
655
- bottom navigation, breadcrumbs. The action menu is the exception and shows at
656
- every size. A screen needs both treatments; check the small viewport
657
- before calling it done.
658
- - Out of scope for this local: installing or upgrading the gem, changing the
659
- palette or theme defaults, and editing the components themselves. Building a
660
- new UI primitive belongs in the gem, not in a host app's views — raise it with
661
- the developer rather than approximating it locally.
512
+ - **Helpers only.** Call `ui_*` helpers from ERB and never name a component
513
+ class in the app. `Keystone::Ui::Column` is the one exception, since it is
514
+ passed as an argument and renders nothing.
515
+ - **Never hand-write Tailwind for something a helper covers.** The gem owns
516
+ spacing, colour, borders, radius, shadow and dark mode.
517
+ - **Never restyle a helper from outside** — no wrapper that overrides its
518
+ padding or width, and no CSS aimed at its markup. Choose a different option
519
+ instead, or say the helper does not fit.
520
+ - **`class:` needs the developer's approval.** `ui_page`, `ui_section`,
521
+ `ui_page_header`, `ui_card`, `ui_alert` and `ui_badge` append it to their
522
+ outer element, where it can override anything the gem sets. Name the class
523
+ and the reason and wait for a yes.
524
+ - **Semantic colour only.** Themed colour is `accent-*` and `surface-*`. Never
525
+ write a literal colour into a view.
526
+ - **Options are per helper.** A button's `variant:` and a badge's `variant:`
527
+ take different symbols, so use the values listed for that helper.
528
+ - **Containers take blocks, leaves take keywords.** The data table, page header
529
+ and swipe deck render only what is registered through the object they yield,
530
+ so anything else emitted in their block is dropped.
531
+ - **Check both screen sizes.** Page headers, mobile headers, mobile actions,
532
+ bottom navigation, breadcrumbs and `ui_navigation`'s bars render on one side
533
+ of `lg:` or `sm:` only, so a screen needs both treatments.
534
+ - Out of scope: installing or upgrading the gem, its configuration, the theme
535
+ attributes on the layout, and the palette, which belong to
536
+ `keystone_ui-install`. A new UI piece belongs in the gem, so raise it with
537
+ the developer rather than building it in the app's views.