ruby_ui_admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +165 -0
  4. data/app/assets/stylesheets/ruby_ui_admin/application.tailwind.css +111 -0
  5. data/app/components/ruby_ui_admin/ui/base.rb +26 -0
  6. data/app/components/ruby_ui_admin/ui/icon.rb +79 -0
  7. data/app/components/ruby_ui_admin/ui/native_select.rb +37 -0
  8. data/app/components/ruby_ui_admin/ui/native_select_group.rb +18 -0
  9. data/app/components/ruby_ui_admin/ui/native_select_icon.rb +42 -0
  10. data/app/components/ruby_ui_admin/ui/native_select_option.rb +18 -0
  11. data/app/components/ruby_ui_admin/ui/pagination.rb +62 -0
  12. data/app/components/ruby_ui_admin/ui/pagination_content.rb +18 -0
  13. data/app/components/ruby_ui_admin/ui/pagination_ellipsis.rb +45 -0
  14. data/app/components/ruby_ui_admin/ui/pagination_item.rb +32 -0
  15. data/app/components/ruby_ui_admin/ui/select.rb +29 -0
  16. data/app/components/ruby_ui_admin/ui/toast_action.rb +30 -0
  17. data/app/components/ruby_ui_admin/ui/toast_cancel.rb +30 -0
  18. data/app/components/ruby_ui_admin/ui/toast_close.rb +43 -0
  19. data/app/components/ruby_ui_admin/ui/toast_description.rb +21 -0
  20. data/app/components/ruby_ui_admin/ui/toast_icon.rb +69 -0
  21. data/app/components/ruby_ui_admin/ui/toast_item.rb +75 -0
  22. data/app/components/ruby_ui_admin/ui/toast_region.rb +147 -0
  23. data/app/components/ruby_ui_admin/ui/toast_title.rb +21 -0
  24. data/app/components/ruby_ui_admin/ui.rb +12 -0
  25. data/app/components/ruby_ui_admin/views/action.rb +37 -0
  26. data/app/components/ruby_ui_admin/views/action_form.rb +104 -0
  27. data/app/components/ruby_ui_admin/views/action_trigger.rb +148 -0
  28. data/app/components/ruby_ui_admin/views/actions_menu.rb +34 -0
  29. data/app/components/ruby_ui_admin/views/base.rb +301 -0
  30. data/app/components/ruby_ui_admin/views/card_component.rb +69 -0
  31. data/app/components/ruby_ui_admin/views/dashboard.rb +40 -0
  32. data/app/components/ruby_ui_admin/views/docs.rb +91 -0
  33. data/app/components/ruby_ui_admin/views/field_input.rb +156 -0
  34. data/app/components/ruby_ui_admin/views/field_value.rb +301 -0
  35. data/app/components/ruby_ui_admin/views/filter_bar.rb +111 -0
  36. data/app/components/ruby_ui_admin/views/form.rb +151 -0
  37. data/app/components/ruby_ui_admin/views/home.rb +35 -0
  38. data/app/components/ruby_ui_admin/views/index.rb +324 -0
  39. data/app/components/ruby_ui_admin/views/path_helpers.rb +36 -0
  40. data/app/components/ruby_ui_admin/views/rails_helpers.rb +15 -0
  41. data/app/components/ruby_ui_admin/views/show.rb +77 -0
  42. data/app/components/ruby_ui_admin/views/show_fields.rb +37 -0
  43. data/app/components/ruby_ui_admin/views/show_tab.rb +58 -0
  44. data/app/components/ruby_ui_admin/views/structure_renderer.rb +148 -0
  45. data/app/components/ruby_ui_admin/views/translation.rb +12 -0
  46. data/app/controllers/ruby_ui_admin/actions_controller.rb +162 -0
  47. data/app/controllers/ruby_ui_admin/application_controller.rb +81 -0
  48. data/app/controllers/ruby_ui_admin/dashboards_controller.rb +12 -0
  49. data/app/controllers/ruby_ui_admin/docs_controller.rb +166 -0
  50. data/app/controllers/ruby_ui_admin/home_controller.rb +18 -0
  51. data/app/controllers/ruby_ui_admin/resources_controller.rb +259 -0
  52. data/config/locales/ruby_ui_admin.en.yml +59 -0
  53. data/config/locales/ruby_ui_admin.pt-BR.yml +59 -0
  54. data/config/routes/dynamic_routes.rb +11 -0
  55. data/config/routes.rb +23 -0
  56. data/docs/README.md +53 -0
  57. data/docs/actions/overview.md +112 -0
  58. data/docs/authorization/action-policy.md +72 -0
  59. data/docs/authorization/adapters.md +101 -0
  60. data/docs/authorization/explicit-mode.md +45 -0
  61. data/docs/authorization/field-authorization.md +53 -0
  62. data/docs/authorization/policies.md +62 -0
  63. data/docs/authorization/scopes.md +48 -0
  64. data/docs/customization/controllers.md +62 -0
  65. data/docs/customization/ejecting.md +55 -0
  66. data/docs/customization/javascript.md +161 -0
  67. data/docs/customization/theming-rubyui.md +80 -0
  68. data/docs/dashboards/overview.md +65 -0
  69. data/docs/fields/overview.md +94 -0
  70. data/docs/filters/overview.md +73 -0
  71. data/docs/generators-and-tasks/overview.md +42 -0
  72. data/docs/getting-started/authentication.md +98 -0
  73. data/docs/getting-started/configuration.md +174 -0
  74. data/docs/getting-started/installation.md +201 -0
  75. data/docs/getting-started/internationalization.md +50 -0
  76. data/docs/getting-started/practical-guide.md +310 -0
  77. data/docs/resources/associations.md +66 -0
  78. data/docs/resources/index-customization.md +68 -0
  79. data/docs/resources/overview.md +50 -0
  80. data/docs/resources/scopes.md +54 -0
  81. data/docs/resources/tabs-panels.md +65 -0
  82. data/lib/generators/ruby_ui_admin/action/action_generator.rb +21 -0
  83. data/lib/generators/ruby_ui_admin/action/templates/action.rb.tt +24 -0
  84. data/lib/generators/ruby_ui_admin/assets/assets_generator.rb +71 -0
  85. data/lib/generators/ruby_ui_admin/card/card_generator.rb +28 -0
  86. data/lib/generators/ruby_ui_admin/card/templates/chart_card.rb.tt +14 -0
  87. data/lib/generators/ruby_ui_admin/card/templates/metric_card.rb.tt +14 -0
  88. data/lib/generators/ruby_ui_admin/card/templates/partial_card.rb.tt +13 -0
  89. data/lib/generators/ruby_ui_admin/components/components_generator.rb +43 -0
  90. data/lib/generators/ruby_ui_admin/controller/controller_generator.rb +30 -0
  91. data/lib/generators/ruby_ui_admin/controller/templates/controller.rb.tt +24 -0
  92. data/lib/generators/ruby_ui_admin/dashboard/dashboard_generator.rb +18 -0
  93. data/lib/generators/ruby_ui_admin/dashboard/templates/dashboard.rb.tt +14 -0
  94. data/lib/generators/ruby_ui_admin/eject/eject_generator.rb +55 -0
  95. data/lib/generators/ruby_ui_admin/filter/filter_generator.rb +28 -0
  96. data/lib/generators/ruby_ui_admin/filter/templates/boolean_filter.rb.tt +13 -0
  97. data/lib/generators/ruby_ui_admin/filter/templates/select_filter.rb.tt +19 -0
  98. data/lib/generators/ruby_ui_admin/filter/templates/text_filter.rb.tt +13 -0
  99. data/lib/generators/ruby_ui_admin/install/install_generator.rb +62 -0
  100. data/lib/generators/ruby_ui_admin/install/templates/initializer.rb.tt +41 -0
  101. data/lib/generators/ruby_ui_admin/locales/locales_generator.rb +22 -0
  102. data/lib/generators/ruby_ui_admin/policy/policy_generator.rb +18 -0
  103. data/lib/generators/ruby_ui_admin/policy/templates/policy.rb.tt +24 -0
  104. data/lib/generators/ruby_ui_admin/resource/resource_generator.rb +84 -0
  105. data/lib/generators/ruby_ui_admin/resource/templates/resource.rb.tt +15 -0
  106. data/lib/generators/ruby_ui_admin/scope/scope_generator.rb +18 -0
  107. data/lib/generators/ruby_ui_admin/scope/templates/scope.rb.tt +11 -0
  108. data/lib/ruby_ui_admin/authorization/action_policy_adapter.rb +69 -0
  109. data/lib/ruby_ui_admin/authorization/adapter.rb +80 -0
  110. data/lib/ruby_ui_admin/authorization/can_can_can_adapter.rb +70 -0
  111. data/lib/ruby_ui_admin/authorization/pundit_adapter.rb +86 -0
  112. data/lib/ruby_ui_admin/authorization.rb +40 -0
  113. data/lib/ruby_ui_admin/base_action.rb +211 -0
  114. data/lib/ruby_ui_admin/base_dashboard.rb +70 -0
  115. data/lib/ruby_ui_admin/base_policy.rb +38 -0
  116. data/lib/ruby_ui_admin/base_resource.rb +503 -0
  117. data/lib/ruby_ui_admin/cards/base_card.rb +59 -0
  118. data/lib/ruby_ui_admin/cards/chart_card.rb +16 -0
  119. data/lib/ruby_ui_admin/cards/metric_card.rb +22 -0
  120. data/lib/ruby_ui_admin/cards/partial_card.rb +14 -0
  121. data/lib/ruby_ui_admin/configuration.rb +177 -0
  122. data/lib/ruby_ui_admin/current.rb +20 -0
  123. data/lib/ruby_ui_admin/dashboard_manager.rb +52 -0
  124. data/lib/ruby_ui_admin/engine.rb +80 -0
  125. data/lib/ruby_ui_admin/execution_context.rb +36 -0
  126. data/lib/ruby_ui_admin/fields/association_field.rb +67 -0
  127. data/lib/ruby_ui_admin/fields/badge_field.rb +19 -0
  128. data/lib/ruby_ui_admin/fields/base_field.rb +208 -0
  129. data/lib/ruby_ui_admin/fields/belongs_to_field.rb +49 -0
  130. data/lib/ruby_ui_admin/fields/boolean_field.rb +13 -0
  131. data/lib/ruby_ui_admin/fields/boolean_group_field.rb +54 -0
  132. data/lib/ruby_ui_admin/fields/code_field.rb +18 -0
  133. data/lib/ruby_ui_admin/fields/date_field.rb +34 -0
  134. data/lib/ruby_ui_admin/fields/date_time_field.rb +37 -0
  135. data/lib/ruby_ui_admin/fields/field_manager.rb +27 -0
  136. data/lib/ruby_ui_admin/fields/file_field.rb +67 -0
  137. data/lib/ruby_ui_admin/fields/files_field.rb +62 -0
  138. data/lib/ruby_ui_admin/fields/has_and_belongs_to_many_field.rb +9 -0
  139. data/lib/ruby_ui_admin/fields/has_many_field.rb +24 -0
  140. data/lib/ruby_ui_admin/fields/has_one_field.rb +13 -0
  141. data/lib/ruby_ui_admin/fields/hidden_field.rb +14 -0
  142. data/lib/ruby_ui_admin/fields/id_field.rb +19 -0
  143. data/lib/ruby_ui_admin/fields/key_value_field.rb +41 -0
  144. data/lib/ruby_ui_admin/fields/number_field.rb +13 -0
  145. data/lib/ruby_ui_admin/fields/password_field.rb +21 -0
  146. data/lib/ruby_ui_admin/fields/record_link_field.rb +45 -0
  147. data/lib/ruby_ui_admin/fields/select_field.rb +71 -0
  148. data/lib/ruby_ui_admin/fields/status_field.rb +21 -0
  149. data/lib/ruby_ui_admin/fields/text_field.rb +14 -0
  150. data/lib/ruby_ui_admin/fields/textarea_field.rb +13 -0
  151. data/lib/ruby_ui_admin/fields/url_field.rb +21 -0
  152. data/lib/ruby_ui_admin/filters/base_filter.rb +81 -0
  153. data/lib/ruby_ui_admin/filters/boolean_filter.rb +9 -0
  154. data/lib/ruby_ui_admin/filters/multiple_select_filter.rb +11 -0
  155. data/lib/ruby_ui_admin/filters/select_filter.rb +9 -0
  156. data/lib/ruby_ui_admin/filters/text_filter.rb +9 -0
  157. data/lib/ruby_ui_admin/menu/builder.rb +94 -0
  158. data/lib/ruby_ui_admin/resource_manager.rb +82 -0
  159. data/lib/ruby_ui_admin/scopes/base_scope.rb +107 -0
  160. data/lib/ruby_ui_admin/services/authorization_service.rb +38 -0
  161. data/lib/ruby_ui_admin/structure.rb +40 -0
  162. data/lib/ruby_ui_admin/tailwind_source.rb +65 -0
  163. data/lib/ruby_ui_admin/version.rb +5 -0
  164. data/lib/ruby_ui_admin/view.rb +34 -0
  165. data/lib/ruby_ui_admin.rb +110 -0
  166. data/lib/tasks/ruby_ui_admin.rake +71 -0
  167. data/public/ruby-ui-admin-assets/application.css +2 -0
  168. data/public/ruby-ui-admin-assets/controllers/index.js +36 -0
  169. data/public/ruby-ui-admin-assets/controllers/rua--bulk-select_controller.js +19 -0
  170. data/public/ruby-ui-admin-assets/controllers/rua--confirm_controller.js +51 -0
  171. data/public/ruby-ui-admin-assets/controllers/rua--dialog_controller.js +55 -0
  172. data/public/ruby-ui-admin-assets/controllers/rua--row-link_controller.js +16 -0
  173. data/public/ruby-ui-admin-assets/controllers/rua--tabs_controller.js +36 -0
  174. data/public/ruby-ui-admin-assets/controllers/ruby_ui--combobox_controller.js +197 -0
  175. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_content_controller.js +7 -0
  176. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_controller.js +15 -0
  177. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sidebar_controller.js +67 -0
  178. data/public/ruby-ui-admin-assets/controllers/ruby_ui--toast_controller.js +151 -0
  179. data/public/ruby-ui-admin-assets/controllers/ruby_ui--toaster_controller.js +314 -0
  180. data/public/ruby-ui-admin-assets/vendor/stimulus.js +2567 -0
  181. data/public/ruby-ui-admin-assets/vendor/turbo.js +7178 -0
  182. metadata +379 -0
@@ -0,0 +1,101 @@
1
+ # Authorization adapters
2
+
3
+ Authorization is **pluggable**. The admin checks every request through an *adapter*, selected with
4
+ `config.authorization_client`:
5
+
6
+ ```ruby
7
+ RubyUIAdmin.configure do |config|
8
+ config.authorization_client = :action_policy # default
9
+ # config.authorization_client = :pundit
10
+ # config.authorization_client = :cancancan
11
+ # config.authorization_client = MyApp::CustomAdapter
12
+ # config.authorization_client = nil # disable authorization entirely
13
+ end
14
+ ```
15
+
16
+ Internally `RubyUIAdmin::Services::AuthorizationService` is a thin facade that resolves the adapter
17
+ (`RubyUIAdmin::Authorization.adapter_class`) and delegates to it, so controllers, views and
18
+ resources stay backend-agnostic.
19
+
20
+ ## Built-in adapters
21
+
22
+ | Value | Backend | Requires gem | Policy resolution |
23
+ |---|---|---|---|
24
+ | `:action_policy` (default) | [action_policy](https://actionpolicy.evilmartians.io) | — (gem dependency) | per-resource `authorization_policy` (`RubyUIAdmin::BasePolicy` subclass) |
25
+ | `:pundit` | [Pundit](https://github.com/varvet/pundit) | `pundit` | per-resource `authorization_policy`, else Pundit inference |
26
+ | `:cancancan` | [CanCanCan](https://github.com/CanCanCommunity/cancancan) | `cancancan` | a single global `Ability` (`config.cancancan_ability_class`) |
27
+
28
+ `:pundit`/`:cancancan` are loaded **lazily** — the gem only requires them when selected, so they're
29
+ optional dependencies you add to your own `Gemfile`.
30
+
31
+ ## Capability matrix
32
+
33
+ | Capability | action_policy | Pundit | CanCanCan |
34
+ |---|---|---|---|
35
+ | CRUD (`index`/`show`/`new`/`edit`/`create`/`update`/`destroy`) | ✅ | ✅ | ✅ |
36
+ | Record scope on the index | ✅ `relation_scope` | ✅ policy `Scope` | ✅ `accessible_by` |
37
+ | Field-level rules (`view_<field>?`, …) | ✅ | ✅ (if the policy defines them) | ❌ not supported (all fields visible) |
38
+ | Custom action auth (`act_on?`) | ✅ | ✅ (if defined) | ⚠️ via the mapped action / `:manage` |
39
+ | `true_user` (impersonation context) | ✅ | ❌ (policies get `user` only) | ❌ (Ability gets `user` only) |
40
+ | `explicit_authorization` fallback (undefined rule → allowed/denied) | ✅ | ✅ | ❌ (CanCanCan denies whatever the Ability doesn't grant) |
41
+
42
+ > **Recommendation:** use `:action_policy` (or `:pundit`) if you need field-level rules,
43
+ > impersonation context, or the `explicit_authorization` fallback. `:cancancan` covers CRUD +
44
+ > record scoping; field-level authorization is silently skipped (every field stays visible).
45
+
46
+ ## Using Pundit
47
+
48
+ Add `gem "pundit"`, set `config.authorization_client = :pundit`, and point each resource's
49
+ `authorization_policy` at a Pundit policy (one that responds to `index?`/`show?`/… and, optionally,
50
+ a `Scope`). Rules the policy doesn't define fall back to `explicit_authorization`. Pundit policies
51
+ receive only `user` — `true_user` is not passed.
52
+
53
+ ## Using CanCanCan
54
+
55
+ Add `gem "cancancan"`, set `config.authorization_client = :cancancan`, and define a single
56
+ `Ability` (override the class with `config.cancancan_ability_class` if it isn't named `Ability`):
57
+
58
+ ```ruby
59
+ class Ability
60
+ include CanCan::Ability
61
+ def initialize(user)
62
+ can :manage, :all if user&.admin?
63
+ can :read, Post, published: true
64
+ end
65
+ end
66
+ ```
67
+
68
+ The index scope follows the Ability's rules (`accessible_by`). Note the limitations in the matrix:
69
+ no field-level rules, and `explicit_authorization` has no effect (grant access in the Ability).
70
+
71
+ ## Writing a custom adapter
72
+
73
+ Subclass `RubyUIAdmin::Authorization::Adapter` and implement the four backend methods. The base
74
+ provides the context (`user`, `true_user`, `record`, `policy_class`), `allowed?`, `normalize_rule`
75
+ and `handle_missing_policy`:
76
+
77
+ ```ruby
78
+ module MyApp
79
+ class CustomAdapter < RubyUIAdmin::Authorization::Adapter
80
+ # Authorize a rule (`:show`, `:update`, `:act_on`, `:view_<field>`…). Return a boolean; raise
81
+ # RubyUIAdmin::NotAuthorizedError when denied and raise_exception is true.
82
+ def authorize_action(rule, record: nil, raise_exception: true)
83
+ # ... your logic, using `user` / `true_user` / `policy_class` ...
84
+ end
85
+
86
+ # Apply a record scope to the index relation (return it unchanged when there's no scope).
87
+ def apply_policy(scope) = scope
88
+
89
+ # Does the resolved policy respond to this rule (for the given record)?
90
+ def has_rule?(rule, record: nil) = false
91
+
92
+ # Does the policy CLASS explicitly define this rule? (field-level: undefined ⇒ visible)
93
+ def defines_rule?(rule) = false
94
+ end
95
+ end
96
+
97
+ RubyUIAdmin.configure { |c| c.authorization_client = MyApp::CustomAdapter }
98
+ ```
99
+
100
+ A custom adapter class is used as-is. To expose it under a symbol instead, add it to
101
+ `RubyUIAdmin::Authorization::REGISTRY`.
@@ -0,0 +1,45 @@
1
+ # Explicit authorization mode
2
+
3
+ `config.explicit_authorization` controls what happens when authorization can't find an
4
+ explicit answer.
5
+
6
+ ```ruby
7
+ RubyUIAdmin.configure do |config|
8
+ config.explicit_authorization = false # default
9
+ end
10
+ ```
11
+
12
+ ## What it affects
13
+
14
+ ### Resource & action rules
15
+
16
+ For `index?/show?/create?/update?/destroy?/act_on?`, a rule the policy doesn't define falls
17
+ back to the policy's default rule (`manage?`), which `RubyUIAdmin::BasePolicy` ties to this
18
+ flag:
19
+
20
+ | `explicit_authorization` | Undefined rule | No policy resolved at all |
21
+ |---|---|---|
22
+ | `false` (default) | **allowed** | **allowed** |
23
+ | `true` | **denied** | **denied** |
24
+
25
+ So `true` is a strict, deny-by-default posture: every allowed action must be declared in a
26
+ policy. This matches a security-first setup.
27
+
28
+ ### Field rules
29
+
30
+ Field/association visibility (`view_<field>?`) is **not** governed by this flag. A field is
31
+ always visible unless a policy explicitly defines its rule and returns false. This is
32
+ deliberate — turning on `explicit_authorization` must not silently hide every unconfigured
33
+ field. See [Field-level authorization](field-authorization.md).
34
+
35
+ ## Disabling authorization
36
+
37
+ Set `config.authorization_client = nil` to turn authorization off entirely (all rules pass,
38
+ scopes are not applied). Useful for a trusted internal tool, or while bootstrapping.
39
+
40
+ ## Choosing a mode
41
+
42
+ - **`false`** — convenient: define only the rules that restrict access; everything else is
43
+ open. Good default for small teams / trusted admins.
44
+ - **`true`** — strict: nothing is permitted unless a policy says so. Prefer this when the
45
+ admin is exposed to many roles and you want fail-closed behaviour.
@@ -0,0 +1,53 @@
1
+ # Field-level authorization
2
+
3
+ You can hide individual fields per user by defining rules on the resource's policy. This
4
+ applies everywhere a field would appear: the index columns, the show view, **and** the
5
+ new/edit forms — including param permitting, so an unauthorized field can't be submitted.
6
+
7
+ ## Rules
8
+
9
+ For a field `:views_count`, define one of these on the resource policy:
10
+
11
+ ```ruby
12
+ module RubyUIAdmin
13
+ module Policies
14
+ class PostPolicy < RubyUIAdmin::BasePolicy
15
+ # Generic visibility (all views):
16
+ def view_views_count? = user.admin?
17
+
18
+ # Or per-view (most specific wins):
19
+ def index_views_count? = user.admin? # index column
20
+ def show_views_count? = true # show view
21
+ def edit_views_count? = user.admin? # new + edit forms
22
+ end
23
+ end
24
+ end
25
+ ```
26
+
27
+ ### Resolution order
28
+
29
+ For a field `<id>` in a given view, the first **defined** rule wins:
30
+
31
+ | View | Rules checked (in order) |
32
+ |---|---|
33
+ | index | `index_<id>?` → `view_<id>?` |
34
+ | show | `show_<id>?` → `view_<id>?` |
35
+ | new | `new_<id>?` → `edit_<id>?` → `view_<id>?` |
36
+ | edit | `edit_<id>?` → `view_<id>?` |
37
+
38
+ For association fields the `<id>` is the association name (e.g. `view_comments?`).
39
+
40
+ ## Default behaviour
41
+
42
+ - A field is **visible** unless a matching rule is defined **and returns false**.
43
+ - An **undefined** rule means "no opinion" — the field stays visible. This is deliberate:
44
+ enabling [`explicit_authorization`](action-policy.md) does **not** silently hide every
45
+ unconfigured field (only resource/action authorization is affected by that flag).
46
+ - Field authorization only runs when the resource has an explicit
47
+ `self.authorization_policy` and `authorization_client` isn't `nil`.
48
+
49
+ ## Security
50
+
51
+ Because the same check filters the fields used for **param permitting and assignment**, a
52
+ field hidden from a user is also stripped from `create`/`update` — they can't set it by
53
+ crafting a request.
@@ -0,0 +1,62 @@
1
+ # Policies
2
+
3
+ Authorization is handled by [action_policy](https://actionpolicy.evilmartians.io). Admin
4
+ policies live in `app/ruby_ui_admin/policies` (`RubyUIAdmin::Policies::*`) and inherit
5
+ `RubyUIAdmin::BasePolicy`. Attach one to a resource with `self.authorization_policy`.
6
+
7
+ ```ruby
8
+ # app/ruby_ui_admin/resources/post.rb
9
+ class Post < RubyUIAdmin::BaseResource
10
+ self.authorization_policy = RubyUIAdmin::Policies::PostPolicy
11
+ end
12
+
13
+ # app/ruby_ui_admin/policies/post_policy.rb
14
+ module RubyUIAdmin
15
+ module Policies
16
+ class PostPolicy < RubyUIAdmin::BasePolicy
17
+ def index? = true
18
+ def show? = true
19
+ def create? = user.admin?
20
+ def update? = user.admin? || record.user_id == user.id
21
+ def destroy? = user.admin?
22
+ def act_on? = user.admin? # gate custom actions
23
+ end
24
+ end
25
+ end
26
+ ```
27
+
28
+ ## Rule mapping
29
+
30
+ | Controller action | Rule | Target |
31
+ |---|---|---|
32
+ | index | `index?` | model class |
33
+ | show | `show?` | record |
34
+ | new / create | `create?` | record |
35
+ | edit / update | `update?` | record |
36
+ | destroy | `destroy?` | record |
37
+ | custom actions | `act_on?` | record(s) / model class |
38
+
39
+ Field- and association-level rules (`view_<field>?`, `show_<field>?`, …) are covered in
40
+ [Field-level authorization](field-authorization.md).
41
+
42
+ ## Defaults
43
+
44
+ `RubyUIAdmin::BasePolicy` wires action_policy's default rule (`manage?`) to
45
+ [`explicit_authorization`](explicit-mode.md): a rule you don't define falls back to `manage?`,
46
+ which is allowed when `explicit_authorization` is false (the default) and denied when true.
47
+
48
+ `authorize :user` is declared with `allow_nil: true`, so rules must guard a possibly-nil user
49
+ (`user&.admin?`) when the admin can be reached without a signed-in user.
50
+
51
+ ## Context
52
+
53
+ The `user` comes from `config.current_user_method`. `true_user` (the real user behind an
54
+ impersonation) is also available, resolved from `config.true_user_method` (defaults to the
55
+ current user) — gate on it with `true_user&.admin?`. To pass *additional* context (e.g. an
56
+ account), declare it in a custom base policy and provide it — see the
57
+ [action_policy docs](https://actionpolicy.evilmartians.io/#/authorization_context).
58
+
59
+ ## The current user
60
+
61
+ `RubyUIAdmin::Current.user` exposes the resolved user during a request. In views, use
62
+ `authorized_to?(:rule, record, policy_class:)` to show/hide buttons and links.
@@ -0,0 +1,48 @@
1
+ # Authorization scopes
2
+
3
+ A policy's **scope** filters the index query so each user only sees the records they're
4
+ allowed to — action_policy's equivalent of Pundit's `policy_scope`. Define it with
5
+ `relation_scope` in the resource policy.
6
+
7
+ ```ruby
8
+ module RubyUIAdmin
9
+ module Policies
10
+ class PostPolicy < RubyUIAdmin::BasePolicy
11
+ relation_scope do |relation|
12
+ next relation if user.admin?
13
+
14
+ relation.where(user_id: user.id) # members see only their own
15
+ end
16
+ end
17
+ end
18
+ end
19
+ ```
20
+
21
+ ## How it's applied
22
+
23
+ On the index, the query flows through:
24
+
25
+ ```
26
+ model.all → policy relation_scope → named scope → filters → sorting → pagination
27
+ ```
28
+
29
+ So the authorization scope runs first and can't be bypassed by filters or named scopes.
30
+
31
+ - If the policy defines **no** `relation_scope`, the relation is returned unchanged (all
32
+ records, subject to the other steps).
33
+ - If `config.authorization_client` is `nil`, scoping is skipped entirely.
34
+
35
+ ## Not the same as named scopes
36
+
37
+ This is **record-level authorization**, distinct from [named index scopes](../resources/scopes.md)
38
+ (the tab bar). Both narrow the index query, but:
39
+
40
+ - **Authorization scope** (`relation_scope`) is enforced for security and always applied.
41
+ - **Named scopes** are user-facing tabs the user chooses between; they compose *after* the
42
+ authorization scope.
43
+
44
+ ## Using a scope elsewhere
45
+
46
+ Because the index uses the policy scope, a user who isn't allowed to see a record also can't
47
+ reach it through the index. Direct access (show/edit) is still guarded by the per-record
48
+ rules (`show?`, `update?`, …).
@@ -0,0 +1,62 @@
1
+ # Custom controllers & lifecycle hooks
2
+
3
+ Each resource is served by a generic `RubyUIAdmin::ResourcesController`. To customize CRUD
4
+ behaviour for one resource, generate a per-resource controller:
5
+
6
+ ```bash
7
+ rails g ruby_ui_admin:controller Buyer
8
+ # => app/controllers/ruby_ui_admin/buyers_controller.rb
9
+ # class RubyUIAdmin::BuyersController < RubyUIAdmin::ResourcesController
10
+ ```
11
+
12
+ When that file exists, the resource's routes point to it automatically (otherwise they use
13
+ the generic controller). Restart the server after adding a new controller so routes redraw.
14
+
15
+ ## Lifecycle hooks
16
+
17
+ Override any of these (defaults live in `ResourcesController`):
18
+
19
+ | Hook | Default | When |
20
+ |---|---|---|
21
+ | `create_success_action` | redirect to `after_create_path`, flash notice | record saved on create |
22
+ | `create_fail_action` | re-render the form (422) | validation failed on create |
23
+ | `update_success_action` | redirect to `after_update_path`, flash notice | record saved on update |
24
+ | `update_fail_action` | re-render the form (422) | validation failed on update |
25
+ | `destroy_success_action` | redirect to `after_destroy_path`, flash notice | record destroyed |
26
+ | `destroy_fail_action` | redirect to the record, flash alert | `destroy` returned false |
27
+ | `after_create_path` / `after_update_path` | the record's show page | used by the success actions |
28
+ | `after_destroy_path` | the resource index | used by destroy success |
29
+
30
+ ```ruby
31
+ module RubyUIAdmin
32
+ class BuyersController < RubyUIAdmin::ResourcesController
33
+ # Custom redirect + flash after update
34
+ def update_success_action
35
+ redirect_to resources_index_path, notice: "Buyer updated."
36
+ end
37
+
38
+ # Delete asynchronously instead of inline
39
+ def destroy_fail_action
40
+ Buyers::DeleteBuyerJob.perform_later(buyer: @record)
41
+ redirect_to resources_index_path, notice: "Deletion started in the background."
42
+ end
43
+ end
44
+ end
45
+ ```
46
+
47
+ Inside hooks you have the usual controller context: `@record`, `@resource`, `params`,
48
+ `current_user`, `redirect_to`/`render`, `flash`, and the path helpers `resources_index_path`
49
+ and `record_path(record)`.
50
+
51
+ ## Overriding the CRUD actions
52
+
53
+ You can also override `create`, `update`, `destroy` (etc.) directly and call `super`:
54
+
55
+ ```ruby
56
+ def create
57
+ # custom param munging…
58
+ super
59
+ end
60
+ ```
61
+
62
+ Prefer the [eject generator](ejecting.md) for changing the generic controller or views.
@@ -0,0 +1,55 @@
1
+ # Ejecting & customizing
2
+
3
+ When configuration isn't enough, you can **eject** any engine file into your app and edit it.
4
+ A copy placed at the same path in your app's `app/` shadows the engine's version (Zeitwerk
5
+ loads your app's file first), so the framework picks up your customization automatically.
6
+
7
+ ## The generator
8
+
9
+ ```bash
10
+ # View components (app/components/ruby_ui_admin/views/*)
11
+ rails g ruby_ui_admin:eject --view index # the index table/page
12
+ rails g ruby_ui_admin:eject --view show
13
+ rails g ruby_ui_admin:eject --view form
14
+ rails g ruby_ui_admin:eject --view layout # alias for the Base layout (sidebar/topbar)
15
+ rails g ruby_ui_admin:eject --view field_value # how field values render
16
+ rails g ruby_ui_admin:eject --view field_input # how form inputs render
17
+
18
+ # Controllers (app/controllers/ruby_ui_admin/*)
19
+ rails g ruby_ui_admin:eject --controller resources # CRUD behaviour
20
+ rails g ruby_ui_admin:eject --controller application # base controller (auth hooks, etc.)
21
+
22
+ # The admin's own UI components (app/components/ruby_ui_admin/ui/*) — to restyle
23
+ rails g ruby_ui_admin:eject --ui icon
24
+ rails g ruby_ui_admin:eject --ui pagination
25
+ rails g ruby_ui_admin:eject --ui select
26
+ ```
27
+
28
+ The file is copied verbatim to the same relative path in your app. Edit it there.
29
+
30
+ > **Restyling RubyUI primitives** (button, table, badge, card, sidebar, …): those aren't ejected
31
+ > from the gem — the admin renders **your app's** `RubyUI::*` components, so just edit them in
32
+ > `app/components/ruby_ui/*` (where RubyUI generated them). `--ui` only covers the components the
33
+ > gem owns: `icon`, `pagination`, `select`, and the `toast_*` family.
34
+
35
+ ## How overriding works
36
+
37
+ The engine's controllers and Phlex components are autoloaded Ruby classes (e.g.
38
+ `RubyUIAdmin::Views::Index`, `RubyUIAdmin::ResourcesController`). Because your app's autoload
39
+ paths take precedence over an engine's, a file at the identical path in your app defines the
40
+ same class and **wins** — no configuration or monkey-patching required.
41
+
42
+ ## Common customizations
43
+
44
+ | Goal | Eject |
45
+ |---|---|
46
+ | Change the sidebar/top bar/branding | `--view layout` |
47
+ | Tweak the index table markup | `--view index` |
48
+ | Change how a field type renders | `--view field_value` / `--view field_input` |
49
+ | Override CRUD redirects/messages | `--controller resources` |
50
+ | Add an authentication/authorization hook | `--controller application` |
51
+ | Restyle a RubyUI primitive (button/table/badge/…) | edit your app's `app/components/ruby_ui/*` |
52
+ | Restyle an admin-owned component (icon/pagination/select/toast) | `--ui icon` / `--ui pagination` / … |
53
+
54
+ > Eject only what you need — every ejected file is a copy you now maintain. Prefer
55
+ > [configuration](../getting-started/configuration.md) when it covers your use case.
@@ -0,0 +1,161 @@
1
+ # JavaScript
2
+
3
+ The admin's interactive behaviour is built with native **Hotwire** — **Stimulus controllers** plus
4
+ **Turbo** (Drive + Frames) — that **progressively enhance** the server-rendered HTML. Everything
5
+ works without JavaScript; Hotwire only improves a few interactions.
6
+
7
+ ## How it's delivered
8
+
9
+ The admin renders **your host app's RubyUI components**, so the JavaScript comes from **your app's
10
+ bundler/importmap** — the admin doesn't ship its own runtime. It needs two sets of controllers
11
+ registered:
12
+
13
+ - The admin's own controllers, prefixed **`rua--*`**: `rua--tabs`, `rua--dialog` (action modals),
14
+ `rua--bulk-select`, `rua--row-link`, `rua--confirm` (delete confirmation).
15
+ - **`ruby-ui--toaster` / `ruby-ui--toast`** — the admin renders RubyUI Toast for flash messages
16
+ **even if your app doesn't use RubyUI Toast**, so you probably won't already have these (the
17
+ toaster is patched for server-rendered flash). The other `ruby-ui--*` it uses (`ruby-ui--sidebar`
18
+ /`ruby-ui--sheet`, `ruby-ui--combobox`, …) are the same ones you already use for RubyUI elsewhere
19
+ — combobox/popover depend on `@floating-ui/dom`, so make sure it's available.
20
+
21
+ **Don't wire these by hand.** The gem ships them flat under
22
+ `public/ruby-ui-admin-assets/controllers/` plus an `index.js` that registers each under the right
23
+ identifier. The `ruby_ui_admin:assets` generator copies the whole directory into your app and tells
24
+ you the one import to add:
25
+
26
+ ```bash
27
+ bin/rails generate ruby_ui_admin:assets
28
+ # Bundler (esbuild/jsbundling/vite): copies to app/javascript/ruby_ui_admin/,
29
+ # then add to application.js: import "./ruby_ui_admin"
30
+ # Importmap + Propshaft: does NOT copy — pins the engine-served (undigested) entrypoint:
31
+ # config/importmap.rb: pin "ruby_ui_admin", to: "/ruby-ui-admin-assets/controllers/index.js"
32
+ # application.js: import "ruby_ui_admin"
33
+ ```
34
+
35
+ Why importmap doesn't copy: Propshaft content-digests copied files, but `index.js`'s relative
36
+ `./x.js` imports resolve to the *undigested* URLs (import maps only remap bare specifiers), which
37
+ Propshaft won't serve → 404. The engine serves the same files **undigested** via Rack::Static, so
38
+ relative imports work there and `@hotwired/stimulus`/`@hotwired/turbo` resolve through your own
39
+ import map.
40
+
41
+ Avoid translating the files into `controllers/rua/…` folders and running
42
+ `stimulus:manifest:update` — the manifest would derive the wrong identifiers. The shipped `index.js`
43
+ already registers everything correctly; you just import it.
44
+
45
+ > Symptom: if flash toasts don't appear after an action, `ruby-ui--toaster` / `ruby-ui--toast`
46
+ > are missing from your bundle — re-run `ruby_ui_admin:assets` and re-add the import.
47
+
48
+ You wire these (plus your stylesheet) into the admin layout through **`config.head_assets`**:
49
+
50
+ ```ruby
51
+ RubyUIAdmin.configure do |config|
52
+ config.head_assets = lambda do
53
+ safe_join([
54
+ stylesheet_link_tag("application", "data-turbo-track": "reload"),
55
+ javascript_importmap_tags # or javascript_include_tag("application", type: "module")
56
+ ])
57
+ end
58
+ end
59
+ ```
60
+
61
+ `config.head_assets` is a proc evaluated in the Rails view context, so it can call the usual asset
62
+ helpers. When it's `nil`, the layout emits no assets (the page still renders, just unstyled and
63
+ without enhancement). `config.javascript` is a convenience flag your proc can read to skip the JS
64
+ tags.
65
+
66
+ > **Turbo is on.** Navigation uses Turbo Drive (no full-page reloads), so POST→redirect responses
67
+ > use **303 See Other** and the toaster region is re-rendered each visit (it is intentionally not
68
+ > `turbo-permanent`) so flash messages always appear. Lazy tabs and the action modal load through
69
+ > **Turbo Frames**. Caveat: if your *host* app uses Turbo + importmap, the link into the engine must
70
+ > be a full page load (`data-turbo="false"` on that link) so the engine's own importmap applies.
71
+
72
+ ## What it enhances
73
+
74
+ ### Tabs (`rua--tabs`)
75
+
76
+ Tab groups (`tabs do … end` in a resource) render as stacked, titled cards **without JS**. The
77
+ controller reveals the tab bar, hides the redundant per-panel headings, and shows only the active
78
+ panel. Switching tabs is instant and needs no server round-trip.
79
+
80
+ #### Lazy tabs
81
+
82
+ When `config.lazy_tabs` is on, non-active tabs render only a `<turbo-frame loading="lazy" src="…">`
83
+ (a spinner until it loads). Turbo fetches the fragment URL when the tab becomes visible (the show
84
+ action returns just that tab's content wrapped in the matching `<turbo-frame id>`), and swaps it in
85
+ — no custom fetch; the `rua--tabs` controller only switches panels. Without JS the deferred tabs
86
+ show a `<noscript>` "requires JavaScript" notice. See
87
+ [Tabs & Panels › Lazy tab loading](../resources/tabs-panels.md#lazy-tab-loading).
88
+
89
+ ### Action modals (`rua--dialog`)
90
+
91
+ A custom action trigger opens an inline **modal** whose body is a `<turbo-frame>` holding the
92
+ action's form (loaded lazily, so the action's fields aren't evaluated until the modal opens).
93
+ **Non-bulk** modals carry a static `src` + `loading="lazy"` (Turbo loads them when the dialog
94
+ becomes visible); **bulk** modals have no server-side `src` — `rua--dialog#open` sets it from the
95
+ currently checked rows, so reopening with a different selection refetches. The form carries
96
+ `data-turbo-frame="_top"`, so submitting breaks out into a full Turbo visit (POST→303→redirect+flash)
97
+ rather than reloading just the frame. Without JS the same trigger is a plain link to the action's
98
+ page, so actions always work. The modal closes on the ✕ button, the backdrop, the cancel link, or
99
+ the Escape key.
100
+
101
+ ### Bulk selection (`rua--bulk-select`)
102
+
103
+ On index tables with bulk actions, the controller reveals the "select all" checkbox (hidden
104
+ without JS) and toggles the per-row record checkboxes; the checked ids are submitted with a bulk
105
+ action. Without JS there is no client-side selection.
106
+
107
+ ### Row links (`rua--row-link`)
108
+
109
+ With `config.click_row_to_view_record`, clicking an index row (or a row in a `has_many`
110
+ association table) navigates to the record's show page — ignoring clicks on links/buttons/inputs
111
+ so row controls keep working. Without JS rows aren't clickable (the per-row show/edit links remain).
112
+
113
+ ### Delete confirmation (`rua--confirm`)
114
+
115
+ The **Delete** control opens a confirmation **AlertDialog** (shared, on `<body>`) before
116
+ submitting; confirming submits the delete form. Without JS the form submits directly, so deletion
117
+ still works.
118
+
119
+ ### Notifications (Toast) — `ruby-ui--toaster` / `ruby-ui--toast`
120
+
121
+ Flash messages (`notice` / `alert`, and any `succeed`/`error`/`inform`/`warn` from an action)
122
+ render through the genuine **RubyUI Toast** component (vendored under `RubyUIAdmin::UI::Toast*`).
123
+ Toasts appear **bottom-right**, with a variant-coloured left border and icon, stack, auto-dismiss,
124
+ and can be swiped or closed. Flash key → variant: `notice`/`success` → **success** (green),
125
+ `alert`/`error` → **error** (red), plus `warning`/`info`. You can also raise them from your own JS:
126
+
127
+ ```js
128
+ RubyUI.toast.success("Saved")
129
+ RubyUI.toast.error("Something went wrong", { description: "Try again." })
130
+ ```
131
+
132
+ Without JS, a `<noscript>` fallback shows the messages as badges so they're never lost.
133
+
134
+ ### Sidebar — `ruby-ui--sidebar`
135
+
136
+ The left navigation is the genuine **RubyUI Sidebar** (`RubyUIAdmin::UI::Sidebar*`). It is **fixed
137
+ and expanded by default**; the top-bar trigger collapses it to an **icon rail** (`collapsible: :icon`)
138
+ and the choice is **persisted in a cookie** (`sidebar_state`, 7 days). Below `768px` it becomes a
139
+ **drawer** (a RubyUI Sheet). Without JS the sidebar simply renders expanded.
140
+
141
+ ## Markup hooks
142
+
143
+ The controllers are driven by standard Stimulus attributes (`data-controller`,
144
+ `data-action`, `data-<id>-target`, `data-<id>-<name>-param/value`) plus a few `data-rua-*` markers
145
+ (handy if you eject/customize views):
146
+
147
+ | Hook | Purpose |
148
+ |---|---|
149
+ | `[data-controller="rua--tabs"]` | tab group; targets `tab`/`panel`/`nav`/`heading`, action `click->rua--tabs#show` |
150
+ | `[data-rua-tab="key"]` / `[data-rua-tab-panel="key"]` | button↔panel key matching |
151
+ | `<turbo-frame id="rua-tab-frame-N" loading="lazy" src="/url">` | a deferred tab panel; Turbo loads it when the tab is shown (`config.lazy_tabs`) |
152
+ | `[data-controller="rua--dialog"]` | manages action modals; trigger `click->rua--dialog#open` + `…-id-param`/`…-bulk-param`, close via `click->rua--dialog#close` |
153
+ | `[data-rua-dialog="id"]` + inner `<turbo-frame>` | the modal; the frame loads the form (`[data-rua-frame-base]` on bulk frames is the base URL the controller appends checked ids to) |
154
+ | `[data-controller="rua--bulk-select"]` | table bulk selection; target `selectAll`, action `change->rua--bulk-select#toggleAll`; `[data-rua-row-select]` marks a record checkbox |
155
+ | `[data-controller="rua--row-link"]` | clickable row; `…-url-value` + `click->rua--row-link#navigate` |
156
+ | `[data-controller="rua--confirm"]` (on `<body>`) | shared confirm AlertDialog; targets `dialog`/`title`/`message`; trigger `click->rua--confirm#request` + `…-message-param`/`…-heading-param`; `#confirm`/`#cancel` |
157
+ | `[data-controller="ruby-ui--toaster"]` / `…--toast` | the RubyUI Toast region/items |
158
+ | `[data-controller="ruby-ui--sidebar"]` + `[data-sidebar="trigger"]` | the RubyUI Sidebar (collapse + mobile drawer) |
159
+ | `[data-controller="ruby-ui--combobox"]` | the RubyUI Combobox (searchable filters and actions menu) |
160
+
161
+ > Progressive enhancement is intentional: the whole admin is usable with JavaScript disabled.
@@ -0,0 +1,80 @@
1
+ # Theming with RubyUI
2
+
3
+ The admin renders **your host app's RubyUI (Phlex) components**, so it inherits your app's RubyUI
4
+ theme automatically — there's no separate admin theme to keep in sync.
5
+
6
+ ## How styling is delivered
7
+
8
+ The admin renders the same `RubyUI::*` components you generated into your app, which emit Tailwind
9
+ utility classes. Styling comes from **your app's Tailwind build** — there is no admin-specific
10
+ stylesheet. You must ensure your Tailwind content/sources cover:
11
+
12
+ - `app/components/ruby_ui/**` (your RubyUI components), and
13
+ - the admin's own views (the engine's `app/components/ruby_ui_admin/**`), so the classes used by
14
+ the admin's layout/lists/forms are generated.
15
+
16
+ Then point the admin layout at your compiled CSS via `config.head_assets` (see
17
+ [JavaScript › head assets](javascript.md)). Because the admin uses your RubyUI components, any
18
+ RubyUI design tokens they rely on (e.g. `success` / `warning` colors) must be defined in your
19
+ theme.
20
+
21
+ ## Design tokens
22
+
23
+ RubyUI components resolve colors through CSS variables (design tokens) defined in your app's
24
+ RubyUI stylesheet — typically:
25
+
26
+ ```css
27
+ :root {
28
+ --background: #ffffff;
29
+ --foreground: #0a0a0a;
30
+ --muted: #f4f4f5; --muted-foreground: #71717a;
31
+ --border: #e4e4e7; --input: #e4e4e7; --ring: #18181b;
32
+ --primary: #18181b; --primary-foreground: #fafafa;
33
+ --secondary: #f4f4f5; --secondary-foreground: #18181b;
34
+ --accent: #f4f4f5; --accent-foreground: #18181b;
35
+ --destructive: #ef4444;
36
+ --popover: #ffffff; --popover-foreground: #0a0a0a;
37
+ /* Sidebar (left navigation) */
38
+ --sidebar: #fafafa; --sidebar-foreground: #3f3f46;
39
+ --sidebar-accent: #f4f4f5; --sidebar-accent-foreground: #18181b;
40
+ --sidebar-border: #e5e7eb; --sidebar-ring: #3b82f6;
41
+ }
42
+ ```
43
+
44
+ Utilities like `bg-background`, `text-muted-foreground`, `border-border`, `bg-primary/90`,
45
+ `bg-sidebar`, `text-sidebar-accent-foreground` resolve through these variables, so re-theming
46
+ is a matter of changing the token values. The `--sidebar-*` tokens style the left navigation
47
+ independently from the page (so you can give it its own surface colour).
48
+
49
+ ## Dark mode
50
+
51
+ A `.dark` variant is included. Add the `dark` class to a parent element (e.g. `<html>`) to
52
+ switch. The tokens above have dark overrides bundled.
53
+
54
+ ## Make sure your Tailwind build covers the admin
55
+
56
+ Because the admin renders with your app's RubyUI components and its own Phlex views, your
57
+ Tailwind build's content/sources must include both, or admin pages will be missing styles:
58
+
59
+ - `app/components/ruby_ui/**` — your RubyUI components, and
60
+ - the admin's views — the engine's `app/components/ruby_ui_admin/**` (in the installed gem).
61
+
62
+ Rather than hardcode the gem's path in `@source` (it varies per environment), run
63
+ `bin/rails ruby_ui_admin:tailwind_source` to extract the admin's class inventory into
64
+ `app/assets/tailwind/ruby_ui_admin_classes.html`, `@source` that (commit it; re-run on upgrade), then
65
+ expose the compiled stylesheet to the admin layout through `config.head_assets`.
66
+
67
+ ## Integrating with the host's own Tailwind build (advanced)
68
+
69
+ If you'd rather own the build (e.g. to override tokens app-wide or share your design system),
70
+ point your host Tailwind v4 entry at the engine's components and define the tokens yourself:
71
+
72
+ ```css
73
+ @import "tailwindcss";
74
+ @source "./ruby_ui_admin_classes.html"; /* generated by rake ruby_ui_admin:tailwind_source */
75
+ /* define the :root tokens above (or reuse your existing RubyUI tokens) */
76
+ ```
77
+
78
+ Then disable the bundled stylesheet by ejecting the layout, or simply let both load (the host
79
+ build wins by load order). Apps that already use RubyUI (with the same token vocabulary) only
80
+ need the `@source` line.