plutonium 0.49.1 → 0.50.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-definition/SKILL.md +87 -2
  3. data/.claude/skills/plutonium-installation/SKILL.md +6 -0
  4. data/.claude/skills/plutonium-views/SKILL.md +59 -0
  5. data/CHANGELOG.md +12 -0
  6. data/app/assets/plutonium.css +2 -2
  7. data/app/assets/plutonium.js +369 -25
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +45 -45
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +4 -4
  12. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  13. data/app/views/resource/_resource_grid.html.erb +1 -0
  14. data/config/brakeman.ignore +25 -2
  15. data/docs/reference/definition/actions.md +14 -1
  16. data/docs/reference/definition/index.md +58 -0
  17. data/docs/reference/views/index.md +43 -0
  18. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  19. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  20. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  23. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  24. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  25. data/lib/plutonium/action/base.rb +44 -1
  26. data/lib/plutonium/action/interactive.rb +1 -1
  27. data/lib/plutonium/configuration.rb +4 -0
  28. data/lib/plutonium/definition/actions.rb +3 -0
  29. data/lib/plutonium/definition/base.rb +8 -0
  30. data/lib/plutonium/definition/metadata.rb +40 -0
  31. data/lib/plutonium/definition/views.rb +94 -0
  32. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  33. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  34. data/lib/plutonium/query/base.rb +8 -0
  35. data/lib/plutonium/query/filters/association.rb +30 -8
  36. data/lib/plutonium/query/filters/boolean.rb +5 -0
  37. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  38. data/lib/plutonium/resource/definition.rb +42 -0
  39. data/lib/plutonium/resource/query_object.rb +64 -6
  40. data/lib/plutonium/testing/resource_definition.rb +2 -2
  41. data/lib/plutonium/ui/action_button.rb +4 -2
  42. data/lib/plutonium/ui/component/kit.rb +12 -0
  43. data/lib/plutonium/ui/display/base.rb +3 -1
  44. data/lib/plutonium/ui/display/resource.rb +109 -25
  45. data/lib/plutonium/ui/display/theme.rb +2 -1
  46. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  47. data/lib/plutonium/ui/empty_card.rb +1 -1
  48. data/lib/plutonium/ui/form/base.rb +29 -1
  49. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  50. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  51. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  52. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  53. data/lib/plutonium/ui/form/resource.rb +48 -9
  54. data/lib/plutonium/ui/form/theme.rb +1 -1
  55. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  56. data/lib/plutonium/ui/grid/card.rb +235 -0
  57. data/lib/plutonium/ui/grid/resource.rb +149 -0
  58. data/lib/plutonium/ui/layout/base.rb +37 -1
  59. data/lib/plutonium/ui/layout/header.rb +1 -2
  60. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  61. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  62. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  63. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  64. data/lib/plutonium/ui/modal/base.rb +109 -0
  65. data/lib/plutonium/ui/modal/centered.rb +21 -0
  66. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  67. data/lib/plutonium/ui/page/base.rb +25 -6
  68. data/lib/plutonium/ui/page/edit.rb +13 -1
  69. data/lib/plutonium/ui/page/index.rb +40 -1
  70. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  71. data/lib/plutonium/ui/page/new.rb +13 -1
  72. data/lib/plutonium/ui/page/show.rb +8 -1
  73. data/lib/plutonium/ui/page_header.rb +8 -13
  74. data/lib/plutonium/ui/panel.rb +10 -19
  75. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  76. data/lib/plutonium/ui/tab_list.rb +29 -7
  77. data/lib/plutonium/ui/table/base.rb +106 -0
  78. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  79. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  80. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  81. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  82. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  83. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  84. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  85. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  86. data/lib/plutonium/ui/table/resource.rb +158 -89
  87. data/lib/plutonium/ui/table/theme.rb +14 -5
  88. data/lib/plutonium/version.rb +1 -1
  89. data/lib/plutonium.rb +6 -0
  90. data/package.json +1 -1
  91. data/src/css/components.css +304 -131
  92. data/src/css/tokens.css +101 -85
  93. data/src/js/controllers/autosubmit_controller.js +24 -0
  94. data/src/js/controllers/bulk_actions_controller.js +15 -16
  95. data/src/js/controllers/capture_url_controller.js +14 -0
  96. data/src/js/controllers/filter_panel_controller.js +77 -19
  97. data/src/js/controllers/frame_navigator_controller.js +34 -6
  98. data/src/js/controllers/icon_rail_controller.js +22 -0
  99. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  100. data/src/js/controllers/register_controllers.js +16 -0
  101. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  102. data/src/js/controllers/row_click_controller.js +21 -0
  103. data/src/js/controllers/table_column_menu_controller.js +43 -0
  104. data/src/js/controllers/table_header_controller.js +16 -0
  105. data/src/js/controllers/view_switcher_controller.js +29 -0
  106. metadata +31 -3
@@ -0,0 +1,841 @@
1
+ # UI Layout Overhaul Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Implement the UI layout overhaul per `docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md` — icon rail + topbar shell, Stripe-style PageHeader, redesigned index/show/form pages, balanced density tokens, and centered + slideover modals.
6
+
7
+ **Architecture:** Five phases delivered top-down: density tokens & PageHeader first (foundational), then the new shell, then per-page redesigns, then modals. Each task ends in a committable, working state. Phlex components are the primary unit of work; CSS changes flow through `src/css/components.css` + `tokens.css`. Existing `.pu-*` class names are preserved; values shift.
8
+
9
+ **Tech Stack:** Ruby 3+, Rails 7/8, Phlex, Tailwind v4, Stimulus, Turbo. Test framework: Minitest with Capybara for system tests.
10
+
11
+ **User Verification:** NO — the design was validated via brainstorming. Implementation executes against the spec; no in-loop user verification needed during build.
12
+
13
+ ---
14
+
15
+ ## Reference Files (read before starting)
16
+
17
+ - Spec: `docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md`
18
+ - Existing layout: `lib/plutonium/ui/layout/{base,sidebar,header,resource_layout}.rb`
19
+ - Existing pages: `lib/plutonium/ui/page/{base,index,show,new,edit,interactive_action}.rb`
20
+ - Existing PageHeader: `lib/plutonium/ui/page_header.rb`
21
+ - Existing breadcrumbs: `lib/plutonium/ui/breadcrumbs.rb`
22
+ - Existing CSS: `src/css/{components,tokens}.css`
23
+ - Sort logic: `lib/plutonium/resource/query_object.rb:126` (`sort_params_for`)
24
+ - Resource definition: `lib/plutonium/resource/definition.rb` (for `modal` DSL addition)
25
+ - Interaction: `lib/plutonium/resource/interaction.rb` and `lib/plutonium/interaction/base.rb` (for `modal:` option)
26
+
27
+ ---
28
+
29
+ ## Pre-flight: Asset Build
30
+
31
+ Every task that edits `src/css/*.css` or `src/js/**/*.js` requires `yarn dev` to be running OR `yarn build` to be run after the change. The dummy app at `test/dummy/` reads from `src/build/` in dev. Mention this once per task that requires it.
32
+
33
+ ---
34
+
35
+ # Phase 1 — Foundation
36
+
37
+ ### Task 0: Density tokens
38
+
39
+ **Goal:** Codify the balanced density scale (§6 of spec) so subsequent tasks reference one source of truth.
40
+
41
+ **Files:**
42
+ - Modify: `src/css/tokens.css`
43
+ - Modify: `src/css/components.css` (button, input, card sizes)
44
+ - Modify: `lib/plutonium/ui/component/tokens.rb` (Phlex-side mirror if used)
45
+
46
+ **Acceptance Criteria:**
47
+ - [ ] `--pu-row-height: 32px`, `--pu-section-gap: 16px`, `--pu-field-gap: 12px`, `--pu-page-padding: 24px` defined in `:root` and `.dark`
48
+ - [ ] `.pu-input` height = 36px in forms, 32px in toolbars (introduce `.pu-input-toolbar` modifier)
49
+ - [ ] `.pu-btn-md` = 32px height, 14px text; `.pu-btn-sm` = 28px, 13px
50
+ - [ ] `.pu-card` padding = 16px
51
+ - [ ] No regressions: `bundle exec appraisal rails-8.1 rake test` passes
52
+ - [ ] `yarn build` succeeds
53
+
54
+ **Verify:** `cd test/dummy && yarn build && bundle exec appraisal rails-8.1 ruby -Itest test/system -e ""`
55
+
56
+ **Steps:**
57
+
58
+ - [ ] **Step 1:** Add density variables to `src/css/tokens.css` `:root`:
59
+
60
+ ```css
61
+ :root {
62
+ /* ... existing tokens ... */
63
+ --pu-row-height: 32px;
64
+ --pu-section-gap: 16px;
65
+ --pu-field-gap: 12px;
66
+ --pu-page-padding: 24px;
67
+ }
68
+ ```
69
+
70
+ - [ ] **Step 2:** Update `.pu-btn-md` and `.pu-btn-sm` in `src/css/components.css`:
71
+
72
+ ```css
73
+ .pu-btn-md { @apply px-3 h-8 text-sm; }
74
+ .pu-btn-sm { @apply px-2.5 h-7 text-[13px]; }
75
+ .pu-btn-xs { @apply px-2 h-6 text-xs; }
76
+ ```
77
+
78
+ - [ ] **Step 3:** Update `.pu-input` and add `.pu-input-toolbar`:
79
+
80
+ ```css
81
+ .pu-input {
82
+ /* existing background/border/color */
83
+ @apply w-full px-3 h-9 text-sm focus:outline-none;
84
+ }
85
+ .pu-input-toolbar { @apply h-8 text-sm; }
86
+ ```
87
+
88
+ - [ ] **Step 4:** Update `.pu-card-body` padding to 16px (down from `var(--pu-space-lg)` if larger).
89
+ - [ ] **Step 5:** Run `yarn build` and `bundle exec appraisal rails-8.1 rake test`. Expect pass.
90
+ - [ ] **Step 6:** Commit:
91
+
92
+ ```bash
93
+ git add src/css/tokens.css src/css/components.css lib/plutonium/ui/component/tokens.rb
94
+ git commit -m "feat(ui): codify balanced density tokens"
95
+ ```
96
+
97
+ ---
98
+
99
+ ### Task 1: PageHeader redesign (Stripe-style)
100
+
101
+ **Goal:** Replace the current `PageHeader` with a tighter, Stripe-style header — title + description + right-aligned actions on one row, tabs strip rendered underneath.
102
+
103
+ **Files:**
104
+ - Modify: `lib/plutonium/ui/page_header.rb`
105
+ - Modify: `lib/plutonium/ui/page/base.rb` (call site stays; rendering logic shifts)
106
+ - Test: `test/plutonium/ui/page_header_test.rb` (create if missing)
107
+
108
+ **Acceptance Criteria:**
109
+ - [ ] Title is 18-20px (`text-xl font-semibold`), description is 13px (`text-sm`) muted
110
+ - [ ] Actions render right-aligned at title vertical level
111
+ - [ ] Tabs (when present) render directly below the header as a connected strip
112
+ - [ ] No 8px margin-bottom gap between header and tabs
113
+ - [ ] `actions: nil` and `description: nil` render gracefully
114
+
115
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page_header_test.rb -v` → all pass
116
+
117
+ **Steps:**
118
+
119
+ - [ ] **Step 1:** Update `lib/plutonium/ui/page_header.rb` `view_template` markup to:
120
+
121
+ ```ruby
122
+ def view_template
123
+ div(class: "flex items-start justify-between gap-4 mb-4") do
124
+ div(class: "min-w-0 flex-1") do
125
+ render_title @title if @title
126
+ render_description @description if @description.present?
127
+ end
128
+ render_actions if @actions.any?
129
+ end
130
+ end
131
+
132
+ def render_title(title)
133
+ h1(class: "text-xl font-semibold leading-tight text-[var(--pu-text)] truncate") { title }
134
+ end
135
+
136
+ def render_description(description)
137
+ p(class: "mt-1 text-sm text-[var(--pu-text-muted)]") { description }
138
+ end
139
+ ```
140
+
141
+ - [ ] **Step 2:** Update `lib/plutonium/ui/page/base.rb` `render_header` so the tab strip renders directly after `PageHeader` with no gap (drop the `mb-8` from header; let tabs sit flush against a `border-b` baseline).
142
+ - [ ] **Step 3:** Add/update test cases:
143
+ - title-only renders correctly
144
+ - title + description renders correctly
145
+ - title + description + 2 actions: actions are right-aligned
146
+ - dropdown actions render when secondary actions exist
147
+ - [ ] **Step 4:** Run `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page_header_test.rb -v`. Expect pass.
148
+ - [ ] **Step 5:** Manual: boot dummy app (`cd test/dummy && bundle exec rails s`), navigate to `/admin/users` (or any index), confirm header layout. Then `/admin/users/1` confirm tabs render flush below.
149
+ - [ ] **Step 6:** Commit: `feat(ui): redesign PageHeader Stripe-style`
150
+
151
+ ---
152
+
153
+ # Phase 2 — App Shell
154
+
155
+ ### Task 2: IconRail component
156
+
157
+ **Goal:** New `Plutonium::UI::Layout::IconRail` Phlex component — 56px icon-only nav with tooltips on hover, replacing the expanded `SidebarMenu` for the new shell.
158
+
159
+ **Files:**
160
+ - Create: `lib/plutonium/ui/layout/icon_rail.rb`
161
+ - Create: `test/plutonium/ui/layout/icon_rail_test.rb`
162
+ - Modify: `src/css/components.css` (add `.pu-icon-rail`, `.pu-icon-rail-item` if needed)
163
+
164
+ **Acceptance Criteria:**
165
+ - [ ] Renders fixed-position aside, `width: 56px`, full-height, `var(--pu-surface)` background, right border
166
+ - [ ] Item slots: brand (top), nav items (middle, grouped with dividers), settings/theme (bottom)
167
+ - [ ] Each nav item is icon-only with a Tailwind tooltip (group-hover) showing the label
168
+ - [ ] Active item: filled primary tone background, primary text
169
+ - [ ] Mobile (`<lg`): rail hidden, drawer toggled by topbar hamburger (controller wiring deferred to Task 4)
170
+ - [ ] Compatible with `phlexi-menu` items (accepts an Items tree like `SidebarMenu`)
171
+
172
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/icon_rail_test.rb -v` → all pass
173
+
174
+ **Steps:**
175
+
176
+ - [ ] **Step 1:** Sketch the component structure:
177
+
178
+ ```ruby
179
+ module Plutonium
180
+ module UI
181
+ module Layout
182
+ class IconRail < Plutonium::UI::Component::Base
183
+ include Phlexi::Menu::Component
184
+
185
+ def view_template
186
+ aside(
187
+ id: "sidebar-navigation",
188
+ data: {controller: "sidebar"},
189
+ class: "fixed top-0 left-0 z-40 h-screen w-14 bg-[var(--pu-surface)] " \
190
+ "border-r border-[var(--pu-border)] transition-transform " \
191
+ "-translate-x-full lg:translate-x-0 flex flex-col"
192
+ ) do
193
+ div(class: "py-3 flex flex-col items-center gap-1", data: {sidebar_target: "scroll"}) do
194
+ render_brand
195
+ render_items(@menu.items) if @menu&.items
196
+ end
197
+ div(class: "mt-auto py-3 flex flex-col items-center gap-1") do
198
+ render_footer_items
199
+ end
200
+ end
201
+ end
202
+ # ...
203
+ end
204
+ end
205
+ end
206
+ end
207
+ ```
208
+
209
+ - [ ] **Step 2:** Implement `render_item_link` to render a single icon (Tabler icon) with a tooltip via:
210
+
211
+ ```ruby
212
+ def render_item_link(item, depth)
213
+ a(href: item.url, class: rail_link_classes(item),
214
+ title: item.label,
215
+ aria: {label: item.label}) do
216
+ render item.icon if item.icon
217
+ end
218
+ end
219
+
220
+ def rail_link_classes(item)
221
+ base = "flex items-center justify-center w-10 h-10 rounded-md " \
222
+ "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] " \
223
+ "hover:bg-[var(--pu-surface-alt)] transition-colors"
224
+ active?(item) ? "#{base} bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300" : base
225
+ end
226
+ ```
227
+
228
+ - [ ] **Step 3:** Add `.pu-icon-rail` and `.pu-icon-rail-item` helper classes to `src/css/components.css` if extracting markup is clearer. Otherwise, leave Tailwind-utility-only.
229
+ - [ ] **Step 4:** Write tests covering:
230
+ - aside renders at expected width
231
+ - item with `active: true` gets active classes
232
+ - item without icon still renders (label fallback as initials)
233
+ - [ ] **Step 5:** `yarn build && bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/icon_rail_test.rb -v`. Expect pass.
234
+ - [ ] **Step 6:** Commit: `feat(ui): add IconRail layout component`
235
+
236
+ ---
237
+
238
+ ### Task 3: Topbar component
239
+
240
+ **Goal:** New `Plutonium::UI::Layout::Topbar` — sticky 48px topbar with breadcrumbs (left), global search (center), user/notif (right). Replaces the legacy `Layout::Header`.
241
+
242
+ **Files:**
243
+ - Create: `lib/plutonium/ui/layout/topbar.rb`
244
+ - Modify: `lib/plutonium/ui/breadcrumbs.rb` (slim down for topbar context — no large title)
245
+ - Modify: `app/assets` if a new Stimulus controller is needed for the search omnibox (defer if not)
246
+ - Test: `test/plutonium/ui/layout/topbar_test.rb`
247
+
248
+ **Acceptance Criteria:**
249
+ - [ ] `nav` element, fixed top, height 48px, full width minus left rail (`left-14` on lg)
250
+ - [ ] Slots: `breadcrumbs` (left), `search` (center, max ~360px), `actions` (right — color mode toggle, user menu)
251
+ - [ ] Mobile: hamburger button replaces breadcrumbs/center area (toggles `#sidebar-navigation`)
252
+ - [ ] Renders nothing or a sensible fallback if breadcrumbs slot empty
253
+
254
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb -v`
255
+
256
+ **Steps:**
257
+
258
+ - [ ] **Step 1:** Implement Topbar:
259
+
260
+ ```ruby
261
+ class Topbar < Plutonium::UI::Component::Base
262
+ include Phlex::Slotable
263
+ slot :breadcrumbs
264
+ slot :search
265
+ slot :action, collection: true
266
+
267
+ def view_template
268
+ nav(
269
+ class: "fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
270
+ "bg-[var(--pu-surface)] border-b border-[var(--pu-border)] " \
271
+ "flex items-center gap-3 px-4",
272
+ data: {controller: "resource-header",
273
+ resource_header_sidebar_outlet: "#sidebar-navigation"}
274
+ ) do
275
+ render_hamburger
276
+ render_breadcrumbs_section
277
+ render_search_section
278
+ render_actions_section
279
+ end
280
+ end
281
+ # ...
282
+ end
283
+ ```
284
+
285
+ - [ ] **Step 2:** Slim `Breadcrumbs` for topbar use — drop large-title duplication; render as compact ` › `-separated path with last segment as the current page label (no link).
286
+ - [ ] **Step 3:** Tests:
287
+ - renders nav with expected classes
288
+ - breadcrumb slot renders into left position
289
+ - search slot renders centered with max-width
290
+ - hamburger button toggles correctly (assert `data-action` present)
291
+ - [ ] **Step 4:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb -v`. Expect pass.
292
+ - [ ] **Step 5:** Commit: `feat(ui): add Topbar layout component`
293
+
294
+ ---
295
+
296
+ ### Task 4: Wire ResourceLayout to new shell
297
+
298
+ **Goal:** Replace `ResourceLayout`'s Rails partials (`resource_header`, `resource_sidebar`) with the Phlex `IconRail` + `Topbar`. Drop the old `Layout::Header` and `Layout::Sidebar` once nothing references them.
299
+
300
+ **Files:**
301
+ - Modify: `lib/plutonium/ui/layout/resource_layout.rb`
302
+ - Delete (or deprecate): `lib/plutonium/ui/layout/header.rb`, `lib/plutonium/ui/layout/sidebar.rb`
303
+ - Modify: `lib/plutonium/ui/layout/base.rb` (`main_attributes` padding adjusted for 56px rail + 48px topbar)
304
+ - Delete (or replace): partial templates `_resource_header.*`, `_resource_sidebar.*` if Phlex-only path
305
+ - Update: `test/dummy/` portal config if it references old layouts
306
+
307
+ **Acceptance Criteria:**
308
+ - [ ] `ResourceLayout#render_before_main` renders `IconRail` + `Topbar` Phlex components instead of partials
309
+ - [ ] `main_attributes` padding: `pt-12 lg:pl-14 px-6`
310
+ - [ ] Booting dummy app and visiting any portal URL shows: 56px rail, 48px topbar, content offset correctly
311
+ - [ ] No references to `Layout::Header` or `Layout::Sidebar` remain (or they're deprecated with a notice)
312
+ - [ ] System test: at least one existing portal-page system test passes
313
+
314
+ **Verify:** `bundle exec appraisal rails-8.1 rake test` and manual smoke test in dummy
315
+
316
+ **Steps:**
317
+
318
+ - [ ] **Step 1:** Replace `render partial("resource_header")` and `render partial("resource_sidebar")` calls in `resource_layout.rb` with `render IconRail.new(menu: ...)` and `render Topbar.new { |t| ... }`.
319
+ - [ ] **Step 2:** Update `main_attributes`:
320
+
321
+ ```ruby
322
+ def main_attributes = mix(super, {class: "pt-12 lg:pl-14 px-6 min-h-screen"})
323
+ ```
324
+
325
+ - [ ] **Step 3:** Move portal logo/brand/menu data sources from old partials into the new components. Likely a portal-level helper or initializer change.
326
+ - [ ] **Step 4:** Delete `lib/plutonium/ui/layout/header.rb` and `lib/plutonium/ui/layout/sidebar.rb` if no callers remain. Otherwise add a deprecation warning and a TODO.
327
+ - [ ] **Step 5:** Delete `_resource_header.*` and `_resource_sidebar.*` partials if no longer rendered.
328
+ - [ ] **Step 6:** Run full test suite: `bundle exec appraisal rails-8.1 rake test`. Fix breakages.
329
+ - [ ] **Step 7:** Manual smoke: `cd test/dummy && bundle exec rails s`, navigate every portal type. Confirm layout renders.
330
+ - [ ] **Step 8:** Commit: `refactor(ui): wire ResourceLayout to icon-rail + topbar shell`
331
+
332
+ ---
333
+
334
+ # Phase 3 — Index Page
335
+
336
+ ### Task 5: Index toolbar
337
+
338
+ **Goal:** New `Plutonium::UI::Table::Toolbar` Phlex component — view switcher (Grid only initially, Cards/Kanban as placeholders), Filter button (popover), Group button (popover), visible search input, column-config + overflow icon buttons.
339
+
340
+ **Files:**
341
+ - Create: `lib/plutonium/ui/table/components/toolbar.rb`
342
+ - Create: `lib/plutonium/ui/table/components/view_switcher.rb`
343
+ - Modify: `lib/plutonium/ui/table/resource.rb` (render Toolbar above the table)
344
+ - Test: `test/plutonium/ui/table/components/toolbar_test.rb`
345
+
346
+ **Acceptance Criteria:**
347
+ - [ ] Toolbar order (left → right): view switcher, divider, Filter button, Group button, spacer (`flex-grow`), search input, divider, column-config icon, overflow icon
348
+ - [ ] Search input shows current `params[:search]` value, submits on enter
349
+ - [ ] Filter button click opens existing filter panel as a popover (anchored under button), not a slideout
350
+ - [ ] Group button is a placeholder dropdown stub if grouping isn't implemented yet (greyed-out menu items "Group by …")
351
+ - [ ] View switcher: Grid is active by default; Cards/Kanban are visible but disabled (with `title="Coming soon"`)
352
+
353
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/toolbar_test.rb -v`
354
+
355
+ **Steps:**
356
+
357
+ - [ ] **Step 1:** Implement `ViewSwitcher` as a 3-segment control:
358
+
359
+ ```ruby
360
+ class ViewSwitcher < Plutonium::UI::Component::Base
361
+ def initialize(active: :grid)
362
+ @active = active
363
+ end
364
+
365
+ def view_template
366
+ div(class: "inline-flex h-8 rounded-md border border-[var(--pu-border)] bg-[var(--pu-surface)] overflow-hidden text-sm") do
367
+ segment(:grid, "Grid")
368
+ segment(:cards, "Cards", disabled: true)
369
+ segment(:kanban, "Kanban", disabled: true)
370
+ end
371
+ end
372
+ # ...
373
+ end
374
+ ```
375
+
376
+ - [ ] **Step 2:** Implement `Toolbar`:
377
+
378
+ ```ruby
379
+ class Toolbar < Plutonium::UI::Component::Base
380
+ def initialize(query:, filters_present:)
381
+ @query = query
382
+ @filters_present = filters_present
383
+ end
384
+
385
+ def view_template
386
+ div(class: "flex items-center gap-2 px-4 py-2 border-b border-[var(--pu-border)] bg-[var(--pu-surface-alt)]") do
387
+ render ViewSwitcher.new
388
+ divider
389
+ filter_button
390
+ group_button
391
+ div(class: "flex-1")
392
+ search_input
393
+ divider
394
+ column_config_button
395
+ overflow_button
396
+ end
397
+ end
398
+ # ...
399
+ end
400
+ ```
401
+
402
+ - [ ] **Step 3:** In `lib/plutonium/ui/table/resource.rb`, render the Toolbar above the existing table markup. Confirm filter-panel-controller still hooks correctly when clicked.
403
+ - [ ] **Step 4:** Tests:
404
+ - toolbar renders all elements in expected order
405
+ - search input echoes current search param
406
+ - disabled segments have `disabled` attribute and tooltip
407
+ - [ ] **Step 5:** Manual: dummy app index page shows new toolbar; clicking Filter opens existing filter UI as a popover.
408
+ - [ ] **Step 6:** Commit: `feat(ui): add index toolbar with view switcher`
409
+
410
+ ---
411
+
412
+ ### Task 6: Active filter pills + result count
413
+
414
+ **Goal:** Below the toolbar, render a strip showing active filters as removable pills, a `+ Filter` dashed pill, and a right-aligned result count.
415
+
416
+ **Files:**
417
+ - Create: `lib/plutonium/ui/table/components/filter_pills.rb`
418
+ - Modify: `lib/plutonium/ui/table/resource.rb` (render pills strip after toolbar)
419
+ - Modify: `lib/plutonium/resource/query_object.rb` if a helper for "active filters list" is needed
420
+ - Test: `test/plutonium/ui/table/components/filter_pills_test.rb`
421
+
422
+ **Acceptance Criteria:**
423
+ - [ ] Each active filter renders as `<field> <op> <value>` pill with `✕` (links to URL with that filter cleared)
424
+ - [ ] `+ Filter` dashed pill opens the same popover as the toolbar Filter button
425
+ - [ ] Right-aligned: total record count from pagination ("147 results")
426
+ - [ ] Strip is hidden entirely when no filters active and result count is 0 (or render only count)
427
+
428
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/filter_pills_test.rb -v`
429
+
430
+ **Steps:**
431
+
432
+ - [ ] **Step 1:** Add `query_object#active_filter_descriptions` returning `[{name:, label:, op:, value:, clear_url:}]`.
433
+ - [ ] **Step 2:** Implement `FilterPills`:
434
+
435
+ ```ruby
436
+ class FilterPills < Plutonium::UI::Component::Base
437
+ def initialize(query:, total_count:)
438
+ @query = query
439
+ @total_count = total_count
440
+ end
441
+
442
+ def view_template
443
+ return if @query.active_filter_descriptions.empty? && @total_count.zero?
444
+ div(class: "flex items-center gap-1.5 px-4 py-2 border-b border-[var(--pu-border)] flex-wrap") do
445
+ @query.active_filter_descriptions.each { |f| render_pill(f) }
446
+ render_add_filter_pill
447
+ div(class: "ml-auto text-xs text-[var(--pu-text-muted)]") { "#{@total_count} results" }
448
+ end
449
+ end
450
+ # ...
451
+ end
452
+ ```
453
+
454
+ - [ ] **Step 3:** Pill style: `h-6 px-2 rounded-full bg-primary-50 border border-primary-200 text-xs text-primary-700 inline-flex items-center gap-1.5`. Add-filter pill: `border-dashed`.
455
+ - [ ] **Step 4:** Wire into `lib/plutonium/ui/table/resource.rb` between Toolbar and table.
456
+ - [ ] **Step 5:** Tests:
457
+ - empty filters + zero count → renders nothing
458
+ - 2 active filters → 2 pills + add-pill + count
459
+ - clearing a pill: `clear_url` builds correctly via `query.build_url(filter: ...)` reset
460
+ - [ ] **Step 6:** Commit: `feat(ui): add active filter pills strip with result count`
461
+
462
+ ---
463
+
464
+ ### Task 7: Column-header sort with priority badges + ⋯ menu
465
+
466
+ **Goal:** Sort moves into table column headers. Click cycles asc → desc → none. Shift-click adds secondary sort. Active sort columns show ↑/↓ + priority badge (1, 2, …). Per-column `⋯` opens a menu with sort/group/filter/hide options.
467
+
468
+ **Files:**
469
+ - Modify: `lib/plutonium/ui/table/resource.rb` (header cell rendering)
470
+ - Modify: `lib/plutonium/ui/table/theme.rb` (header cell classes)
471
+ - Modify: `lib/plutonium/resource/query_object.rb` — `sort_params_for` returns `{url:, reset_url:, position:, direction:, multi_url:}` where `multi_url` is the shift-click target (preserves other sorts).
472
+ - Modify: existing sort-toggle Stimulus controller if any, or wire shift-click via JS
473
+ - Test: `test/plutonium/resource/query_object_test.rb`, `test/plutonium/ui/table/resource_test.rb`
474
+
475
+ **Acceptance Criteria:**
476
+ - [ ] Plain click navigates to `sort_params[:url]` (resets other sorts, sets this column)
477
+ - [ ] Shift-click navigates to `sort_params[:multi_url]` (preserves other sorts, toggles this column's direction)
478
+ - [ ] Active sort column shows ↑/↓ icon and priority badge
479
+ - [ ] Priority badge hidden when only one column is sorted
480
+ - [ ] `⋯` button per column reveals menu with: Sort asc, Sort desc, Clear sort, Group by …, Filter by …, Hide column
481
+ - [ ] Hide column persists per-user (use existing column-config mechanism if present, or `localStorage` via Stimulus)
482
+
483
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium -v`
484
+
485
+ **Steps:**
486
+
487
+ - [ ] **Step 1:** Update `lib/plutonium/resource/query_object.rb` `sort_params_for` to also build `multi_url` (preserves all other current sorts, toggles this one):
488
+
489
+ ```ruby
490
+ def sort_params_for(name)
491
+ return unless sort_definitions[name]
492
+ {
493
+ url: build_url(sort: name, replace: true),
494
+ multi_url: build_url(sort: name),
495
+ reset_url: build_url(sort: name, reset: true),
496
+ position: selected_sort_fields.index(name.to_s),
497
+ direction: selected_sort_directions[name]
498
+ }
499
+ end
500
+ ```
501
+
502
+ (Implementation of `replace: true` requires updating `build_url`/sort param parsing — sort is appended to existing list normally; `replace: true` discards prior sorts.)
503
+
504
+ - [ ] **Step 2:** Update header cell rendering in `lib/plutonium/ui/table/resource.rb` to:
505
+
506
+ ```ruby
507
+ def render_sort_header(column, sort_params)
508
+ th(class: "...") do
509
+ a(href: sort_params[:url],
510
+ data: {action: "click->table#headerClick"},
511
+ data_multi_href: sort_params[:multi_url],
512
+ class: "flex items-center gap-1 cursor-pointer") do
513
+ span { column.label }
514
+ render_sort_indicator(sort_params)
515
+ render_column_menu_trigger(column)
516
+ end
517
+ end
518
+ end
519
+ ```
520
+
521
+ - [ ] **Step 3:** Add Stimulus controller `table_controller.js` (or extend existing) to handle shift-click → use `data-multi-href`:
522
+
523
+ ```javascript
524
+ headerClick(event) {
525
+ if (event.shiftKey) {
526
+ event.preventDefault();
527
+ const url = event.currentTarget.dataset.multiHref;
528
+ if (url) Turbo.visit(url);
529
+ }
530
+ }
531
+ ```
532
+
533
+ - [ ] **Step 4:** Implement priority badge — show only when `selected_sort_fields.size > 1`.
534
+ - [ ] **Step 5:** Implement column `⋯` menu (Phlex component or existing dropdown). Items navigate to URL fragments: sort asc/desc/clear use `sort_params`, group/filter open the existing popovers pre-filled with the column.
535
+ - [ ] **Step 6:** Tests:
536
+ - `sort_params_for(:name)` returns `multi_url` and `url` distinct
537
+ - clicking sets sort to single
538
+ - shift-click adds to multi-sort
539
+ - [ ] **Step 7:** Manual: index page, click Name → sorts; shift-click Created → priority badges 1 and 2 appear.
540
+ - [ ] **Step 8:** Commit: `feat(ui): column-header sort with shift-click multi-sort`
541
+
542
+ ---
543
+
544
+ ### Task 8: Floating bulk action bar
545
+
546
+ **Goal:** When ≥1 row is selected, hide the filter pills strip and show a tinted bulk-action bar in its place — selection count, action buttons (Export, Archive, Delete with danger tone), Clear selection.
547
+
548
+ **Files:**
549
+ - Create: `lib/plutonium/ui/table/components/bulk_action_bar.rb`
550
+ - Modify: `lib/plutonium/ui/table/resource.rb`
551
+ - Modify: `src/js/controllers/bulk_actions_controller.js` (toggle visibility of pills vs bulk bar based on selection count)
552
+ - Test: `test/plutonium/ui/table/components/bulk_action_bar_test.rb`
553
+
554
+ **Acceptance Criteria:**
555
+ - [ ] Bulk bar is hidden when 0 selected, visible when ≥1
556
+ - [ ] Pills strip is hidden when bulk bar is visible (mutually exclusive)
557
+ - [ ] Action buttons come from the resource's `bulk_action`s, sorted by `position`
558
+ - [ ] Delete action uses danger tone (red border, red text)
559
+ - [ ] "Clear selection" button deselects all and re-shows pills strip
560
+ - [ ] Background: `bg-primary-50 dark:bg-primary-950/30`
561
+
562
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/bulk_action_bar_test.rb -v`
563
+
564
+ **Steps:**
565
+
566
+ - [ ] **Step 1:** Implement `BulkActionBar`:
567
+
568
+ ```ruby
569
+ class BulkActionBar < Plutonium::UI::Component::Base
570
+ def initialize(actions:)
571
+ @actions = actions
572
+ end
573
+
574
+ def view_template
575
+ div(class: "hidden bg-primary-50 dark:bg-primary-950/30 border-b border-[var(--pu-border)] px-4 py-2 flex items-center gap-3",
576
+ data: {bulk_actions_target: "bar"}) do
577
+ span(class: "text-sm font-medium text-primary-700 dark:text-primary-300") {
578
+ plain_text "0 selected"
579
+ }
580
+ div(class: "flex items-center gap-1.5") { @actions.each { |a| render_action_button(a) } }
581
+ div(class: "flex-1")
582
+ button(class: "text-xs text-primary-700 hover:underline",
583
+ data: {action: "bulk-actions#clear"}) { "Clear selection" }
584
+ end
585
+ end
586
+ # ...
587
+ end
588
+ ```
589
+
590
+ - [ ] **Step 2:** Update `bulk_actions_controller.js` to:
591
+ - track selection count
592
+ - toggle `hidden` class on the bar element AND the pills strip
593
+ - update count text in the bar
594
+
595
+ - [ ] **Step 3:** Wire bar into `lib/plutonium/ui/table/resource.rb` directly above the table, alongside the pills strip (only one visible at a time).
596
+ - [ ] **Step 4:** Tests for component rendering and Stimulus selection count update.
597
+ - [ ] **Step 5:** Manual: select row → pills hide, bar shows with count "1 selected"; select more → count updates; click Clear → bar hides.
598
+ - [ ] **Step 6:** Commit: `feat(ui): add floating bulk action bar`
599
+
600
+ ---
601
+
602
+ # Phase 4 — Show + Form Pages
603
+
604
+ ### Task 9: Show page redesign
605
+
606
+ **Goal:** Show page becomes a single-column layout under PageHeader, with field panels as cards and a reserved (empty) `render_aside` slot for the future metadata DSL.
607
+
608
+ **Files:**
609
+ - Modify: `lib/plutonium/ui/page/show.rb`
610
+ - Modify: `lib/plutonium/ui/page/base.rb` (add empty `render_aside` hook)
611
+ - Modify: `lib/plutonium/ui/display/resource.rb`
612
+ - Modify: `lib/plutonium/ui/panel.rb` (card styling)
613
+ - Test: `test/plutonium/ui/page/show_test.rb`
614
+
615
+ **Acceptance Criteria:**
616
+ - [ ] Show page renders single column, max-width ~960px, centered when viewport allows
617
+ - [ ] Field panels use `pu-card` chrome with uppercase 9px section labels
618
+ - [ ] `render_aside` exists in `Page::Base`, no-op by default; show layout reserves space for it (e.g., grid with `grid-cols-[1fr_240px]` when `aside_present?` else `grid-cols-1`)
619
+ - [ ] Tabs render flush against header (Phase 1 work continues to apply)
620
+
621
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page/show_test.rb -v`
622
+
623
+ **Steps:**
624
+
625
+ - [ ] **Step 1:** Add `render_aside` no-op to `Page::Base`:
626
+
627
+ ```ruby
628
+ def render_aside
629
+ # no-op by default; show page reserves layout slot
630
+ end
631
+
632
+ def aside_present? = false
633
+ ```
634
+
635
+ - [ ] **Step 2:** Update `Page::Show`:
636
+
637
+ ```ruby
638
+ def render_default_content
639
+ div(class: aside_present? ? "grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6" : "max-w-[960px] mx-auto") do
640
+ div { render partial("resource_details") }
641
+ aside(class: "hidden lg:block") { render_aside } if aside_present?
642
+ end
643
+ end
644
+ ```
645
+
646
+ - [ ] **Step 3:** Update panel/section component to render with `pu-card` + uppercase 9px label:
647
+
648
+ ```ruby
649
+ section(class: "pu-card p-4 mb-4") do
650
+ div(class: "text-[9px] font-semibold uppercase tracking-wider text-[var(--pu-text-muted)] mb-2") { label }
651
+ # ... fields ...
652
+ end
653
+ ```
654
+
655
+ - [ ] **Step 4:** Tests covering: layout grid, aside slot empty by default, panel chrome.
656
+ - [ ] **Step 5:** Manual: dummy app `/admin/users/1` shows single column with card panels.
657
+ - [ ] **Step 6:** Commit: `feat(ui): redesign Show page single-column with reserved aside`
658
+
659
+ ---
660
+
661
+ ### Task 10: Form page redesign
662
+
663
+ **Goal:** New/edit/interactive-action page renders a 580px centered column with card sections and a sticky footer for Cancel/Save. Inline validation: errors render as 12px danger text directly below each field.
664
+
665
+ **Files:**
666
+ - Modify: `lib/plutonium/ui/page/{new,edit,interactive_action}.rb`
667
+ - Modify: `lib/plutonium/ui/form/resource.rb`
668
+ - Modify: `lib/plutonium/ui/form/interaction.rb`
669
+ - Modify: `lib/plutonium/ui/form/theme.rb` (section card classes)
670
+ - Create: `lib/plutonium/ui/form/components/sticky_footer.rb`
671
+ - Test: `test/plutonium/ui/form/sticky_footer_test.rb`
672
+
673
+ **Acceptance Criteria:**
674
+ - [ ] Form column max-width 580px, centered (`max-w-[580px] mx-auto`)
675
+ - [ ] Field groups render as `pu-card` panels with uppercase 9px labels
676
+ - [ ] Sticky footer at viewport bottom: 56px tall, white surface, top border, Cancel + Save right-aligned
677
+ - [ ] Inline validation: error text under field, `text-xs text-danger-600 mt-1`; no toasts for field errors
678
+ - [ ] Modal context: sticky footer not rendered (modal owns its footer)
679
+
680
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/form -v`
681
+
682
+ **Steps:**
683
+
684
+ - [ ] **Step 1:** Implement `StickyFooter`:
685
+
686
+ ```ruby
687
+ class StickyFooter < Plutonium::UI::Component::Base
688
+ def view_template(&)
689
+ div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
690
+ "h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
691
+ "px-4 flex items-center justify-end gap-2", &)
692
+ end
693
+ end
694
+ ```
695
+
696
+ - [ ] **Step 2:** Update `Form::Resource` and `Form::Interaction` to wrap content in centered column and emit submit/cancel into `StickyFooter` (skip when `@in_modal`).
697
+ - [ ] **Step 3:** Add `@in_modal` initializer kwarg to forms; `Page::InteractiveAction` and remote-modal-rendered new/edit set it `true`.
698
+ - [ ] **Step 4:** Update form theme `error_message` to `"text-xs text-danger-600 mt-1"`. Confirm error toasts (flash messages) are unaffected — only field errors style changed.
699
+ - [ ] **Step 5:** Adjust page main padding to leave room for sticky footer: `pb-16` on form pages.
700
+ - [ ] **Step 6:** Tests:
701
+ - `StickyFooter` renders with expected classes
702
+ - form in non-modal context renders sticky footer
703
+ - form with `in_modal: true` does NOT render sticky footer
704
+ - [ ] **Step 7:** Manual: dummy app `/admin/users/new` shows centered form with sticky bottom footer; submit error → inline error under field.
705
+ - [ ] **Step 8:** Commit: `feat(ui): redesign form pages with centered column and sticky footer`
706
+
707
+ ---
708
+
709
+ # Phase 5 — Modals
710
+
711
+ ### Task 11: Slideover modal mode + per-interaction option
712
+
713
+ **Goal:** Add a slideover modal mode alongside the existing centered modal. Allow interactions to opt in via `interactive_action :name, modal: :slideover`.
714
+
715
+ **Files:**
716
+ - Modify: `lib/plutonium/resource/interactions/options.rb` (or wherever `interactive_action` registers options) — accept `modal:` kwarg
717
+ - Modify: existing modal Phlex component (likely `lib/plutonium/ui/component/...` or `app/views/layouts/_remote_modal*`) — split into `centered` and `slideover` outer containers, shared header/body/footer
718
+ - Modify: `src/js/controllers/remote_modal_controller.js` — read `data-modal-mode` and apply correct outer classes/animation
719
+ - Test: `test/plutonium/interaction/...` for the `modal:` option, plus a system test for slideover rendering
720
+
721
+ **Acceptance Criteria:**
722
+ - [ ] `interactive_action :reschedule, modal: :slideover` stores the mode on the action definition
723
+ - [ ] When this action triggers the remote modal, the modal renders as a right slideover (`right-0 top-0 h-full w-[480px]`) instead of centered
724
+ - [ ] Defaulted modal mode is `:centered`
725
+ - [ ] Slideover animates in from the right (Tailwind transition utilities)
726
+ - [ ] Mobile: slideover becomes full-screen
727
+
728
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction -v` and a new system test
729
+
730
+ **Steps:**
731
+
732
+ - [ ] **Step 1:** Locate where `interactive_action` registers — extend the option object with `modal: :centered` default and `:slideover` accepted value.
733
+ - [ ] **Step 2:** Pass the chosen mode to the modal renderer (likely via a controller assignment to `@modal_mode` or a turbo-stream attribute).
734
+ - [ ] **Step 3:** Refactor the modal markup:
735
+
736
+ ```erb
737
+ <%# centered (default) %>
738
+ <dialog class="fixed inset-0 m-auto max-w-[520px] w-full max-h-[80vh] rounded-lg ..." data-modal-mode="centered">
739
+ ...
740
+ </dialog>
741
+
742
+ <%# slideover %>
743
+ <dialog class="fixed top-0 right-0 h-screen w-full sm:w-[480px] m-0 rounded-none ..." data-modal-mode="slideover">
744
+ ...
745
+ </dialog>
746
+ ```
747
+
748
+ - [ ] **Step 4:** Add CSS transitions for slideover (translate-x-full → translate-x-0 on open).
749
+ - [ ] **Step 5:** Add a system test: define an interactive action with `modal: :slideover`, trigger it, assert `[data-modal-mode='slideover']` element is present in DOM.
750
+ - [ ] **Step 6:** Commit: `feat(ui): support slideover modal mode for interactions`
751
+
752
+ ---
753
+
754
+ ### Task 12: Per-resource modal declaration
755
+
756
+ **Goal:** Resource definitions can declare `modal :slideover` to control how new/edit forms render when triggered through the modal turbo frame.
757
+
758
+ **Files:**
759
+ - Modify: `lib/plutonium/resource/definition.rb` (add `modal` class-level DSL with default `:centered`)
760
+ - Modify: `lib/plutonium/resource/controllers/crud_actions.rb` (or whichever controller invokes new/edit) — when request targets the remote modal frame, pass `definition.modal_mode` to the layout
761
+ - Modify: layout for modal-rendered new/edit forms to read mode from definition
762
+ - Test: `test/plutonium/resource/definition_test.rb`, system test for slideover-quick-create
763
+
764
+ **Acceptance Criteria:**
765
+ - [ ] `class CustomerDefinition; modal :slideover; end` is accepted; `current_definition.modal_mode == :slideover`
766
+ - [ ] Default value is `:centered`
767
+ - [ ] When `+ New` from the index toolbar targets the remote modal frame and the resource declares `modal :slideover`, the modal renders as slideover
768
+ - [ ] Page-level (non-modal) new/edit URLs render the §5 page form regardless of declaration
769
+
770
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/definition_test.rb -v` plus a system test
771
+
772
+ **Steps:**
773
+
774
+ - [ ] **Step 1:** Add to `lib/plutonium/resource/definition.rb`:
775
+
776
+ ```ruby
777
+ class_attribute :modal_mode, default: :centered
778
+
779
+ def self.modal(mode)
780
+ raise ArgumentError, "modal must be :centered or :slideover" unless [:centered, :slideover].include?(mode)
781
+ self.modal_mode = mode
782
+ end
783
+ ```
784
+
785
+ - [ ] **Step 2:** Where the remote modal renders new/edit (probably a layout/template that wraps `resource_form`), read `current_definition.modal_mode` and apply mode.
786
+ - [ ] **Step 3:** Update the index toolbar `+ New` button to target the remote modal frame (existing behavior; confirm).
787
+ - [ ] **Step 4:** Tests:
788
+ - `definition_test.rb`: defining `modal :slideover` sets `modal_mode`; invalid raises
789
+ - system test: `+ New` on a resource with `modal :slideover` opens slideover
790
+ - [ ] **Step 5:** Commit: `feat(ui): per-resource modal mode declaration`
791
+
792
+ ---
793
+
794
+ ## Final: Cleanup & Docs
795
+
796
+ ### Task 13: Documentation + changelog
797
+
798
+ **Goal:** Update Plutonium docs to reflect new UI patterns; add an upgrade note for app developers.
799
+
800
+ **Files:**
801
+ - Modify: `docs/getting-started/*` (any screenshots/snippets that show old UI)
802
+ - Modify: `docs/guides/*`
803
+ - Create: `docs/guides/ui-overhaul-2026.md` — what changed, what apps need to do
804
+ - Update: `.claude/skills/plutonium-views.md` to reference new components
805
+ - Update: `CHANGELOG.md`
806
+
807
+ **Acceptance Criteria:**
808
+ - [ ] Upgrade guide explains: replaced sidebar with icon rail; new toolbar; column-header sort; sticky form footer; new modal modes
809
+ - [ ] Skill files updated for IconRail, Topbar, Toolbar, FilterPills, BulkActionBar, StickyFooter
810
+ - [ ] Changelog entry under unreleased
811
+
812
+ **Verify:** `yarn docs:build` succeeds; manual scan of docs site
813
+
814
+ **Steps:**
815
+
816
+ - [ ] **Step 1:** Write upgrade guide.
817
+ - [ ] **Step 2:** Update affected skill files in `.claude/skills/`.
818
+ - [ ] **Step 3:** Add changelog entry.
819
+ - [ ] **Step 4:** `yarn docs:build`.
820
+ - [ ] **Step 5:** Commit: `docs(ui): document UI layout overhaul`
821
+
822
+ ---
823
+
824
+ ## Self-Review Notes
825
+
826
+ **Spec coverage:**
827
+ - §1 Shell → Tasks 2, 3, 4
828
+ - §2 PageHeader → Task 1
829
+ - §3 Index → Tasks 5, 6, 7, 8
830
+ - §4 Show → Task 9
831
+ - §5 Form → Task 10
832
+ - §6 Density → Task 0
833
+ - §7 Modals → Tasks 11, 12
834
+ - §8 Compatibility / cleanup → Tasks 4 (drop legacy), 13 (docs)
835
+ - §9 Out-of-scope items not implemented (correct, by design)
836
+
837
+ **User Verification scan:** Spec does not require user-in-the-loop verification — design was validated during brainstorming. NO verification tasks needed.
838
+
839
+ **Type/name consistency:** `IconRail`, `Topbar`, `Toolbar`, `FilterPills`, `BulkActionBar`, `StickyFooter`, `ViewSwitcher` — used consistently across tasks.
840
+
841
+ **Phase boundaries are commit-able:** after each phase, the framework is in a working state. Phase 1 alone delivers visible improvement (tighter header + density). Phase 2 swaps the shell. Phase 3 transforms the index page. Phase 4 polishes show/form. Phase 5 adds modal flexibility.