plutonium 0.65.0 β†’ 0.66.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -1,6 +1,6 @@
1
1
  # Positioning & Drag-to-Reorder
2
2
 
3
- Manual ordering for a resource: a decimal `position` column on the model, a `position_on` line in the definition, and Plutonium renders a drag grip on the index **table** and the **card grid** β€” and on any **nested association table** of the same resource.
3
+ Manual ordering for a resource: a decimal `position` column on the model, a `position_on` line in the definition, and Plutonium renders a drag grip on the index **table** and the **card grid**, and on any **nested association table** of the same resource.
4
4
 
5
5
  Ordering is **fractional**. A drop writes one decimal (the midpoint between its two neighbours), so the common case updates exactly one row. No `UPDATE … SET position = position + 1` sweep across the table.
6
6
 
@@ -8,12 +8,12 @@ The same machinery drives the [kanban board](/reference/kanban/), which is why a
8
8
 
9
9
  ## 🚨 Critical
10
10
 
11
- - **The concern is `Plutonium::Positioning::Model`, not `Plutonium::Positioning`.** This changed β€” see [the upgrade note](#upgrading-from-include-plutonium-positioning) before you touch anything.
11
+ - **The concern is `Plutonium::Positioning::Model`, not `Plutonium::Positioning`.** This changed, see [the upgrade note](#upgrading-from-include-plutonium-positioning) before you touch anything.
12
12
  - **`position_on` silently sets `default_sort`** when your definition hasn't declared one. See [the warning](#what-position-on-expands-to).
13
13
  - **The model owns storage; the definition owns the UI.** `positioned_on` (model) says *how positions are stored*. `position_on` (definition) says *this list is orderable*. Never restate the column or the scope in the definition.
14
14
  - **Dragging is offered only while the collection is sorted ascending by the position attribute.** Under any other sort the grip renders as a link back to that sort, and the server rejects the drop outright.
15
- - **`reposition?` on the policy gates the drop.** It defaults to `update?`. Override it to let someone reorder without granting full edit access β€” or to forbid reordering while still allowing edits.
16
- - **Prefer [Mode A](#mode-a-delegate) β€” the framework-owned write.** A block ([Mode B](#mode-b)) is a supported escape hatch for models already ordered by a positioning gem, but it hands you semantics Mode A handles for you. Already on `acts_as_list`? [Migrating](#migrating-off-a-positioning-gem) is a column change, two lines on the model, and a backfill.
15
+ - **`reposition?` on the policy gates the drop.** It defaults to `update?`. Override it to let someone reorder without granting full edit access, or to forbid reordering while still allowing edits.
16
+ - **Prefer [Mode A](#mode-a-delegate): the framework-owned write.** A block ([Mode B](#mode-b)) is a supported escape hatch for models already ordered by a positioning gem, but it hands you semantics Mode A handles for you. Already on `acts_as_list`? [Migrating](#migrating-off-a-positioning-gem) is a column change, two lines on the model, and a backfill.
17
17
 
18
18
  ## Two verbs, one feature {#two-verbs-one-feature}
19
19
 
@@ -24,7 +24,7 @@ There are exactly two, and the split is deliberate:
24
24
  | `positioned_on :column, scope: :attr` | the **model** | *How are positions stored?* Which column, and what groups rows into independent orderings. |
25
25
  | `position_on` | the **definition** (and inside `kanban do…end`) | *Is this list orderable, and who writes the new position?* |
26
26
 
27
- The definition-layer verb is `position_on` on both a definition and a kanban board β€” the **same** verb, not a third one. A board with no `position_on` of its own inherits the definition's. So the framework's whole positioning vocabulary is two words, and you learn the board by learning the table.
27
+ The definition-layer verb is `position_on` on both a definition and a kanban board, the **same** verb, not a third one. A board with no `position_on` of its own inherits the definition's. So the framework's whole positioning vocabulary is two words, and you learn the board by learning the table.
28
28
 
29
29
  ```ruby
30
30
  class Task < ApplicationRecord
@@ -43,7 +43,7 @@ That second line names neither the column nor the scope. It reads them off the m
43
43
 
44
44
  ## Quick start
45
45
 
46
- **1. Migration** β€” use the `t.position` helper. It emits a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so the scale can't be too small to rebalance cleanly. It works in `create_table` and `change_table` alike:
46
+ **1. Migration.** Use the `t.position` helper. It emits a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so the scale can't be too small to rebalance cleanly. It works in `create_table` and `change_table` alike:
47
47
 
48
48
  ```ruby
49
49
  create_table :tasks do |t|
@@ -61,7 +61,7 @@ t.position index: true # also add a single-column index
61
61
  t.position scale: 10 # override precision/scale
62
62
  ```
63
63
 
64
- **2. Model** β€” include the concern and declare the column:
64
+ **2. Model.** Include the concern and declare the column:
65
65
 
66
66
  ```ruby
67
67
  class Task < ApplicationRecord
@@ -72,7 +72,7 @@ class Task < ApplicationRecord
72
72
  end
73
73
  ```
74
74
 
75
- **3. Definition** β€” one line:
75
+ **3. Definition.** One line:
76
76
 
77
77
  ```ruby
78
78
  class TaskDefinition < Plutonium::Resource::Definition
@@ -90,11 +90,11 @@ That's it. The index table and the card grid now render a drag grip, `POST /task
90
90
 
91
91
  ---
92
92
 
93
- ## The model layer β€” `Plutonium::Positioning::Model`
93
+ ## The model layer: `Plutonium::Positioning::Model`
94
94
 
95
95
  ### Upgrading from `include Plutonium::Positioning` {#upgrading-from-include-plutonium-positioning}
96
96
 
97
- ::: danger Breaking change β€” the concern moved down a level
97
+ ::: danger Breaking change: the concern moved down a level
98
98
  `Plutonium::Positioning` is now a **pure namespace**. The ActiveRecord concern is `Plutonium::Positioning::Model`.
99
99
 
100
100
  ```ruby
@@ -105,9 +105,9 @@ include Plutonium::Positioning
105
105
  include Plutonium::Positioning::Model
106
106
  ```
107
107
 
108
- `positioned_on`, `reposition!` and `backfill_positions!` are unchanged β€” only the `include` line moves.
108
+ `positioned_on`, `reposition!` and `backfill_positions!` are unchanged; only the `include` line moves.
109
109
 
110
- **Why it had to change.** Every constant nested inside an included module joins the including class's constant lookup. While the concern *was* `Plutonium::Positioning`, a bare `Config` written anywhere inside a positioned model resolved to `Plutonium::Positioning::Config` instead of the application's own `::Config` β€” silently, with no error, in a class the app author never suspected. Splitting the namespace from the mixin stops the leak: `Model` nests nothing.
110
+ **Why it had to change.** Every constant nested inside an included module joins the including class's constant lookup. While the concern *was* `Plutonium::Positioning`, a bare `Config` written anywhere inside a positioned model resolved to `Plutonium::Positioning::Config` instead of the application's own `::Config`, silently and with no error, in a class the app author never suspected. Splitting the namespace from the mixin stops the leak: `Model` nests nothing.
111
111
  :::
112
112
 
113
113
  ### `positioned_on(column = :position, scope: nil)`
@@ -124,7 +124,7 @@ After the call the model gains:
124
124
  - `backfill_positions!(order: :created_at)` on the class.
125
125
 
126
126
  ::: warning Including the concern is not enough
127
- `include Plutonium::Positioning::Model` without a `positioned_on` call installs no `before_create` hook β€” every row is created with a `NULL` position and the list orders arbitrarily. `position_on` in the definition raises at class-load rather than let that ship.
127
+ `include Plutonium::Positioning::Model` without a `positioned_on` call installs no `before_create` hook: every row is created with a `NULL` position and the list orders arbitrarily. `position_on` in the definition raises at class-load rather than let that ship.
128
128
  :::
129
129
 
130
130
  ### `reposition!(prev_record:, next_record:)`
@@ -139,22 +139,22 @@ task.reposition!(prev_record: last, next_record: nil) # append
139
139
 
140
140
  It returns a `Plutonium::Positioning::Result`, whose **`rebalanced?`** tells the caller whether rows *other than this one* moved. That is the signal the drop endpoint uses to decide between "204, nothing to repaint" and "here is the whole collection back".
141
141
 
142
- The arithmetic, the `EPSILON = 1e-6` rebalance threshold, and the pure `Plutonium::Positioning.position_between` / `.gap_exhausted?` helpers are documented in full under [Kanban β€Ί Positioning](/reference/kanban/positioning) β€” the model layer is shared, so there is one description of it and both surfaces point at it.
142
+ The arithmetic, the `EPSILON = 1e-6` rebalance threshold, and the pure `Plutonium::Positioning.position_between` / `.gap_exhausted?` helpers are documented in full under [Kanban β€Ί Positioning](/reference/kanban/positioning); the model layer is shared, so there is one description of it and both surfaces point at it.
143
143
 
144
144
  ---
145
145
 
146
- ## The definition layer β€” `position_on`
146
+ ## The definition layer: `position_on`
147
147
 
148
148
  ### Four forms
149
149
 
150
150
  ```ruby
151
- position_on # Mode A β€” follow the model's column ← use this
152
- position_on :sort_order # Mode A β€” must MATCH the model's column
153
- position_on(:rank) { |move| … } # Mode B β€” another gem owns the write (escape hatch)
154
- position_on false # Mode C β€” ordering off
151
+ position_on # Mode A: follow the model's column ← use this
152
+ position_on :sort_order # Mode A: must MATCH the model's column
153
+ position_on(:rank) { |move| … } # Mode B: another gem owns the write (escape hatch)
154
+ position_on false # Mode C: ordering off
155
155
  ```
156
156
 
157
- [Mode A](#mode-a-delegate) is the one to reach for: the framework owns the write, and the bare form cannot disagree with the model. [Mode B](#mode-b) exists for models already ordered by a positioning gem β€” it is supported and tested, but it hands you semantics Mode A handles, so prefer [migrating off the gem](#migrating-off-a-positioning-gem) where you can.
157
+ [Mode A](#mode-a-delegate) is the one to reach for: the framework owns the write, and the bare form cannot disagree with the model. [Mode B](#mode-b) exists for models already ordered by a positioning gem; it is supported and tested, but it hands you semantics Mode A handles, so prefer [migrating off the gem](#migrating-off-a-positioning-gem) where you can.
158
158
 
159
159
  ### What `position_on` expands to {#what-position-on-expands-to}
160
160
 
@@ -169,9 +169,9 @@ action :reposition, hidden: true # route + policy predicate, no button
169
169
  The `sort` registration is load-bearing rather than a convenience: dragging is only permitted while the list is in ascending position order, so without a permitted sort there would be **no way back out** of the disabled state.
170
170
 
171
171
  ::: warning `position_on` claims `default_sort`
172
- If your definition has not declared a `default_sort`, `position_on` sets it to `<attribute>, :asc` β€” replacing the framework default of `id, :desc`. A resource that used to list newest-first will list in position order after you add this line.
172
+ If your definition has not declared a `default_sort`, `position_on` sets it to `<attribute>, :asc`, replacing the framework default of `id, :desc`. A resource that used to list newest-first will list in position order after you add this line.
173
173
 
174
- That is almost always what you want (a hand-ordered list that ignores its own order is useless), but it is a change you did not write. To keep a different default, just declare one β€” **in either order**, above or below `position_on`:
174
+ That is almost always what you want (a hand-ordered list that ignores its own order is useless), but it is a change you did not write. To keep a different default, just declare one, **in either order**, above or below `position_on`:
175
175
 
176
176
  ```ruby
177
177
  class TaskDefinition < Plutonium::Resource::Definition
@@ -180,7 +180,7 @@ class TaskDefinition < Plutonium::Resource::Definition
180
180
  end
181
181
  ```
182
182
 
183
- `position_on` only claims `default_sort` while nobody has declared one, so an explicit declaration always wins regardless of where it sits in the class body. Note the consequence: with a foreign default sort, the list opens **not draggable** β€” the grip renders as a link that applies the position sort.
183
+ `position_on` only claims `default_sort` while nobody has declared one, so an explicit declaration always wins regardless of where it sits in the class body. Note the consequence: with a foreign default sort, the list opens **not draggable**: the grip renders as a link that applies the position sort.
184
184
 
185
185
  "Explicit" is by declaration, not by value: `default_sort :id, :desc` wins too, even though it names the same field and direction as the framework default. Writing it means you chose it.
186
186
 
@@ -192,7 +192,7 @@ class ResourceDefinition < Plutonium::Resource::Definition
192
192
  end
193
193
 
194
194
  class TaskDefinition < ResourceDefinition
195
- position_on # inherits :created_at β€” NOT draggable on open
195
+ position_on # inherits :created_at: NOT draggable on open
196
196
  end
197
197
  ```
198
198
 
@@ -206,7 +206,7 @@ end
206
206
  ```
207
207
  :::
208
208
 
209
- ### Mode A β€” delegate (the default) {#mode-a-delegate}
209
+ ### Mode A: delegate (the default) {#mode-a-delegate}
210
210
 
211
211
  The framework owns the write. On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`.
212
212
 
@@ -220,12 +220,12 @@ Mode A validates the model **at class-load**, with errors that name the fix:
220
220
  | Situation | Result |
221
221
  |---|---|
222
222
  | Model does not `include Plutonium::Positioning::Model` | `ArgumentError` pointing at the concern, and at Mode B as the escape hatch |
223
- | Model includes it but never calls `positioned_on` | `ArgumentError` β€” no `before_create`, so every row would sort arbitrarily |
224
- | `position_on :rank` while the model says `positioned_on :position` | `ArgumentError` β€” the list would be *ordered* by one column while `reposition!` *wrote* another, so dragging would appear to do nothing |
223
+ | Model includes it but never calls `positioned_on` | `ArgumentError`: no `before_create`, so every row would sort arbitrarily |
224
+ | `position_on :rank` while the model says `positioned_on :position` | `ArgumentError`, the list would be *ordered* by one column while `reposition!` *wrote* another, so dragging would appear to do nothing |
225
225
 
226
226
  That last one is why the bare form is the recommended one: it cannot disagree with the model.
227
227
 
228
- ### Mode B β€” bring your own positioning gem {#mode-b}
228
+ ### Mode B: bring your own positioning gem {#mode-b}
229
229
 
230
230
  ::: tip Reach for Mode A first
231
231
  Mode A is one word in the definition. The *correct* `acts_as_list` block further down this page is fifteen lines of rank arithmetic, and writing it means already knowing three things neither library's README tells you:
@@ -234,12 +234,12 @@ Mode A is one word in the definition. The *correct* `acts_as_list` block further
234
234
  - **removing a record shifts its neighbours' ranks by one**, in a direction that depends on whether the record started above or below them;
235
235
  - **a blank `move.prev` means "nothing above me *on screen*"**, not "top of the list".
236
236
 
237
- These docs got two of those three wrong for a while β€” in the very section written to explain the first. That is the honest case for preferring Mode A. Not that Mode B is broken: it is supported, it is [tested against the real gem](#why-move-index-cannot-be-the-anchor), and the recipe below is correct. But its correctness lives in arithmetic you own and have to keep owning, and Mode A's does not.
237
+ These docs got two of those three wrong for a while, in the very section written to explain the first. That is the honest case for preferring Mode A. Not that Mode B is broken: it is supported, it is [tested against the real gem](#why-move-index-cannot-be-the-anchor), and the recipe below is correct. But its correctness lives in arithmetic you own and have to keep owning, and Mode A's does not.
238
238
 
239
239
  Choosing today? Choose Mode A. Already on a positioning gem? [Migrating](#migrating-off-a-positioning-gem) is a column change, two lines on the model, and a backfill.
240
240
  :::
241
241
 
242
- Give `position_on` a block and Plutonium stops writing positions. It still **orders** the collection by the attribute you name, still renders the grip, still routes and authorizes the drop β€” but the block persists the new value.
242
+ Give `position_on` a block and Plutonium stops writing positions. It still **orders** the collection by the attribute you name, still renders the grip, still routes and authorizes the drop, but the block persists the new value.
243
243
 
244
244
  The block receives a single `Plutonium::Positioning::Move`:
245
245
 
@@ -248,10 +248,10 @@ The block receives a single `Plutonium::Positioning::Move`:
248
248
  | `move.record` | the dropped record |
249
249
  | `move.prev` | the record immediately **before** the slot **on the client's page**, or `nil` |
250
250
  | `move.next` | the record immediately **after** the slot **on the client's page**, or `nil` |
251
- | `move.index` | 0-based insertion index among the other rows **on that page** β€” see [why it cannot be your anchor](#why-move-index-cannot-be-the-anchor) |
252
- | `move.column` | the destination kanban column key β€” `nil` on tables and grids, which have no columns |
251
+ | `move.index` | 0-based insertion index among the other rows **on that page**: see [why it cannot be your anchor](#why-move-index-cannot-be-the-anchor) |
252
+ | `move.column` | the destination kanban column key: `nil` on tables and grids, which have no columns |
253
253
 
254
- It is called with `call`, not `instance_exec` β€” `self` inside the block is wherever you wrote it.
254
+ It is called with `call`, not `instance_exec`; `self` inside the block is wherever you wrote it.
255
255
 
256
256
  #### What the framework stops doing {#mode-b-handover}
257
257
 
@@ -259,11 +259,11 @@ A block is an opaque write. Plutonium cannot know what it touched, or against wh
259
259
 
260
260
  | | Mode A | Mode B |
261
261
  |---|---|---|
262
- | **Hidden boundary neighbours** | resolved server-side before the write, so a drop at the edge of a page anchors to the real row the client couldn't see | not resolved β€” `resolve_position_boundaries` returns early unless the config delegates. The block gets the client's viewport verbatim, `nil` and all |
262
+ | **Hidden boundary neighbours** | resolved server-side before the write, so a drop at the edge of a page anchors to the real row the client couldn't see | not resolved, `resolve_position_boundaries` returns early unless the config delegates. The block gets the client's viewport verbatim, `nil` and all |
263
263
  | **Drop under a foreign sort** | rejected `422` before any write | not checked server-side. Only the client-side gate applies; the block owns its own notion of neighbours |
264
264
  | **Response** | `204` when nothing else moved | always `200` + a turbo-stream of the collection |
265
265
 
266
- That last row is not a missing optimisation. Gems in this space routinely renumber the entire group on every move β€” `acts_as_list` does exactly that β€” so the client's optimistic DOM is stale by definition. A repaint per drop is the only way the two are guaranteed to agree.
266
+ That last row is not a missing optimisation. Gems in this space routinely renumber the entire group on every move (`acts_as_list` does exactly that), so the client's optimistic DOM is stale by definition. A repaint per drop is the only way the two are guaranteed to agree.
267
267
 
268
268
  The first two rows are the ones to weigh before choosing Mode B: they are the semantics you are taking on, and the worked example below is what taking them on looks like.
269
269
 
@@ -271,7 +271,7 @@ The first two rows are the ones to weigh before choosing Mode B: they are the se
271
271
 
272
272
  If nothing external depends on the gem's contiguous integer ranks, moving to Mode A is a column change, two lines on the model, and a backfill.
273
273
 
274
- **1. Change the column.** `acts_as_list` stores contiguous integers; Plutonium stores fractional decimals, and `t.position` emits `decimal(16, 8)` for exactly that reason β€” a whole-number column would round every midpoint straight back onto a neighbour. `t.position` *adds* a column, so an existing one wants `change_column`:
274
+ **1. Change the column.** `acts_as_list` stores contiguous integers; Plutonium stores fractional decimals, and `t.position` emits `decimal(16, 8)` for exactly that reason: a whole-number column would round every midpoint straight back onto a neighbour. `t.position` *adds* a column, so an existing one wants `change_column`:
275
275
 
276
276
  ```ruby
277
277
  class ChangeTaskPositionToDecimal < ActiveRecord::Migration[8.0]
@@ -281,7 +281,9 @@ class ChangeTaskPositionToDecimal < ActiveRecord::Migration[8.0]
281
281
  end
282
282
  ```
283
283
 
284
- **2. Swap the macro on the model.** Note that `scope:` takes a bare Symbol here β€” the [Array-form trap](#worked-example-acts-as-list) goes away with the gem:
284
+ Match the `ActiveRecord::Migration[...]` version to your app's existing migrations (or generate the file with `rails g migration`) rather than copying `[8.0]`.
285
+
286
+ **2. Swap the macro on the model.** Note that `scope:` takes a bare Symbol here: the [Array-form trap](#worked-example-acts-as-list) goes away with the gem:
285
287
 
286
288
  ```ruby
287
289
  class Task < ApplicationRecord
@@ -299,18 +301,18 @@ end
299
301
  Task.backfill_positions!(order: :position)
300
302
  ```
301
303
 
302
- Then drop the block from the definition β€” a bare `position_on` is the whole of Mode A.
304
+ Then drop the block from the definition; a bare `position_on` is the whole of Mode A.
303
305
 
304
306
  ::: warning `backfill_positions!` is a one-shot
305
- It loads the table, groups it in Ruby, and writes every row with `update_column` β€” no callbacks, no validations, no `updated_at`. That is what you want for a backfill and not what you want in a request. Run it from a migration or a `rails runner`, once.
307
+ It loads the table, groups it in Ruby, and writes every row with `update_column`: no callbacks, no validations, no `updated_at`. That is what you want for a backfill and not what you want in a request. Run it from a migration or a `rails runner`, once.
306
308
  :::
307
309
 
308
310
  #### Worked example: staying on `acts_as_list` {#worked-example-acts-as-list}
309
311
 
310
- For when the gem is not yours to remove β€” another codepath calls `move_higher`, a report reads the integer ranks, or the migration simply isn't due yet. This block is correct and stays correct; it is just longer than the one word above.
312
+ For when the gem is not yours to remove: another codepath calls `move_higher`, a report reads the integer ranks, or the migration simply isn't due yet. This block is correct and stays correct; it is just longer than the one word above.
311
313
 
312
314
  ::: danger Anchor off the neighbours, never off `move.index`
313
- `insert_at(move.index + 1)` is the obvious block to write and it is **wrong on any list that paginates or filters** β€” which, since Plutonium paginates every index at 20 rows by default, means wrong on any list with 21 rows in it. The numbers are in [Why `move.index` cannot be the anchor](#why-move-index-cannot-be-the-anchor). Use the block below.
315
+ `insert_at(move.index + 1)` is the obvious block to write and it is **wrong on any list that paginates or filters**, which, since Plutonium paginates every index at 20 rows by default, means wrong on any list with 21 rows in it. The numbers are in [Why `move.index` cannot be the anchor](#why-move-index-cannot-be-the-anchor). Use the block below.
314
316
  :::
315
317
 
316
318
  ```ruby
@@ -326,7 +328,7 @@ class Task < ApplicationRecord
326
328
  end
327
329
 
328
330
  class TaskDefinition < Plutonium::Resource::Definition
329
- # No Plutonium::Positioning::Model, no positioned_on β€” acts_as_list owns
331
+ # No Plutonium::Positioning::Model, no positioned_on: acts_as_list owns
330
332
  # both the column and the write. Plutonium only orders, routes and authorizes.
331
333
  position_on :position do |move|
332
334
  record = move.record
@@ -334,7 +336,7 @@ class TaskDefinition < Plutonium::Resource::Definition
334
336
  target =
335
337
  if move.prev
336
338
  # Land immediately after prev. When the record currently sits ABOVE
337
- # prev, removing it shifts prev up one β€” so prev's own rank is already
339
+ # prev, removing it shifts prev up one: so prev's own rank is already
338
340
  # the slot the record should occupy.
339
341
  (record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
340
342
  elsif move.next
@@ -351,19 +353,19 @@ class TaskDefinition < Plutonium::Resource::Definition
351
353
  end
352
354
  ```
353
355
 
354
- `insert_at(n)` in `acts_as_list` means "end up at rank `n` after the move", and every rank in the expression above is a **real** rank read off a neighbour record β€” never a viewport offset. `default_sort :position, :asc` is registered for you, which is exactly the order `acts_as_list` maintains.
356
+ `insert_at(n)` in `acts_as_list` means "end up at rank `n` after the move", and every rank in the expression above is a **real** rank read off a neighbour record, never a viewport offset. `default_sort :position, :asc` is registered for you, which is exactly the order `acts_as_list` maintains.
355
357
 
356
358
  Both the `elsif move.next` branch and the `else 1` are load-bearing. A blank `prev` means "nothing above me **on my screen**"; falling straight through to rank 1 sends the row to the top of the whole list, past every row the page or the filter hid.
357
359
 
358
360
  ::: tip `insert_at` swallows a failed save
359
- `insert_at` calls `save`, not `save!`, so a record that fails validation mid-move silently no-ops β€” the drop answers `200` and the streamed collection shows the row back where it started. Use `insert_at!` if you would rather the endpoint surface the errors as a `422` with a toast β€” the validation-failure row of [the response table](#the-endpoint).
361
+ `insert_at` calls `save`, not `save!`, so a record that fails validation mid-move silently no-ops; the drop answers `200` and the streamed collection shows the row back where it started. Use `insert_at!` if you would rather the endpoint surface the errors as a `422` with a toast, the validation-failure row of [the response table](#the-endpoint).
360
362
  :::
361
363
 
362
364
  #### Why `move.index` cannot be the anchor {#why-move-index-cannot-be-the-anchor}
363
365
 
364
- Plutonium hands a Mode B block the client's neighbours **verbatim** β€” it does not look up the rows pagination or a filter hid, the way Mode A does. So `move.index` is a claim about the **viewport**, while `insert_at` addresses the **whole scope group**. The two only agree on an unfiltered page 1.
366
+ Plutonium hands a Mode B block the client's neighbours **verbatim**; it does not look up the rows pagination or a filter hid, the way Mode A does. So `move.index` is a claim about the **viewport**, while `insert_at` addresses the **whole scope group**. The two only agree on an unfiltered page 1.
365
367
 
366
- These are measured, not reasoned β€” `test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb` drives each one through `POST <member>/reposition` against the real gem:
368
+ These are measured, not reasoned: `test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb` drives each one through `POST <member>/reposition` against the real gem:
367
369
 
368
370
  | List | Gesture | `insert_at(move.index + 1)` | Anchored off neighbours |
369
371
  |---|---|---|---|
@@ -372,15 +374,15 @@ These are measured, not reasoned β€” `test/plutonium/resource/controllers/positi
372
374
  | 10 rows, filter shows ranks 1 and 10 | drag rank 1 below the filtered row at rank 10 (`to_index: 1`) | `insert_at(2)` β†’ the row moves **one slot**, staying at the top | rank **10**, below the hidden rows |
373
375
  | 10 rows, filter shows ranks 5 and 10 | drag rank 10 above the filtered row at rank 5 (`prev_id` blank, `to_index: 0`) | `insert_at(1)` β†’ **rank 1**, above four rows the filter hid | rank **5**, immediately above the row it was dropped on |
374
376
 
375
- Mode A has none of this to think about: it resolves hidden boundary neighbours server-side before the write (see [Nested resources & scope groups](#nested-resources-scope-groups)). Mode B is a real escape hatch and the block above is a correct one β€” the price of the hatch is simply that the semantics are yours. If nothing outside the gem depends on those integer ranks, [the migration](#migrating-off-a-positioning-gem) hands them back.
377
+ Mode A has none of this to think about: it resolves hidden boundary neighbours server-side before the write (see [Nested resources & scope groups](#nested-resources-scope-groups)). Mode B is a real escape hatch and the block above is a correct one; the price of the hatch is simply that the semantics are yours. If nothing outside the gem depends on those integer ranks, [the migration](#migrating-off-a-positioning-gem) hands them back.
376
378
 
377
- ### Mode C β€” disabled
379
+ ### Mode C: disabled
378
380
 
379
381
  ```ruby
380
382
  position_on false
381
383
  ```
382
384
 
383
- No ordering is applied (the relation passes through unchanged), no `sort`/`default_sort` is registered, no `reposition` action is created, and the endpoint answers **404**. Useful to switch a resource's ordering off in one portal while another keeps it β€” see [portal overrides](/reference/resource/definition).
385
+ No ordering is applied (the relation passes through unchanged), no `sort`/`default_sort` is registered, no `reposition` action is created, and the endpoint answers **404**. Useful to switch a resource's ordering off in one portal while another keeps it; see [portal overrides](/reference/resource/definition).
384
386
 
385
387
  ### Kanban boards inherit the definition's `position_on`
386
388
 
@@ -407,7 +409,7 @@ class TaskDefinition < Plutonium::Resource::Definition
407
409
  end
408
410
  ```
409
411
 
410
- Resolution is **lazy**, so declaration order in the class body does not matter β€” a `kanban do…end` written above `position_on` still picks it up.
412
+ Resolution is **lazy**, so declaration order in the class body does not matter, a `kanban do…end` written above `position_on` still picks it up.
411
413
 
412
414
  ---
413
415
 
@@ -415,14 +417,14 @@ Resolution is **lazy**, so declaration order in the class body does not matter
415
417
 
416
418
  A drop says "put me between these two rows". That only describes a position when the **visual order is the stored order**. Under a title sort the neighbours say nothing; under a *descending* position sort they say the opposite of what the write would assume.
417
419
 
418
- So the grip is live only when the collection is sorted **ascending, by the position attribute, and by nothing else** β€” whether that comes from the default sort or from the user clicking the column header. Both halves of the feature enforce the same rule from the same predicate:
420
+ So the grip is live only when the collection is sorted **ascending, by the position attribute, and by nothing else**, whether that comes from the default sort or from the user clicking the column header. Both halves of the feature enforce the same rule from the same predicate:
419
421
 
420
- - **Client** β€” the Stimulus controller is not even attached under a foreign sort. There is nothing to drag against.
421
- - **Server** β€” a Mode A drop arriving under a foreign sort is rejected with `422` **before any write**, and the collection is streamed back with a toast. (Mode B has no server-side sort check: the block owns its own notion of neighbours, so only the client-side gate applies.)
422
+ - **Client**: the Stimulus controller is not even attached under a foreign sort. There is nothing to drag against.
423
+ - **Server**: a Mode A drop arriving under a foreign sort is rejected with `422` **before any write**, and the collection is streamed back with a toast. (Mode B has no server-side sort check: the block owns its own notion of neighbours, so only the client-side gate applies.)
422
424
 
423
425
  ### The disabled grip is the way out of the disabled state
424
426
 
425
- Under a foreign sort the grip does not disappear β€” it renders as a **link that applies the ascending position sort**. Hiding it would leave the user with no hint that the list is reorderable at all, and no way to make it so. This is precisely why `position_on` registers `sort <attribute>`.
427
+ Under a foreign sort the grip does not disappear; it renders as a **link that applies the ascending position sort**. Hiding it would leave the user with no hint that the list is reorderable at all, and no way to make it so. This is precisely why `position_on` registers `sort <attribute>`.
426
428
 
427
429
  Per record, the grip is also gated on `reposition?`: a row this viewer may not reorder renders exactly as it did before, with no grip at all. Offering an affordance that can only ever answer `403` is worse than offering none.
428
430
 
@@ -439,20 +441,20 @@ Per record, the grip is also gated on `reposition?`: a row this viewer may not r
439
441
 
440
442
  On a table the grip sits *inside* the first cell, pulled left into padding the cell already had, so the cell's content does not shift by a pixel whether the grip is there or not. It appears on hover, and on keyboard focus.
441
443
 
442
- ::: details Why only the grip is draggable on a table β€” but the whole card is on a board
444
+ ::: details Why only the grip is draggable on a table, but the whole card is on a board
443
445
  Two concrete costs of making a `<tr>` draggable, both silent regressions on an ordinary data table:
444
446
 
445
447
  1. **`draggable="true"` disables text selection inside the element** in every major browser. On a data table that quietly removes the ability to select and copy a cell value.
446
448
  2. **`row_click_controller` makes the whole row the Show affordance.** A draggable row fights it: a drag that starts and ends in place still fires a click, and the user is navigated away instead of left where they were.
447
449
 
448
- Neither applies to a kanban card β€” it has no cell text to select and no row-click behaviour β€” so the board keeps whole-card dragging, which is the better gesture where you can afford it. The inconsistency is deliberate.
450
+ Neither applies to a kanban card: it has no cell text to select and no row-click behaviour, so the board keeps whole-card dragging, which is the better gesture where you can afford it. The inconsistency is deliberate.
449
451
 
450
452
  A **grid** card gets a grip rather than whole-card dragging, because unlike a kanban card it *does* carry a row-click show affordance: reason 2 applies to it exactly as it does to a table row.
451
453
  :::
452
454
 
453
455
  ---
454
456
 
455
- ## The endpoint β€” `POST <member>/reposition` {#the-endpoint}
457
+ ## The endpoint: `POST <member>/reposition` {#the-endpoint}
456
458
 
457
459
  Mounted on every resource (like the kanban move routes). Resources that declare no `position_on` answer `404`.
458
460
 
@@ -461,19 +463,19 @@ POST /tasks/42/reposition?<the collection's own query string>
461
463
  prev_id=41&next_id=43&to_index=2
462
464
  ```
463
465
 
464
- The trailing query string is load-bearing, not decoration: it is the index's own query β€” search, filters, scope, sort, page, view β€” and it is what lets the endpoint re-render **exactly the page the user is looking at** through the ordinary index pipeline. Page 3 of a filtered list comes back as page 3 of that filtered list.
466
+ The trailing query string is load-bearing, not decoration: it is the index's own query (search, filters, scope, sort, page, view), and it is what lets the endpoint re-render **exactly the page the user is looking at** through the ordinary index pipeline. Page 3 of a filtered list comes back as page 3 of that filtered list.
465
467
 
466
468
  The client moves the row **optimistically**, so the response is deliberately quiet in the common case:
467
469
 
468
470
  | Outcome | Response |
469
471
  |---|---|
470
- | Clean Mode A drop, both neighbours resolved | `204 No Content` β€” nothing repaints |
472
+ | Clean Mode A drop, both neighbours resolved | `204 No Content`: nothing repaints |
471
473
  | `reposition!` had to rebalance the group | `200` + turbo-stream of the collection |
472
- | A neighbour did not resolve, or belongs to another positioning group, but the other one anchored the drop | `200` + stream β€” the record **did** move, so there is nothing to explain |
473
- | Both neighbours rejected | `200` + stream, **no write** β€” plus a toast when the cause was a foreign group rather than transient drift |
474
+ | A neighbour did not resolve, or belongs to another positioning group, but the other one anchored the drop | `200` + stream, the record **did** move, so there is nothing to explain |
475
+ | Both neighbours rejected | `200` + stream, **no write**: plus a toast when the cause was a foreign group rather than transient drift |
474
476
  | Mode B (opaque block write) | `200` + stream |
475
477
  | `reposition?` denied | `403` + stream + toast (the row snaps back) |
476
- | `index?` denied | `403`, **no body** β€” you may not see the list, so the refusal must not carry it to you |
478
+ | `index?` denied | `403`, **no body**: you may not see the list, so the refusal must not carry it to you |
477
479
  | Mode A drop under a foreign sort | `422` + stream + toast, **no write** |
478
480
  | Validation failure, or the record was destroyed meanwhile | `422` + stream + toast |
479
481
  | No `position_on`, Mode C, or the kanban view is selected | `404` |
@@ -489,7 +491,7 @@ end
489
491
 
490
492
  Two checks run, in this order:
491
493
 
492
- 1. **`index?` on the resource class.** You must be able to *see* a list to reorder it β€” without this, a policy with `index? == false` but `update? == true` would be handed the whole listing by the reconciliation render.
494
+ 1. **`index?` on the resource class.** You must be able to *see* a list to reorder it; without this, a policy with `index? == false` but `update? == true` would be handed the whole listing by the reconciliation render.
493
495
  2. **`reposition?` on the record.**
494
496
 
495
497
  The same `reposition?` decides whether the grip renders at all, per row.
@@ -514,12 +516,12 @@ Each product's variants are numbered independently. A rebalance touches one prod
514
516
  **Positioned globally, rendered nested:**
515
517
 
516
518
  ```ruby
517
- positioned_on :position # scope: nil β€” one ordering across the whole table
519
+ positioned_on :position # scope: nil: one ordering across the whole table
518
520
  ```
519
521
 
520
- Reordering within one parent still works correctly. Positions interleave across parents (product A holds `1.0, 3.0, 5.0` while product B holds `2.0, 4.0`), but because the list is ordered *by position*, the relative order inside A is exactly what the user sees and drags. And when a drop lands at the top or bottom of the visible page, the server looks up the real boundary neighbour in the model's group rather than taking `nil` at face value β€” which is what stops a bottom-of-page drop writing a position that duplicates a row the client could not see.
522
+ Reordering within one parent still works correctly. Positions interleave across parents (product A holds `1.0, 3.0, 5.0` while product B holds `2.0, 4.0`), but because the list is ordered *by position*, the relative order inside A is exactly what the user sees and drags. And when a drop lands at the top or bottom of the visible page, the server looks up the real boundary neighbour in the model's group rather than taking `nil` at face value, which is what stops a bottom-of-page drop writing a position that duplicates a row the client could not see.
521
523
 
522
- The one thing that leaks is a **rebalance**: gap exhaustion renumbers the whole *model-level* group, which with `scope: nil` is every row in the table, including other parents'. Positions stay in the same relative order, so nothing visibly moves β€” but far more rows are written than the user's gesture suggests. If a resource is normally viewed per-parent, scope it per-parent.
524
+ The one thing that leaks is a **rebalance**: gap exhaustion renumbers the whole *model-level* group, which with `scope: nil` is every row in the table, including other parents'. Positions stay in the same relative order, so nothing visibly moves, but far more rows are written than the user's gesture suggests. If a resource is normally viewed per-parent, scope it per-parent.
523
525
 
524
526
  ::: tip A neighbour from another group is drift, not an anchor
525
527
  When the model *is* scoped, the endpoint rejects a neighbour id that resolves to a row in a **different** scope group and reconciles instead. This is not exotic: a `scope: :status` resource lists several groups in one table, with independent and freely interleaved numberings, so anchoring off a neighbour from another group would fling the record to an arbitrary point in its own.
@@ -529,12 +531,12 @@ When the model *is* scoped, the endpoint rejects a neighbour id that resolves to
529
531
 
530
532
  ## Hidden actions
531
533
 
532
- `position_on` registers `action :reposition, hidden: true`. The flag is general β€” see [Actions β€Ί Hidden actions](/reference/resource/actions#hidden-actions).
534
+ `position_on` registers `action :reposition, hidden: true`. The flag is general; see [Actions β€Ί Hidden actions](/reference/resource/actions#hidden-actions).
533
535
 
534
536
  A hidden action has a live route, a policy predicate, and (if interactive) the full form/params machinery. It simply renders in **no** toolbar, row dropdown, card, or bulk bar. It is how the framework exposes an endpoint reachable by a gesture rather than a button.
535
537
 
536
538
  ::: danger `hidden: true` is a display gate, NOT an authorization boundary
537
- The route is live. Anyone who can construct the URL can `POST` to it. Authorization lives in the policy β€” `def reposition?` β€” and runs whether or not anything rendered.
539
+ The route is live. Anyone who can construct the URL can `POST` to it. Authorization lives in the policy (`def reposition?`) and runs whether or not anything rendered.
538
540
  :::
539
541
 
540
542
  ---
@@ -548,22 +550,22 @@ The route is live. Anyone who can construct the URL can `POST` to it. Authorizat
548
550
  | <kbd>↑</kbd> | move the row/card one slot earlier |
549
551
  | <kbd>↓</kbd> | move it one slot later |
550
552
 
551
- Focus travels with the row β€” including across a rebalance, where the whole collection is replaced and focus is restored onto the same record's new grip.
553
+ Focus travels with the row, including across a rebalance, where the whole collection is replaced and focus is restored onto the same record's new grip.
552
554
 
553
555
  Arrow navigation is deliberately **linear on a grid too**: <kbd>↑</kbd> means the previous *card* in reading order, not the card one line above. A single position attribute stores a one-dimensional order, and two-dimensional navigation could not express the in-between slots at all.
554
556
 
555
557
  ::: warning Drag does not work on touch devices
556
558
  The drag gesture uses **native HTML5 drag-and-drop**, which browsers do not fire from touch input. This limitation is inherited from the kanban board and applies identically here. Touch users cannot drag to reorder.
557
559
 
558
- There is no automatic fallback. If touch reordering matters for your resource, expose an explicit ordering path β€” a "move up"/"move down" pair of [record actions](/reference/resource/actions), or a numeric position field on the edit form.
560
+ There is no automatic fallback. If touch reordering matters for your resource, expose an explicit ordering path: a "move up"/"move down" pair of [record actions](/reference/resource/actions), or a numeric position field on the edit form.
559
561
  :::
560
562
 
561
563
  ---
562
564
 
563
565
  ## Related
564
566
 
565
- - [Kanban β€Ί Positioning](/reference/kanban/positioning) β€” the shared model API in full: arithmetic, `EPSILON`, rebalancing, `backfill_positions!`, the pure helpers
566
- - [Kanban β€Ί DSL](/reference/kanban/dsl) β€” `position_on` inside `kanban do…end`
567
- - [Actions](/reference/resource/actions) β€” `hidden:`, `condition:`, and the policy rule
568
- - [Query](/reference/resource/query) β€” `sort`, `default_sort`
569
- - [Nested resources](/reference/tenancy/nested-resources) β€” nested association tables
567
+ - [Kanban β€Ί Positioning](/reference/kanban/positioning): the shared model API in full: arithmetic, `EPSILON`, rebalancing, `backfill_positions!`, the pure helpers
568
+ - [Kanban β€Ί DSL](/reference/kanban/dsl): `position_on` inside `kanban do…end`
569
+ - [Actions](/reference/resource/actions): `hidden:`, `condition:`, and the policy rule
570
+ - [Query](/reference/resource/query): `sort`, `default_sort`
571
+ - [Nested resources](/reference/tenancy/nested-resources): nested association tables
@@ -68,10 +68,10 @@ end
68
68
 
69
69
  ### Search powers typeahead too
70
70
 
71
- The same `search` block drives **typeahead lookups** on association inputs that target this resource β€” when you write `input :author, …` for an association, the dropdown's autocomplete calls the target resource's `search` block.
71
+ The same `search` block drives **typeahead lookups** on association inputs that target this resource: when you write `input :author, …` for an association, the dropdown's autocomplete calls the target resource's `search` block.
72
72
 
73
73
  ::: tip Typeahead fallback when there's no search block
74
- A resource without a `search` block still gets typeahead β€” the framework runs a case-insensitive `LIKE` against the first column that exists, in priority order:
74
+ A resource without a `search` block still gets typeahead: the framework runs a case-insensitive `LIKE` against the first column that exists, in priority order:
75
75
 
76
76
  1. The input's `label_method:` option, if it names a real column on the model.
77
77
  2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
@@ -84,7 +84,7 @@ For large tables, write an explicit `search` block backed by a trigram or full-t
84
84
 
85
85
  Six built-in filter types. Use the shorthand symbol or the full class name.
86
86
 
87
- `search`, `scope`, `sort_fields`, and `sort_directions` are reserved filter names β€” they're built-in controls in the `q[<name>]` namespace, so `filter :scope` raises `ArgumentError`.
87
+ `search`, `scope`, `sort_fields`, and `sort_directions` are reserved filter names; they're built-in controls in the `q[<name>]` namespace, so `filter :scope` raises `ArgumentError`.
88
88
 
89
89
  | Type | Symbol | Params in URL | Options |
90
90
  |---|---|---|---|
@@ -191,7 +191,7 @@ class PostDefinition < ResourceDefinition
191
191
  scope :published # uses Post.published
192
192
  scope :draft # uses Post.draft
193
193
 
194
- # Inline scope β€” block runs with the scope as argument
194
+ # Inline scope: block runs with the scope as argument
195
195
  scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
196
196
  scope(:this_month) { |s| s.where(created_at: Time.current.all_month) }
197
197
  end
@@ -216,9 +216,9 @@ When a default is set:
216
216
  - The default scope button is highlighted (not "All").
217
217
  - Clicking "All" shows the unscoped collection.
218
218
 
219
- ### Conditional visibility β€” `condition:`
219
+ ### Conditional visibility: `condition:`
220
220
 
221
- Like `condition:` on [actions](./actions), a scope can be **defined but only render its button when a runtime proc is truthy**. The scope (and its URL) stays live either way β€” `condition:` only toggles the button.
221
+ Like `condition:` on [actions](./actions), a scope can be **defined but only render its button when a runtime proc is truthy**. The scope (and its URL) stays live either way; `condition:` only toggles the button.
222
222
 
223
223
  ```ruby
224
224
  scope :admin_only, condition: -> { current_user.admin? }
@@ -228,7 +228,7 @@ scope :beta_feature, condition: -> { params[:beta] == "1" }
228
228
  scope :internal, condition: -> { false }
229
229
  ```
230
230
 
231
- The proc is evaluated against the view context so `current_user`, `params`, `request`, and `allowed_to?` are all available directly. There is no `object`/`record` β€” scopes have no single-record context.
231
+ The proc is evaluated against the view context so `current_user`, `params`, `request`, and `allowed_to?` are all available directly. There is no `object`/`record`; scopes have no single-record context.
232
232
 
233
233
  ::: danger `condition:` is NOT authorization
234
234
  A hidden scope button still has a **live URL** anyone can navigate to. `condition:` decides whether the *button renders*, not whether the *records are accessible*.
@@ -316,12 +316,12 @@ scope :this_month
316
316
  - **Add indexes** for filtered and sorted columns.
317
317
  - **Use `.distinct`** when joining associations in search to avoid duplicate rows.
318
318
  - **Prefer scopes over filters** for queries used often (faster, no input parsing).
319
- - **`pg_search` / FTS** for complex search β€” write an explicit `search` block.
320
- - **`LIKE '%q%'` can't use a b-tree index** β€” the typeahead fallback and naive search blocks get slow on large tables. Plan a trigram or full-text index when scaling.
319
+ - **`pg_search` / FTS** for complex search: write an explicit `search` block.
320
+ - **`LIKE '%q%'` can't use a b-tree index**: the typeahead fallback and naive search blocks get slow on large tables. Plan a trigram or full-text index when scaling.
321
321
 
322
322
  ## Related
323
323
 
324
- - [Definition](./definition) β€” field/input/display configuration
325
- - [Actions](./actions) β€” custom and bulk actions
326
- - [Behavior β€Ί Policy](/reference/behavior/policies) β€” `relation_scope` (filters records to what the user can see)
327
- - [Tenancy β€Ί Entity scoping](/reference/tenancy/entity-scoping) β€” multi-tenant filtering
324
+ - [Definition](./definition): field/input/display configuration
325
+ - [Actions](./actions): custom and bulk actions
326
+ - [Behavior β€Ί Policy](/reference/behavior/policies): `relation_scope` (filters records to what the user can see)
327
+ - [Tenancy β€Ί Entity scoping](/reference/tenancy/entity-scoping): multi-tenant filtering