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,103 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md",
3
+ "tasks": [
4
+ {
5
+ "id": 7,
6
+ "subject": "Task 0: Density tokens",
7
+ "status": "pending",
8
+ "description": "**Goal:** Codify balanced density scale.\n\n```json:metadata\n{\"files\":[\"src/css/tokens.css\",\"src/css/components.css\",\"lib/plutonium/ui/component/tokens.rb\"],\"verifyCommand\":\"yarn build && bundle exec appraisal rails-8.1 rake test\",\"acceptanceCriteria\":[\"density vars defined\",\"button/input sizes updated\",\"card padding 16px\",\"tests pass\"],\"requiresUserVerification\":false}\n```"
9
+ },
10
+ {
11
+ "id": 8,
12
+ "subject": "Task 1: PageHeader redesign (Stripe-style)",
13
+ "status": "pending",
14
+ "blockedBy": [7],
15
+ "description": "**Goal:** Tighter Stripe-style PageHeader.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page_header.rb\",\"lib/plutonium/ui/page/base.rb\",\"test/plutonium/ui/page_header_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page_header_test.rb -v\",\"acceptanceCriteria\":[\"title 18-20px\",\"description muted\",\"actions right-aligned\",\"tabs flush\"],\"requiresUserVerification\":false}\n```"
16
+ },
17
+ {
18
+ "id": 9,
19
+ "subject": "Task 2: IconRail component",
20
+ "status": "pending",
21
+ "blockedBy": [7],
22
+ "description": "**Goal:** 56px icon-only nav.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/icon_rail.rb\",\"test/plutonium/ui/layout/icon_rail_test.rb\",\"src/css/components.css\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/icon_rail_test.rb -v\",\"acceptanceCriteria\":[\"aside 56px\",\"slots present\",\"active styling\",\"mobile-hidden\"],\"requiresUserVerification\":false}\n```"
23
+ },
24
+ {
25
+ "id": 10,
26
+ "subject": "Task 3: Topbar component",
27
+ "status": "pending",
28
+ "blockedBy": [7],
29
+ "description": "**Goal:** Sticky 48px topbar.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/topbar.rb\",\"lib/plutonium/ui/breadcrumbs.rb\",\"test/plutonium/ui/layout/topbar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb -v\",\"acceptanceCriteria\":[\"48px height\",\"breadcrumbs/search/actions slots\",\"hamburger wired\",\"empty-breadcrumbs handled\"],\"requiresUserVerification\":false}\n```"
30
+ },
31
+ {
32
+ "id": 11,
33
+ "subject": "Task 4: Wire ResourceLayout to new shell",
34
+ "status": "pending",
35
+ "blockedBy": [9, 10],
36
+ "description": "**Goal:** Replace partials with IconRail+Topbar; drop legacy.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/resource_layout.rb\",\"lib/plutonium/ui/layout/base.rb\",\"lib/plutonium/ui/layout/header.rb\",\"lib/plutonium/ui/layout/sidebar.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 rake test\",\"acceptanceCriteria\":[\"new shell wired\",\"old partials removed\",\"tests pass\"],\"requiresUserVerification\":false}\n```"
37
+ },
38
+ {
39
+ "id": 12,
40
+ "subject": "Task 5: Index toolbar",
41
+ "status": "pending",
42
+ "blockedBy": [7, 8],
43
+ "description": "**Goal:** New Toolbar component (view switcher, filter, group, search, column-config, overflow).\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/toolbar.rb\",\"lib/plutonium/ui/table/components/view_switcher.rb\",\"lib/plutonium/ui/table/resource.rb\",\"test/plutonium/ui/table/components/toolbar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/toolbar_test.rb -v\",\"acceptanceCriteria\":[\"toolbar order\",\"search wired\",\"filter popover\",\"disabled segments\"],\"requiresUserVerification\":false}\n```"
44
+ },
45
+ {
46
+ "id": 13,
47
+ "subject": "Task 6: Active filter pills + result count",
48
+ "status": "pending",
49
+ "blockedBy": [12],
50
+ "description": "**Goal:** Removable filter pills + result count strip.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/filter_pills.rb\",\"lib/plutonium/ui/table/resource.rb\",\"lib/plutonium/resource/query_object.rb\",\"test/plutonium/ui/table/components/filter_pills_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/filter_pills_test.rb -v\",\"acceptanceCriteria\":[\"pill per filter\",\"add-filter pill\",\"clear URL\",\"result count\"],\"requiresUserVerification\":false}\n```"
51
+ },
52
+ {
53
+ "id": 14,
54
+ "subject": "Task 7: Column-header sort with multi-sort + ⋯ menu",
55
+ "status": "pending",
56
+ "blockedBy": [12],
57
+ "description": "**Goal:** Sort in column headers, shift-click multi-sort, per-column ⋯ menu.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/query_object.rb\",\"lib/plutonium/ui/table/resource.rb\",\"lib/plutonium/ui/table/theme.rb\",\"src/js/controllers/table_controller.js\",\"test/plutonium/resource/query_object_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/query_object_test.rb -v\",\"acceptanceCriteria\":[\"sort_params_for has multi_url\",\"click vs shift-click\",\"priority badges\",\"column menu\"],\"requiresUserVerification\":false}\n```"
58
+ },
59
+ {
60
+ "id": 15,
61
+ "subject": "Task 8: Floating bulk action bar",
62
+ "status": "pending",
63
+ "blockedBy": [13],
64
+ "description": "**Goal:** Bulk action bar replaces pills strip when rows selected.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/bulk_action_bar.rb\",\"lib/plutonium/ui/table/resource.rb\",\"src/js/controllers/bulk_actions_controller.js\",\"test/plutonium/ui/table/components/bulk_action_bar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/bulk_action_bar_test.rb -v\",\"acceptanceCriteria\":[\"bar/pills mutual exclusion\",\"selection count\",\"danger tone\",\"clear deselect\"],\"requiresUserVerification\":false}\n```"
65
+ },
66
+ {
67
+ "id": 16,
68
+ "subject": "Task 9: Show page redesign",
69
+ "status": "pending",
70
+ "blockedBy": [8],
71
+ "description": "**Goal:** Single-column show + reserved aside slot.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page/show.rb\",\"lib/plutonium/ui/page/base.rb\",\"lib/plutonium/ui/display/resource.rb\",\"lib/plutonium/ui/panel.rb\",\"test/plutonium/ui/page/show_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page/show_test.rb -v\",\"acceptanceCriteria\":[\"max-width 960\",\"card panels\",\"aside slot reserved\",\"tabs flush\"],\"requiresUserVerification\":false}\n```"
72
+ },
73
+ {
74
+ "id": 17,
75
+ "subject": "Task 10: Form page redesign + sticky footer",
76
+ "status": "pending",
77
+ "blockedBy": [8],
78
+ "description": "**Goal:** Centered narrow form column + sticky footer + inline validation.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page/new.rb\",\"lib/plutonium/ui/page/edit.rb\",\"lib/plutonium/ui/page/interactive_action.rb\",\"lib/plutonium/ui/form/resource.rb\",\"lib/plutonium/ui/form/interaction.rb\",\"lib/plutonium/ui/form/theme.rb\",\"lib/plutonium/ui/form/components/sticky_footer.rb\",\"test/plutonium/ui/form/sticky_footer_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/form -v\",\"acceptanceCriteria\":[\"580px centered\",\"sticky footer\",\"inline validation\",\"modal mode skips footer\"],\"requiresUserVerification\":false}\n```"
79
+ },
80
+ {
81
+ "id": 18,
82
+ "subject": "Task 11: Slideover modal mode + per-interaction option",
83
+ "status": "pending",
84
+ "blockedBy": [17],
85
+ "description": "**Goal:** Add slideover mode; opt-in via interactive_action :name, modal: :slideover.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/interaction.rb\",\"lib/plutonium/interaction/base.rb\",\"src/js/controllers/remote_modal_controller.js\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction -v\",\"acceptanceCriteria\":[\"modal: kwarg accepted\",\"two outer containers\",\"slideover transition\",\"mobile full-screen\"],\"requiresUserVerification\":false}\n```"
86
+ },
87
+ {
88
+ "id": 19,
89
+ "subject": "Task 12: Per-resource modal declaration",
90
+ "status": "pending",
91
+ "blockedBy": [18],
92
+ "description": "**Goal:** modal :slideover on resource definition for new/edit modal mode.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/definition.rb\",\"test/plutonium/resource/definition_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/definition_test.rb -v\",\"acceptanceCriteria\":[\"modal DSL on definition\",\"invalid raises\",\"modal frame uses declared mode\",\"page URLs unchanged\"],\"requiresUserVerification\":false}\n```"
93
+ },
94
+ {
95
+ "id": 20,
96
+ "subject": "Task 13: Documentation + changelog",
97
+ "status": "pending",
98
+ "blockedBy": [16, 19],
99
+ "description": "**Goal:** Document overhaul; update skills.\n\n```json:metadata\n{\"files\":[\"docs/guides/ui-overhaul-2026.md\",\"CHANGELOG.md\",\".claude/skills/plutonium-views.md\"],\"verifyCommand\":\"yarn docs:build\",\"acceptanceCriteria\":[\"upgrade guide written\",\"skills updated\",\"changelog entry\",\"docs build passes\"],\"requiresUserVerification\":false}\n```"
100
+ }
101
+ ],
102
+ "lastUpdated": "2026-05-07"
103
+ }
@@ -0,0 +1,270 @@
1
+ # Plutonium UI Layout Overhaul — Design Spec
2
+
3
+ **Date:** 2026-05-07
4
+ **Scope:** Visual + structural redesign of Plutonium's app shell, page header, index/show/form pages. Code-level component refactoring (slot APIs, hook reduction, partial-to-Phlex conversion) is intentionally out of scope here — this spec captures the *target UI* only. A separate refactor pass can re-architect the Phlex internals to deliver this target.
5
+
6
+ ## Goals
7
+
8
+ 1. Modernize the look and feel to match contemporary admin tools (Linear, Stripe Dashboard, Vercel, Plane).
9
+ 2. Increase information density without sacrificing scannability.
10
+ 3. Establish a coherent visual vocabulary across all four page types (index / show / form / interactive-action).
11
+ 4. Leave clean extension points for upcoming features (metadata side panel, view switchers).
12
+
13
+ ## Non-Goals
14
+
15
+ - Theming / token rebuild (deferred — current `--pu-*` token system stays).
16
+ - Component API consolidation (separate effort).
17
+ - Mobile-first redesign (mobile must work, but desktop is the optimization target).
18
+ - New colors / typography (use existing tokens unless a decision below requires a new one).
19
+
20
+ ---
21
+
22
+ ## 1. App Shell — Icon Rail + Topbar
23
+
24
+ A narrow icon-only left rail plus a topbar replaces the current expanded sidebar.
25
+
26
+ **Left rail**
27
+ - Width: ~56px, fixed.
28
+ - Icon-only nav items with tooltips on hover.
29
+ - Top: brand mark / portal switcher.
30
+ - Middle: primary nav (resources grouped by section, dividers between groups).
31
+ - Bottom: settings, theme toggle.
32
+ - Active item: filled background, primary tone.
33
+ - Mobile (<lg breakpoint): rail collapses to hamburger drawer.
34
+
35
+ **Topbar**
36
+ - Height: ~48px, sticky.
37
+ - Left: breadcrumbs (resource path; replaces in-content breadcrumbs).
38
+ - Center: global search input (filled, ~360px max).
39
+ - Right: notifications, user menu.
40
+
41
+ **Removed**
42
+ - Current expanded sidebar (240px) — labels now live in tooltips and breadcrumbs.
43
+ - In-page breadcrumbs above title — moved to topbar.
44
+
45
+ ---
46
+
47
+ ## 2. Page Header — Stripe-Style
48
+
49
+ Every page renders a unified header below the topbar.
50
+
51
+ ```
52
+ ┌────────────────────────────────────────────────────┐
53
+ │ Customers [Cancel] [Save] │
54
+ │ Manage customer accounts and contact details │
55
+ ├────────────────────────────────────────────────────┤
56
+ │ Overview │ Orders │ Invoices │ Activity │
57
+ └────────────────────────────────────────────────────┘
58
+ ```
59
+
60
+ - Title: 18–20px, semibold.
61
+ - Description: 13px, muted, optional, sits directly below title.
62
+ - Actions: right-aligned at title's vertical level. Primary as filled button, secondary as outline. Overflow into a `⋯` dropdown after 2 visible actions.
63
+ - Tabs: connected strip directly under header (no gap), 1px bottom border becomes the active-tab indicator's baseline.
64
+
65
+ The header is uniform across index / show / form / interactive-action.
66
+
67
+ ---
68
+
69
+ ## 3. Index Page — Hybrid Toolbar + Pills + Column Sort
70
+
71
+ ### Toolbar (single 36-40px row above the table)
72
+
73
+ Order, left to right:
74
+ 1. **View switcher** — segmented control (Grid / Cards / Kanban — Cards/Kanban are placeholders for now; only Grid is wired initially).
75
+ 2. Vertical divider.
76
+ 3. **Filter** button (popover).
77
+ 4. **Group** button (popover).
78
+ 5. Spacer (`flex-grow`).
79
+ 6. **Search input** — visible, ~220px wide, expands on focus.
80
+ 7. Vertical divider.
81
+ 8. **Column config** icon button (`⊞`).
82
+ 9. **Overflow** icon button (`⋯`) — exports, advanced options.
83
+
84
+ The "Sort" button is intentionally absent — sort is column-driven (see below).
85
+
86
+ ### Active Filter Strip (below toolbar, only when filters are active)
87
+
88
+ - Each active filter renders as a removable pill: `<field> <op> <value>` with `✕`.
89
+ - After the last pill: `+ Filter` dashed pill that opens the same popover as the toolbar Filter button.
90
+ - Right-aligned: result count (e.g., "147 results").
91
+
92
+ ### Table — Column-Header Sort
93
+
94
+ - Click a column header: sorts asc → desc → none (cycles).
95
+ - Shift-click: adds a secondary/tertiary sort (multi-sort).
96
+ - Active sort columns show: arrow (↑/↓) + small priority badge (1, 2, 3) when more than one column is active.
97
+ - Each header has a `⋯` menu: Sort asc / Sort desc / Clear sort / Group by / Filter by / Hide column.
98
+ - Row height: 32px (balanced density). Header height: 32px.
99
+ - Selection: leftmost column is a 12px checkbox.
100
+
101
+ ### Bulk Action Bar
102
+
103
+ - Appears as a 36px strip *replacing* the active-filter strip when ≥1 row is selected.
104
+ - Tinted background (primary-50 light / primary-950/30 dark).
105
+ - Left: count + "Clear selection".
106
+ - After spacer: action buttons (Export, Archive, Delete) — Delete uses danger tone.
107
+
108
+ ### Pagination Footer
109
+
110
+ - Sticky-ish strip below the table.
111
+ - Left: "Showing N–M of Total".
112
+ - Right: prev / page indicator / next.
113
+
114
+ ---
115
+
116
+ ## 4. Show Page — Single Column + Tabs
117
+
118
+ A single content column under the page header. Nested resources render as tabs.
119
+
120
+ ### Structure
121
+
122
+ ```
123
+ PageHeader (title, description, actions, tab strip)
124
+ └── content
125
+ ├── [Aside slot — empty by default; reserved]
126
+ └── Main column
127
+ ├── Field panel: Details
128
+ ├── Field panel: Address
129
+ └── ...
130
+ ```
131
+
132
+ - Field panels: card-styled (1px border, radius-md, white surface) with uppercase 9px section labels.
133
+ - Default content max-width: ~960px, centered if rail+topbar leaves wider area.
134
+ - Tabs render nested resources (the existing tab strip mechanism).
135
+
136
+ ### Reserved Aside Slot (Future Hook)
137
+
138
+ The page layout reserves a `render_aside` slot that is empty by default. A future `metadata` DSL on resource definitions will populate this slot:
139
+
140
+ ```ruby
141
+ class CustomerDefinition < Plutonium::Resource::Definition
142
+ metadata do
143
+ field :status, badge: true
144
+ field :owner
145
+ field :created_at
146
+ end
147
+ end
148
+ ```
149
+
150
+ When populated, the aside renders as a 200–240px right side panel with a sticky 16px-padded background-`surface-alt` column. Implementation of the DSL itself is a separate task — this spec only requires the layout to leave room.
151
+
152
+ ---
153
+
154
+ ## 5. Form Page — Centered Narrow + Sticky Footer
155
+
156
+ For new / edit / interactive-action.
157
+
158
+ ### Structure
159
+
160
+ ```
161
+ PageHeader (title, description; no actions in header)
162
+ └── content (max-width ~580px, centered)
163
+ ├── Card: Section 1 (uppercase 9px label + fields)
164
+ ├── Card: Section 2
165
+ └── ...
166
+ StickyFooter (full width, right-aligned [Cancel] [Save])
167
+ ```
168
+
169
+ - Form column max-width: 580px.
170
+ - Card-style sections, same chrome as show-page panels.
171
+ - Inline validation: errors render as 12px danger text directly under each field. No toasts for field-level errors. Toast/flash only for form-level outcomes.
172
+ - Sticky footer: 56px tall, white surface, top border, sticks to viewport bottom when form scrolls.
173
+ - Cancel: outline button. Save: primary filled button. Right-aligned.
174
+
175
+ ### Modal Variant
176
+
177
+ The same form can render in a modal when triggered as a quick-create / quick-edit (e.g., `+ New` from an index toolbar that targets the remote modal frame, or a row-edit action):
178
+ - No sticky footer; the modal's own footer bar holds Cancel / Save.
179
+ - Internal layout otherwise identical (card sections, inline validation).
180
+ - Modal mode (`:centered` vs `:slideover`) is configurable per resource definition — see §7.
181
+ - Page-level new/edit URLs always render the full page form (§5); modal rendering is invoked via the modal turbo frame.
182
+
183
+ ---
184
+
185
+ ## 6. Density
186
+
187
+ **Balanced (Stripe / Vercel-class)** as the framework default.
188
+
189
+ | Token | Value |
190
+ |------------------|---------------|
191
+ | Table row height | 32px |
192
+ | Body text | 14px |
193
+ | Section gap | 16px |
194
+ | Field gap | 12px |
195
+ | Button (md) | 32px height, 14px text, 12px horizontal padding |
196
+ | Button (sm) | 28px, 13px, 10px |
197
+ | Input height | 36px (forms), 32px (toolbars) |
198
+ | Card padding | 16px |
199
+ | Page side padding | 24px |
200
+
201
+ These values become the canonical scale; spot-deviations are allowed but should be rare.
202
+
203
+ ---
204
+
205
+ ## 7. Modals — Both Modes, Per-Action Opt-In
206
+
207
+ Two modal modes ship as siblings.
208
+
209
+ ### Default: Centered Dialog
210
+ - Max-width 520px, max-height 80vh, centered, dimmed backdrop.
211
+ - Header: dialog title + close (✕).
212
+ - Body: form / content with internal scroll.
213
+ - Footer: 56px strip, right-aligned [Cancel] [Confirm].
214
+ - Use cases: short forms, confirmations, most interactive actions.
215
+
216
+ ### Opt-In: Right Slide-Over Panel
217
+ - Slides in from right, full height, 480px wide on desktop, full-screen on mobile.
218
+ - Header / body / footer same as centered.
219
+ - Underlying list visible (dimmed); user keeps context.
220
+ ### Configuration
221
+
222
+ **Per interaction** — defaults to `:centered`, opt into `:slideover`:
223
+ ```ruby
224
+ interactive_action :reschedule, modal: :slideover
225
+ interactive_action :archive # implicit modal: :centered
226
+ ```
227
+
228
+ **Per resource (for quick-create / quick-edit modals)** — definition declares the mode used when new/edit is rendered through the modal turbo frame:
229
+ ```ruby
230
+ class CustomerDefinition < Plutonium::Resource::Definition
231
+ modal :slideover # default :centered
232
+ end
233
+ ```
234
+
235
+ The page-level new/edit URLs always render the full §5 page layout. Whether `+ New` opens a modal or navigates to the page is a per-context call-site choice (e.g., index toolbar can target the modal frame for quick-create; a "Create customer" landing CTA navigates to the full page).
236
+
237
+ Both modal modes share the same Phlex modal component; only the outer container varies.
238
+
239
+ ---
240
+
241
+ ## 8. Compatibility & Migration Notes
242
+
243
+ - **`Layout::ResourceLayout`** currently uses Rails partials for `resource_header` / `resource_sidebar`. Conversion to Phlex is implicit in this work — the icon rail and topbar must be Phlex components.
244
+ - **`Page::Base` hook explosion** (~12 before/after hooks) — most apps don't use these. The redesign assumes apps that override `render_breadcrumbs` etc. continue to work; new slot APIs are additive. Hook deprecation is a future cleanup.
245
+ - **Existing CSS classes** — `.pu-input`, `.pu-btn`, `.pu-card` keep their names; sizes shift to the density table above. Apps that hard-code Tailwind utilities on top will need cosmetic touch-ups but no breakage.
246
+ - **Breadcrumbs** — moving from in-page to topbar means `Plutonium::UI::Breadcrumbs` becomes a topbar component. The definition-level `breadcrumbs` toggle stays; "off" means hidden in topbar (or replaced with title only).
247
+
248
+ ---
249
+
250
+ ## 9. Out-of-Scope Followups (referenced, not designed here)
251
+
252
+ - **Metadata DSL** for show-page side panel (§4).
253
+ - **View switcher** wiring beyond Grid (Cards, Kanban) — placeholders in toolbar; implementation deferred.
254
+ - **Code-level Phlex refactor** — slot API, hook reduction, asset registry — separate spec.
255
+ - **Token / theme rebuild** — separate spec.
256
+
257
+ ---
258
+
259
+ ## 10. Acceptance Checklist
260
+
261
+ - [ ] Icon rail (56px) replaces expanded sidebar; topbar adds breadcrumbs + search + user.
262
+ - [ ] Page header is a single component (`PageHeader`) used by every page type, supporting title / description / actions / tabs.
263
+ - [ ] Index page renders the toolbar in the order of §3 with no Sort button.
264
+ - [ ] Active filters render as removable pills below the toolbar with a result count.
265
+ - [ ] Column headers sort on click (asc/desc/none) with shift-click multi-sort and priority badges.
266
+ - [ ] Bulk action bar replaces the filter strip when rows are selected.
267
+ - [ ] Show page is single-column with tab strip; an empty aside slot is reserved.
268
+ - [ ] Form pages use a 580px centered column with sticky footer; modal variant uses dialog footer.
269
+ - [ ] Density tokens (§6) are codified in CSS / Phlex constants and used consistently.
270
+ - [ ] Modal component supports both `:centered` (default) and `:slideover` via per-action / per-form opt-in.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.49.0)
4
+ plutonium (0.49.1)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -3,5 +3,6 @@
3
3
  Plutonium.configure do |config|
4
4
  config.load_defaults 1.0
5
5
 
6
+ config.shell = :modern
6
7
  # Configure plutonium above.
7
8
  end
@@ -9,10 +9,14 @@ module Pu
9
9
 
10
10
  desc "Update Plutonium gem and npm package to the latest version"
11
11
 
12
+ SHELL_PIN_THRESHOLD = "0.49.1"
13
+
12
14
  def start
15
+ @previous_gem_version = installed_gem_version
13
16
  update_gem
14
17
  update_npm_package
15
18
  sync_skills_if_present
19
+ pin_shell_to_classic
16
20
  rescue => e
17
21
  exception "#{self.class} failed:", e
18
22
  end
@@ -26,6 +30,22 @@ module Pu
26
30
  Rails::Generators.invoke("pu:skills:sync", [], destination_root: Rails.root)
27
31
  end
28
32
 
33
+ # Pin the shell config to :classic on apps upgrading from a version
34
+ # that predates the shell change so the upgrade is invisible. Newer
35
+ # apps installed after the shell change get :modern from the install
36
+ # template and shouldn't be flipped.
37
+ def pin_shell_to_classic
38
+ return unless @previous_gem_version
39
+ return unless SemanticRange.satisfies?(@previous_gem_version, "<=#{SHELL_PIN_THRESHOLD}")
40
+
41
+ initializer = Rails.root.join("config", "initializers", "plutonium.rb")
42
+ return unless File.file?(initializer)
43
+ return if File.read(initializer).match?(/^\s*config\.shell\s*=/)
44
+
45
+ say_status :update, "Pinning Plutonium shell to :classic...", :green
46
+ configure_plutonium "config.shell = :classic"
47
+ end
48
+
29
49
  def update_gem
30
50
  say_status :update, "Updating plutonium gem...", :green
31
51
  run "bundle update plutonium"
@@ -70,21 +70,70 @@ module Pu
70
70
 
71
71
  def setup_recurring_tasks
72
72
  recurring_file = "config/recurring.yml"
73
- return unless File.exist?(File.expand_path(recurring_file, destination_root))
73
+ full_path = File.expand_path(recurring_file, destination_root)
74
+ return unless File.exist?(full_path)
74
75
  return if file_includes?(recurring_file, "rails_pulse")
75
76
 
76
- recurring_tasks = <<~YAML
77
+ content = File.read(full_path)
78
+ env_keys = %w[production development staging test]
79
+ env_scoped = content.lines.any? { |l| l.match?(/^(#{env_keys.join("|")}):\s*$/) }
77
80
 
81
+ if env_scoped
82
+ create_file recurring_file, inject_rails_pulse_under_envs(content, env_keys), force: true
83
+ else
84
+ append_to_file recurring_file, "\n" + rails_pulse_tasks_yaml(0)
85
+ end
86
+ end
87
+
88
+ def rails_pulse_tasks_yaml(indent)
89
+ pad = " " * indent
90
+ <<~YAML.gsub(/^(?=.)/, pad)
78
91
  rails_pulse_summary:
79
92
  class: RailsPulse::SummaryJob
80
- schedule: "5 * * * *" # 5 minutes past every hour
93
+ queue: default
94
+ schedule: every hour at minute 5
95
+ description: "Roll up Rails Pulse raw records into summary tables"
81
96
 
82
97
  rails_pulse_cleanup:
83
98
  class: RailsPulse::CleanupJob
84
- schedule: "0 1 * * *" # Daily at 1am
99
+ queue: default
100
+ schedule: every day at 1am
101
+ description: "Archive/purge old Rails Pulse data"
85
102
  YAML
103
+ end
104
+
105
+ def inject_rails_pulse_under_envs(content, env_keys)
106
+ lines = content.lines
107
+ env_re = /^(#{env_keys.join("|")}):\s*$/
108
+
109
+ env_starts = lines.each_with_index.select { |l, _| env_re.match?(l) }.map(&:last)
110
+
111
+ env_starts.reverse_each do |start|
112
+ end_idx = lines.length
113
+ ((start + 1)...lines.length).each do |i|
114
+ if lines[i].match?(/^[^\s#]/)
115
+ end_idx = i
116
+ break
117
+ end
118
+ end
119
+
120
+ indent = 2
121
+ ((start + 1)...end_idx).each do |i|
122
+ if (m = lines[i].match(/^(\s+)\S/))
123
+ indent = m[1].length
124
+ break
125
+ end
126
+ end
127
+
128
+ insert_at = end_idx
129
+ while insert_at > start + 1 && lines[insert_at - 1].strip.empty?
130
+ insert_at -= 1
131
+ end
132
+
133
+ lines.insert(insert_at, "\n", rails_pulse_tasks_yaml(indent))
134
+ end
86
135
 
87
- append_to_file recurring_file, recurring_tasks
136
+ lines.join
88
137
  end
89
138
  end
90
139
  end
@@ -16,7 +16,7 @@ module Plutonium
16
16
  # @attr_reader [Symbol, nil] category The category of the action.
17
17
  # @attr_reader [Integer] position The position of the action within its category.
18
18
  class Base
19
- attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :turbo_frame, :color, :category, :position, :return_to
19
+ attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :turbo_frame, :color, :category, :position, :return_to, :modal
20
20
 
21
21
  # Initialize a new action.
22
22
  #
@@ -57,6 +57,8 @@ module Plutonium
57
57
  @resource_action = options[:resource_action] || false
58
58
  @category = ActiveSupport::StringInquirer.new((options[:category] || :secondary).to_s)
59
59
  @position = options[:position] || 50
60
+ @modal = options[:modal] || :centered
61
+ validate_modal!
60
62
 
61
63
  freeze
62
64
  end
@@ -85,8 +87,49 @@ module Plutonium
85
87
  policy.allowed_to?(:"#{name}?")
86
88
  end
87
89
 
90
+ # Returns a new Action with the given options merged over this one.
91
+ # Used by the resource definition to derive variants (e.g. dropping
92
+ # `turbo_frame` when `modal false` is configured) without mutating
93
+ # the frozen original.
94
+ def with(**overrides)
95
+ self.class.new(name, **to_options.merge(overrides))
96
+ end
97
+
98
+ protected
99
+
100
+ # Canonical representation for reconstruction via `with`. Every
101
+ # attribute set in `initialize` MUST appear here; otherwise
102
+ # `with(**overrides)` would silently drop it on round-trip.
103
+ # `category` is exposed as a Symbol since `initialize` re-wraps
104
+ # it in StringInquirer.
105
+ def to_options
106
+ {
107
+ label: @label,
108
+ description: @description,
109
+ icon: @icon,
110
+ color: @color,
111
+ confirmation: @confirmation,
112
+ route_options: @route_options,
113
+ turbo: @turbo,
114
+ turbo_frame: @turbo_frame,
115
+ return_to: @return_to,
116
+ bulk_action: @bulk_action,
117
+ collection_record_action: @collection_record_action,
118
+ record_action: @record_action,
119
+ resource_action: @resource_action,
120
+ category: @category.to_sym,
121
+ position: @position,
122
+ modal: @modal
123
+ }
124
+ end
125
+
88
126
  private
89
127
 
128
+ def validate_modal!
129
+ return if [:centered, :slideover].include?(@modal)
130
+ raise ArgumentError, "modal must be :centered or :slideover, got #{@modal.inspect}"
131
+ end
132
+
90
133
  # Build RouteOptions from the provided options
91
134
  #
92
135
  # @param [RouteOptions, Hash, nil] options The routing options
@@ -22,7 +22,7 @@ module Plutonium
22
22
  options[:label] ||= interaction.label
23
23
  options[:description] ||= interaction.description
24
24
  options[:icon] ||= interaction.icon
25
- options[:turbo_frame] = "remote_modal" unless options.key?(:turbo_frame)
25
+ options[:turbo_frame] = Plutonium::REMOTE_MODAL_FRAME unless options.key?(:turbo_frame)
26
26
 
27
27
  super(name, **options)
28
28
  end
@@ -27,6 +27,9 @@ module Plutonium
27
27
  # @return [Float] the current defaults version
28
28
  attr_reader :defaults_version
29
29
 
30
+ # @return [Symbol] :classic (legacy Header/Sidebar) or :modern (Topbar/IconRail)
31
+ attr_accessor :shell
32
+
30
33
  # Map of version numbers to their default configurations
31
34
  VERSION_DEFAULTS = {
32
35
  1.0 => proc do |config|
@@ -48,6 +51,7 @@ module Plutonium
48
51
  @development = parse_boolean_env("PLUTONIUM_DEV")
49
52
  @cache_discovery = !Rails.env.development?
50
53
  @enable_hotreload = Rails.env.development?
54
+ @shell = :modern
51
55
  end
52
56
 
53
57
  # Load default configuration for a specific version
@@ -32,6 +32,9 @@ module Plutonium
32
32
 
33
33
  # standard CRUD actions
34
34
 
35
+ # turbo_frame for :new and :edit is set by
36
+ # Resource::Definition.configure_crud_modal_targets! based on the
37
+ # `modal` config. Don't hard-code it here.
35
38
  action(:new, route_options: {action: :new},
36
39
  resource_action: true, category: :primary,
37
40
  icon: Phlex::TablerIcons::Plus, position: 10)
@@ -33,6 +33,8 @@ module Plutonium
33
33
  include Scoping
34
34
  include Search
35
35
  include NestedInputs
36
+ include Views
37
+ include Metadata
36
38
 
37
39
  class IndexPage < Plutonium::UI::Page::Index; end
38
40
 
@@ -48,6 +50,8 @@ module Plutonium
48
50
 
49
51
  class Table < Plutonium::UI::Table::Resource; end
50
52
 
53
+ class Grid < Plutonium::UI::Grid::Resource; end
54
+
51
55
  class Display < Plutonium::UI::Display::Resource; end
52
56
 
53
57
  class QueryForm < Plutonium::UI::Form::Query; end
@@ -114,6 +118,10 @@ module Plutonium
114
118
  self.class::Table
115
119
  end
116
120
 
121
+ def grid_class
122
+ self.class::Grid
123
+ end
124
+
117
125
  def detail_class
118
126
  self.class::Display
119
127
  end