administratum 0.2.4

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 (88) hide show
  1. checksums.yaml +7 -0
  2. data/DESIGN.md +68 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +431 -0
  5. data/Rakefile +6 -0
  6. data/app/admin/administratum/user_admin.rb +39 -0
  7. data/app/assets/builds/administratum.css +2 -0
  8. data/app/assets/javascripts/administratum.js +250 -0
  9. data/app/assets/tailwind/administratum/components.css +199 -0
  10. data/app/assets/tailwind/administratum/index.css +8 -0
  11. data/app/assets/tailwind/administratum/theme.css +62 -0
  12. data/app/controllers/administratum/application_controller.rb +31 -0
  13. data/app/controllers/administratum/dashboard_controller.rb +7 -0
  14. data/app/controllers/administratum/page.rb +51 -0
  15. data/app/controllers/administratum/password_resets_controller.rb +42 -0
  16. data/app/controllers/administratum/resource.rb +224 -0
  17. data/app/controllers/administratum/sessions_controller.rb +33 -0
  18. data/app/helpers/administratum/application_helper.rb +11 -0
  19. data/app/helpers/administratum/form_helper.rb +115 -0
  20. data/app/helpers/administratum/island_helper.rb +16 -0
  21. data/app/helpers/administratum/layout_helper.rb +81 -0
  22. data/app/helpers/administratum/resource_helper.rb +199 -0
  23. data/app/mailers/administratum/application_mailer.rb +8 -0
  24. data/app/mailers/administratum/user_mailer.rb +11 -0
  25. data/app/models/administratum/application_record.rb +7 -0
  26. data/app/models/administratum/user.rb +13 -0
  27. data/app/views/administratum/dashboard/index.html.erb +16 -0
  28. data/app/views/administratum/fields/_belongs_to.html.erb +1 -0
  29. data/app/views/administratum/fields/_boolean.html.erb +1 -0
  30. data/app/views/administratum/fields/_date.html.erb +1 -0
  31. data/app/views/administratum/fields/_datetime.html.erb +1 -0
  32. data/app/views/administratum/fields/_email.html.erb +1 -0
  33. data/app/views/administratum/fields/_has_many_ids.html.erb +1 -0
  34. data/app/views/administratum/fields/_hidden.html.erb +1 -0
  35. data/app/views/administratum/fields/_island.html.erb +4 -0
  36. data/app/views/administratum/fields/_json.html.erb +1 -0
  37. data/app/views/administratum/fields/_multi_select.html.erb +1 -0
  38. data/app/views/administratum/fields/_number.html.erb +1 -0
  39. data/app/views/administratum/fields/_password.html.erb +1 -0
  40. data/app/views/administratum/fields/_select.html.erb +1 -0
  41. data/app/views/administratum/fields/_string.html.erb +1 -0
  42. data/app/views/administratum/fields/_textarea.html.erb +1 -0
  43. data/app/views/administratum/fields/_wrapper.html.erb +11 -0
  44. data/app/views/administratum/pages/index.html.erb +7 -0
  45. data/app/views/administratum/password_resets/edit.html.erb +17 -0
  46. data/app/views/administratum/password_resets/new.html.erb +11 -0
  47. data/app/views/administratum/resources/_attributes.html.erb +11 -0
  48. data/app/views/administratum/resources/_form.html.erb +14 -0
  49. data/app/views/administratum/resources/_table.html.erb +42 -0
  50. data/app/views/administratum/resources/edit.html.erb +7 -0
  51. data/app/views/administratum/resources/index.html.erb +17 -0
  52. data/app/views/administratum/resources/new.html.erb +7 -0
  53. data/app/views/administratum/resources/show.html.erb +7 -0
  54. data/app/views/administratum/sessions/new.html.erb +18 -0
  55. data/app/views/administratum/shared/_card.html.erb +9 -0
  56. data/app/views/administratum/shared/_filters.html.erb +43 -0
  57. data/app/views/administratum/shared/_flash.html.erb +9 -0
  58. data/app/views/administratum/shared/_form_errors.html.erb +10 -0
  59. data/app/views/administratum/shared/_nav.html.erb +10 -0
  60. data/app/views/administratum/shared/_page_header.html.erb +17 -0
  61. data/app/views/administratum/shared/_panel.html.erb +24 -0
  62. data/app/views/administratum/shared/_topbar.html.erb +29 -0
  63. data/app/views/administratum/user_mailer/reset_password.html.erb +4 -0
  64. data/app/views/administratum/user_mailer/reset_password.text.erb +7 -0
  65. data/app/views/layouts/administratum/application.html.erb +25 -0
  66. data/app/views/layouts/administratum/authentication.html.erb +32 -0
  67. data/config/locales/en.yml +65 -0
  68. data/config/routes.rb +16 -0
  69. data/db/migrate/20261016000000_create_administratum_users.rb +17 -0
  70. data/lib/administratum/configuration.rb +66 -0
  71. data/lib/administratum/definition.rb +283 -0
  72. data/lib/administratum/dsl.rb +195 -0
  73. data/lib/administratum/engine.rb +31 -0
  74. data/lib/administratum/field_types.rb +77 -0
  75. data/lib/administratum/page_dsl.rb +48 -0
  76. data/lib/administratum/paths.rb +85 -0
  77. data/lib/administratum/ransackable.rb +41 -0
  78. data/lib/administratum/registry.rb +95 -0
  79. data/lib/administratum/routing.rb +57 -0
  80. data/lib/administratum/tailwind.rb +34 -0
  81. data/lib/administratum/version.rb +5 -0
  82. data/lib/administratum.rb +43 -0
  83. data/lib/generators/administratum/install/install_generator.rb +42 -0
  84. data/lib/generators/administratum/install/templates/administratum.rb +41 -0
  85. data/lib/generators/administratum/resource/resource_generator.rb +30 -0
  86. data/lib/generators/administratum/resource/templates/resource.rb.tt +20 -0
  87. data/lib/tasks/administratum.rake +32 -0
  88. metadata +209 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fcaca7de2a8fed0707058baa27c24e2a9c2ab553dcdbec4a8f3b953896fc28fb
4
+ data.tar.gz: 8d3d03d1d3faa80e0b70f326cac95d271f768e70c68c8a4b52da4c442732e408
5
+ SHA512:
6
+ metadata.gz: e447dab0a88331180f092c0a86997df9108790d4e8d5cca733b35dae7b8fc451c144e95c49a8820683c47c65b7bf7e1a6f0fe319816de6a6b4a8f8a530ee57e9
7
+ data.tar.gz: df80245537c76698d02d2a60b340b5dfe446872b0bdd1dee7ecce34cdba5e339826467e70d7ccb080e568fd1ca4284173967dca614f97e4f92776c6f75f5557c
data/DESIGN.md ADDED
@@ -0,0 +1,68 @@
1
+ # Administratum design system
2
+
3
+ Read this before changing any view, helper or stylesheet in the gem.
4
+
5
+ ## Rules
6
+
7
+ - **Colours come only from tokens.** Never write a raw colour or a stock palette utility (`bg-blue-600`) in a view, helper or component. Use `bg-adm-primary`, `text-adm-muted`, `border-adm-border`, and so on.
8
+ - **Markup uses `adm-*` component classes.** They're defined in `app/assets/tailwind/administratum/components.css` with `@apply` on the tokens. Add a new component class when a pattern repeats; one-off layout tweaks can use plain utilities.
9
+ - **Class names must appear literally** in `app/views`, `app/helpers` or `app/assets/javascripts`, so Tailwind's `@source` finds them. Never interpolate class names.
10
+ - **Rebuild after changes.** Run `bin/build-css` after touching views, helpers or CSS, and commit `app/assets/builds/administratum.css`.
11
+ - **No inline styles or inline event handlers.** Behaviour lives in `administratum.js` and is attached through `data-adm-*` attributes.
12
+
13
+ ## Tokens (`theme.css`)
14
+
15
+ | Token | Role |
16
+ |---|---|
17
+ | `--color-adm-bg` | Page background |
18
+ | `--color-adm-surface` | Cards, bars, panels |
19
+ | `--color-adm-surface-muted` | Table headers, card footers, hover rows |
20
+ | `--color-adm-border` | All borders and dividers |
21
+ | `--color-adm-text` | Primary text |
22
+ | `--color-adm-muted` | Secondary text, labels in tables |
23
+ | `--color-adm-primary` / `--color-adm-primary-fg` | Primary buttons, active nav, links |
24
+ | `--color-adm-danger` / `--color-adm-danger-fg` | Destructive actions, errors, alert flash |
25
+ | `--color-adm-success` | Positive badges |
26
+ | `--color-adm-warning` | Reserved for warnings |
27
+ | `--color-adm-ring` | Focus rings |
28
+ | `--color-adm-overlay` | Offcanvas backdrop |
29
+ | `--radius-adm` | Corner radius for cards, inputs and buttons |
30
+ | `--font-adm`, `--font-adm-mono` | Body and code fonts |
31
+
32
+ Dark values are set under `:root.dark`, and under `prefers-color-scheme: dark` unless `:root.light` is set, which covers pages with JS disabled.
33
+
34
+ An inline script always puts `.dark` or `.light` on `<html>` before first paint: the stored choice from `localStorage["administratum-theme"]` if there is one, otherwise the OS preference. Until the viewer picks a theme, it follows OS changes. Host styles can therefore rely on `.dark`, for example through Tailwind's class-based `dark` variant, which `theme.css` defines.
35
+
36
+ Overrides, in order of precedence:
37
+ 1. `config.theme` / `config.dark_theme`, rendered as an inline `<style>`.
38
+ 2. The host app's `@theme`, in `:host` stylesheet mode.
39
+ 3. The defaults above.
40
+
41
+ ## Components (`components.css`)
42
+
43
+ | Area | Classes |
44
+ |---|---|
45
+ | Layout | `adm-container`, `adm-main`, `adm-page-body`, `adm-grid` |
46
+ | Chrome | `adm-topbar`, `adm-brand`, `adm-nav`, `adm-nav-link`, `adm-nav-link-active`, `adm-dropdown`, `adm-dropdown-menu`, `adm-dropdown-item` |
47
+ | Page header | `adm-page-header`, `adm-page-title`, `adm-page-actions`, `adm-breadcrumb`, `adm-breadcrumb-item` |
48
+ | Cards | `adm-card`, `adm-card-header`, `adm-card-title`, `adm-card-body`, `adm-card-footer`, `adm-tile` |
49
+ | Buttons | `adm-btn` plus one of `adm-btn-primary`, `-secondary`, `-danger`, `-ghost`; sizes `adm-btn-sm`, `adm-btn-block`; `adm-btn-group`, `adm-icon-btn`, `adm-link` |
50
+ | Tables | `adm-table-wrap`, `adm-table`, `adm-table-actions`, `adm-table-footer`, `adm-empty`, `adm-pagy` |
51
+ | Show page | `adm-attributes`, `adm-attribute` |
52
+ | Forms | `adm-form`, `adm-field`, `adm-field-horizontal`, `adm-field-control`, `adm-label`, `adm-input`, `adm-checkbox`, `adm-check`, `adm-hint`, `adm-error`, `adm-form-footer`, `adm-alert` |
53
+ | Feedback | `adm-badge`, `adm-badge-success`, `adm-flash`, `adm-flash-notice`, `adm-flash-alert` |
54
+ | Panels | `adm-offcanvas`, `adm-offcanvas-panel`, `adm-offcanvas-header`, `adm-offcanvas-body` |
55
+ | Auth | `adm-auth`, `adm-auth-panel`, `adm-auth-title` |
56
+
57
+ ## Behaviour hooks (`administratum.js`)
58
+
59
+ | Attribute | Effect |
60
+ |---|---|
61
+ | `data-adm-offcanvas-open="name"` | Opens `[data-adm-offcanvas="name"]` |
62
+ | `data-adm-offcanvas-close` | Closes the open panel; Escape does too |
63
+ | `data-adm-dropdown`, `-toggle`, `-menu` | Dropdowns |
64
+ | `data-adm-confirm="…"` (on a form) | Asks for confirmation before submitting |
65
+ | `data-adm-toggle="id"` | Toggles `adm-open` on the element with that id |
66
+ | `data-adm-theme-toggle` | Switches between light and dark |
67
+ | `data-adm-flash`, `data-adm-dismiss` | Flash messages auto-dismiss after 6s, or close on click |
68
+ | `data-administratum-island="name"` | Island mount point; rendered by the `island` helper |
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright buffpojken
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,431 @@
1
+ # Administratum
2
+
3
+ A Rails admin engine where every adminable resource is one file in `app/admin`.
4
+ You get a Tailwind v4 CRUD UI with Devise login out of the box. When the defaults
5
+ stop fitting, you can replace any part of it: a field, a partial, a whole page, or
6
+ a JS island running Vue, React or anything else.
7
+
8
+ - One class per resource, and that class is a real controller.
9
+ - Index, show, new, edit and delete, with filters (Ransack) and pagination (Pagy).
10
+ - A field DSL with built-in types, plus your own types backed by a partial.
11
+ - Template overrides by file location: `app/views/admin/<resource>/edit.html.erb`.
12
+ - Nested resources, shallow or not.
13
+ - Custom member and collection actions, and buttons that trigger them.
14
+ - Framework-agnostic JS islands, mounted with props from ERB.
15
+ - Styling through design tokens. Use the bundled CSS, or build it with your own Tailwind v4.
16
+
17
+ Requires Rails 8, with Propshaft or Sprockets. With Sprockets, add `//= link administratum.css` and
18
+ `//= link administratum.js` to `app/assets/config/manifest.js`.
19
+
20
+ ## Install
21
+
22
+ ```ruby
23
+ gem "administratum", "~> 0.2"
24
+ ```
25
+
26
+ ### Switching from CodeArtifact or a git source
27
+
28
+ - Replace the `source "https://greyseal-…codeartifact…" do` block (or the `git:` line) with the line above,
29
+ then run `bundle update administratum`.
30
+ - Drop the CodeArtifact token from the Kamal `builder.secrets`, `.kamal/secrets` and the
31
+ `RUN --mount=type=secret` in the Dockerfile.
32
+ - Unset `bundle config local.administratum` if you set it (add `--global` if it was set there).
33
+ - To work against a local checkout, temporarily use `gem "administratum", path: "../administratum"`. Don't commit it.
34
+
35
+ ### Setting up an app
36
+
37
+ ```sh
38
+ bin/rails g administratum:install # initializer, migration, app/admin, mount at /administratum
39
+ bin/rails db:migrate
40
+ bin/rails administratum:create_user EMAIL=you@example.com
41
+ bin/rails g administratum:resource Post # app/admin/post_admin.rb
42
+ ```
43
+
44
+ ### Alongside ActiveAdmin
45
+
46
+ ActiveAdmin also uses `app/admin`, and removes it from Zeitwerk. Administratum skips that directory
47
+ with a warning in the log, so nothing breaks. Its resources then need a directory of their own, and
48
+ **this initializer should ship in the same deploy as the Gemfile change**:
49
+
50
+ ```ruby
51
+ # config/initializers/administratum.rb
52
+ Administratum.configure do |config|
53
+ config.resource_paths = ["app/admin_next"]
54
+ config.view_prefix = "admin_next" # templates in app/views/admin_next/<resource>/
55
+ end
56
+ ```
57
+
58
+ Mount the engine away from ActiveAdmin's path with `bin/rails g administratum:install --path=/admin_next`.
59
+ Once ActiveAdmin is gone, move everything back to `app/admin` and drop both settings.
60
+
61
+ ## Resources
62
+
63
+ ```ruby
64
+ # app/admin/post_admin.rb
65
+ class PostAdmin < Administratum::Resource
66
+ model Post # inferred from the class name when omitted
67
+ title :title # display name of a record (default: title/name)
68
+ label "Article", plural: "Articles"
69
+
70
+ filters :title, :published, :category_id # default: the title attribute
71
+ filter :language, as: :select, collection: %w[en sv]
72
+
73
+ index sort: "created_at desc", per_page: 50, csv: true do
74
+ column :title
75
+ column :category # belongs_to: links to the record
76
+ column :published # booleans render as badges
77
+ column(:comments, sortable: false) { |post| post.comments.size } # runs in the view
78
+ column(:preview, csv: false) { |post| link_to "Preview", post } # left out of the CSV
79
+ end
80
+
81
+ form do
82
+ field :title, hint: "Shown in listings"
83
+ field :body, as: :textarea, rows: 8
84
+ field :published
85
+ field :category_id # inferred :belongs_to select
86
+ field :tag_ids # inferred :has_many_ids multi-select
87
+ field :status, as: :select, collection: -> { Post::STATUSES }
88
+ end
89
+
90
+ show do # default: id, form fields, timestamps
91
+ attribute :title
92
+ attribute(:words) { |post| post.body.to_s.split.size }
93
+ attribute :published_at, if: ->(post) { post.published? } # row hidden otherwise
94
+ end
95
+
96
+ permit :title, :body, :published, :category_id, tag_ids: [] # default: the form fields
97
+ scope { |posts| posts.includes(:category) } # every query starts here
98
+ scope :published # index tab, calls Post.published
99
+ actions except: :destroy # :index :show :new :edit :destroy
100
+ menu label: "Articles", priority: 10 # `menu false` hides it
101
+ end
102
+ ```
103
+
104
+ A resource class is a controller (`Administratum::Resource < ActionController::Base`
105
+ by default). Everything you'd do in a controller works: `before_action`, `rescue_from`,
106
+ `helper`, overriding `resource_params` or `scoped_collection`, and so on. In views, the
107
+ current record is `@resource`, the index records are `@resources`, and the parent of a
108
+ nested resource is `@parent`.
109
+
110
+ For a namespaced model (`model ActsAsTaggableOn::Tag`), Rails derives the route key and form
111
+ params from the full name: `/acts_as_taggable_on_tags` and `params[:acts_as_taggable_on_tag]`.
112
+ Set `route :tags` for a shorter URL. The param key stays the same, so `permit` and
113
+ `resource_params` work as usual.
114
+
115
+ Resource files are loaded to draw routes, so the DSL never touches the database at
116
+ class load. Types and defaults are resolved when a request is made.
117
+
118
+ ### Field types
119
+
120
+ | Type | Notes |
121
+ |---|---|
122
+ | `string` | Default for string columns |
123
+ | `textarea` | `rows:` |
124
+ | `number` | `step:`, `min:`, `max:` |
125
+ | `boolean` | |
126
+ | `select` | `collection:` takes an array, a relation, or a lambda (optionally receiving the record); `include_blank:` |
127
+ | `multi_select` | Permitted as an array |
128
+ | `belongs_to` | Inferred for `*_id` belongs_to columns |
129
+ | `has_many_ids` | Inferred for `*_ids`; permitted as an array |
130
+ | `date` | Text input for ISO dates (YYYY-MM-DD), whatever the browser locale |
131
+ | `datetime` | Text input for YYYY-MM-DD HH:MM, parsed in the app time zone |
132
+ | `email` | |
133
+ | `password` | |
134
+ | `hidden` | |
135
+ | `json` | Pretty-printed textarea |
136
+ | `island` | Rendered by a JS island (see below): `island: "name", props: ->(record) { {...} }` |
137
+
138
+ Every field also takes these options:
139
+
140
+ | Option | Effect |
141
+ |---|---|
142
+ | `label:` | Overrides the label. Defaults to the attribute's human name, with `tag_ids` shown as "Tags". |
143
+ | `hint:` | Help text shown under the input and linked to it with `aria-describedby`. |
144
+ | `placeholder:` | Placeholder text. On selects it becomes the blank option's text. |
145
+ | `required:` | Shows or hides the required marker. Defaults to whether the model validates presence. |
146
+ | `input_html: {}` | Extra HTML attributes on the input. |
147
+
148
+ Custom field partials get the same wiring by building their attributes with `input_options(field, form:, class: "adm-input")`.
149
+
150
+ Register your own type with a partial:
151
+
152
+ ```ruby
153
+ # config/initializers/administratum.rb
154
+ config.field_types.register :color, partial: "admin/fields/color" # param: :scalar (default)
155
+ config.field_types.register :tag_list, partial: "admin/fields/tags", param: :array
156
+ ```
157
+
158
+ ```erb
159
+ <%# app/views/admin/fields/_color.html.erb — locals: form, field, resource, value %>
160
+ <%= form.color_field field.name, class: "adm-input" %>
161
+ ```
162
+
163
+ The label, hint and errors wrapper is added for you. To restyle a built-in type,
164
+ shadow its partial at `app/views/administratum/fields/_<type>.html.erb`.
165
+
166
+ ### Index scopes
167
+
168
+ Named scopes become tabs above the index table, each with a record count:
169
+
170
+ ```ruby
171
+ class PostAdmin < Administratum::Resource
172
+ scope :all # Post.all
173
+ scope :published # Post.published
174
+ scope(:recent, label: "This week") { |posts| posts.where(created_at: 1.week.ago..) }
175
+ scope :archived, count: false # skip the count query
176
+ end
177
+ ```
178
+
179
+ - The first tab is active unless one says `default: true`. The URL carries `?scope=published`.
180
+ - Counts and results respect the current filters. Switching tabs keeps filters and sorting, and goes back to page 1.
181
+ - The CSV export follows the active tab.
182
+ - Labels default to `administratum.scopes.<name>` in your locale, then to the humanized name.
183
+ - `scope` with only a block (no name) still sets the base query for every request, not a tab.
184
+ - In a custom `index.html.erb`, render the tabs with `<%= index_scope_tabs %>`.
185
+
186
+ ### Template overrides
187
+
188
+ Templates are looked up in `app/views/admin/<route_key>/` first, then in the engine.
189
+ To replace a page, add a file:
190
+
191
+ | File | Replaces |
192
+ |---|---|
193
+ | `index.html.erb` | The index page |
194
+ | `show.html.erb` | The show page |
195
+ | `new.html.erb` / `edit.html.erb` | The whole page |
196
+ | `_form.html.erb` | Only the form, on both new and edit |
197
+ | `_table.html.erb` / `_attributes.html.erb` | The index table / the show attribute list |
198
+ | `<custom_action>.html.erb` | A custom action's page |
199
+
200
+ You can also point `form template: "admin/posts/editor"` at a partial elsewhere.
201
+ To keep these templates somewhere other than `app/views/admin` (for example while an
202
+ older admin still lives there), set `config.view_prefix = "admin_next"`.
203
+
204
+ Helpers available in templates:
205
+
206
+ ```erb
207
+ <%= page_header title: "Write #{@resource.title}" do %>
208
+ <%= buttons_for(:show) %> <%# built-in plus custom buttons for a page %>
209
+ <% end %>
210
+
211
+ <%= administratum_form do |form| %> <%# form_with with the right URL and method %>
212
+ <%= render_fields form, except: [:body] %> <%# DSL fields %>
213
+ <%= render_field form, :body %> <%# a single DSL field %>
214
+ <%= render_field form, :notes, as: :textarea %> <%# an ad-hoc field %>
215
+ <%= adm_date_field form, :ends_on %> <%# ISO date input; also adm_datetime_field %>
216
+ <%= form.submit class: "adm-btn adm-btn-primary" %>
217
+ <% end %>
218
+ ```
219
+
220
+ Path helpers:
221
+ - `collection_path`, `new_resource_path`, `resource_path(record)`, `edit_resource_path(record)`
222
+ - `resource_form_path(record)`, `action_path(:write, record)`, `parent_path`
223
+ - `paths_for(OtherAdmin).index`, for paths to another resource
224
+
225
+ Layout helpers: `card(title:) { }`, `island`, `record_title`, `format_value`.
226
+
227
+ ### Nested resources
228
+
229
+ ```ruby
230
+ class CommentAdmin < Administratum::Resource
231
+ parent :post, shallow: true # association defaults to post.comments
232
+ end
233
+ ```
234
+
235
+ - Collection routes (index, new, create) nest under the parent: `/posts/1/comments`.
236
+ - Shallow member routes stand alone: `/comments/5/edit`. Without `shallow:`, they also nest.
237
+ - Queries go through the parent's association, so a child of another parent returns 404.
238
+ - Breadcrumbs include the parent.
239
+ - Nested resources are left out of the menu. Link to them with a button (below).
240
+
241
+ One level of nesting is supported.
242
+
243
+ ### Custom actions and buttons
244
+
245
+ ```ruby
246
+ class PostAdmin < Administratum::Resource
247
+ member_action :write # GET, renders admin/posts/write.html.erb
248
+ member_action :save_body, method: :patch
249
+ member_action :publish, method: :post
250
+ collection_action :export
251
+ collection_action :import, method: :post
252
+
253
+ button :write, on: %i[show row], style: :primary
254
+ button :publish, on: :show, confirm: "Publish?", if: ->(post) { !post.published? }
255
+ button :comments, on: :show, to: ->(post) { post_comments_path(post) } # plain link
256
+ button :import, on: :index, upload: ".json" # file upload panel
257
+ button :add_tag, on: :show, panel: "admin/posts/add_tag" # your partial in a side panel
258
+
259
+ def save_body
260
+ @resource.update!(body: params.require(:body))
261
+ render json: { body: @resource.body }
262
+ end
263
+
264
+ def publish
265
+ @resource.update!(published: true)
266
+ redirect_to resource_path, notice: "Published"
267
+ end
268
+
269
+ def export = send_data(Post.export_json, filename: "posts.json")
270
+
271
+ def import
272
+ Post.import_json(params.require(:file).read)
273
+ redirect_to collection_path
274
+ end
275
+ end
276
+ ```
277
+
278
+ Button options:
279
+ - `on:` takes `:index`, `:show`, `:new`, `:edit` or `:row` (one button per index row).
280
+ - `style:` takes `:primary`, `:secondary`, `:danger` or `:ghost`.
281
+ - `if:` and `to:` lambdas run in the view and receive the record when they take an argument.
282
+
283
+ Non-GET buttons render as small forms, so no rails-ujs is needed.
284
+
285
+ A panel partial receives `resource:` and `button:`.
286
+
287
+ ## Pages
288
+
289
+ A page is a screen that isn't CRUD on one model, like a report, a tool or a status page. It
290
+ lives in `app/admin` like a resource, gets a menu entry and a route, and is a controller:
291
+
292
+ ```ruby
293
+ # app/admin/reports_page.rb
294
+ class ReportsPage < Administratum::Page
295
+ label "Reports" # default: from the class name
296
+ menu priority: 5 # or `menu false`
297
+ page_action :refresh, method: :post # POST /administratum/reports/refresh
298
+ page_action :weekly # GET /administratum/reports/weekly
299
+ button :refresh, confirm: "Refresh?", style: :primary # buttons go in the page header
300
+ button :weekly
301
+
302
+ def index
303
+ @stats = Stats.compute
304
+ end
305
+
306
+ def refresh
307
+ Stats.refresh!
308
+ redirect_to page_path, notice: "Refreshed"
309
+ end
310
+ end
311
+ ```
312
+
313
+ ```erb
314
+ <%# app/views/admin/reports/_content.html.erb: the default index renders the header, then this %>
315
+ <%= card title: "Posts" do %><%= @stats[:posts] %><% end %>
316
+ ```
317
+
318
+ - Templates live in `app/views/admin/<route_key>/`. The route key comes from the class name
319
+ (`ReportsPage` is `reports`), or set it with `route :stats`.
320
+ - Replace the whole page with `index.html.erb`. A GET `page_action` without a method renders `<name>.html.erb`.
321
+ - In templates and the class: `page_path`, `action_path(:refresh)`, and `page_class`.
322
+ - From elsewhere: `administratum.reports_page_path` and `administratum.refresh_reports_page_path`.
323
+ - Pages have the same sign-in, layout, `before_action` and `helper` support as resources.
324
+ - Pagy is included, so `@pagy, @rows = pagy(scope)` and `<%== pagy_nav(@pagy) %>` work on pages too.
325
+
326
+ ## JS islands
327
+
328
+ An island is a mount point with JSON props. What runs inside it is up to you:
329
+
330
+ ```erb
331
+ <%= island :post_writer, props: { body: @resource.body, saveUrl: action_path(:save_body) } %>
332
+ ```
333
+
334
+ ```js
335
+ // your admin JS, loaded through config.head
336
+ import { createApp } from "vue"
337
+ import PostWriter from "./PostWriter.vue"
338
+
339
+ window.Administratum.islands.register("post-writer", (el, props) => {
340
+ const app = createApp(PostWriter, props) // props.csrfToken is always present
341
+ app.mount(el)
342
+ return () => app.unmount() // teardown, called before Turbo swaps the page
343
+ })
344
+ ```
345
+
346
+ - Islands mount on `DOMContentLoaded` and `turbo:load`, and never twice.
347
+ - If your script can run before Administratum's, queue registrations: `(window.Administratum ||= { islands: { queue: [] } }).islands.queue.push([name, fn])`.
348
+ - `Administratum.csrfToken()` is also available.
349
+
350
+ Load your own JS and CSS with any asset pipeline:
351
+
352
+ ```ruby
353
+ config.head = ->(view) { view.javascript_importmap_tags("admin") } # importmap
354
+ config.head = ->(view) { view.vite_javascript_tag("admin") } # vite_ruby
355
+ config.head_partial = "admin/head" # or a partial
356
+ ```
357
+
358
+ ## Styling
359
+
360
+ All colours are CSS variables (`--color-adm-*`), so theming never needs a rebuild:
361
+
362
+ ```ruby
363
+ config.theme = { primary: "#7c3aed", bg: "#fafafa" }
364
+ config.dark_theme = { primary: "#a78bfa" }
365
+ ```
366
+
367
+ The available tokens are listed in [DESIGN.md](DESIGN.md).
368
+
369
+ **Host Tailwind v4.** If your app already builds Tailwind v4, compile Administratum into
370
+ your own build so tokens live in your `@theme`:
371
+
372
+ ```sh
373
+ bin/rails administratum:tailwind:sync # writes app/assets/tailwind/administratum.gen.css (gitignore it)
374
+ ```
375
+
376
+ ```css
377
+ @import "tailwindcss";
378
+ @import "./administratum.gen.css";
379
+ @theme { --color-adm-primary: var(--color-brand); }
380
+ ```
381
+
382
+ ```ruby
383
+ config.stylesheet = :host # stop loading the bundled stylesheet
384
+ ```
385
+
386
+ Once the generated file exists, the sync task runs before `assets:precompile` and `vite:build`.
387
+
388
+ ## Configuration
389
+
390
+ See the generated `config/initializers/administratum.rb` for every option. The main ones:
391
+ - `app_name`, `logo`, `dashboard`
392
+ - `parent_controller`, `helpers`, `per_page`
393
+ - `mailer_sender`, `manage_users`
394
+ - `head`, `head_partial`, `theme`, `stylesheet`, `field_types`
395
+
396
+ ## Authentication
397
+
398
+ - **Accounts.** Admins are `Administratum::User` records (table `administratum_users`) on a separate Devise scope, `:administratum_user`. They never collide with your app's own users.
399
+ - **Sign-out.** Signing out of the admin leaves the app's session alone.
400
+ - **Password reset.** Built in. The reset form doesn't reveal whether an email exists.
401
+ - **Current admin.** Available in views as `current_admin`.
402
+
403
+ ## Development
404
+
405
+ ```sh
406
+ bundle install
407
+ bin/rails test
408
+ bundle exec rubocop
409
+ bin/build-css # rebuild app/assets/builds/administratum.css after changing views or CSS
410
+ ```
411
+
412
+ The dummy app in `test/dummy` exercises each extension point:
413
+ - custom field type, write page with an island, and export/import (posts)
414
+ - shallow nesting (comments) and non-shallow nesting (attachments)
415
+ - custom index template (categories) and custom form partial (tags)
416
+
417
+ ## Releasing
418
+
419
+ Releases go to [rubygems.org](https://rubygems.org/gems/administratum).
420
+
421
+ 1. Bump `Administratum::VERSION` in `lib/administratum/version.rb`, run `bundle install` to update
422
+ `Gemfile.lock`, and commit and push to `main`.
423
+ 2. In GitHub, run the **Release** workflow (Actions → Release → Run workflow).
424
+
425
+ The workflow builds the gem, tags `vX.Y.Z`, pushes the tag and pushes the gem. It uses rubygems.org
426
+ trusted publishing, so no API key is stored anywhere.
427
+
428
+ To release from your own machine instead, run `bundle exec rake release` with a clean working tree.
429
+ It asks for your rubygems.org login and an MFA code.
430
+
431
+ Host apps pick up the new version with `bundle update administratum`.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Administratum
4
+ # Built-in management of admin users. Disable with config.manage_users = false.
5
+ class UserAdmin < Administratum::Resource
6
+ model Administratum::User
7
+ title :email
8
+ label "Admin", plural: "Admins"
9
+ route :admin_users
10
+ menu priority: 1000
11
+
12
+ filters :email
13
+ index sort: "email asc" do
14
+ column :email
15
+ column :created_at
16
+ end
17
+
18
+ form do
19
+ field :email
20
+ field :password
21
+ field :password_confirmation, as: :password
22
+ end
23
+
24
+ show do
25
+ attribute :email
26
+ attribute :created_at
27
+ attribute :updated_at
28
+ end
29
+
30
+ private
31
+
32
+ # Leaving the password blank on edit keeps the current one.
33
+ def resource_params
34
+ attrs = super
35
+ attrs = attrs.except(:password, :password_confirmation) if action_name == "update" && attrs[:password].blank?
36
+ attrs
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-divide-y-reverse:0;--tw-leading:initial;--tw-duration:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-3xl:48rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-relaxed:1.625;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-adm-bg:oklch(97.5% .004 255);--color-adm-surface:oklch(100% 0 0);--color-adm-surface-muted:oklch(96.5% .006 255);--color-adm-border:oklch(91% .008 255);--color-adm-text:oklch(24% .02 260);--color-adm-muted:oklch(52% .02 260);--color-adm-primary:oklch(52% .18 262);--color-adm-primary-fg:oklch(99% 0 0);--color-adm-danger:oklch(56% .2 25);--color-adm-danger-fg:oklch(99% 0 0);--color-adm-success:oklch(58% .14 155);--color-adm-ring:oklch(52% .18 262/.35);--color-adm-overlay:oklch(20% .02 260/.45);--radius-adm:.5rem;--font-adm:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--font-adm-mono:ui-monospace, SFMono-Regular, Menlo, monospace}:root{color-scheme:light dark}:root.light{color-scheme:light}:root.dark{color-scheme:dark;--color-adm-bg:oklch(18% .012 260);--color-adm-surface:oklch(23% .014 260);--color-adm-surface-muted:oklch(27% .014 260);--color-adm-border:oklch(33% .014 260);--color-adm-text:oklch(93% .008 260);--color-adm-muted:oklch(70% .015 260);--color-adm-primary:oklch(68% .15 262);--color-adm-primary-fg:oklch(16% .02 260);--color-adm-ring:oklch(68% .15 262/.4);--color-adm-overlay:oklch(5% 0 0/.6)}@media (prefers-color-scheme:dark){:root:not(.light){--color-adm-bg:oklch(18% .012 260);--color-adm-surface:oklch(23% .014 260);--color-adm-surface-muted:oklch(27% .014 260);--color-adm-border:oklch(33% .014 260);--color-adm-text:oklch(93% .008 260);--color-adm-muted:oklch(70% .015 260);--color-adm-primary:oklch(68% .15 262);--color-adm-primary-fg:oklch(16% .02 260);--color-adm-ring:oklch(68% .15 262/.4);--color-adm-overlay:oklch(5% 0 0/.6)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}.adm-body{background-color:var(--color-adm-bg);min-height:100vh;font-family:var(--font-adm);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-adm-text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.adm-scroll-lock{overflow:hidden}}@layer components{.adm-container{width:100%;max-width:var(--container-7xl);padding-inline:calc(var(--spacing) * 4);margin-inline:auto}@media (min-width:40rem){.adm-container{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:64rem){.adm-container{padding-inline:calc(var(--spacing) * 8)}}.adm-main{padding-bottom:calc(var(--spacing) * 12);flex-direction:column;display:flex}.adm-page-body{gap:calc(var(--spacing) * 6);padding-top:calc(var(--spacing) * 6);flex-direction:column;display:flex}.adm-grid{gap:calc(var(--spacing) * 4);display:grid}@media (min-width:40rem){.adm-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:64rem){.adm-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}.adm-sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.adm-muted{color:var(--color-adm-muted)}.adm-topbar{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface)}.adm-topbar-inner{height:calc(var(--spacing) * 14);align-items:center;gap:calc(var(--spacing) * 3);display:flex}.adm-brand{align-items:center;gap:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-adm-text);text-decoration-line:none;display:flex}.adm-brand-name{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.adm-logo{height:calc(var(--spacing) * 7);width:auto}.adm-topbar-actions{align-items:center;gap:var(--spacing);margin-left:auto;display:flex}.adm-nav{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface);display:none}@media (min-width:48rem){.adm-nav{display:block}}.adm-nav.adm-open{display:block}.adm-nav-list{gap:var(--spacing);padding-block:calc(var(--spacing) * 2);flex-direction:column;margin-bottom:-1px;display:flex}@media (min-width:48rem){.adm-nav-list{gap:calc(var(--spacing) * 6);flex-direction:row;padding-block:0}}.adm-nav-link{border-bottom-style:var(--tw-border-style);padding-block:calc(var(--spacing) * 3);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-adm-muted);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-color:#0000;border-bottom-width:2px;text-decoration-line:none;display:block}@media (hover:hover){.adm-nav-link:hover{color:var(--color-adm-text)}}.adm-nav-link-active{border-color:var(--color-adm-primary);color:var(--color-adm-text)}.adm-dropdown{position:relative}.adm-dropdown-menu{z-index:30;margin-top:calc(var(--spacing) * 2);min-width:calc(var(--spacing) * 44);border-radius:var(--radius-adm);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface);padding-block:var(--spacing);--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);position:absolute;right:0;overflow:hidden}.adm-dropdown-item{cursor:pointer;width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);text-align:left;color:var(--color-adm-text);display:block}@media (hover:hover){.adm-dropdown-item:hover{background-color:var(--color-adm-surface-muted)}}.adm-page-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface)}.adm-page-header-inner{align-items:flex-end;gap:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 5);flex-wrap:wrap;display:flex}.adm-page-heading{flex:1;min-width:0}.adm-page-title{text-overflow:ellipsis;white-space:nowrap;font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight);color:var(--color-adm-text);overflow:hidden}.adm-page-actions{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.adm-breadcrumb{margin-bottom:var(--spacing);align-items:center;gap:calc(var(--spacing) * 1.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-adm-muted);flex-wrap:wrap;display:flex}.adm-breadcrumb-item a{color:var(--color-adm-muted);text-decoration-line:none}@media (hover:hover){.adm-breadcrumb-item a:hover{color:var(--color-adm-text)}}.adm-breadcrumb-item+.adm-breadcrumb-item:before{content:"/";margin-right:calc(var(--spacing) * 1.5);color:var(--color-adm-border)}.adm-card{border-radius:var(--radius-adm);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.adm-card-header{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 4);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);display:flex}.adm-card-title{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-adm-text)}.adm-card-body{padding:calc(var(--spacing) * 5)}.adm-card-footer{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface-muted);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3)}.adm-tile{gap:var(--spacing);padding:calc(var(--spacing) * 5);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));flex-direction:column;text-decoration-line:none;display:flex}@media (hover:hover){.adm-tile:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.adm-tile-label{color:var(--color-adm-muted)}.adm-tile-value{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-adm-text);--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.adm-btn{cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-adm);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);white-space:nowrap;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-width:1px;text-decoration-line:none;display:inline-flex}.adm-btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-adm-ring);--tw-outline-style:none;outline-style:none}.adm-btn:disabled{cursor:not-allowed;opacity:.5}.adm-btn-primary{border-color:var(--color-adm-primary);background-color:var(--color-adm-primary);color:var(--color-adm-primary-fg)}@media (hover:hover){.adm-btn-primary:hover{opacity:.9}}.adm-btn-secondary{border-color:var(--color-adm-border);background-color:var(--color-adm-surface);color:var(--color-adm-text)}@media (hover:hover){.adm-btn-secondary:hover{background-color:var(--color-adm-surface-muted)}}.adm-btn-danger{border-color:var(--color-adm-border);background-color:var(--color-adm-surface);color:var(--color-adm-danger)}@media (hover:hover){.adm-btn-danger:hover{border-color:var(--color-adm-danger);background-color:var(--color-adm-danger);color:var(--color-adm-danger-fg)}}.adm-btn-ghost{color:var(--color-adm-text);background-color:#0000;border-color:#0000}@media (hover:hover){.adm-btn-ghost:hover{background-color:var(--color-adm-surface-muted)}}.adm-btn-sm{padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.adm-btn-block{width:100%}.adm-btn-group{justify-content:flex-end;align-items:center;gap:var(--spacing);display:flex}.adm-inline-form{display:inline-flex}.adm-icon-btn{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9);cursor:pointer;border-radius:var(--radius-adm);font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));color:var(--color-adm-muted);justify-content:center;align-items:center;display:inline-flex}@media (hover:hover){.adm-icon-btn:hover{background-color:var(--color-adm-surface-muted);color:var(--color-adm-text)}}.adm-icon-btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-adm-ring);--tw-outline-style:none;outline-style:none}.adm-icon{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.adm-link{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-adm-primary);text-decoration-line:none}@media (hover:hover){.adm-link:hover{text-decoration-line:underline}}.adm-table-wrap{overflow-x:auto}.adm-table{border-collapse:collapse;text-align:left;width:100%}.adm-table th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface-muted);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);white-space:nowrap;color:var(--color-adm-muted);text-transform:uppercase}.adm-table th a{color:var(--color-adm-muted);text-decoration-line:none}@media (hover:hover){.adm-table th a:hover{color:var(--color-adm-text)}}.adm-table td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);vertical-align:middle}.adm-table tbody tr:last-child td{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.adm-table tbody tr:hover td{background-color:var(--color-adm-surface-muted)}.adm-table-actions{text-align:right;white-space:nowrap;width:1px}.adm-table-footer{justify-content:space-between;align-items:center;gap:calc(var(--spacing) * 3);flex-wrap:wrap;display:flex}.adm-empty{padding-block:calc(var(--spacing) * 10);text-align:center;color:var(--color-adm-muted)}.adm-tabs{margin-bottom:calc(var(--spacing) * -2);column-gap:calc(var(--spacing) * 6);row-gap:var(--spacing);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);flex-wrap:wrap;display:flex}.adm-tab{align-items:center;gap:calc(var(--spacing) * 2);border-bottom-style:var(--tw-border-style);padding-block:calc(var(--spacing) * 2);--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-adm-muted);border-color:#0000;border-bottom-width:2px;margin-bottom:-1px;text-decoration-line:none;display:inline-flex}@media (hover:hover){.adm-tab:hover{color:var(--color-adm-text)}}.adm-tab-active{border-color:var(--color-adm-primary);color:var(--color-adm-text)}.adm-tab-count{background-color:var(--color-adm-surface-muted);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-adm-muted);border-radius:3.40282e38px}.adm-pagy .pagy{align-items:center;gap:var(--spacing);display:flex}.adm-pagy .pagy a{border-radius:var(--radius-adm);padding-inline:calc(var(--spacing) * 2.5);padding-block:var(--spacing);color:var(--color-adm-text);text-decoration-line:none}@media (hover:hover){.adm-pagy .pagy a:hover{background-color:var(--color-adm-surface)}}.adm-pagy .pagy a.current,.adm-pagy .pagy a[aria-current=page]{background-color:var(--color-adm-primary);color:var(--color-adm-primary-fg)}.adm-pagy .pagy a:not([href]){color:var(--color-adm-muted)}:where(.adm-attributes>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--color-adm-border)}.adm-attribute{gap:var(--spacing);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3);display:grid}@media (min-width:40rem){.adm-attribute{gap:calc(var(--spacing) * 6);grid-template-columns:12rem 1fr}}.adm-attribute dt{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-adm-muted)}.adm-attribute dd{overflow-wrap:break-word;min-width:0;color:var(--color-adm-text)}.adm-form{gap:calc(var(--spacing) * 5);flex-direction:column;display:flex}.adm-form-flush{gap:0}.adm-form .field_with_errors{display:contents}.adm-form-errors{padding-inline:calc(var(--spacing) * 6);padding-top:calc(var(--spacing) * 6)}.adm-form-body{gap:calc(var(--spacing) * 6);padding:calc(var(--spacing) * 6);flex-direction:column;display:flex}.adm-field{gap:calc(var(--spacing) * 2);flex-direction:column;display:flex}.adm-field-horizontal{gap:calc(var(--spacing) * 2)}@media (min-width:40rem){.adm-field-horizontal{column-gap:calc(var(--spacing) * 8);grid-template-columns:minmax(0,13rem) minmax(0,1fr);display:grid}}.adm-field-label{flex-direction:column;display:flex}@media (min-width:40rem){.adm-field-label{padding-top:calc(var(--spacing) * 2.5)}}.adm-field-control{width:100%;max-width:var(--container-3xl);gap:calc(var(--spacing) * 2);flex-direction:column;min-width:0;display:flex}.adm-field-row{gap:calc(var(--spacing) * 2);display:flex}.adm-label{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-adm-text)}.adm-required{margin-left:calc(var(--spacing) * .5);color:var(--color-adm-danger)}.adm-hint{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed);color:var(--color-adm-muted)}.adm-error{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-adm-danger)}.adm-input{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-adm);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface);width:100%;padding-inline:calc(var(--spacing) * 3.5);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-adm-text);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:block}.adm-input::placeholder{color:#626975b3}@supports (color:color-mix(in lab, red, red)){.adm-input::placeholder{color:color-mix(in oklab, var(--color-adm-muted) 70%, transparent)}}.adm-input:focus{border-color:var(--color-adm-primary);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-adm-ring);--tw-outline-style:none;outline-style:none}textarea.adm-input{min-height:calc(var(--spacing) * 24);--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}select[multiple].adm-input{padding-block:calc(var(--spacing) * 1.5)}select.adm-input:not([multiple]){appearance:none;padding-right:calc(var(--spacing) * 9);background-repeat:no-repeat;background-image:linear-gradient(45deg, transparent 50%, var(--color-adm-muted) 50%), linear-gradient(135deg, var(--color-adm-muted) 50%, transparent 50%);background-position:calc(100% - 1.125rem) 52%,calc(100% - .8125rem) 52%;background-size:.3125rem .3125rem}.adm-field-invalid .adm-input{border-color:var(--color-adm-danger)}.adm-checkbox{width:calc(var(--spacing) * 4.5);height:calc(var(--spacing) * 4.5);accent-color:var(--color-adm-primary);border-radius:.25rem}@media (min-width:40rem){.adm-field-horizontal .adm-field-control>.adm-checkbox{margin-top:calc(var(--spacing) * 3)}}.adm-form-actions{align-items:center;gap:calc(var(--spacing) * 2);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--color-adm-border);background-color:var(--color-adm-surface-muted);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);flex-wrap:wrap;display:flex}@media (min-width:40rem){.adm-form-actions{padding-left:16.5rem}}.adm-check{align-items:center;gap:calc(var(--spacing) * 2);color:var(--color-adm-text);display:flex}.adm-code{font-family:var(--font-adm-mono);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.adm-form-footer{align-items:center;gap:calc(var(--spacing) * 2);flex-wrap:wrap;display:flex}.adm-alert{border-radius:var(--radius-adm);border-style:var(--tw-border-style);border-width:1px;border-color:#d02b3166}@supports (color:color-mix(in lab, red, red)){.adm-alert{border-color:color-mix(in oklab, var(--color-adm-danger) 40%, transparent)}}.adm-alert{background-color:#d02b311a}@supports (color:color-mix(in lab, red, red)){.adm-alert{background-color:color-mix(in oklab, var(--color-adm-danger) 10%, transparent)}}.adm-alert{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-adm-text)}.adm-alert-title{margin-bottom:var(--spacing);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-adm-danger)}.adm-alert ul{padding-left:calc(var(--spacing) * 5);list-style-type:disc}.adm-badge{background-color:var(--color-adm-surface-muted);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-adm-muted);border-radius:3.40282e38px;align-items:center;display:inline-flex}.adm-badge-success{background-color:#0e925426}@supports (color:color-mix(in lab, red, red)){.adm-badge-success{background-color:color-mix(in oklab, var(--color-adm-success) 15%, transparent)}}.adm-badge-success{color:var(--color-adm-success)}.adm-offcanvas{z-index:40;position:fixed;inset:0}.adm-offcanvas-backdrop{background-color:var(--color-adm-overlay);opacity:0;transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;transition-duration:.2s;position:absolute;inset:0}.adm-offcanvas-panel{width:100%;max-width:var(--container-sm);--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y);background-color:var(--color-adm-surface);--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;inset-block:0;flex-direction:column;transition-duration:.2s;display:flex;position:absolute;right:0}.adm-offcanvas-open .adm-offcanvas-backdrop{opacity:1}.adm-offcanvas-open .adm-offcanvas-panel{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.adm-offcanvas-header{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-adm-border);padding-inline:calc(var(--spacing) * 5);padding-block:calc(var(--spacing) * 3.5);justify-content:space-between;align-items:center;display:flex}.adm-offcanvas-body{padding:calc(var(--spacing) * 5);flex:1;overflow-y:auto}.adm-flash-stack{pointer-events:none;inset-inline:0;top:calc(var(--spacing) * 4);z-index:50;align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);flex-direction:column;display:flex;position:fixed}.adm-flash{pointer-events:auto;max-width:var(--container-lg);align-items:center;gap:calc(var(--spacing) * 3);border-radius:var(--radius-adm);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2.5);--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-width:1px;display:flex}.adm-flash-notice{border-color:var(--color-adm-border);background-color:var(--color-adm-text);color:var(--color-adm-surface)}.adm-flash-alert{border-color:var(--color-adm-danger);background-color:var(--color-adm-danger);color:var(--color-adm-danger-fg)}.adm-flash-close{cursor:pointer;font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-leading:1;opacity:.7;line-height:1}@media (hover:hover){.adm-flash-close:hover{opacity:1}}.adm-auth{padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 16);justify-content:center;align-items:center;display:flex}.adm-auth-panel{width:100%;max-width:var(--container-sm);gap:calc(var(--spacing) * 6);flex-direction:column;display:flex}.adm-auth-brand{text-align:center;font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-adm-text)}.adm-auth-title{margin-bottom:calc(var(--spacing) * 4);font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-adm-text)}.adm-auth-lead{margin-bottom:calc(var(--spacing) * 4)}.adm-auth-footer{margin-top:calc(var(--spacing) * 4);text-align:center}}@layer utilities{.block{display:block}.hidden{display:none}.table{display:table}@media (min-width:48rem){.md\:hidden{display:none}}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}