drive 0.1.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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/CLAUDE.md +690 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +77 -0
  6. data/Rakefile +32 -0
  7. data/STYLE.md +435 -0
  8. data/app/controllers/recourses_controller.rb +96 -0
  9. data/app/javascript/recourse/phone_controller.js +33 -0
  10. data/app/views/layouts/application.html.erb +69 -0
  11. data/app/views/recourses/_breadcrumb.html.erb +18 -0
  12. data/app/views/recourses/_combobox.html.erb +23 -0
  13. data/app/views/recourses/_fields.html.erb +3 -0
  14. data/app/views/recourses/_flash.html.erb +13 -0
  15. data/app/views/recourses/_form.html.erb +9 -0
  16. data/app/views/recourses/_none.html.erb +1 -0
  17. data/app/views/recourses/_row.html.erb +4 -0
  18. data/app/views/recourses/_sidebar.html.erb +10 -0
  19. data/app/views/recourses/_table.html.erb +30 -0
  20. data/app/views/recourses/edit.html.erb +3 -0
  21. data/app/views/recourses/index.html.erb +14 -0
  22. data/app/views/recourses/new.html.erb +3 -0
  23. data/lib/drive.rb +3 -0
  24. data/lib/recourse/controllers.rb +13 -0
  25. data/lib/recourse/engine.rb +27 -0
  26. data/lib/recourse/helpers/cells.rb +44 -0
  27. data/lib/recourse/helpers/comboboxes.rb +33 -0
  28. data/lib/recourse/helpers/constraints.rb +94 -0
  29. data/lib/recourse/helpers/examples.rb +35 -0
  30. data/lib/recourse/helpers/fields.rb +54 -0
  31. data/lib/recourse/helpers/navigation.rb +68 -0
  32. data/lib/recourse/helpers/references.rb +89 -0
  33. data/lib/recourse/helpers.rb +60 -0
  34. data/lib/recourse/icons.rb +21 -0
  35. data/lib/recourse/recoursive.rb +19 -0
  36. data/lib/recourse/routes.rb +14 -0
  37. data/lib/recourse/version.rb +4 -0
  38. data/lib/recourse.rb +31 -0
  39. data/vendor/recourse/bootstrap-icons.min.css +5 -0
  40. data/vendor/recourse/bootstrap.bundle.min.js +9 -0
  41. data/vendor/recourse/bootstrap.min.css +2 -0
  42. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  43. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  44. data/vendor/recourse/stimulus.js +2563 -0
  45. metadata +144 -0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 claudiob
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Recourse
2
+
3
+ A `routes.rb` DSL that mounts ready-made resource screens.
4
+
5
+ Add one line to `config/routes.rb` and Recourse draws the routes and serves the
6
+ controllers and views needed to browse a resource. Nothing is written into your
7
+ app — and when you want to customize a screen, you eject it.
8
+
9
+ > **Status:** early development. `index`, `new`, `create`, `edit` and `update`
10
+ > work; `show`, `destroy` and the eject generator are not implemented yet.
11
+
12
+ ## Installation
13
+
14
+ Add the gem to your Gemfile:
15
+
16
+ ```ruby
17
+ gem 'drive'
18
+ ```
19
+
20
+ Then run `bundle install`.
21
+
22
+ ## Usage
23
+
24
+ ```ruby
25
+ # config/routes.rb
26
+ Rails.application.routes.draw do
27
+ recourses :contacts, only: :index
28
+ end
29
+ ```
30
+
31
+ With no `ContactsController` and no templates in your app, `/contacts` now lists
32
+ the id of every `Contact`. Recourse supplies both the controller and the view.
33
+
34
+ Anything you write yourself wins. Add `app/controllers/contacts_controller.rb`
35
+ and Recourse leaves it alone; add `app/views/contacts/index.html.erb` and Rails
36
+ renders yours instead of the one the gem ships.
37
+
38
+ The usual thing to override is a single row. Add
39
+ `app/views/contacts/_row.html.erb` and Recourse's table renders yours for
40
+ `/contacts` while every other resource keeps the default:
41
+
42
+ ```erb
43
+ <%# locals: (recourse: nil, heading: false) -%>
44
+ <% if heading %>
45
+ <th scope='col'>Contact</th>
46
+ <% else %>
47
+ <td data-cell='Contact'><%= recourse.name %></td>
48
+ <% end %>
49
+ ```
50
+
51
+ The partial is rendered once for the header row with `heading: true` and no
52
+ record, then once per record with `heading: false`. It builds the cells only —
53
+ the table, the pagination and the layout stay Recourse's.
54
+
55
+ Recourse's controllers inherit from your `ApplicationController`, so its pages
56
+ render inside `app/views/layouts/application.html.erb` alongside the rest of your
57
+ app, and go through whatever that base class already does. Each page sets its
58
+ title with `content_for :title`, so put `yield :title` in that layout's `<title>`
59
+ to see it.
60
+
61
+ ## Development
62
+
63
+ After checking out the repo, run `bin/setup` to install dependencies. Then run
64
+ `rake test` to run the tests, or `rake` to run the tests and RuboCop. You can
65
+ also run `bin/console` for an interactive prompt.
66
+
67
+ To install this gem onto your local machine, run `bundle exec rake install`.
68
+
69
+ ## Contributing
70
+
71
+ Bug reports and pull requests are welcome on GitHub at
72
+ https://github.com/claudiob/recourse.
73
+
74
+ ## License
75
+
76
+ The gem is available as open source under the terms of the
77
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'minitest/test_task'
3
+
4
+ Minitest::TestTask.create
5
+
6
+ require 'rubocop/rake_task'
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ # Ceiling for every code file, blank and comment lines included.
11
+ MAX_FILE_LINES = 100
12
+
13
+ # Prose and markup are exempt: docs, the license, and views of any length.
14
+ EXEMPT_EXTENSIONS = %w[.erb .html .md .txt].freeze
15
+
16
+ # Two directories are exempt: a backfill is as long as the data it carries, and
17
+ # upstream's formatting is not ours to fix.
18
+ EXEMPT_DIRECTORIES = %w[db/migrate/ vendor/].freeze
19
+
20
+ desc "Fail if any code file is longer than #{MAX_FILE_LINES} lines"
21
+ task :file_length do
22
+ files = `git ls-files -z`.split "\x0"
23
+ code = files.reject do |file|
24
+ EXEMPT_EXTENSIONS.include?(File.extname(file)) ||
25
+ EXEMPT_DIRECTORIES.any? { |directory| file.include? directory }
26
+ end
27
+ too_long = code.select { |file| File.readlines(file).size > MAX_FILE_LINES }
28
+
29
+ abort "Longer than #{MAX_FILE_LINES} lines: #{too_long.join ', '}" if too_long.any?
30
+ end
31
+
32
+ task default: %i[test rubocop file_length]
data/STYLE.md ADDED
@@ -0,0 +1,435 @@
1
+ # Design Guidelines
2
+
3
+ **Scope:** how the pages this gem serves look and are marked up. `CLAUDE.md` is
4
+ the authority for code style; this file is the authority for design. Read it
5
+ before writing or editing any layout, view or partial.
6
+
7
+ ## Bootstrap 6 Alpha for all markup
8
+
9
+ - Every layout, view and partial follows Bootstrap 6 Alpha conventions.
10
+ Reference: https://v6-dev--twbs-bootstrap.netlify.app/llms-full.txt
11
+ - Check class names against those docs rather than recalling Bootstrap 5. v6
12
+ renames and removes plenty: responsive utilities are prefixed
13
+ (`md:col-6`, not `col-md-6`), `.bg-light` / `.bg-dark` are gone in favour of
14
+ the `.bg-1` / `.bg-2` scale, and `.text-body-secondary` is now `.fg-2`.
15
+ - Page wrappers use `.container-fluid`, never `.container`. A table wants the
16
+ whole width on a desktop, not a centred column with margins either side.
17
+ - Still current from v5: `.container-fluid`, `.table`, `.table-responsive`, and
18
+ `data-bs-theme="light|dark"` for color modes — though `color-scheme: light
19
+ dark` on `:root` follows the system by default, so most pages need no theme
20
+ attribute at all.
21
+ - The gem ships `app/views/layouts/application.html.erb` for hosts that have
22
+ none: Bootstrap's CSS in the head, the JS bundle as a module before `</body>`,
23
+ and Geist and Geist Mono from Google Fonts.
24
+ - Bootstrap is *vendored*, not linked. `vendor/recourse/` holds
25
+ `bootstrap.min.css`, `bootstrap.bundle.min.js`, `bootstrap-icons.min.css` and
26
+ the two icon fonts, and the layout asks for `/recourse/…`. A CDN that moves or
27
+ goes down would otherwise take every page's styling with it, and the Bootstrap
28
+ 6 CSS is served from a preview host rather than a release one.
29
+ - The icon fonts are not optional extras. `bootstrap-icons.min.css` reaches for
30
+ `fonts/bootstrap-icons.woff2` beside itself, so vendoring the CSS alone leaves
31
+ every icon a blank box.
32
+ - The engine serves them with `Rack::Static`, since a host may run no asset
33
+ pipeline at all — see CLAUDE.md, "Vendor what a page cannot render without".
34
+
35
+ ## The navbar
36
+
37
+ - Every page opens with a navbar holding a breadcrumb, then `yield :actions`,
38
+ both to the left.
39
+ - The breadcrumb ends at the current page, and that last item is *not* a link:
40
+ a `<span class='breadcrumb-link active'>` inside an
41
+ `<li class='breadcrumb-item' aria-current='page'>`. Bootstrap's own example
42
+ uses an `<a>` there; we deliberately do not.
43
+ - Earlier items are links and carry the resource icon, separated by empty
44
+ `<li class='breadcrumb-divider'>` elements — v6 draws the chevron from that
45
+ element, not from a CSS `content` string on `::before` as v5 did.
46
+ - An index has one item, its own name. Any other page links back to the index
47
+ first and then names itself: `/counties/new` reads `Counties` as a link, then
48
+ `New county` as plain text.
49
+ - A view contributes buttons with `content_for :actions`; the layout only
50
+ yields. Nothing else belongs in the navbar.
51
+ - A breadcrumb link and its sidebar twin line up vertically, which constrains
52
+ both. The `<nav class='navbar'>` carries no horizontal margin or padding of
53
+ its own, so both columns reduce to `container-fluid` (0.75rem) plus a link
54
+ padding of 0.75rem — the sidebar's own 0.75rem container padding is cancelled
55
+ by `.row`'s negative margin. Adding `px-*` or `mx-*` to the navbar shifts the
56
+ breadcrumb out of line by exactly that much.
57
+ - `.breadcrumb-link` needs `gap-2`. Both link types are flex, but only
58
+ `.nav-link` ships a `gap`, and a whitespace-only text node is not a flex item
59
+ — so without it the breadcrumb's icon and text would touch while the
60
+ sidebar's sit 0.5rem apart.
61
+ - An index offers `Add <resource>` only when there is somewhere to go: the
62
+ `new` route has to be drawn *and* the controller has to implement the action,
63
+ or the button would 404 or raise. Its classes are
64
+ `btn theme-primary btn-sm btn-outline ms-3`.
65
+
66
+ ## Icons on resource links
67
+
68
+ - A link to a resource is preceded by a Bootstrap Icon, using the `<i>` form:
69
+ `<i class='bi bi-person-rolodex'></i> Contacts`. The layout loads
70
+ `bootstrap-icons@1.13.1`.
71
+ - Pick the icon by the displayed title, from this map. It is duplicated in
72
+ `lib/recourse/icons.rb`, which is what the code reads — change both together.
73
+ - An unlisted title falls back to `circle`, so a column of links stays aligned.
74
+ Add a real entry rather than leaving the fallback in place.
75
+
76
+ 'Agents' => 'robot', 'Answers' => 'question-circle', 'Apps' => 'window',
77
+ 'Assessments' => 'clipboard-check', 'Bookings' => 'calendar-check',
78
+ 'Brands' => 'buildings', 'Campaigns' => 'megaphone',
79
+ 'Contacts' => 'person-rolodex', 'Contract' => 'file-earmark-check',
80
+ 'Conversations' => 'chat-dots', 'Counties' => 'map', 'CRM' => 'plugin',
81
+ 'Echoes' => 'soundwave', 'Episodes' => 'collection-play',
82
+ 'Evaluations' => 'speedometer2', 'Franchises' => 'shop', 'Home' => 'house',
83
+ 'Locations' => 'geo-alt', 'Logout' => 'box-arrow-right', 'Markets' => 'pin-map',
84
+ 'Offer questions' => 'gift', 'Optimizations' => 'sliders',
85
+ 'Platforms' => 'plugin', 'Profile' => 'person-circle',
86
+ 'Prompts' => 'terminal', 'Providers' => 'briefcase',
87
+ 'Satisfaction questions' => 'emoji-smile', 'Searches' => 'search',
88
+ 'Settings' => 'gear', 'Sources' => 'signpost', 'Specialties' => 'award',
89
+ 'Specialty matches' => 'award', 'States' => 'geo',
90
+ 'Verticals' => 'bar-chart', 'ZIPs' => 'geo-alt-fill'
91
+
92
+ - `Home` maps to `house`: Bootstrap Icons has no `home`, so that entry would
93
+ have rendered an empty box.
94
+ - Icons go on *links*. The breadcrumb's current-page item is not a link, so it
95
+ carries no icon.
96
+
97
+ ## The sidebar
98
+
99
+ - Below the navbar, an `<aside>` sits to the left of the content holding a
100
+ vertical `ul.nav.flex-column` of links — one per resource `recourses` drew.
101
+ - The order is the order `config/routes.rb` declares them, never sorted.
102
+ - The entry for the page being shown is `nav-link active` with
103
+ `aria-current='page'`. It is matched on the controller, not on the URL, so
104
+ `/contacts?page=2` still marks Contacts active.
105
+ - A resource appears only if its `index` action is routed. `recourses :drafts,
106
+ only: :new` draws no index, so it gets no link rather than a broken one.
107
+ - Layout is `.row` with `aside.col-auto` and `main.col`, inside the page's
108
+ `.container-fluid`.
109
+ - The aside's border runs to the bottom of the window. That takes a chain of
110
+ three: `body.d-flex.flex-column.min-vh-100`, then
111
+ `.container-fluid.flex-grow-1.d-flex`, then `.row.flex-grow-1`. The aside
112
+ stretches because `.row` is a flex container and Bootstrap leaves
113
+ `align-items` unset, so items default to `stretch`.
114
+ - `min-height` rather than `height`, so short pages fill the window without a
115
+ scrollbar and long ones still scroll.
116
+
117
+ ## Forms
118
+
119
+ - The gem serves `new.html.erb`: it sets `:title` to `New <resource>` and
120
+ renders the `form` partial, passing the record explicitly under its own name.
121
+ - It serves `edit.html.erb` the same way, titled after the record instead — the
122
+ value of whatever its model's `recourse_label` names, so a market reads
123
+ `Chicago`. Both render the *same* `form` partial, so a host that writes one
124
+ `_fields.html.erb` gets it on both pages and never writes a second.
125
+ - After a rejected update the title shows what was typed, not what is stored,
126
+ because the record already carries the submitted values. Blanking the label
127
+ blanks the title.
128
+ - The form is `form_with model:` plus one field per *editable* column — every
129
+ column except `id`, `created_at` and `updated_at`. Encrypted columns are
130
+ editable even though the table will not display them.
131
+ - Each field is a `.form-label` and a `.form-control` inside
132
+ `.mb-3.lg:col-6`, and the whole set sits in one `.row`. Two fields to a row on
133
+ a large viewport, stacked below it — a name or a phone number needs nowhere
134
+ near the full width of a page, so a form of full-width inputs reads as a
135
+ column of empty space.
136
+ - The submit is `btn btn-solid theme-primary`. In v6 the fill is a separate class
137
+ from the base: `.btn` sizes, `.btn-solid` / `.btn-outline` / `.btn-subtle`
138
+ fill, and `theme-*` colours.
139
+ - A field whose attribute is not required carries `placeholder='Optional'`.
140
+ Required is judged by the model's validators, not by `null: false` — and a
141
+ `belongs_to` validates the association, so `state_id` counts as required
142
+ through `:state`.
143
+ - A required attribute also gets a required *field*: `required` on the input, so
144
+ the browser turns the form back before the server ever sees it. It is the same
145
+ judgement the placeholder makes, from the same validators — the two are
146
+ readings of one fact and never disagree.
147
+ - Worth knowing before there is an `edit` action: a required encrypted attribute
148
+ is a required password field, and a password field renders empty, so editing a
149
+ record would demand the value be retyped. Revisit the rule then, not now.
150
+ - A required field shows the shape it expects instead: `555-555-5555` for a
151
+ phone, `michael@example.com` for an email. Every other required field has no
152
+ placeholder, since there is nothing useful to show.
153
+ - An explicit `type:` picks the sample on its own, required or not. `field :email,
154
+ type: :email` on an optional column shows `michael@example.com` rather than
155
+ `Optional`: the caller has said what the field is, and the sample is the more
156
+ useful of the two hints.
157
+ - The field list is a `fields` partial of its own, so a host app can replace the
158
+ fields without rewriting `form_with` or the submit button. It renders one
159
+ `field` per editable column; a host writes the calls it wants by hand.
160
+ - `field` takes the column name and two options: `label:` for the heading and
161
+ `type:` for the input. `field :phone, type: :phone` beats every rule below,
162
+ including the encrypted-column one — an explicit type is an instruction.
163
+ - The field type otherwise follows the column, and the rules are in this order: a
164
+ foreign key is a combobox; an encrypted column is a password field; one named
165
+ `email` or `color` gets that input; a `date`, `time` or `datetime` attribute
166
+ gets its own field, the last of those as `datetime-local`; everything else is
167
+ text.
168
+ - Encryption wins over the name, so an encrypted `email` is masked rather than
169
+ typed as an email — protecting the value matters more than the keyboard.
170
+ - Length, format and numericality travel to the browser, and all three are read
171
+ from the model's *validators*, never from the column: `maxlength` and
172
+ `minlength` from a length validator's `maximum`, `minimum` or `is`, `pattern`
173
+ from a format validator with `\A` and `\z` stripped since an HTML pattern is
174
+ anchored already, and `inputmode: 'numeric'` from a numericality validator or
175
+ a digits-only pattern.
176
+ - A field with a `pattern` also carries a `title` showing the shape it wants:
177
+ `\d{5}` gives `title='Please match the format 00000'`. Without one the browser
178
+ says only that the value does not match, which tells nobody what would. The
179
+ example is read off the pattern — `\d` becomes a digit, `\w` a letter, a bracket
180
+ class its first character, and `{n}` repeats — so the phone's
181
+ `[2-9]\d{2}[2-9]\d{6}` reads as `2002000000`.
182
+ - Always pass `size: nil`. Rails mirrors `maxlength` into `size`, and a
183
+ five-character box for a ZIP code undoes the width rule above.
184
+ - Which input a `date`, `time` or `datetime` gets is the one thing no validator
185
+ can say, so it comes from `type_for_attribute` — the model's own attribute
186
+ type, which an `attribute` override still governs — and not from
187
+ `columns_hash`.
188
+
189
+ ## Comboboxes for foreign keys
190
+
191
+ - A form asks for a foreign key one of two ways, and which one is the label's
192
+ decision. Where the label has a *length validator* it is short enough to type,
193
+ so the field asks for the value; otherwise it is a combobox to pick from.
194
+ - A typed reference names both: the label reads `ZIP code`, not `ZIP`, since a
195
+ code is what the field wants. It takes the shape of that attribute —
196
+ `maxlength`, `minlength`, `pattern`, `title`, `inputmode` — from the model the
197
+ attribute belongs to, but takes *required* from the association that needs it,
198
+ which is the page's model and not the other one's.
199
+ - This is what keeps a form from being enormous. `/locations/new` was 3.3 MB when
200
+ its ZIP was a combobox of 40,965 options; typing the code instead makes it
201
+ 6.4 KB. A combobox is right for fifty states and wrong for forty thousand ZIPs.
202
+ - A value that matches no record leaves the foreign key nil, so `belongs_to`
203
+ reports `Must exist` beside the field, and the field keeps what was typed. That
204
+ value comes from `params`, not from the record — nothing was ever assigned to it.
205
+ - `state_id` is still a Bootstrap combobox listing each `State` by `name`, in the
206
+ "Search menu items" form, so a list of fifty stays usable.
207
+ - What each option reads is the model's own `recourse_label` — `name` by default,
208
+ `code` for a ZIP, `email` for an Agent. See CLAUDE.md, "Every model says how it
209
+ is labelled".
210
+ - The menu holds every row, so it is only as usable as the table is small. The
211
+ ZIP combobox on `/locations/new` is 40,965 options and 3.3 MB of HTML: the
212
+ search box finds one instantly, but the page pays for all of them up front.
213
+ Bootstrap filters what is already in the DOM, so there is no cheaper option
214
+ short of a server-side search.
215
+ - The markup is the toggle followed by its `.menu` **sibling** — the plugin finds
216
+ the menu with `SelectorEngine.next`, so anything between them breaks it:
217
+
218
+ <button class='form-control combobox-toggle' type='button' id='county_state_id'
219
+ data-bs-toggle='combobox' data-bs-name='county[state_id]'
220
+ data-bs-placeholder='Select a State…' data-bs-search='true'>
221
+ <span class='combobox-value'>Select a State…</span>
222
+ <i class='bi bi-chevron-down combobox-caret'></i>
223
+ </button>
224
+ <div class='menu'>
225
+ <div class='combobox-search'>
226
+ <input type='text' class='form-control combobox-search-input'
227
+ placeholder='Search…' autocomplete='off' aria-label='Search…'>
228
+ </div>
229
+ <button class='menu-item' type='button' data-bs-value='1'>Alabama</button>
230
+ <div class='combobox-no-results d-none'>No results found</div>
231
+ </div>
232
+
233
+ - `data-bs-name` is what makes it a form control: the plugin inserts a hidden
234
+ input of that name before the toggle and writes the chosen `data-bs-value`
235
+ into it. Never put `name=` on the toggle itself.
236
+ - `data-bs-search='true'` enables filtering, but the search input has to be in
237
+ the markup — the plugin only wires up a `.combobox-search-input` it finds.
238
+ - The toggle carries the `id` the label points at, which is legal because a
239
+ `<button>` is a labelable element. Use `form.field_id` and `form.field_name`
240
+ rather than spelling either out.
241
+ - Bootstrap's example uses an inline SVG caret; ours is
242
+ `<i class='bi bi-chevron-down combobox-caret'></i>`. The class only needs
243
+ `flex-shrink` and a rotation, the icon font is already loaded, and the `<i>`
244
+ form is what every other icon on the page uses.
245
+ - The placeholder doubles as the empty label: `Select a <Model>…`, from
246
+ `model_name.human` so a registered acronym survives. An optional association
247
+ says `Optional` instead, like any other optional field.
248
+ - A required association carries `aria-required` on the toggle rather than
249
+ `required`. The toggle is a `<button>`, which `required` does not apply to, and
250
+ the hidden input that would take it is not in the markup — the plugin writes it
251
+ at runtime. So the requirement is announced, not enforced: the model's
252
+ validation is still what rejects a blank.
253
+
254
+ ## Flash messages
255
+
256
+ - A flash is a Toast, never an inline alert, in a
257
+ `.toast-container.position-fixed.bottom-0.end-0.p-3` at the end of `<body>`.
258
+ `.toast-container` is `position: absolute` in v6, so `position-fixed` is not
259
+ optional — without it the toast scrolls away with the page.
260
+ - The variant is the flash key: `toast theme-success` for a notice, `toast
261
+ theme-danger` for an alert, and a neutral `theme-primary` for a key a host
262
+ invents. The theme goes on the `.toast` itself.
263
+ - The message goes in the *header*, and the body is kept but hidden:
264
+
265
+ <div class='toast theme-success' role='alert' aria-live='assertive' aria-atomic='true'>
266
+ <div class='toast-header border-0'>
267
+ <span class='me-auto'>Contact was created.</span>
268
+ <button type='button' class='btn-close' data-bs-dismiss='toast' aria-label='Close'></button>
269
+ </div>
270
+ <div class='toast-body d-none'></div>
271
+ </div>
272
+
273
+ - That is what tints the whole toast. `.toast-header` takes its background from
274
+ `--bs-theme-bg-subtle` while `.toast` itself takes the plain body background, so
275
+ a message in the body would sit on white below a coloured strip. With the body
276
+ hidden the toast *is* the header, and the theme colours all of it.
277
+ - `border-0` removes the header's `border-block-end`, which would otherwise rule a
278
+ line under the message with nothing beneath it.
279
+ - `me-auto` on the message is what pushes the X to the right. Inside a header the
280
+ close button needs nothing else: v6 gives it margins through
281
+ `.toast-header .btn-close`, which a headerless toast would have had to supply
282
+ itself.
283
+ - It autohides, which is the Toast default — nothing to declare.
284
+ - The wording names the model, never the record: `Contact was created.` and
285
+ `Contact could not be created.`, both from `model_name.human`. Interpolating the
286
+ record instead prints `#<Contact:0x000000012b6febc8>`, because Active Record
287
+ leaves `to_s` as Object's.
288
+ - Toasts need JavaScript twice over. `.toast:not(.show)` is `display: none`, so
289
+ one has to be shown, and the autohide timer only starts when it is. The layout
290
+ imports `Toast` from the bundle and calls `show()` on every `.toast` it finds.
291
+ `data-bs-dismiss='toast'` needs the component loaded too, so the X is dead
292
+ without it.
293
+
294
+ ## Validation errors
295
+
296
+ - A rejected `create` redraws the same page with `422`, never a redirect, so the
297
+ fields keep what was typed and the errors sit beside them.
298
+ - The control that failed gains `is-invalid`, and the message follows it as
299
+ `<small class='invalid-feedback'>`. Both are needed: Bootstrap reveals the
300
+ feedback with `.is-invalid ~ .invalid-feedback`, so a feedback element on its
301
+ own stays hidden and an `is-invalid` on its own only reddens the border.
302
+ - Because that selector is a *sibling* one, the feedback goes after the whole
303
+ control — for a combobox, after the `.menu`, not inside the toggle.
304
+ - Nothing writes that markup by hand. `config.action_view.field_error_proc` does
305
+ it for every field a form builder draws — see CLAUDE.md, "Match Bootstrap with
306
+ field_error_proc".
307
+ - The combobox is the exception, because it is a partial rather than a form
308
+ builder tag, so `field_error_proc` never sees it. It adds its own `is-invalid`
309
+ and its own `.invalid-feedback`.
310
+ - The message is the bare reason, sentence-cased: `Must exist`, `Can't be blank`.
311
+ The label above it already names the attribute, so a full message would repeat
312
+ it.
313
+ - A `belongs_to` reports its error on the association, so a field for `state_id`
314
+ asks the record about both `state_id` and `state` — otherwise a missing state
315
+ reddens nothing.
316
+
317
+ ## Tables
318
+
319
+ - A `<table>` defaults to the hoverable accent, not the striped one:
320
+ `class='table table-hover'`. Reach for `.table-striped` only when a specific
321
+ table is better served by it.
322
+ - Always add `.sm:table-stacked`, so rows become stacked blocks once the
323
+ container gets narrow.
324
+ - Cells live in a `_row` partial, one `column` call each, with the content in a
325
+ block:
326
+
327
+ <%= column header: 'Phone' do %>
328
+ <%= number_to_phone contact.phone %>
329
+ <% end %>
330
+
331
+ - `column` also takes anything `tag` does — `class:`, `style:` — and passes it
332
+ to both the `th` and the `td`.
333
+ - A foreign-key column shows what the record it points at is called, not the id
334
+ that points at it: `/locations` heads a column `ZIP code` and fills it with
335
+ `00501`. The heading is the one the form uses for the same column, so a table
336
+ and its form never disagree about what a column is.
337
+ - Those names cost one query per association rather than one per row, because the
338
+ index eager-loads every `belongs_to` the table can name. Twenty locations still
339
+ cost five queries.
340
+ - Every table ends with an `Actions` column, and `_table` adds it rather than
341
+ `_row`. That is the whole point of putting it there: a host that writes its own
342
+ row still gets the column, appended after whatever columns that row defines, so
343
+ `/contacts` reads `Name | Phone | Created at | Actions`.
344
+ - Where the resource has an `edit` action each row links to it, and the link's
345
+ content is the `<i class='bi bi-pencil-square'></i>` icon rather than the word.
346
+ It carries `aria-label='Edit'`, since an icon alone says nothing to a screen
347
+ reader.
348
+ - The column is there either way, empty for a resource that only has an index.
349
+ - The record arrives under its own name, `contact:` for contacts, so a host
350
+ partial declares `<%# locals: (contact:) -%>`. It is rendered once for the
351
+ header row with that local set to nil, so never assume it is present outside
352
+ a `column` block.
353
+ - A host app overrides one table by defining
354
+ `app/views/<resources>/_row.html.erb`, which wins through the controller's
355
+ template prefixes — so keep everything cell-shaped in that partial and
356
+ nothing else.
357
+ - Stacking needs two more things, or it degrades badly. The table must sit
358
+ inside a `.table-responsive` wrapper, which is the container query's
359
+ container. And every `<td>` needs `data-cell='<heading>'` — that is where the
360
+ labels in the stacked layout come from, so without it a narrow screen shows
361
+ values with nothing naming them.
362
+ - A table of records shows every attribute that is not encrypted, one column
363
+ each — not just the id. Encrypted attributes are omitted entirely: showing
364
+ ciphertext helps nobody, and decrypting it into a list leaks it.
365
+ - Column headings come from `human_attribute_name`, so a host app can rename
366
+ one by translating the attribute.
367
+ - A `belongs_to` currently shows its raw foreign key — `/counties` renders the
368
+ `State` column as `1`. Rendering the associated record instead needs
369
+ `includes` in the controller to stay within the one-count-one-select budget,
370
+ and that work is deferred. Do not reach through an association from a view in
371
+ the meantime.
372
+
373
+ ## Links
374
+
375
+ - Internal links go through Turbo, so navigation is a fetch and a swap rather
376
+ than a full page load. The layout loads Turbo from the CDN.
377
+ - Turbo prefetches a link on `mouseenter`, so a page is already on its way
378
+ before the click lands. This is on by default in Turbo 8 — never add
379
+ `<meta name='turbo-prefetch' content='true'>` to restate it.
380
+ - Do not put `data-turbo='false'` or `data-turbo-prefetch='false'` on an
381
+ internal link. Either one opts that link out of both behaviours.
382
+
383
+ ## Phone numbers
384
+
385
+ - A phone number shown to a user always goes through `number_to_phone`, so
386
+ `5552234567` reads as `555-223-4567`. Never print the stored digits raw.
387
+ - Storage is unaffected: the column still holds ten bare digits, as `CLAUDE.md`
388
+ requires. The formatting is for reading only.
389
+ - In a generic table this keys off the column being named `phone`, which is
390
+ safe because that convention guarantees the name.
391
+ - A phone *field* separates as it is typed, not only once it is stored. Every
392
+ `<input type='phone'>` carries the Stimulus controller that does it:
393
+
394
+ data-controller='phone'
395
+ data-action='keydown->phone#down input->phone#input'
396
+
397
+ - The controller formats on `connect` too, so a form redrawn after a rejected
398
+ `create` shows the separators rather than the ten digits it was sent.
399
+ - Because the value now carries separators, the `pattern` has to accept them or
400
+ the browser refuses to submit what it just helped type. A phone's pattern is
401
+ therefore `[2-9]\d{2}-[2-9]\d{2}-\d{4}` — the separated form of the model's
402
+ `NORTH_AMERICAN_PHONES`, keeping the rule that an area or exchange code cannot
403
+ start with 0 or 1. The server sees bare digits regardless, since `Phonable`
404
+ normalizes them away.
405
+ - Never put a length validator on a phone. `maxlength` would come from it and cut
406
+ the value off at ten characters, three short of `555-555-5555`.
407
+ - The `title` says `Please match the format 555-555-5555`, matching the
408
+ placeholder. Where a field has a canonical sample the title uses it rather than
409
+ a shape derived from the pattern, so the two never disagree.
410
+
411
+ ## Times and dates
412
+
413
+ - A time on a page reads `%b %-d at %I:%M%P %Z` — `Aug 4 at 07:16pm EDT` —
414
+ wrapped in a `<time>` tag carrying the machine-readable value:
415
+
416
+ <time datetime='2026-08-04T19:16:51-04:00'>Aug 4 at 07:16pm EDT</time>
417
+
418
+ - Rails' `time_tag` builds both halves: `time_tag value,
419
+ value.strftime(TIME_FORMAT)`. Pass the text explicitly, or the helper reaches
420
+ for I18n instead.
421
+ - The `datetime` attribute is `rfc3339`, so it carries seconds and the offset.
422
+ The visible text drops both; the attribute is what a machine reads.
423
+ - Zone comes from `config.time_zone`, so `%Z` reads `EDT` or `EST` depending on
424
+ the date, never `UTC`.
425
+
426
+ ## Pagination
427
+
428
+ - Paginate with the `pagy` gem, never hand-rolled offsets.
429
+ - The page limit is 20, which is already pagy's own default — so never pass
430
+ `limit:` to restate it.
431
+ - Below the table, in this order: `info_tag` for the item count, then
432
+ `series_nav :bootstrap` for the links. Both need `<%==` rather than `<%=`,
433
+ since they return HTML.
434
+ - Leave `max_limit` unset. Without it pagy ignores a `?limit=` in the query
435
+ string, so a visitor cannot ask for a page of 100,000 rows.
@@ -0,0 +1,96 @@
1
+ # Superclass of the controllers the gem defines when a host app has none.
2
+ class RecoursesController < ApplicationController
3
+ include Pagy::Method
4
+
5
+ helper Recourse::Helpers
6
+
7
+ # `find` raises RecordNotFound, so an id that names nothing answers 404.
8
+ before_action :find_resource, only: %i[edit update]
9
+
10
+ # Lists one page of the model the route is named after.
11
+ def index
12
+ @pagy, @resources = pagy resource_scope
13
+ end
14
+
15
+ # Builds a blank record under the name Rails would use: @contact for contacts.
16
+ def new
17
+ assign resource_class.new
18
+ end
19
+
20
+ # Saves a submitted record, then shows the index again or redraws the form.
21
+ def create
22
+ record = assign resource_class.new(resource_params)
23
+
24
+ if record.save
25
+ flash.notice = "#{human_name} was created."
26
+ redirect_to url_for(action: :index), status: :see_other
27
+ else
28
+ flash.now.alert = "#{human_name} could not be created."
29
+ render :new, status: :unprocessable_entity
30
+ end
31
+ end
32
+
33
+ # Shows the form for the record the id names, which is already known to exist.
34
+ def edit; end
35
+
36
+ # Saves changes to a record, then shows the index again or redraws the form.
37
+ def update
38
+ if @recourse.update resource_params
39
+ flash.notice = "#{human_name} was updated."
40
+ redirect_to url_for(action: :index), status: :see_other
41
+ else
42
+ flash.now.alert = "#{human_name} could not be updated."
43
+ render :edit, status: :unprocessable_entity
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Every table cell that names a referenced record would otherwise be a query.
50
+ def resource_scope
51
+ names = resource_class.reflect_on_all_associations(:belongs_to).map(&:name)
52
+ return resource_class.all if names.empty?
53
+
54
+ resource_class.includes(*names)
55
+ end
56
+
57
+ def find_resource
58
+ assign resource_class.find(params.expect(:id))
59
+ end
60
+
61
+ def assign(record)
62
+ @recourse = record
63
+ instance_variable_set "@#{controller_name.singularize}", record
64
+ end
65
+
66
+ def resource_class
67
+ controller_name.classify.constantize
68
+ end
69
+
70
+ def human_name
71
+ resource_class.model_name.human
72
+ end
73
+
74
+ def resource_params
75
+ permitted = Recourse.editable_columns resource_class
76
+
77
+ resolve_references params.expect(controller_name.singularize.to_sym => permitted)
78
+ end
79
+
80
+ # A foreign key whose label is typed arrives as that label, so it is looked up
81
+ # here. Nothing found leaves the key nil, and `belongs_to` reports it missing.
82
+ def resolve_references(attributes)
83
+ resource_class.reflect_on_all_associations(:belongs_to).each do |association|
84
+ key = association.foreign_key.to_s
85
+ next unless attributes.key?(key) && association.klass.recourse_typed_label?
86
+
87
+ attributes[key] = reference_id association, attributes[key]
88
+ end
89
+
90
+ attributes
91
+ end
92
+
93
+ def reference_id(association, label)
94
+ association.klass.find_by(association.klass.recourse_label => label)&.id
95
+ end
96
+ end