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