diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b451c68..84b2d3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,28 +6,21 @@ on: pull_request: jobs: - css: + test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - ruby: ['3.1', '3.4'] + ruby: ['3.3', '3.4'] steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - run: bundle exec rake css - - # Capybara integration test for the utility-nav server clock. Needs headless - # Chrome (preinstalled on ubuntu-latest) and Rails 8 (Ruby >= 3.2). - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ruby/setup-ruby@v1 + - uses: actions/setup-node@v4 with: - ruby-version: '3.4' - bundler-cache: true + node-version: 22 + cache: npm + cache-dependency-path: test/dummy/package-lock.json - run: bundle exec rake test diff --git a/.gitignore b/.gitignore index e15aa1e..4bef1b3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ /src/ /test/dummy/tmp/ /test/dummy/log/ -/test/dummy/app/assets/builds/ /test/dummy/node_modules/ +/test/dummy/app/assets/builds/* +!/test/dummy/app/assets/builds/.keep +/tmp/ diff --git a/Gemfile b/Gemfile index 49e2c88..ef09166 100644 --- a/Gemfile +++ b/Gemfile @@ -1,26 +1,36 @@ source 'https://rubygems.org' # Specify your gem's dependencies in active_admin_theme.gemspec + +# Run against an unreleased ActiveAdmin, such as a branch with the layout hooks: +# ACTIVEADMIN_PATH=../activeadmin bundle exec rake test +# ACTIVEADMIN_BRANCH=extensible-layout bundle exec rake test +if ENV["ACTIVEADMIN_PATH"] + gem "activeadmin", path: ENV["ACTIVEADMIN_PATH"] +elsif ENV["ACTIVEADMIN_BRANCH"] + gem "activeadmin", github: ENV.fetch("ACTIVEADMIN_REPO", "senid231/activeadmin"), branch: ENV["ACTIVEADMIN_BRANCH"] +end gemspec -group :development, :test do - gem 'rake' - gem 'sassc' +# Run against an unreleased ActiveAdmin, such as a branch with the layout hooks: +# ACTIVEADMIN_PATH=../activeadmin bundle exec rake test +# ACTIVEADMIN_BRANCH=extensible-layout bundle exec rake test +if ENV["ACTIVEADMIN_PATH"] + gem "activeadmin", path: ENV["ACTIVEADMIN_PATH"] +elsif ENV["ACTIVEADMIN_BRANCH"] + gem "activeadmin", github: ENV.fetch("ACTIVEADMIN_REPO", "senid231/activeadmin"), branch: ENV["ACTIVEADMIN_BRANCH"] end -# Dummy ActiveAdmin app (test/dummy) that hosts the Capybara integration test -# (`rake test`) and sources the neutral README screenshot. Sprockets + -# sassc-rails is the pipeline AA 3.x targets (it emits `//= require` directives -# and SCSS). Rails 8 needs Ruby >= 3.2, so keep these out of the 3.1 bundle — -# `rake css` still runs on 3.1 without them. -if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2') - group :development, :test do - gem 'rails', '~> 8.0' - gem 'sprockets-rails' - gem 'sassc-rails' - gem 'sqlite3' - gem 'puma' - gem 'capybara' - gem 'cuprite' - end +# Dummy ActiveAdmin 4 app (test/dummy): importmap + propshaft, Tailwind CSS +# built with the npm CLI. It hosts the Capybara tests and the screenshot +# scripts. +group :development, :test do + gem 'rake' + gem 'rails', '~> 8.0' + gem 'propshaft' + gem 'importmap-rails' + gem 'sqlite3' + gem 'puma' + gem 'capybara' + gem 'cuprite' end diff --git a/README.md b/README.md index 67806d0..474eb25 100644 --- a/README.md +++ b/README.md @@ -1,486 +1,338 @@ # ActiveAdminTheme -Custom ActiveAdmin templates +A dense desktop theme and a set of shared components for [ActiveAdmin](https://github.com/activeadmin/activeadmin) 4. +ActiveAdmin 4 keeps its menu in a left sidebar and gives breadcrumbs and titles a lot of room. That works on mobile but wastes width on a laptop with a large table and many filters. This theme keeps the compact desktop layout of theme 3 and keeps ActiveAdmin 4's mobile support: -## Installation -As active_skin is the css theme for the [activeadmin](https://github.com/activeadmin/activeadmin) administration framework - you have to install if first. +- **Desktop** (768px and wider): a horizontal top menu, compact breadcrumbs and title, filters left of the table (collapsible). +- **Mobile** (narrower than 768px): the menu opens in a drawer, filters sit above the table, forms and show pages use the full width. +- Index tables scroll sideways when they are wider than the screen, and cells stay on one line. +- Theme 3 colors, density, and typography on top of ActiveAdmin 4's own dark mode. + +Theme 4 needs ActiveAdmin 4 (`>= 4.0.0.beta23`) and Ruby 3.3 or newer. For ActiveAdmin 3, use theme 3.x (branch `3.x`). See [docs/migrating-from-v3.md](docs/migrating-from-v3.md). -#### As a Gem -Having active admin installed add the following line to your application's Gemfile: +## Installation +Add the gem and install it: ```ruby -gem 'active_admin_theme' +gem "active_admin_theme" ``` -And then execute: +The gem requires `active_admin` first, so its views and partials win over ActiveAdmin's own. Do not reorder that. - $ bundle +### CSS -Or install it yourself as: +ActiveAdmin 4 builds its CSS with Tailwind CSS 4. Your Tailwind entry file (generated by `rails g active_admin:assets`, usually `app/assets/stylesheets/active_admin.css`) must import the theme CSS after Tailwind. Pick one route. - $ gem install active_admin_theme +**From the installed gem.** The gem's location differs between machines (a laptop, a Docker image, CI), so the path must not be committed. The task writes it into a generated file next to your entry file, and the entry file only imports that file by a fixed name: -#### As a NPM module (Yarn package) -Execute: +```sh +bin/rails active_admin_theme:css +``` - $ npm i @activeadmin-plugins/active_admin_theme +The entry file gets this block, which is the same on every machine, so commit it: -Or +```css +@import "tailwindcss"; +@config "../../../tailwind-active_admin.config.js"; - $ yarn add @activeadmin-plugins/active_admin_theme +/* active_admin_theme:begin */ +@import "./active_admin_theme.generated.css"; +/* active_admin_theme:end */ +``` -Or add manually to `package.json`: +The task writes `active_admin_theme.generated.css`, which holds the machine-specific path. Add it to `.gitignore`: ``` -"dependencies": { - "@activeadmin-plugins/active_admin_theme": "^3.1.1" -} +/app/assets/stylesheets/active_admin_theme.generated.css ``` -and execute: - $ yarn +Run the task before every Tailwind build, because the generated file does not exist on a fresh checkout and its path changes when the gem upgrades. For example, in the build script that runs Tailwind: -## Usage +```sh +bin/rails active_admin_theme:css && npx tailwindcss -i app/assets/stylesheets/active_admin.css -o app/assets/builds/active_admin.css +``` -In your base stylesheet entry point `active_admin.scss` (as example), add line: +If that is awkward for your build, use the npm route. + +**From the npm package.** This avoids a machine-specific path and a build step, and fits hosts that already install `@activeadmin/activeadmin` from npm: + +```sh +npm install @activeadmin-plugins/active_admin_theme +``` -#### As a Gem via Sprockets ```css -@import 'wigu/active_admin_theme'; +@import "tailwindcss"; +@config "../../../tailwind-active_admin.config.js"; +@import "@activeadmin-plugins/active_admin_theme/css"; ``` -#### As a NPM module (Yarn package) via Webpacker or any other assets bundler +Keep the npm package and the gem on the same version. -```css -@import '@activeadmin-plugins/active_admin_theme'; +Tailwind only generates the classes it finds in files it scans. In `tailwind-active_admin.config.js`, add the gem's files to `content`: + +```js +const themePath = execSync('bundle show active_admin_theme', { encoding: 'utf-8' }).trim().split(/\r?\n/).pop(); + +export default { + content: [ + // ActiveAdmin's entries stay as they are + `${themePath}/app/**/*.{arb,erb,html,rb,js}`, + `${themePath}/lib/**/*.rb`, + // ... + ], + // ... +} ``` -## Customising +### JavaScript -Set any of the variables below *above* the import line: +ActiveAdmin 4 renders its own import map, `ActiveAdmin.importmap`, not the import map in your `config/importmap.rb`. A `pin` in your own `config/importmap.rb` loads nothing on ActiveAdmin pages. + +The theme's engine pins its modules into `ActiveAdmin.importmap`, so importmap hosts need no setup. To load one of your own scripts on ActiveAdmin pages, pin it into the same map, for example in `config/initializers/active_admin.rb`: + +```ruby +ActiveAdmin.importmap.pin "my_admin_script", to: "my_admin_script.js" +``` -```scss -$skinMainFirstColor: #A5A7AA; -$skinMainSecondColor: #0066CC; -$skinBorderWindowColor: #B8BABE; +ActiveAdmin pages import the modules listed in `ActiveAdminTheme.javascripts`. With an ActiveAdmin that has the `javascript_modules` setting ([activeadmin/activeadmin#9148](https://github.com/activeadmin/activeadmin/pull/9148)), the theme adds them to that setting after your initializers run. With an older ActiveAdmin, the theme's own `_html_head` imports them. The default list is: -@import 'wigu/active_admin_theme'; +```ruby +%w[ + active_admin_theme/layout + active_admin_theme/server_clock + active_admin_theme/table_crisscross +] ``` -Variables are typed. A value of the wrong kind — `none` where a colour is -expected, or a length without its unit — fails the build with a message -naming the variable, instead of silently emitting CSS the browser discards. - -### Dark mode - -The theme follows the operating system via `prefers-color-scheme`, and can be -pinned per page with `data-theme="light"` or `data-theme="dark"` on ``. -Every colour below that has a `…Dark` twin is what dark mode uses; each twin -defaults to its light counterpart unless noted, so a project that only sets -the light value keeps one consistent colour in both modes. - -### A worked example - -The defaults are the configuration this theme is run with in -[yeti-web](https://github.com/yeti-switch/yeti-web). To go back to the blue -header the theme shipped before: - -```scss -$skinMenuPillColor: $skinMainSecondColor; -$skinMenuPanelColor: $skinMainSecondColor; -$skinMenuTextColor: #ffffff; -$skinMenuItemHoverColor: transparent; -$skinMenuItemHoverTextColor: #ffffff; -$skinMenuFontSize: 1em; -$skinHeaderPaddingY: 7px; // one value top and bottom -$skinTitleBarColor: lighten($skinMainFirstColor, 8%); -$skinTitleBarBorderWidth: 3px; -$skinPanelHeaderColor: $skinMainSecondColor; -$skinPanelHeaderTextColor: #ffffff; -$skinTabInactiveColor: $skinMainSecondColor; -$skinInactiveTabTextColor: #ffffff; -$skinLinkColor: $skinMainSecondColor; - -@import 'wigu/active_admin_theme'; +Change the list in an initializer to load fewer scripts or to add the optional ones: + +```ruby +ActiveAdminTheme.javascripts -= %w[active_admin_theme/table_crisscross] +ActiveAdminTheme.javascripts += %w[active_admin_theme/secret_reveal] ``` -Note `var(--aa-page-bg)` works as a variable value: a custom property follows -the mode on its own, so one line covers both themes. +**jsbundling hosts.** The npm package exports each module (`@activeadmin-plugins/active_admin_theme/layout`, `.../server_clock`, and so on). Import the ones you want from your `active_admin` entry file, and override `app/views/active_admin/_html_head.html.erb` to load your bundle. + +## Overriding + +A host partial with the same path overrides the theme's. Copy the partial from the gem into your app's `app/views/active_admin/` and edit it. The theme's partials are: -### Switching themes +- `_header_leading`: the menu button and the site title. +- `_header_navigation`: the top menu. +- `_main_navigation`: the mobile drawer menu. +- `_header_actions`: the server clock, the dark mode switch, the current user, and the logout icon. +- `_page_heading`, `_breadcrumbs`, `_page_title`: the sidebar toggle, the breadcrumbs, and the page title. +- `_page_content`: the grid that holds the sidebar and the page. +- `_sidebar`. -The stylesheet follows the operating system on its own and honours -`data-theme="light"` or `data-theme="dark"` on ``. A switch is optional -and ships with the gem: +ActiveAdmin renders these partials from its own layout once it has the layout hooks ([activeadmin/activeadmin#9149](https://github.com/activeadmin/activeadmin/pull/9149) to [#9152](https://github.com/activeadmin/activeadmin/pull/9152)). Until then, the theme also ships copies of the layout, `_site_header`, `_page_header`, and `_html_head` in `app/views_compat` that render the same partials. The engine adds that directory to the view paths only when the installed ActiveAdmin lacks the hooks, so override the small partials above rather than those copies. -```scss -// app/assets/javascripts/active_admin.js -//= require wigu/theme_toggle +A host Arbre component with the same builder method name replaces the theme's, as long as it loads after the theme's. Components in `app/admin_components` (or any file loaded in a `to_prepare` block) do. + +## Colors + +All colors are CSS custom properties with a light value on `:root` and a dark value under `.dark`. Override any of them in your own CSS: + +```css +:root { + --aat-header-bg: #1b3a57; + --aat-button: #1f6f4a; +} +.dark { + --aat-surface: #20242b; +} ``` -```js -// or, as an npm module -import "@activeadmin-plugins/active_admin_theme/src/theme_toggle"; +| Property | Used for | +| --- | --- | +| `--aat-page-bg`, `--aat-surface`, `--aat-surface-2`, `--aat-surface-hover`, `--aat-selected-row` | Page, panels, table headers, hover, selected rows | +| `--aat-text`, `--aat-text-muted`, `--aat-border` | Text and borders | +| `--aat-input-bg`, `--aat-input-border` | Form controls | +| `--aat-accent`, `--aat-link` | Accent and links | +| `--aat-button`, `--aat-button-text` | Buttons and action items | +| `--aat-header-bg`, `--aat-header-text`, `--aat-menu-*` | Header and top menu | + +## DSL + +### `columns` + +ActiveAdmin 4 removed `columns` and `column`. The theme restores them, with separate control for mobile and desktop. + +```ruby +columns "1:3" do # desktop: 1/4 + 3/4; mobile: stacked + column { ... } + column { ... } +end + +columns "1:1", mobile: "1:1" do # side by side on mobile too + ... +end + +columns do # no ratio: equal widths, any number of columns + ... +end ``` -A gem cannot add a menu item: ActiveAdmin builds the utility navigation from -the host application's initializer, and nothing in a stylesheet or an asset -runs at that point. So with nothing else to do, the script injects its own -`li#theme_toggle` into `#utility_nav` on load. That works, but the item is -inserted before the server-rendered ones and is not yours to order or hide. +- The ratio string lists one weight per column. Pixel widths are not supported. +- On mobile, columns stack unless `mobile:` sets a ratio. +- A column has a minimum width of `--aat-col-min` (14rem). When the row has no room for it, the column moves to the next line instead of clipping its content. +- `column span: N` from ActiveAdmin 3 still works and counts as the column's weight. -Declaring it yourself costs four lines and puts it under your control — this is -how [yeti-web](https://github.com/yeti-switch/yeti-web) does it: +### `tabs` ```ruby -# config/initializers/active_admin.rb -config.namespace :admin do |admin| - admin.build_menu :utility_navigation do |menu| - # A real url, not "#": ActiveAdmin drops a blank utility item. - menu.add id: "theme_toggle", label: "", url: "#theme", - priority: 9_999_998, html_options: { role: "button" } - end +tabs do + tab("Details") { ... } + tab("History", id: "history") { ... } end ``` -The script finds `#theme_toggle` or anything carrying `.dark-mode-toggle`, -binds by delegation — so the control survives a re-render — and writes nothing -to the page but `data-mode`. Everything visible comes from the stylesheet. +Tabs use Flowbite's tabs, which ActiveAdmin already loads. -[![Theme switch](./img/switch.png)](./img/switch.png) +### `panel` -Below the two pages: the three states at rest — half circle for **auto**, sun -for **light**, moon for **dark** — then the last two hovered. A click moves to -the next state, so the control costs the width of one icon in a header that is -usually already full. The title says where that click goes, since one icon -cannot show both. +ActiveAdmin 4 still has `panel`, but no longer wraps sidebar content or the show page's `attributes_table` in one. The theme wraps both again: sidebar sections in a panel titled with the section name, and `attributes_table` on a show page in a "Model Details" panel. `Panel#header_action` is not restored. -`auto` removes the attribute, so the media query decides and the page follows -the operating system live; the other two pin the choice in `localStorage` under -`aa-theme`. The third state is there so that following the system stays -reachable: a two-state toggle writes a preference on the first click and has no -way back short of clearing storage by hand. +## Components -The glyphs are inline SVG used as a CSS `mask`, so the gem still ships no image -files, there is nothing for a host application's CSP to allow, and the icon -takes `currentColor` — `$skinMenuTextColor` like the rest of the bar, and the -hover colour on hover. To use your own icon font instead, override -`$theme-icon-auto` / `$theme-icon-light` / `$theme-icon-dark`, or restyle -`#theme_toggle > a:before` outright. +Every component is opt-in: nothing changes for an app that does not call it or load its script. In a `content do` block, wrap the helpers that return strings (`with_tooltip`, `pre_wrap`) in `text_node`. -The control is a link with no destination, so Tab reaches it and -Enter and Space operate it. Changing the theme in one tab -applies it in the others. +### `status_tag` -### Disabling dark mode +`ok`, `warning`, `error`, `info`, and `empty` have theme colors in light and dark mode. For any other value, define a CSS custom property named `--aat-status-` in your CSS. For a one-off color, pass `color:` (a hex, named, `rgb()`, or `hsl()` color; anything else raises `ArgumentError`): -To ship light-only, two manual steps (there is no build flag): +```ruby +status_tag "Shipped" # uses --aat-status-shipped, if you define it +status_tag "Late", color: "#b91c1c" +``` -- Don't include `wigu/theme_toggle` in your JS manifest. That removes the switch - and the automatic `data-theme` switching it drives. -- Pin the document to light: add `data-theme="light"` to the `` element - (e.g. by overriding the ActiveAdmin layout), so the - `@media (prefers-color-scheme: dark)` styles never apply. +### `truncate_text` -### Server clock +One line of text with an ellipsis and the full value in the `title` attribute: -The `#utility_nav` now carries a ticking server clock — two lines, time + zone -over the date — rendered by the theme's `server_clock.js` and injected per -namespace by its Ruby backend (`ActiveAdminTheme.install!`), with the logout -button beside it. +```ruby +truncate_text(order.note, length: 40, max_width: "12rem") +``` -The backend injects only the markup; load the script the same way as the -theme switch, or the nav shows a static one-line label and never ticks: +### `with_tooltip` -```js -// app/assets/javascripts/active_admin.js -//= require wigu/server_clock +A plain `title` hint by default. With `html: true`, the tooltip is sanitized HTML shown by ActiveAdmin's Flowbite tooltip. The HTML tooltip is positioned inside its container, so a wrapper with `overflow` (a scrolling table) can clip it. -// or, as an npm module -import "@activeadmin-plugins/active_admin_theme/src/server_clock"; +```ruby +text_node with_tooltip("42", "Total orders") +text_node with_tooltip("42", "Total orders", html: true) ``` -[![Two-line server clock in the utility nav](./img/clock.png)](./img/clock.png) +### `pre_wrap` -The theme also keeps a logout item in the nav. If your app has no logout route -(custom auth) or you deliberately run a logout-free nav, use ActiveAdmin's own -per-namespace switch — the theme honours it: +Preformatted text that wraps long lines: ```ruby -# config/initializers/active_admin.rb -config.logout_link_path = nil +text_node pre_wrap(response.body) ``` -The restored current-user item has a theme switch (ActiveAdmin offers none -short of unsetting `current_user_method`, which breaks authorization): +### `secret_reveal` + +A masked value that is fetched and shown on click. The URL must return `{"value": "..."}`. Needs the `active_admin_theme/secret_reveal` script. ```ruby -ActiveAdminTheme.add_current_user = false +secret_reveal url: secret_admin_account_path(account), mask: "••••••••" ``` -### Crisscross table highlighting +### `confirm_dialog` -Clicking a cell in an index table highlights its whole row and column — the -cells get class `selected`, which the theme paints with `--aa-selected`, so the -clicked value sits at the centre of a cross, with an accent outline on the cell -itself. Handy for tracing a value to its row and column in a wide table. +A confirmation dialog with optional form fields, built on the Flowbite modal that ActiveAdmin uses for batch actions. It submits a real form with the CSRF token. Needs the `active_admin_theme/confirm_dialog` script for focus handling and field validation. Do not place it inside another form. -The `selected` fill is always in the theme (it also covers ActiveAdmin's own -batch-action row selection); the click behaviour is opt-in — load the script to -enable it, leave it out to keep tables static: +```ruby +confirm_dialog "Reject", title: "Reject order", message: "The buyer is notified.", + url: reject_admin_order_path(order), method: :post, + fields: [ + {name: :reason, label: "Reason", as: :text, required: true}, + {name: :kind, as: :select, collection: [["Fraud", "fraud"], ["Other", "other"]]}, + {name: :notify, label: "Notify the buyer", as: :boolean, value: true} + ] +``` -```js -// app/assets/javascripts/active_admin.js -//= require wigu/table_crisscross +Field types are `:string`, `:text`, `:select`, and `:boolean`. + +### Searchable select + +A select built on [tom-select](https://tom-select.js.org) that searches over AJAX, loads more options as you scroll, and can depend on another select. It works in forms and filters, and on mobile. The gem vendors a pinned tom-select and pins it into `ActiveAdmin.importmap`; an app that does not load the script does not load tom-select. npm hosts add `tom-select` to their own dependencies. Add the script: + +```ruby +ActiveAdminTheme.javascripts += %w[active_admin_theme/searchable_select] +``` + +If your app already defines its own `searchable_select_options` in an initializer, yours wins: the theme includes its version earlier. -// or, as an npm module -import "@activeadmin-plugins/active_admin_theme/src/table_crisscross"; +Serve the options from the resource that holds them: + +```ruby +ActiveAdmin.register Customer do + searchable_select_options label: :name, search: :name_cont, per_page: 25 +end +``` + +`searchable_select_options(name = :all, scope: nil, label: nil, search: nil, value: :id, order: nil, per_page: 25)` adds a collection action that returns `{results: [{id:, text:}], has_more: true}` for `?term=` and `?page=`. It applies the authorization adapter's read scope. Use the input in a form or a filter: + +```ruby +f.input :customer, as: :searchable_select +f.input :city, as: :searchable_select, depends_on: :country_id, depends_placeholder: "Select a country first" +filter :customer, as: :searchable_select ``` -It uses jQuery, which ActiveAdmin already loads. +A dependent select stays disabled until its parent has a value, and reloads when the parent changes. Other options: `url:`, `resource:`, `options_name:`, `placeholder:`, `depends_param:`. -[![Crisscross cell highlighting](./img/crisscross-light.png)](./img/crisscross-light.png) +## Optional scripts -### Pinned bars and sticky table headers +| Script | Default | What it does | +| --- | --- | --- | +| `active_admin_theme/layout` | on | Collapsible sidebar | +| `active_admin_theme/server_clock` | on | Header clock | +| `active_admin_theme/table_crisscross` | on | Row and column highlight on a cell click | +| `active_admin_theme/confirm_dialog` | off | Focus and validation for `confirm_dialog` | +| `active_admin_theme/secret_reveal` | off | `secret_reveal` | +| `active_admin_theme/searchable_select` | off | Searchable select | -On pages with a table wider than the viewport the whole page scrolls sideways; -the theme pins `#header`, `#title_bar` (title and action buttons) and the flash -bar to the visible viewport so they stay put. The bars are sized with `100vw`, -which includes the vertical scrollbar gutter — load the shipped -`scrollbar_width.js` to subtract it, or the bars sit ~15px past the visible -viewport at the far-right scroll position: +## Nested menus -```js -// app/assets/javascripts/active_admin.js -//= require wigu/scrollbar_width +ActiveAdmin 4 supports one submenu level and raises `TypeError` for a parent chain. The theme brings back the chain from ActiveAdmin 3, so menus can be as deep as you need: -// or, as an npm module -import "@activeadmin-plugins/active_admin_theme/src/scrollbar_width"; +```ruby +menu parent: ["Customer", "Referral Program"], priority: 30 ``` -Without the script the CSS falls back to plain `100vw` — nothing breaks, you -just keep the small far-right shift. +In the top menu each deeper level flies out beside its item, and in the drawer it expands below its item. A top item is marked current when the page is anywhere below it. + +## Header -Index-table column headers also stick to the viewport top while a long listing -scrolls vertically. This is on by default for ActiveAdmin index tables. AA 3.x -hardcodes the index table's classes (`index class:` is ignored), so opt out -app-wide with one line in your stylesheet: +The header shows the site title, the top menu, a server clock, the dark mode switch, the current user, and a logout icon. They come from the signed-in user (`current_active_admin_user?`) and `config.logout_link_path`. Without a current user the header shows only the title, the menu, and the dark mode switch. To change the header, override `_header_actions.html.erb`, `_header_leading.html.erb`, or `_header_navigation.html.erb`. -```scss -.paginated_collection table.index_table thead tr th { position: static; } +## Development + +```sh +bundle install +bundle exec rake test # builds the dummy app's Tailwind CSS, runs smoke and browser tests (needs Chrome) +bundle exec ruby test/screenshot.rb # PAGES, WIDTHS, MODES, OUT environment variables +``` + +`test/dummy` is an ActiveAdmin 4 app (importmap, propshaft, Tailwind through the npm CLI). Run `npm run build` in `test/dummy` to rebuild its CSS. + +To run the tests against an unreleased ActiveAdmin, such as a branch with the layout hooks, point Bundler at it: + +```sh +ACTIVEADMIN_PATH=../activeadmin bundle exec rake test +ACTIVEADMIN_BRANCH=extensible-layout bundle exec rake test # from ACTIVEADMIN_REPO, default senid231/activeadmin ``` -For tables whose markup you render yourself, `skip-sticky-headers` on an -`index_table` opts out, and `with-sticky-headers` opts any hand-built table in -(single-row theads: only the first header row pins). - -`//= require wigu/all` pulls all four shipped scripts at once; each stays -individually requirable. - -### Trix / ActionText editor - -Trix ships with ActionText (a Rails built-in), so any ActiveAdmin app can have a -rich-text field. The theme styles the editor chrome — toolbar, editor surface, -link dialog, content `pre`/`blockquote` — off the theme variables in both light -and dark, and inverts Trix's black toolbar icons in dark mode so they stay -legible. Nothing to opt into: your app still imports Trix's own base CSS -(`trix/dist/trix`), the theme only restyles it, and the rules are inert when no -editor is on the page. - -[![Trix editor, light](./img/trix-light.png)](./img/trix-light.png) -[![Trix editor, dark](./img/trix-dark.png)](./img/trix-dark.png) - -### Upgrading - -#### 3.2.0 - -The variable contract only grows — `$skinTitleBarHeight` joins it and nothing -that worked against 3.1.1 stops working — but several defaults now look -different, and two of them take layout away from the host app: - -* `#header`, `#title_bar` and the flash bar are pinned to the visible viewport, - so they stay put while a table wider than the window scrolls the page - sideways. Apps that pinned those bars with their own stylesheet should drop - those rules: the theme lays the title bar out with flexbox and resets - `#titlebar_right` to `position: static`, and a leftover `position: - absolute/sticky` fights it. Load `wigu/scrollbar_width` to subtract the - scrollbar gutter — see *Pinned bars and sticky table headers*. -* Index-table column headers stick to the viewport top on every ActiveAdmin - index table. One line in your stylesheet opts out app-wide: - `.paginated_collection table.index_table thead tr th { position: static; }`. -* The title bar owns its own metrics now: the record title is 16px, on one line - with an ellipsis, instead of ActiveAdmin's large wrapping heading; action - items are spaced; the band is `$skinTitleBarHeight` (37px) tall, with - `$skinTitleBarButtonPaddingY` / `$skinTitleBarButtonPaddingX` on the buttons. - Title-bar dropdown panels right-align under their own button. -* The utility nav carries the server clock, the theme switch and a logout drawn - as a sign-out glyph rather than the word *Logout*. `ActiveAdminTheme - .add_current_user = false` drops the added current-user item, and - ActiveAdmin's own `config.logout_link_path = nil` drops the logout one. - -New and opt-in, nothing to change on upgrade: the Trix / ActionText editor is -themed in both modes, crisscross cell highlighting (`wigu/table_crisscross`), -`--aa-datetimepicker-*` bridge variables for the companion datetimepicker, dark -styling for select2 and tom-select, and `//= require wigu/all` to pull every -shipped script at once. - -#### 3.1.0 - -* Status tag fills are darker, so the label can be white and still clear 4.5:1 — - it was black on mid-tone fills before. Each fill is a variable now - (`$skinStatusTagOkColor` and the four beside it), and - `$skinStatusTagTextColor: #000000;` restores the old label with the fills - you choose. -* Index-table column headings take `$skinTextColor` rather than a muted grey, - so a heading reads as strongly as the column under it. - -#### 3.0.0 - -Three things changed shape and are worth knowing if you already set variables: - -* Dropdown panels — the title-bar menu, the batch-actions menu and the - table-tools menus — now follow the surface palette (`$skinSurfaceColor` and - friends) instead of `$skinMainFirstColor` / `$skinMainSecondColor`. That is - what lets them work in both modes. If you branded those panels through the - two main colours, point `$skinSurfaceColor` and `$skinSurfaceHoverColor` at - the same values. -* The primary button fill is darker (`darken($skinMainSecondColor, 20%)` rather - than the accent itself). White on the accent is 2.74:1, under the 4.5:1 small - text needs, and dark mode was already using this tone — the button is now one - colour in both modes. Set `$skinButtonColor: $skinMainSecondColor;` for the - old look. -* The default content link colour is darker (`#1f5f8d` rather than the accent). - The accent is a fill colour and failed WCAG AA as body text. Set - `$skinLinkColor` back to `$skinMainSecondColor` if you prefer the old look. - -### Variables - -#### Core - -| Variable | Default (light / dark) | | -|---|---|---| -| `$skinMainFirstColor` | ![#23282f](docs/swatches/23282f.svg) `#23282f` | | -| `$skinMainSecondColor` | ![#5ea3d3](docs/swatches/5ea3d3.svg) `#5ea3d3` | | -| `$skinBorderRadius` | `4px` | | -| `$skinBorderWindowColor` | ![#e6e9ee](docs/swatches/e6e9ee.svg) `#e6e9ee` | | -| `$skinTablePadding` | `10px` | | - -#### Surfaces, text and borders - -| Variable | Default (light / dark) | | -|---|---|---| -| `$skinPageBgColor` / `$skinPageBgColorDark` | ![#f7f9fb](docs/swatches/f7f9fb.svg) `#f7f9fb` / ![#161a1e](docs/swatches/161a1e.svg) `#161a1e` | page background | -| `$skinSurfaceColor` / `$skinSurfaceColorDark` | ![#ffffff](docs/swatches/ffffff.svg) `#ffffff` / ![#25292f](docs/swatches/25292f.svg) `#25292f` | panels / cards / content | -| `$skinSurface2Color` / `$skinSurface2ColorDark` | ![#f0f2f5](docs/swatches/f0f2f5.svg) `#f0f2f5` / ![#2c3137](docs/swatches/2c3137.svg) `#2c3137` | table headers / striping / subtle fills | -| `$skinSurfaceHoverColor` / `$skinSurfaceHoverColorDark` | ![#f5f7fa](docs/swatches/f5f7fa.svg) `#f5f7fa` / ![#3f454d](docs/swatches/3f454d.svg) `#3f454d` | row / item hover | -| `$skinSelectedRowColor` / `$skinSelectedRowColorDark` | ![#d9e4ec](docs/swatches/d9e4ec.svg) `#d9e4ec` / ![#304457](docs/swatches/304457.svg) `#304457` | checked table row | -| `$skinElevatedColor` / `$skinElevatedColorDark` | `$skinSurfaceColor` / ![#363c43](docs/swatches/363c43.svg) `#363c43` | tool buttons and dropdown panels floating above the page | -| `$skinTextColor` / `$skinTextColorDark` | ![#323537](docs/swatches/323537.svg) `#323537` / ![#dde2e8](docs/swatches/dde2e8.svg) `#dde2e8` | body text | -| `$skinTextMutedColor` / `$skinTextMutedColorDark` | ![#6b7177](docs/swatches/6b7177.svg) `#6b7177` / ![#b0b8c2](docs/swatches/b0b8c2.svg) `#b0b8c2` | secondary text / axis labels | -| `$skinBorderColor` / `$skinBorderColorDark` | ![#e0e4e9](docs/swatches/e0e4e9.svg) `#e0e4e9` / ![#404750](docs/swatches/404750.svg) `#404750` | borders / grid lines | -| `$skinInputBgColor` / `$skinInputBgColorDark` | ![#ffffff](docs/swatches/ffffff.svg) `#ffffff` / ![#1e2227](docs/swatches/1e2227.svg) `#1e2227` | form control background | -| `$skinInputBorderColor` / `$skinInputBorderColorDark` | ![#c9ced4](docs/swatches/c9ced4.svg) `#c9ced4` / ![#4d555f](docs/swatches/4d555f.svg) `#4d555f` | form control border | -| `$skinErrorTextColor` / `$skinErrorTextColorDark` | ![#932419](docs/swatches/932419.svg) `#932419` / ![#f49b9b](docs/swatches/f49b9b.svg) `#f49b9b` | validation messages | -| `$skinErrorBgColor` / `$skinErrorBgColorDark` | ![#fae6e4](docs/swatches/fae6e4.svg) `#fae6e4` / ![#3a2224](docs/swatches/3a2224.svg) `#3a2224` | error summary above a form | - -#### Header menu - -| Variable | Default (light / dark) | | -|---|---|---| -| `$skinMenuPillColor` | ![#2e3236](docs/swatches/2e3236.svg) `#2e3236` | top-level current/hover pill | -| `$skinMenuPillTextColor` | ![#6cb0de](docs/swatches/6cb0de.svg) `#6cb0de` | text on that pill; follows the dropdown text so a | -| `$skinMenuPanelColor` | ![#2e3236](docs/swatches/2e3236.svg) `#2e3236` | dropdown panel bg + hover "bridge" border | -| `$skinMenuTextColor` | ![#dfe2e6](docs/swatches/dfe2e6.svg) `#dfe2e6` | dropdown item text (was: inherited #fff) | -| `$skinMenuItemHoverColor` | ![#3f454c](docs/swatches/3f454c.svg) `#3f454c` | dropdown item hover/current bg (was: none) | -| `$skinMenuItemHoverTextColor` | ![#6cb0de](docs/swatches/6cb0de.svg) `#6cb0de` | hover/current dropdown item text, same reason | -| `$skinMenuFontSize` | `13px` | header menu text size | -| `$skinMenuItemPaddingY` | `5px` | dropdown item top/bottom padding (was 6px/4px + a 7px border) | -| `$skinMenuItemLineHeight` | `1.35` | dropdown item line-height | -| `$skinMenuPanelMaxWidth` | `260px` | dropdown panel ceiling; longer labels wrap instead of leaving the viewport | -| `$skinHeaderPaddingY` | `null` | sets both halves at once | -| `$skinHeaderPaddingTop` | `4.5px` | header top padding (base value, kept so the header does not shift) | -| `$skinHeaderPaddingBottom` | `4.5px` | header bottom padding | -| `$skinHeaderLogoMaxHeight` | `none` | cap the site_title logo image height | - -#### Title bar - -| Variable | Default (light / dark) | | -|---|---|---| -| `$skinTitleBarColor` | ![#343c46](docs/swatches/343c46.svg) `#343c46` | | -| `$skinTitleBarBorderColor` | `$skinMainSecondColor` | | -| `$skinTitleBarBorderWidth` | `0` | | -| `$skinTitleBarButtonPaddingY` | `6px` | action button vertical padding | -| `$skinTitleBarButtonPaddingX` | `10px` | action button horizontal padding | -| `$skinTitleBarHeight` | `37px` | minimum bar height; grows to fit content | - -#### Panels, tabs and labels - -| Variable | Default (light / dark) | | -|---|---|---| -| `$skinPanelHeaderColor` / `$skinPanelHeaderColorDark` | `var(--aa-page-bg)` / `var(--aa-page-bg)` | | -| `$skinPanelHeaderTextColor` / `$skinPanelHeaderTextColorDark` | `var(--aa-inactive-tab-text)` / `var(--aa-inactive-tab-text)` | | -| `$skinPanelHeaderPaddingY` | `5px` | panel + sidebar header height | -| `$skinLabelColor` / `$skinLabelColorDark` | ![#8494a8](docs/swatches/8494a8.svg) `#8494a8` / `$skinTextColorDark` | | -| `$skinTabInactiveColor` / `$skinTabInactiveColorDark` | ![#f7f9fb](docs/swatches/f7f9fb.svg) `#f7f9fb` / ![#161a1e](docs/swatches/161a1e.svg) `#161a1e` | inactive tab fill | -| `$skinActiveTabTextColor` / `$skinActiveTabTextColorDark` | `$skinMainSecondColor` / ![#7cc0ec](docs/swatches/7cc0ec.svg) `#7cc0ec` | selected tab label | -| `$skinInactiveTabTextColor` / `$skinInactiveTabTextColorDark` | ![#5e6469](docs/swatches/5e6469.svg) `#5e6469` / ![#b0b8c2](docs/swatches/b0b8c2.svg) `#b0b8c2` | inactive tab label | -| `$skinTableHeaderTextColor` / `$skinTableHeaderTextColorDark` | `$skinTextColor` / `$skinTextColorDark` | index-table column header text; the body text colour, so headings read as strongly as the rows | -| `$skinStatusTagTextColor` | ![#ffffff](docs/swatches/ffffff.svg) `#ffffff` | label inside a filled status tag; `empty` / `unknown` / `none` have no fill and keep `$skinTextMutedColor` | -| `$skinStatusTagNeutralColor` | ![#707681](docs/swatches/707681.svg) `#707681` | unclassified tags: `No`, protocol tags | -| `$skinStatusTagOkColor` | ![#5e7e63](docs/swatches/5e7e63.svg) `#5e7e63` | `ok` `published` `complete` `completed` `green` `yes` | -| `$skinStatusTagNoticeColor` | ![#3874d2](docs/swatches/3874d2.svg) `#3874d2` | `notice` `blue` | -| `$skinStatusTagWarnColor` | ![#9e6c15](docs/swatches/9e6c15.svg) `#9e6c15` | `warn` `warning` `orange` | -| `$skinStatusTagErrorColor` | ![#ce483b](docs/swatches/ce483b.svg) `#ce483b` | `error` `errored` `red` | -| `$skinTabPaddingY` | `8px` | tab height | -| `$skinTabPaddingX` | `15px` | tab label horizontal padding (text → border) | - -#### Buttons and table tools - -| Variable | Default (light / dark) | | -|---|---|---| -| `$skinButtonColor` / `$skinButtonColorDark` | `darken($skinMainSecondColor, 20%)` / `$skinButtonColor` | one tone in both modes; white on it is 5.35:1 | -| `$skinButtonTextColor` / `$skinButtonTextColorDark` | ![#ffffff](docs/swatches/ffffff.svg) `#ffffff` / `$skinButtonTextColor` | label on those buttons | -| `$skinTableToolsHeight` | `30px` | | -| `$skinTableToolsPaddingX` | `$skinTableToolsHeight * 0.4` | 12px at 30px | - -#### Links - -| Variable | Default (light / dark) | | -|---|---|---| -| `$skinAccentColor` / `$skinAccentColorDark` | `$skinMainSecondColor` / `$skinAccentColor` | focus ring / accent outline | -| `$skinLinkColor` / `$skinLinkColorDark` | ![#38678b](docs/swatches/38678b.svg) `#38678b` / ![#7cc0ec](docs/swatches/7cc0ec.svg) `#7cc0ec` | | -| `$skinDeleteLinkColor` / `$skinDeleteLinkColorDark` | `$skinLinkColor` / ![#f49b9b](docs/swatches/f49b9b.svg) `#f49b9b` | | - -## Screen - -Index with filters, show page, nested `has_many` form, an open batch-actions -menu and the datepicker — the same admin in both modes. The theme follows the -operating system and can be pinned per page with `data-theme`. - -#### Light - -[![Light](./img/light.png)](./img/light.png) - -#### Dark - -[![Dark](./img/dark.png)](./img/dark.png) - -#### Form and filter controls - -Shown at full size, because the two shots above scale the controls down past -the point where you can tell what colour they are. Inputs, selects and -textareas take `$skinInputBgColor` / `$skinInputBorderColor` — white on -`#c9ced4` in light mode, a recessed `#1e2227` well on `#4d555f` in dark — and -the focused field (`Name`, `Title`) carries `$skinMainSecondColor`. - -[![Form and filter inputs](./img/inputs.png)](./img/inputs.png) - -## Contributing - -1. Fork it ( https://github.com/activeadmin-plugins/active_admin_theme/fork ) -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request +The theme replaces some of ActiveAdmin's own view files with its copies. `test/aa_overrides_test.rb` fails when the installed ActiveAdmin changes one of them. It records the originals for both modes, with and without the layout hooks, so run it against both. After you raise the ActiveAdmin version, diff each reported file against the theme's copy in `app/views` or `app/views_compat`, port what applies, then refresh the record: + +```sh +UPDATE_AA_DIGESTS=1 bundle exec ruby test/aa_overrides_test.rb +``` + +## License + +MIT diff --git a/Rakefile b/Rakefile index b090a1d..22f613c 100644 --- a/Rakefile +++ b/Rakefile @@ -1,21 +1,23 @@ require "bundler/gem_tasks" -desc "Compile the theme against ActiveAdmin and check the result" -task :css do - ruby "test/css_check.rb" +desc "Build the dummy app's Tailwind CSS (npm CLI)" +task :assets do + Dir.chdir("test/dummy") do + unless Dir.exist?("node_modules") + sh "npm #{ENV['CI'] ? 'ci' : 'install'} --no-audit --no-fund" + end + sh "npm run build" + end end -desc "Capybara integration test: the server clock renders + ticks (needs Chrome)" -task :test do - ruby "test/clock_test.rb" +desc "Smoke test: every demo page renders with the theme (builds CSS first)" +task test: :assets do + Dir["test/*_test.rb"].sort.each { |file| ruby file } end -desc "Regenerate the neutral README screenshot img/clock.png (needs Chrome)" +desc "Regenerate the README screenshot img/clock.png (needs Chrome)" task :screenshot do ruby "test/screenshot.rb" end -# css only: :test needs the Rails/Capybara stack the Gemfile excludes on -# Ruby 3.1 (the gem's own minimum) plus a local Chrome. Run `rake test` -# explicitly where both are available; CI names its tasks per job anyway. -task default: :css +task default: :test diff --git a/active_admin_theme.gemspec b/active_admin_theme.gemspec index a41ff9a..c9b9155 100644 --- a/active_admin_theme.gemspec +++ b/active_admin_theme.gemspec @@ -8,12 +8,12 @@ Gem::Specification.new do |spec| spec.version = ActiveAdminTheme::VERSION spec.authors = ["Igor Fedoronchuk", "Alex Sikorskiy"] spec.email = ["igor.f@didww.com", "alex.s@didww.com"] - spec.summary = %q{Flat design for ActiveAdmin} - spec.description = %q{Flat design for activeadmin gem } + spec.summary = %q{Dense desktop theme and shared components for ActiveAdmin 4} + spec.description = %q{A compact top-menu layout, theme colors, and opt-in DSL and UI components for ActiveAdmin 4 (Tailwind CSS).} spec.homepage = "https://github.com/activeadmin-plugins/active_admin_theme" spec.license = "MIT" - spec.required_ruby_version = '>= 3.1.0' + spec.required_ruby_version = '>= 3.3.0' # Whitelist, not a reject list: a new directory in the repo does not # reach consumers until it is named here. The reject form needs a new @@ -25,5 +25,5 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "activeadmin", ">= 3.0", "< 4.0.0.a" + spec.add_dependency "activeadmin", ">= 4.0.0.beta23", "< 5.0" end diff --git a/app/assets/javascripts/wigu/all.js b/app/assets/javascripts/wigu/all.js deleted file mode 100644 index d613862..0000000 --- a/app/assets/javascripts/wigu/all.js +++ /dev/null @@ -1,10 +0,0 @@ -// Umbrella require for every script the theme ships, so a Sprockets manifest -// carries one line instead of a hand-maintained list that silently drifts: -// theme CSS can depend on a JS-set property (scrollbar_width feeds the pinned -// bars' width), and a missed require degrades visually with no error. Each -// script stays individually requirable for apps that want a subset; see the -// README for what each one does and how to opt out. -//= require wigu/theme_toggle -//= require wigu/server_clock -//= require wigu/scrollbar_width -//= require wigu/table_crisscross diff --git a/app/assets/javascripts/wigu/scrollbar_width.js b/app/assets/javascripts/wigu/scrollbar_width.js deleted file mode 100644 index 74bb762..0000000 --- a/app/assets/javascripts/wigu/scrollbar_width.js +++ /dev/null @@ -1,72 +0,0 @@ -// Expose the vertical scrollbar width as a CSS custom property -// (--scrollbar-width) on the pinned bars. The sticky #header, #title_bar and -// .flashes are pinned to the viewport's left edge and sized with 100vw, but -// 100vw INCLUDES the vertical scrollbar gutter — so when a page has a vertical -// scrollbar the bars are ~15px wider than the visible viewport and, clamped -// inside their full-content-width containing block, get pushed left by that -// amount at the far-right horizontal scroll position. Subtracting this -// variable (0 when there is no scrollbar) sizes them to the visible viewport -// instead, so they stay put. Optional: the CSS falls back to -// `var(--scrollbar-width, 0px)` = plain 100vw when this script is absent. -// -// The value is written on the three consuming bars, NOT on : a custom -// property changed on the root invalidates style for the entire document — -// one full recalc of a 500-row index table per write — while var() resolves -// from the element itself just as well, confining invalidation to three tiny -// subtrees. -// -// The property name is deliberately the bare --scrollbar-width, not --aa-*: -// consumer apps that already set the same variable with the same semantics -// from their own legacy script keep working before their manifest is updated -// (a root-level legacy value still reaches the bars through inheritance when -// this script is absent). The trade-off is a shared name — load one writer, -// not both. -(function () { - "use strict"; - - var BARS = "#header, #title_bar, .flashes"; - var last = null; - - // Idempotent and cheap: reads the inline style (no layout) and writes only - // real changes, so it is safe to call on every observer delivery — which is - // what picks up bars parsed after the value was first computed, and bars - // replaced by a Turbo/Turbolinks body swap. - function apply() { - if (last === null) return; - var val = last + "px"; - var bars = document.querySelectorAll(BARS); - for (var i = 0; i < bars.length; i++) { - if (bars[i].style.getPropertyValue("--scrollbar-width") !== val) { - bars[i].style.setProperty("--scrollbar-width", val); - } - } - } - - function update() { - var w = window.innerWidth - document.documentElement.clientWidth; - if (w < 0) w = 0; - last = w; - apply(); - } - - if (window.ResizeObserver) { - // The observer alone is enough: it delivers once right after observe() - // (post-layout, pre-paint, so the read forces nothing) and again whenever - // the root's client box changes — which covers window resizes AND content - // growth toggling the scrollbar. A resize listener on top of it would - // force a synchronous style+layout per event during a window drag (the - // clientWidth read lands before the frame's layout) for no extra - // information. documentElement exists at parse time and survives body - // swaps, so there is no DOMContentLoaded gate and nothing goes stale. - new ResizeObserver(update).observe(document.documentElement); - } else { - // No ResizeObserver: fall back to resize events plus one eager read. - window.addEventListener("resize", update); - update(); - } - // A body swap replaces the bars the value was written on without - // necessarily resizing the root; re-apply the cached value to the fresh - // elements. - document.addEventListener("turbo:load", apply); - document.addEventListener("turbolinks:load", apply); -})(); diff --git a/app/assets/javascripts/wigu/server_clock.js b/app/assets/javascripts/wigu/server_clock.js deleted file mode 100644 index b7af4a5..0000000 --- a/app/assets/javascripts/wigu/server_clock.js +++ /dev/null @@ -1,171 +0,0 @@ -// Server clock for the utility navigation. Shows the server's time ticking once -// a second in li#servertime inside #utility_nav, where the stylesheet styles it. -// -// The instant comes from a [data-servertime] element the host app renders — the -// body in some apps, a footer element in others — holding -// "YYYY MM DD HH MM SS ZONE" (space-separated, exactly what Ruby's -// Time#strftime('%Y %m %d %H %M %S %Z') produces). The clock keeps that server -// instant and advances it by the client's own elapsed time, so a skewed browser -// clock does not throw it off. With no such element it renders nothing: a -// fabricated client clock would bypass every server-side suppression (anonymous -// visitors, hosts without current_user, consumer opt-outs) and show browser -// time masquerading as the server clock. -// -// Vanilla, no deps, mounts itself into #utility_nav. Turbo/Turbolinks swap the -// body without firing DOMContentLoaded, so the clock re-reads the fresh -// [data-servertime] on their load events and re-queries the DOM every tick -// instead of holding node references — a held reference goes detached on the -// first navigation and the visible clock freezes. -(function () { - "use strict"; - - var serverTime, clientTime, zone, fmt; - var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - - function pad2(n) { - return (n < 10 ? "0" : "") + n; - } - - function ready(fn) { - if (document.readyState !== "loading") fn(); - else document.addEventListener("DOMContentLoaded", fn); - } - - // (Re)read the server instant from the current document. - function sync() { - // Prefer the theme's own span inside the nav: a host mid-migration may - // still render its old body[data-servertime] (possibly in another format), - // which would win on first-match-in-tree-order and shadow the fresh - // per-request instant. Fall back to anywhere in the document for hosts - // that only render the attribute on the body or a footer element. - var nav = document.getElementById("utility_nav"); - var source = (nav && nav.querySelector("[data-servertime]")) || - document.querySelector("[data-servertime]"); - - // Preferred source: an absolute instant plus the server's IANA zone - // (rendered by the gem's Ruby backend). Intl re-applies the zone's offset - // on every tick, so a long-lived page follows the server through its own - // DST transitions — the wall-clock path below keeps the offset and label - // frozen from page load. - fmt = null; - serverTime = undefined; // no source on this page: tick() renders nothing - var epoch = source && source.getAttribute("data-servertime-epoch"); - var tz = source && source.getAttribute("data-servertime-zone"); - if (epoch && tz && window.Intl && - Intl.DateTimeFormat.prototype.formatToParts) { - try { - fmt = new Intl.DateTimeFormat("en-US", { - timeZone: tz, hour12: false, hourCycle: "h23", - year: "numeric", month: "short", day: "2-digit", - hour: "2-digit", minute: "2-digit", second: "2-digit", - timeZoneName: "short" - }); - serverTime = epoch * 1000; - // A malformed epoch is truthy but NaN; formatToParts(new Date(NaN)) - // would throw on every tick. Drop to the wall-clock path instead. - if (!isFinite(serverTime)) fmt = null; - } catch (e) { - fmt = null; // unknown zone name: fall through to the wall-clock path - } - } - - var raw = !fmt && source && source.getAttribute("data-servertime"); - - if (raw) { - var bits = raw.split(" "); - // A fixed epoch via Date.UTC, NOT new Date(y,m,d,...): local-zone - // parsing shifts a server instant that falls into the client's DST gap, - // and a tab left open across a client transition jumps the display an - // hour while the server had none. The zone label is carried verbatim. - serverTime = Date.UTC(bits[0], bits[1] - 1, bits[2], bits[3], bits[4], bits[5]); - // No fabricated label: a missing %Z just renders no zone. - zone = bits[6] || ""; - } - clientTime = Date.now(); - tick(); - } - - // The two display lines for this li, created on first use and reused every - // later tick. The server-rendered inner span [data-servertime] is the data - // source for later sync() calls (turbo:load, bfcache restores) — never - // overwrite it; hide it and append the lines beside it. textContent only, - // no innerHTML: the zone token of a host-rendered attribute is untrusted - // text and must not be able to become markup — and skipping the per-second - // innerHTML re-parse stops the node churn too. - function lines(li) { - var time = li.querySelector(".clock-time"); - if (time) return { time: time, date: li.querySelector(".clock-date") }; - var data = li.querySelector("[data-servertime]"); - // Append target: the outer Arbre span when the gem rendered the item, the - // li's own span otherwise. No inline colour — the span inherits the nav's - // text colour from the stylesheet cascade. - var host = data ? data.parentNode : li.querySelector("span"); - if (!host) { - host = document.createElement("span"); - li.appendChild(host); - } - if (data) data.style.display = "none"; - time = document.createElement("span"); - time.className = "clock-time"; - var date = document.createElement("span"); - date.className = "clock-date"; - host.appendChild(time); - host.appendChild(date); - return { time: time, date: date }; - } - - function tick() { - var nav = document.getElementById("utility_nav"); - // isFinite, not an undefined check: a host-rendered data-servertime in an - // unexpected format parses to NaN, which must render nothing — not a - // ticking "NaN:NaN:NaN". - if (!nav || !isFinite(serverTime)) return; - - // Reuse an existing li (idempotent across re-renders); never duplicate. - var li = nav.querySelector("#servertime"); - if (!li) { - li = document.createElement("li"); - li.id = "servertime"; - // Mark it as ours, so the ownership check below keeps passing. - li.setAttribute("data-theme-clock", ""); - nav.appendChild(li); - } else if (!li.hasAttribute("data-theme-clock") && - !li.querySelector("[data-servertime]")) { - // The li belongs to the host — their own servertime menu item, e.g. - // rendered as a plain link. Appending display lines would show the - // host's label and a second clock side by side in one item. - return; - } - var out = lines(li); - - // Two lines, as in yeti-web: time + timezone on top (larger), year month - // day below (smaller). The stylesheet sizes .clock-time / .clock-date. - var now = new Date(serverTime + (Date.now() - clientTime)); - var time, date; - if (fmt) { - // serverTime is an absolute instant; Intl renders it in the server's - // zone, offset and abbreviation recomputed per tick. - var p = {}; - fmt.formatToParts(now).forEach(function (part) { p[part.type] = part.value; }); - time = p.hour + ":" + p.minute + ":" + p.second + " " + p.timeZoneName; - date = p.year + " " + p.month + " " + p.day; - } else { - // UTC getters only — serverTime is a fixed epoch holding a wall clock, - // so the client's zone and DST rules never touch the math. - time = pad2(now.getUTCHours()) + ":" + pad2(now.getUTCMinutes()) + ":" + - pad2(now.getUTCSeconds()) + (zone ? " " + zone : ""); - date = now.getUTCFullYear() + " " + MONTHS[now.getUTCMonth()] + " " + - pad2(now.getUTCDate()); - } - out.time.textContent = time; - out.date.textContent = date; - } - - ready(function () { - sync(); - setInterval(tick, 1000); - }); - document.addEventListener("turbo:load", sync); - document.addEventListener("turbolinks:load", sync); -})(); diff --git a/app/assets/javascripts/wigu/table_crisscross.js b/app/assets/javascripts/wigu/table_crisscross.js deleted file mode 100644 index e885b4d..0000000 --- a/app/assets/javascripts/wigu/table_crisscross.js +++ /dev/null @@ -1,49 +0,0 @@ -// Crisscross highlighting for ActiveAdmin index tables. Clicking a cell marks -// its whole row and column with class "selected", which the stylesheet paints -// with var(--aa-selected) — so the clicked cell sits at the centre of a -// highlighted cross, making it easy to trace a value to its row and column in a -// wide table. -// -// Uses jQuery, which ActiveAdmin loads before this file (its own -// checkbox-toggler is jQuery too); the rest of the theme JS is dependency-free, -// but this behaviour lives entirely in AA's jQuery-driven index table. -// -// AA's table-checkbox-toggler selects the row when a cell is clicked; we undo -// that (re-click the checkbox) so crisscross highlighting never leaves rows -// ticked for batch actions. -$(document).ready(function () { - var marker = 'selected'; - - // "Select all" clears every row marker left by AA's toggler. - $('table.index_table .toggle_all').click(function () { - $(this).closest('table').find('tr.' + marker).removeClass(marker); - }); - - // Clicking the row checkbox itself must not bubble into the cell handler. - $('table.index_table').find(':checkbox').click(function (e) { - e.stopPropagation(); - $(this).closest('tr.' + marker).removeClass(marker); - }); - - $('table.index_table').on('click', 'td', function (e) { - var tdIndex = e.currentTarget.cellIndex, - $cell = $(e.currentTarget), - $table = $cell.closest('table'), - $row = $cell.closest('tr'), - $columnTds = $table.find('td:nth-child(' + (tdIndex + 1) + ')'), - $rowTds = $row.find('td'); - - // AA ticks the row checkbox on a cell click; click it again to revert. - $row.find(':checkbox').click(); - - if (!$cell.hasClass('col-selectable')) { - $table.find('td.' + marker).removeClass(marker); - $table.find('td.selected-cell').removeClass('selected-cell'); - $columnTds.addClass(marker); - $rowTds.addClass(marker); - // Mark the clicked cell itself so the stylesheet can set it apart as the - // centre of the cross. - $cell.addClass('selected-cell'); - } - }); -}); diff --git a/app/assets/javascripts/wigu/theme_toggle.js b/app/assets/javascripts/wigu/theme_toggle.js deleted file mode 100644 index d96b516..0000000 --- a/app/assets/javascripts/wigu/theme_toggle.js +++ /dev/null @@ -1,140 +0,0 @@ -// Theme switch for active_admin_theme. Optional — the stylesheet works without -// it, following the operating system. -// -// Cycles auto -> light -> dark -> auto: -// auto no stored choice; follows prefers-color-scheme, live -// light/dark pins html[data-theme] and remembers it -// -// The only thing this writes to the page is data-mode on the control; the -// stylesheet draws the icon from it. That keeps appearance in the theme, where -// a project can restyle it, instead of in a script it would have to fork. -// -// Binds by delegation to anything carrying .dark-mode-toggle or #theme_toggle, -// so the control can live anywhere and survive a re-render. If neither exists -// it inserts its own entry into the utility navigation. No jQuery and no ujs, -// so it does not care how the admin is built. -(function () { - "use strict"; - - var KEY = "aa-theme"; - var ORDER = { auto: "light", light: "dark", dark: "auto" }; - var LABEL = { - auto: "Theme: auto (follows the system) — click for light", - light: "Theme: light — click for dark", - dark: "Theme: dark — click for auto", - }; - var root = document.documentElement; - - // localStorage throws in private mode in some browsers, and is absent in a few - // embedded webviews. Losing the preference is acceptable; breaking the admin - // is not. - function stored() { - try { return localStorage.getItem(KEY); } catch (e) { return null; } - } - function store(value) { - try { value === null ? localStorage.removeItem(KEY) : localStorage.setItem(KEY, value); } catch (e) {} - } - - // The choice is held here and storage is best-effort persistence. Reading it - // back instead would make the switch a no-op wherever localStorage throws — - // private mode in some browsers — because the write is swallowed and the next - // read returns the old value. - var current = null; - - function mode() { - if (current === null) current = normalise(stored()); - return current; - } - - function normalise(value) { - return value === "light" || value === "dark" ? value : "auto"; - } - - function apply() { - // auto drops the attribute entirely so the media query decides. - if (mode() === "auto") root.removeAttribute("data-theme"); - else root.setAttribute("data-theme", mode()); - } - - // Before DOMContentLoaded on purpose: applied later, the page paints in the - // other theme first and flashes. - apply(); - - function ready(fn) { - if (document.readyState !== "loading") fn(); - else document.addEventListener("DOMContentLoaded", fn); - } - - var SELECTOR = ".dark-mode-toggle, #theme_toggle"; - - function controls() { - return document.querySelectorAll(SELECTOR); - } - - function refresh() { - var active = mode(); - Array.prototype.forEach.call(controls(), function (host) { - // The host itself may be the anchor (a menu item) or wrap one (our own li). - var link = host.tagName === "A" ? host : host.querySelector("a") || host; - host.setAttribute("data-mode", active); - link.setAttribute("title", LABEL[active]); - link.setAttribute("aria-label", LABEL[active]); - }); - } - - function cycle() { - var next = ORDER[mode()]; - current = next; - store(next === "auto" ? null : next); - apply(); - refresh(); - } - - // Delegated, so a control added later — or replaced by a Turbo render — still - // works without rebinding. - document.addEventListener("click", function (event) { - if (!event.target.closest || !event.target.closest(SELECTOR)) return; - event.preventDefault(); - cycle(); - }); - - // The control is a link with no destination, so it is reachable by Tab; that - // makes Enter and Space its keyboard contract. - document.addEventListener("keydown", function (event) { - if (event.key !== "Enter" && event.key !== " ") return; - if (!event.target.closest || !event.target.closest(SELECTOR)) return; - event.preventDefault(); - cycle(); - }); - - // Another tab changed the preference. - window.addEventListener("storage", function (event) { - if (event.key !== KEY) return; - current = normalise(event.newValue); - apply(); - refresh(); - }); - - // In auto mode, follow the operating system while the page is open. - if (window.matchMedia) { - var query = window.matchMedia("(prefers-color-scheme: dark)"); - var onChange = function () { if (mode() === "auto") { apply(); refresh(); } }; - if (query.addEventListener) query.addEventListener("change", onChange); - else if (query.addListener) query.addListener(onChange); - } - - ready(function () { - if (controls().length === 0) { - var nav = document.getElementById("utility_nav"); - if (!nav) return; - var host = document.createElement("li"); - host.id = "theme_toggle"; - var link = host.appendChild(document.createElement("a")); - // Not "#": ActiveAdmin treats a bare hash as a blank menu item. - link.setAttribute("href", "#theme"); - link.setAttribute("role", "button"); - nav.insertBefore(host, nav.firstChild); - } - refresh(); - }); -})(); diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss deleted file mode 100644 index 7d1984f..0000000 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ /dev/null @@ -1,2216 +0,0 @@ -//VAR-----------------------------------------------------------------------------------------------------------------// - -$skinMainFirstColor: #23282f!default; -$skinMainSecondColor: #5ea3d3!default; -$skinBorderRadius: 4px!default; -$skinBorderWindowColor: #e6e9ee!default; -$skinTablePadding: 10px!default; - -// Content palette — the surfaces, text and borders the UI is built from, per -// mode. Each has a light value (the existing look) and a *Dark value; they feed -// the --aa-* custom properties below. Defaults reproduce the current colors, so -// non-configuring projects are unchanged. -$skinPageBgColor: #f7f9fb!default; $skinPageBgColorDark: #161a1e!default; // page background -$skinSurfaceColor: #ffffff!default; $skinSurfaceColorDark: #25292f!default; // panels / cards / content -$skinSurface2Color: #f0f2f5!default; $skinSurface2ColorDark: #2c3137!default; // table headers / striping / subtle fills -$skinSurfaceHoverColor: #f5f7fa!default; $skinSurfaceHoverColorDark:#3f454d!default; // row / item hover -$skinSelectedRowColor: #d9e4ec!default; $skinSelectedRowColorDark: #304457!default; // checked table row -// Things that float above the page — tool-row buttons and dropdown panels. -// On light they match the content surface; on dark they have to sit a step -// lighter than the page or they read as holes in it. -$skinElevatedColor: $skinSurfaceColor!default; $skinElevatedColorDark: #363c43!default; -$skinTextColor: #323537!default; $skinTextColorDark: #dde2e8!default; // body text -$skinTextMutedColor: #6b7177!default; $skinTextMutedColorDark: #b0b8c2!default; // secondary text / axis labels -$skinBorderColor: #e0e4e9!default; $skinBorderColorDark: #404750!default; // borders / grid lines -$skinInputBgColor: #ffffff!default; $skinInputBgColorDark: #1e2227!default; // form control background -$skinInputBorderColor: #c9ced4!default; $skinInputBorderColorDark: #4d555f!default; // form control border - -// Header menu colors. Each defaults to the previous value, so projects that do -// not set these compile to identical CSS (and a project that overrides -// $skinMainSecondColor still gets the menu following that accent). -$skinMenuPillColor: #2e3236!default; // top-level current/hover pill -$skinMenuTextColor: #dfe2e6!default; // dropdown item text (was: inherited #fff) -$skinMenuPillTextColor: #6cb0de!default; // text on that pill; follows the dropdown text so a - // light menu is one variable to set, not two -$skinMenuPanelColor: #2e3236!default; // dropdown panel bg + hover "bridge" border -$skinMenuItemHoverColor: #3f454c!default; // dropdown item hover/current bg (was: none) -$skinMenuItemHoverTextColor: #6cb0de!default; // hover/current dropdown item text, same reason - -// Header / menu sizing. Defaults reproduce (or keep the same total as) the -// previous hard-coded values, so non-configuring projects are unchanged. -$skinMenuFontSize: 13px!default; // header menu text size -$skinMenuItemPaddingY: 5px!default; // dropdown item top/bottom padding (was 6px/4px + a 7px border) -$skinMenuPanelMaxWidth: 260px!default; // dropdown panel ceiling; longer labels wrap instead of leaving the viewport -$skinMenuItemLineHeight: 1.35!default; // dropdown item line-height -// Header padding. Set $skinHeaderPaddingY for the same value top and bottom, -// or the two halves separately — the halves win, since they are read last. -$skinHeaderPaddingY: null!default; -$skinHeaderPaddingTop: if($skinHeaderPaddingY == null, 4.5px, $skinHeaderPaddingY)!default; -$skinHeaderPaddingBottom: if($skinHeaderPaddingY == null, 4.5px, $skinHeaderPaddingY)!default; -$skinHeaderLogoMaxHeight: none!default; // cap the site_title logo image height - -// Title bar -$skinTitleBarColor: #343c46!default; -$skinTitleBarBorderColor: $skinMainSecondColor!default; -$skinTitleBarBorderWidth: 0!default; -$skinTitleBarButtonPaddingY: 6px!default; // action button vertical padding -$skinTitleBarButtonPaddingX: 10px!default; // action button horizontal padding -// Minimum bar height — raise it alongside the button paddings above if your -// buttons outgrow the default band (the bar still grows to fit its content). -$skinTitleBarHeight: 37px!default; - -// Panel/section headers + tabs accent (the blue bar on panel headers and the -// inactive tabs). $skinPanelHeaderColor is the base/light value; -// $skinPanelHeaderColorDark lets a project use a different color in dark mode. -$skinPanelHeaderColor: var(--aa-page-bg)!default; -$skinPanelHeaderColorDark: var(--aa-page-bg)!default; -// Panel/section header TEXT color (the label on the colored header bar), per mode. -$skinPanelHeaderTextColor: var(--aa-inactive-tab-text)!default; -$skinPanelHeaderTextColorDark: var(--aa-inactive-tab-text)!default; - -// Form label / legend text — a muted blue-grey in light; the regular text color -// in dark (where the muted blue-grey would be too dim). -$skinLabelColor: #8494a8!default; -$skinLabelColorDark: $skinTextColorDark!default; - -// Buttons (form submit, title-bar action buttons, batch actions). Separate from -// the accent so buttons can be a non-blue color, with a dark-mode override. -// Darker than the accent because the label is white: the accent itself gives -// 2.74:1, under the 4.5:1 small text needs. At -20% it is 5.35:1, and it is the -// tone dark mode already used, so the button is now one colour in both modes. -$skinButtonColor: darken($skinMainSecondColor, 20%)!default; -// Label on those buttons. A project is invited above to pick a non-blue fill; -// without this the label stays white and a light fill is unreadable. -$skinButtonTextColor: #ffffff!default; -$skinButtonTextColorDark: $skinButtonTextColor!default; -$skinButtonColorDark: $skinButtonColor!default; - -// Vertical padding controlling the height of panel/section headers and tabs. -$skinPanelHeaderPaddingY: 5px!default; // panel + sidebar header height -$skinTabPaddingY: 8px!default; // tab height -$skinTabPaddingX: 15px!default; // tab label horizontal padding (text → border) -// Height of every table-tools button (scopes, batch actions, view switcher, -// custom tools) — matched to the panel-header height. -$skinTableToolsHeight: 30px!default; -// Horizontal text padding inside each table-tools button (controls button width). -// Derived from the button height so the two scale together. -$skinTableToolsPaddingX: $skinTableToolsHeight * 0.4 !default; // 12px at 30px -// Per-group table-tools button fills. All default to the surface palette so the -// whole row shares one neutral look; override any group independently (both modes). - -// Index-table column header text (both the sortable links and plain headers use -// one color, configurable per mode). - -// Inactive tab background. Defaults to the panel-header color so tabs stay -// consistent, but can be set separately (e.g. a more subdued tab bar). - -// Tab text colors — the active (selected) tab and the inactive tabs, per mode. - -// Content link color (generic links inside the admin content), per mode. -// Focus ring / accent outline. Follows the theme accent, so a project that -// rebrands $skinMainSecondColor does not keep a stray blue focus border. -$skinAccentColor: $skinMainSecondColor!default; -$skinAccentColorDark: $skinAccentColor!default; -// Darker than the theme accent on purpose: the accent is a fill colour and -// fails WCAG AA as body text — #5ea3d3 is 2.74 on white, where 4.5 is the bar -// for text this size — and links also sit on zebra rows, table headers and -// selected rows, so the worst of those is what has to clear it. #1f5f8d -// keeps the hue and gives 5.28 against the darkest of them. -$skinLinkColor: #38678b!default; -$skinLinkColorDark: #7cc0ec!default; - -// Delete / destroy links. Light follows the regular link color (so action links -// match); dark uses a lightened red, since the default red is too dark there. -$skinDeleteLinkColor: $skinLinkColor!default; -$skinDeleteLinkColorDark: #f49b9b!default; -// Validation errors: message text and summary block fill. -$skinErrorTextColor: #932419!default; -$skinErrorTextColorDark: #f49b9b!default; -$skinErrorBgColor: #fae6e4!default; -$skinErrorBgColorDark: #3a2224!default; -// Tabs: fill and label colours per mode. yeti-web sets all four to make the -// inactive tabs recede to the page background and the active one read as raised. -$skinTabInactiveColor: #f7f9fb!default; -$skinTabInactiveColorDark: #161a1e!default; -$skinActiveTabTextColor: $skinMainSecondColor!default; -$skinActiveTabTextColorDark: #7cc0ec!default; -$skinInactiveTabTextColor: #5e6469!default; -$skinInactiveTabTextColorDark: #b0b8c2!default; -// Index-table column header text, one colour for sortable and plain headers. -$skinTableHeaderTextColor: $skinTextColor!default; -$skinTableHeaderTextColorDark: $skinTextColorDark!default; - -// Status tags. The label is the same on every filled tag in both modes. The -// fills below are dark enough to carry a white one: every one of the five is -// at least 4.53 against it. The mid-tone fills this palette replaces ran from -// 2.35 to 3.78, under the 4.5 WCAG AA asks of text this small, which is why -// they moved rather than the label. -$skinStatusTagTextColor: #ffffff!default; -$skinStatusTagNeutralColor: #707681!default; // "No", protocol tags, anything unclassified -$skinStatusTagOkColor: #5e7e63!default; // ok / published / complete / green / yes -$skinStatusTagNoticeColor: #3874d2!default; // notice / blue -$skinStatusTagWarnColor: #9e6c15!default; // warn / warning / orange -$skinStatusTagErrorColor: #ce483b!default; // error / errored / red - -//DARK-MODE PALETTE---------------------------------------------------------------------------------------------------// -// Semantic CSS custom properties for runtime light/dark switching. Light values -// (= the existing look) live on :root; the dark values are applied either by the -// OS preference (unless the user forced light) or by an explicit html[data-theme]. -// Phase 0: defined only — surfaces are repointed to these vars in later phases, -// so there is no visual change yet. -@mixin aa-dark-palette { - // Render native controls (checkboxes, radios, native selects, scrollbars) dark. - color-scheme: dark; - --aa-page-bg: #{$skinPageBgColorDark}; - --aa-surface: #{$skinSurfaceColorDark}; - --aa-surface-2: #{$skinSurface2ColorDark}; - // Not $skinSurface2ColorDark: that is the zebra fill, and feeding both from - // one variable left the header band and every other row byte-identical. - // Light mode never had that problem — $skinBorderWindowColor gives the - // header its own step there. - --aa-table-header: #{$skinElevatedColorDark}; - --aa-surface-hover: #{$skinSurfaceHoverColorDark}; - --aa-selected: #{$skinSelectedRowColorDark}; - --aa-elevated: #{$skinElevatedColorDark}; - --aa-text: #{$skinTextColorDark}; - --aa-text-muted: #{$skinTextMutedColorDark}; - --aa-border: #{$skinBorderColorDark}; - --aa-link: #{$skinLinkColorDark}; - --aa-accent: #{$skinAccentColorDark}; - --aa-input-bg: #{$skinInputBgColorDark}; - --aa-input-border: #{$skinInputBorderColorDark}; - --aa-shadow: rgba(0, 0, 0, 0.45); - --aa-panel-header: #{$skinPanelHeaderColorDark}; - --aa-panel-header-text: #{$skinPanelHeaderTextColorDark}; - --aa-label-text: #{$skinLabelColorDark}; - --aa-button: #{$skinButtonColorDark}; - --aa-button-text: #{$skinButtonTextColorDark}; - --aa-button-hover: #{darken($skinButtonColorDark, 8%)}; - --aa-table-header-text: #{$skinTableHeaderTextColorDark}; - --aa-tab-inactive: #{$skinTabInactiveColorDark}; - --aa-active-tab-text: #{$skinActiveTabTextColorDark}; - --aa-inactive-tab-text: #{$skinInactiveTabTextColorDark}; - --aa-delete-link: #{$skinDeleteLinkColorDark}; - --aa-error: #{$skinErrorTextColorDark}; - --aa-error-bg: #{$skinErrorBgColorDark}; -} -:root { - color-scheme: light; - --aa-page-bg: #{$skinPageBgColor}; - --aa-surface: #{$skinSurfaceColor}; - --aa-surface-2: #{$skinSurface2Color}; - // Index-table headers keep following $skinBorderWindowColor, the knob the - // README has documented for years, instead of the new surface scale. - --aa-table-header: #{$skinBorderWindowColor}; - --aa-surface-hover: #{$skinSurfaceHoverColor}; - --aa-selected: #{$skinSelectedRowColor}; - --aa-elevated: #{$skinElevatedColor}; - --aa-text: #{$skinTextColor}; - --aa-text-muted: #{$skinTextMutedColor}; - --aa-border: #{$skinBorderColor}; - --aa-link: #{$skinLinkColor}; - --aa-accent: #{$skinAccentColor}; - --aa-input-bg: #{$skinInputBgColor}; - --aa-input-border: #{$skinInputBorderColor}; - --aa-shadow: rgba(0, 0, 0, 0.08); - --aa-panel-header: #{$skinPanelHeaderColor}; - --aa-panel-header-text: #{$skinPanelHeaderTextColor}; - --aa-label-text: #{$skinLabelColor}; - --aa-button: #{$skinButtonColor}; - --aa-button-text: #{$skinButtonTextColor}; - --aa-button-hover: #{darken($skinButtonColor, 8%)}; - --aa-table-header-text: #{$skinTableHeaderTextColor}; - --aa-tab-inactive: #{$skinTabInactiveColor}; - --aa-active-tab-text: #{$skinActiveTabTextColor}; - --aa-inactive-tab-text: #{$skinInactiveTabTextColor}; - --aa-delete-link: #{$skinDeleteLinkColor}; - --aa-error: #{$skinErrorTextColor}; - --aa-error-bg: #{$skinErrorBgColor}; - - // Bridge for the companion active_admin_datetimepicker plugin: feed its - // --aa-datetimepicker-* inputs from this theme's palette, so the xdsoft picker - // is themed (light + dark) automatically with no per-app override. These point - // at the --aa-* vars, which are themselves redefined per mode, so a single set - // here covers both. Harmless no-op when the plugin isn't installed (nothing - // reads these). A host can still override any of them downstream. - --aa-datetimepicker-accent: var(--aa-accent); - --aa-datetimepicker-surface: var(--aa-surface); - --aa-datetimepicker-surface-2: var(--aa-surface-2); - --aa-datetimepicker-text: var(--aa-text); - --aa-datetimepicker-text-muted: var(--aa-text-muted); - --aa-datetimepicker-border: var(--aa-border); - --aa-datetimepicker-hover: var(--aa-surface-hover); -} -// OS preference, unless the user explicitly chose light -@media (prefers-color-scheme: dark) { - html:not([data-theme="light"]) { @include aa-dark-palette; } -} -// Explicit user choice always wins -html[data-theme="dark"] { @include aa-dark-palette; } - -// Public helper so a host app can scope its own (project-specific) overrides to -// dark mode using the same triggers. Use at the top level and put full selectors -// inside; the --aa-* custom properties are available: -// @include aa-dark { .my-widget { background: var(--aa-surface); } } -@mixin aa-dark { - @media (prefers-color-scheme: dark) { - html:not([data-theme="light"]) { @content; } - } - html[data-theme="dark"] { @content; } -} - -// Sort markers. Three shapes rather than one at two opacities: ActiveAdmin's -// sprite draws a double arrow for sortable-but-unsorted, and reusing the -// descending triangle for it leaves the two states indistinguishable on a -// freshly loaded index. Masked, like the theme switch icons, so they take -// currentColor and follow the heading into dark mode. -$sort-icon-none: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 12'%3E%3Cpath d='M4.5 0 9 5H0z'/%3E%3Cpath d='M4.5 12 0 7h9z'/%3E%3C/svg%3E")!default; -$sort-icon-asc: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 12'%3E%3Cpath d='M4.5 2 9 8H0z'/%3E%3C/svg%3E")!default; -$sort-icon-desc: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 9 12'%3E%3Cpath d='M4.5 10 0 4h9z'/%3E%3C/svg%3E")!default; - -// Theme switch icons. Inline, because the gem ships no image files, and as a -// mask rather than a coloured SVG: a mask takes currentColor and so follows -// $skinMenuTextColor on its own. Half circle = auto, sun = light, moon = dark. -$theme-icon-auto: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.6' fill='none' stroke='black' stroke-width='1.4'/%3E%3Cpath d='M8 2.6a5.4 5.4 0 0 1 0 10.8z'/%3E%3C/svg%3E")!default; -$theme-icon-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round'%3E%3Ccircle cx='8' cy='8' r='3.1'/%3E%3Cpath d='M8 .9v1.8M8 13.3v1.8M.9 8h1.8M13.3 8h1.8M2.9 2.9l1.3 1.3M11.8 11.8l1.3 1.3M13.1 2.9l-1.3 1.3M4.2 11.8l-1.3 1.3'/%3E%3C/svg%3E")!default; -$theme-icon-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M13.4 9.6A5.9 5.9 0 0 1 6.4 2.6a5.9 5.9 0 1 0 7 7z'/%3E%3C/svg%3E")!default; - -// Logout drawn as a sign-out glyph instead of the word "Logout", so it matches -// the icon toggle beside it. Masked like the theme icons above, so it takes -// currentColor and follows $skinMenuTextColor; a consumer can override the var. -$logout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 2.5H3.2v11h3.3'/%3E%3Cpath d='M12.8 8H6'/%3E%3Cpath d='M9.5 5 12.8 8l-3.3 3'/%3E%3C/svg%3E")!default; - -// A wrong-typed override is legal SassScript, so it compiles without a word and -// emits CSS the browser drops — `border-bottom: none solid #5ea3d3`, -// `padding: 10 12px`. The rule silently vanishes and ActiveAdmin's own value -// reappears. Fail the build with a readable message instead. -@each $name, $value in ( - skinBorderRadius: $skinBorderRadius, - skinTablePadding: $skinTablePadding, - skinMenuFontSize: $skinMenuFontSize, - skinMenuItemPaddingY: $skinMenuItemPaddingY, - skinMenuPanelMaxWidth: $skinMenuPanelMaxWidth, - skinHeaderPaddingTop: $skinHeaderPaddingTop, - skinHeaderPaddingBottom: $skinHeaderPaddingBottom, - skinTitleBarBorderWidth: $skinTitleBarBorderWidth, - skinTitleBarButtonPaddingY: $skinTitleBarButtonPaddingY, - skinTitleBarButtonPaddingX: $skinTitleBarButtonPaddingX -) { - @if type-of($value) != number or (unitless($value) and $value != 0) { - @error "$#{$name} must be a length with a unit (e.g. 10px), got `#{$value}`."; - } -} -@each $name, $value in ( - skinMainFirstColor: $skinMainFirstColor, - skinMainSecondColor: $skinMainSecondColor, - skinBorderWindowColor: $skinBorderWindowColor, - skinMenuPillColor: $skinMenuPillColor, - skinMenuPillTextColor: $skinMenuPillTextColor, - skinMenuPanelColor: $skinMenuPanelColor, - skinMenuItemHoverColor: $skinMenuItemHoverColor, - skinMenuTextColor: $skinMenuTextColor, - skinTitleBarColor: $skinTitleBarColor, - skinTitleBarBorderColor: $skinTitleBarBorderColor, - skinSelectedRowColor: $skinSelectedRowColor, - skinSelectedRowColorDark: $skinSelectedRowColorDark, - skinAccentColor: $skinAccentColor, - skinAccentColorDark: $skinAccentColorDark, - skinButtonTextColor: $skinButtonTextColor, - skinButtonTextColorDark: $skinButtonTextColorDark, - skinPageBgColor: $skinPageBgColor, - skinPageBgColorDark: $skinPageBgColorDark, - skinSurfaceColor: $skinSurfaceColor, - skinSurfaceColorDark: $skinSurfaceColorDark, - skinSurface2Color: $skinSurface2Color, - skinSurface2ColorDark: $skinSurface2ColorDark, - skinSurfaceHoverColor: $skinSurfaceHoverColor, - skinSurfaceHoverColorDark: $skinSurfaceHoverColorDark, - skinElevatedColor: $skinElevatedColor, - skinElevatedColorDark: $skinElevatedColorDark, - skinTextColor: $skinTextColor, - skinTextColorDark: $skinTextColorDark, - skinTextMutedColor: $skinTextMutedColor, - skinTextMutedColorDark: $skinTextMutedColorDark, - skinBorderColor: $skinBorderColor, - skinBorderColorDark: $skinBorderColorDark, - skinInputBgColor: $skinInputBgColor, - skinInputBgColorDark: $skinInputBgColorDark, - skinInputBorderColor: $skinInputBorderColor, - skinInputBorderColorDark: $skinInputBorderColorDark, - skinMenuItemHoverTextColor: $skinMenuItemHoverTextColor, - skinLabelColor: $skinLabelColor, - skinLabelColorDark: $skinLabelColorDark, - skinButtonColor: $skinButtonColor, - skinButtonColorDark: $skinButtonColorDark, - skinLinkColor: $skinLinkColor, - skinLinkColorDark: $skinLinkColorDark, - skinDeleteLinkColor: $skinDeleteLinkColor, - skinDeleteLinkColorDark: $skinDeleteLinkColorDark, - skinErrorTextColor: $skinErrorTextColor, - skinErrorTextColorDark: $skinErrorTextColorDark, - skinErrorBgColor: $skinErrorBgColor, - skinErrorBgColorDark: $skinErrorBgColorDark, - skinTabInactiveColor: $skinTabInactiveColor, - skinTabInactiveColorDark: $skinTabInactiveColorDark, - skinActiveTabTextColor: $skinActiveTabTextColor, - skinActiveTabTextColorDark: $skinActiveTabTextColorDark, - skinInactiveTabTextColor: $skinInactiveTabTextColor, - skinInactiveTabTextColorDark: $skinInactiveTabTextColorDark, - skinTableHeaderTextColor: $skinTableHeaderTextColor, - skinTableHeaderTextColorDark: $skinTableHeaderTextColorDark, - skinStatusTagTextColor: $skinStatusTagTextColor, - skinStatusTagNeutralColor: $skinStatusTagNeutralColor, - skinStatusTagOkColor: $skinStatusTagOkColor, - skinStatusTagNoticeColor: $skinStatusTagNoticeColor, - skinStatusTagWarnColor: $skinStatusTagWarnColor, - skinStatusTagErrorColor: $skinStatusTagErrorColor -) { - @if type-of($value) != color { - @error "$#{$name} must be a color (use `transparent`, not `none`), got `#{$value}`."; - } -} -// The panel header pair defaults to a custom property and is documented as -// accepting one, so a plain `type-of != color` would reject the shipped value. -@each $name, $value in ( - skinPanelHeaderColor: $skinPanelHeaderColor, - skinPanelHeaderColorDark: $skinPanelHeaderColorDark, - skinPanelHeaderTextColor: $skinPanelHeaderTextColor, - skinPanelHeaderTextColorDark: $skinPanelHeaderTextColorDark -) { - @if type-of($value) != color and str-index("#{$value}", "var(") != 1 { - @error "$#{$name} must be a color or a CSS custom property (e.g. var(--aa-page-bg)), got `#{$value}`."; - } -} -// A cell's inner corner is 1px tighter than the outer one, so it does not -// show from under the table border. Subtracting px directly will not do: the -// radius is a project's to set, and 0.25rem fails the whole compile with -// "Incompatible units". calc() defers the subtraction to the browser, and a -// unitless 0 is handled separately because calc(0 - 1px) is invalid. -@function inner-radius($radius) { - @if unitless($radius) { @return 0; } - @return calc(#{$radius} - 1px); -} - -//HTML----------------------------------------------------------------------------------------------------------------// - -html { - background: var(--aa-page-bg); -} - -// Pin a bar (#header, #title_bar) to the viewport's left edge at the visible -// viewport width while a wide index table scrolls the whole page sideways -// (#wrapper is display:table, min-width:100%). width = 100vw minus the vertical -// scrollbar — the JS-set --scrollbar-width var (0 when wigu/scrollbar_width is -// absent, and then max-width:100% keeps 100vw's scrollbar allowance from adding a -// stray horizontal scrollbar). It's a plain max-width, not min(…, 100%): -// sassc/libsass evaluates the Sass min() function and rejects calc() as a -// non-number. No `top`, so the bar still scrolls vertically. -@mixin viewport-pinned-bar { - position: sticky; - left: 0; - width: calc(100vw - var(--scrollbar-width, 0px)); - max-width: 100%; - box-sizing: border-box; -} - -body.active_admin { - background: var(--aa-page-bg); -} -#wrapper { - // ActiveAdmin lays #wrapper out as `display: table`, which sizes to its - // content: a wide index table therefore scrolls the WHOLE page horizontally - // (the master / pre-theme behaviour admins are used to). Only guarantee the - // layout fills the viewport when the content is narrow. The utility nav - // (clock / theme toggle / logout) is pinned `sticky` (see #utility_nav below) - // so it stays reachable while the page scrolls sideways. - min-width: 100%; - - div.tabs { - > ul > li > a { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - margin-bottom: 11px; - // Top/side outline only; no bottom outline (the content panel's own top - // border is the baseline, drawn over the tabs via z-index below). - border: 1px solid var(--aa-border); - border-bottom: 0; - vertical-align: top; - background-color: var(--aa-tab-inactive); - color: var(--aa-inactive-tab-text); - margin-right: 5px; - padding: $skinTabPaddingY $skinTabPaddingX; - - -webkit-border-top-left-radius: $skinBorderRadius; - -webkit-border-top-right-radius: $skinBorderRadius; - -moz-border-radius-topleft: $skinBorderRadius; - -moz-border-radius-topright: $skinBorderRadius; - border-top-left-radius: $skinBorderRadius; - border-top-right-radius: $skinBorderRadius; - - -webkit-border-bottom-left-radius: 0; - -moz-border-radius-bottomleft: 0; - border-bottom-left-radius: 0; - - -webkit-border-bottom-right-radius: 0; - -moz-border-radius-bottomright: 0; - border-bottom-right-radius: 0; - } - // Active tab sits above the content panel (z-index: 2) and is filled with the - // content color, so it covers the panel's top border there and merges in. - ul > li.ui-state-active > a { - background-color: var(--aa-surface); - border: 1px solid var(--aa-border); - border-bottom-color: var(--aa-surface); - color: var(--aa-active-tab-text); - position: relative; - z-index: 2; - vertical-align: top; - } - div.tab-content { - background-color: var(--aa-surface); - // Uniform 1px border (no thicker bottom) so the rounded corners are even. - border: 1px solid var(--aa-border); - @include rounded($skinBorderRadius); - // Paint above the inactive tabs so the panel's own top border is the - // baseline drawn over them (the active tab paints above this, z-index: 2). - position: relative; - z-index: 1; - } - } - #header { - background-color: $skinMainFirstColor; - border-bottom: 1px solid lighten( $skinMainFirstColor, 15% )!important; - padding-top: $skinHeaderPaddingTop; - padding-bottom: $skinHeaderPaddingBottom; - border: none; - text-shadow: none; - background-image: none; - - // A wide index table scrolls the WHOLE page sideways. Pin the header on - // screen at the viewport width so it doesn't stretch to the table's width - // (which spread the menu out under the utility nav and overlapped it); it - // still grows to fit a wrapped menu, ActiveAdmin's natural layout. - @include viewport-pinned-bar; - h1.site_title { - color: $skinMainSecondColor; - font-size: 16px; - img { - max-height: $skinHeaderLogoMaxHeight; - } - } - a, a:link { - color: #ffffff; - } - ul.tabs > li > a { - // On the anchor, not the li, so the dropdown subtree does not inherit it. - font-size: $skinMenuFontSize; - @include rounded($skinBorderRadius); - padding: 6px 10px 6px 10px; - } - ul.tabs > li.current > a, ul.tabs > li:hover > a { - background-color: $skinMenuPillColor; - color: $skinMenuPillTextColor; - } - // ActiveAdmin swaps in its "dark" submenu arrow on the current/hovered item, - // which assumes the highlight is lighter than the bar. The default pill is - // not, so keep the light arrow there. - ul.tabs > li.has_nested.current > a, ul.tabs > li.has_nested:hover > a { - background-image: url($menu-arrow-light-icon-url); - } - // The utility nav (username, clock, theme switcher, logout) are not navigation - // tabs: drop the pill padding, and on hover highlight by text colour only — no - // background block. The clock has no click action, so it gets no hover at all. - #utility_nav { - // Sits at the top-right of the header; the header itself is pinned on screen - // (sticky, see #header above) so the clock / theme toggle / logout stay - // reachable while a wide table scrolls the page sideways. - z-index: 2; - white-space: nowrap; - - > li > a { padding: 0 5px; } - > li:hover > a, > li.current > a { background-color: transparent; } - > li#servertime:hover > a { color: #ffffff; } - - // Server clock (server_clock.js), rendered as two compact lines like - // yeti-web: time + timezone (larger) over year/month/day (smaller). A - // right-aligned flex column sized to the nav band (~24px) so both lines - // fit the header height unchanged. Monospace + tabular figures keep the - // ticking time a constant width, so the adjacent logout doesn't jitter. - > li#servertime > span { - display: inline-flex; - flex-direction: column; - justify-content: center; - align-items: flex-end; - vertical-align: middle; - height: 24px; - font-family: 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace; - font-variant-numeric: tabular-nums; - } - > li#servertime .clock-time { font-size: 12px; line-height: 1.1; } - > li#servertime .clock-date { font-size: 9px; line-height: 1.1; } - - // The theme switch (app/assets/javascripts/wigu/theme_toggle.js). The - // script only writes data-mode; the glyph is drawn here, so a project can - // swap it for its own icon font without forking the script. Masked rather - // than embedded as a coloured SVG so it takes currentColor and follows - // $skinMenuTextColor like every other item in the bar. - // On the li, not only the anchor: font-size 0 collapses the anchor to the - // icon's own 16px, which then sits at the top of the item instead of on - // the line the rest of the bar shares. inline-flex, not flex: ActiveAdmin - // lays the utility nav out as `li { display: inline }`, and a block-level - // item breaks the row, dropping the items after it onto new lines. - > li#theme_toggle { display: inline-flex; align-items: center; vertical-align: middle; } - > li#theme_toggle > a { - display: flex; - align-items: center; - font-size: 0; - opacity: 0.75; - - &:before { - content: ""; - display: block; - width: 16px; - height: 16px; - background-color: currentColor; - -webkit-mask: #{$theme-icon-auto} center / contain no-repeat; - mask: #{$theme-icon-auto} center / contain no-repeat; - } - &:hover, &:focus { opacity: 1; } - } - > li#theme_toggle[data-mode="light"] > a:before { - -webkit-mask-image: $theme-icon-light; - mask-image: $theme-icon-light; - } - > li#theme_toggle[data-mode="dark"] > a:before { - -webkit-mask-image: $theme-icon-dark; - mask-image: $theme-icon-dark; - } - - // Logout as an icon ($logout-icon), to match the toggle beside it. - // font-size:0 hides the "Logout" text visually; the accessible name is - // the aria-label the gem's Ruby puts on the anchor, because zero-sized - // text is pruned from the accessibility tree by some screen readers - // (the toggle gets its aria-label from theme_toggle.js the same way). - // The masked glyph follows the bar's text colour. The real logout - // link/method from add_logout_button_to_menu is untouched. - // Scoped to a.wigu-logout — the class the gem's Ruby puts on the item - // it adds — so a consumer-rendered logout keeps its own label instead - // of collapsing to an unlabeled glyph. - > li#logout:has(> a.wigu-logout) { display: inline-flex; align-items: center; vertical-align: middle; } - > li#logout > a.wigu-logout { - display: flex; - align-items: center; - font-size: 0; - opacity: 0.75; - - &:before { - content: ""; - display: block; - width: 16px; - height: 16px; - background-color: currentColor; - -webkit-mask: #{$logout-icon} center / contain no-repeat; - mask: #{$logout-icon} center / contain no-repeat; - } - &:hover, &:focus { opacity: 1; } - } - } - ul.tabs { - li.has_nested { - // `> a` only: leaf items need no room for the submenu arrow. - > a { - padding-right: 20px; - } - ul { - background-color: $skinMenuPanelColor; - @include rounded-all(0, $skinBorderRadius, $skinBorderRadius, $skinBorderRadius); - // Size to content instead of the base's max-width: calc(100% + 20px), - // which wraps long labels onto a second line — but keep a ceiling, or - // a long label pushes the panel out of the viewport where the :hover - // chain cannot follow it. Past the ceiling, labels wrap as before. - // Known limit: a depth-2 flyout under a right-edge tab can still leave - // a 1280px viewport — it sits at left:100% of an already-offset panel, - // and CSS cannot see the distance to the viewport edge. ActiveAdmin's - // own 175px panels already land within 5px of the edge there, so any - // widening inherits this. A full fix needs JS that flips the flyout to - // `right:100%`, or CSS anchor positioning once it is baseline. - width: max-content; - max-width: $skinMenuPanelMaxWidth; - overflow-wrap: break-word; - li > a { - padding-left: 15px; - color: $skinMenuTextColor; - line-height: $skinMenuItemLineHeight; - padding-top: $skinMenuItemPaddingY; - padding-bottom: $skinMenuItemPaddingY; - } - li:hover > a, li.current > a, li > a:focus { - background-color: $skinMenuItemHoverColor; - color: $skinMenuItemHoverTextColor; - } - li.current > a::before, li:hover > a::before, li > a:focus::before { - content: ''; - display: block; - position: absolute; - top: 50%; - left: 4px; - // currentColor, so the marker follows $skinMenuTextColor instead of - // a fixed-palette PNG that only reads against the default panel. - border: 3px solid transparent; - border-left-color: currentColor; - margin-top: -3px; - } - } - } - // `> li.has_nested` + `> a` so the "bridge" border is added only to a - // hovered TOP-LEVEL menu item (bridging its pill to the dropdown). Without - // the `>`, nested has_nested items (e.g. System > Components) also got the - // border, which pushed the next dropdown item down when their submenu - // opened. 5px to match the base's `ul { margin-top: 5px }` gap, and square - // off the corners that face the panel so the header does not show through. - > li.has_nested:hover { - > a { - @include rounded-top($skinBorderRadius); - border-bottom: 5px solid $skinMenuPanelColor; - } - } - } - } - #title_bar { - background-image: none; - border: none; - box-shadow: none; - background-color: $skinTitleBarColor; - border: none; - border-bottom: $skinTitleBarBorderWidth solid $skinTitleBarBorderColor; - // Pin the whole bar to the viewport like #header, so the title AND the action - // buttons stay visible while a wide index table scrolls the page sideways. - @include viewport-pinned-bar; - // Lay the title (left) and action items (right) on one compact row. Without - // this, resetting #titlebar_right to static (below) stacks it under the - // title and the bar grows tall. min-height, NOT height: with a fixed height - // the bar cannot contain its content — action items that wrap on a narrow - // window would overflow it and paint over the page — while min-height still - // makes breadcrumb (detail) pages match plain index pages. Drop the base - // clearfix — in a flex container it becomes a stray third item that - // space-between strands right. - display: flex; - align-items: center; - justify-content: space-between; - min-height: $skinTitleBarHeight; - &::after { display: none; } - #titlebar_left { - display: flex; - align-items: center; - gap: 6px; - height: auto; - // Let the title shrink below its min-content width: flex items refuse to - // otherwise, and a long nowrap record title would push the action items - // out of the pinned bar — unreachable past the viewport on wide pages. - min-width: 0; - span.breadcrumb { - font-size: 12px; - text-shadow: none; - margin-bottom: 0; - a, span { - color: lighten( $skinMainFirstColor, 30% )!important; - } - } - // Own a sensible title size so consumer apps don't each shrink AA's large - // default (which towered over the breadcrumb). margin:0 + the flex parent - // centre it on the breadcrumb's line; nowrap + ellipsis keep a long - // record title (show pages) on one line without pushing the action - // items out of the bar — min-width: 0 lets it actually shrink. - h2 { - color: #ffffff; - text-shadow: none; - font-size: 16px; - margin: 0; - min-width: 0; - // Screen only: AA registers the stylesheet with media "all", and paper - // has no horizontal scroll — a nowrap/ellipsis title would print - // truncated at the page edge where it used to wrap. - @media screen { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - } - #titlebar_right { - // Horizontal pinning is handled by #title_bar (sticky + viewport width), - // so the action items just sit at the right of the bar. Reset any - // consumer `position: absolute/sticky` (each app used to pin the buttons - // itself via local stylesheet overrides). - position: static; - // Shrink to the action-items height (some apps/base leave it ~50px, taller - // than the 37px bar, so centering pushed it over the top edge). - height: auto; - // Space multiple action items apart (e.g. a Menu dropdown next to a New - // button); the base leaves them flush on this side. - .action_items > .action_item:not(:last-child):not(:empty) { margin-right: 10px; } - // The form selectors cover button_to action items: Rails renders them as - // form > input[type=submit] (or button), which the anchor selectors - // miss — consumer apps were re-hardcoding the 6px/10px padding to - // compensate, desyncing from the $skin* knobs on every change. - // `> a` and `> .dropdown_menu > a`: an action item may hold a dropdown, and - // a descendant selector here paints its menu entries with the button fill - // too — with !important, so the entry rules below cannot undo it. - // A button_to action item renders as a block
; inline-block - // keeps it on the bar's single row (consumers used to float it, which - // collapsed the flex bar's height instead). - .action_item > form { display: inline-block; } - .action_item > a, - .action_item > .dropdown_menu > a, - .action_item > form > input[type="submit"], - .action_item > form > button { - background-image: none; - border: none; - box-shadow: none; - text-shadow: none; - @include rounded($skinBorderRadius); - background-color: var(--aa-button); - border: none; - padding: $skinTitleBarButtonPaddingY $skinTitleBarButtonPaddingX; - color: var(--aa-button-text); - font-weight: 600; - &:hover { - background-color: var(--aa-button-hover)!important; - } - } - div.dropdown_menu { - // Containing block for the panel below, so `right: 0` means "the - // trigger's right edge". AA leaves this element unpositioned, which - // makes the panel resolve against #title_bar (sticky) and park at the - // bar's far right no matter which action item was clicked. inline-block - // over AA's `display: inline`: an inline box's containing block spans - // its text fragments, so the markup's whitespace around the button - // pushed the panel a few px past the button's edge. - position: relative; - display: inline-block; - div.dropdown_menu_list_wrapper { - // Always right-align title-bar dropdowns under their own button, - // overriding AA's inline positioning. AA 3.x's _position() sets - // inline `left` OR `right` per open depending on a window-geometry - // branch and never clears the other, and the wrapper persists across - // opens — after a resize between opens both offsets accumulate and - // the width:auto wrapper stretches from the stale left coordinate to - // the right edge. Its `right` branch is wrong here anyway: it offsets - // from the window, not from the bar. Consumer apps used to carry - // exactly this pair as a local hotfix; the theme owns the bar now, so - // it owns the guard. - left: auto !important; - right: 0 !important; - background-color: var(--aa-elevated); - border: 1px solid var(--aa-border); - background-image: none; - box-shadow: none; - // ActiveAdmin draws the nipple as three stacked triangles: the element - // itself is the outline, :before the inner shadow, :after the fill. - // Painting all three the same colour collapses it into a solid arrow - // that is invisible whenever the panel fill is close to the page — - // which is exactly what a light surface is. Outline it like the panel. - div.dropdown_menu_nipple { - // The panel is pinned right (above) while AA still offsets the - // arrow from the panel's LEFT edge by the button's centre, which - // lands it on empty bar. Pin it right too, centred on the button's - // own caret (AA draws that 6px wide at right: 12px). - left: auto !important; - right: 9px !important; - border-color: var(--aa-border) transparent; - &:before, &:after { border-color: var(--aa-elevated) transparent; } - } - ul.dropdown_menu_list { - box-shadow: none; - li { - a { - background-color: var(--aa-surface); - color: var(--aa-text); - border-radius: 0; - &:hover { - // background-image, not just background-color: ActiveAdmin - // paints this hover with a linear-gradient, and a gradient - // sits on top of the background colour — leaving it in place - // made every colour set here invisible. - background-image: none; - background-color: var(--aa-selected); - color: var(--aa-text); - } - } - } - } - } - } - } - } - .flashes { - // Pin like #header/#title_bar: this is the one full-width bar between the - // two pinned ones, and on a wide page scrolled right its left-aligned text - // would leave the viewport, leaving an unreadable colored stripe. AA puts - // all padding/background on the inner .flash, so the always-rendered empty - // wrapper stays zero-height and inert on flash-less pages. - @include viewport-pinned-bar; - .flash { - color: #ffffff; - background-image: none; - text-shadow: none; - } - - .flash_notice { - background-color: #669148; - border-bottom: 2px solid #4C7331; - } - .flash_warning { - background-color: #BDBC45; - border-bottom: 2px solid #8B8A3D; - } - .flash_error { - background-color: #CD3333; - border-bottom: 2px solid #962F2F; - } - } - #active_admin_content { - span.blank_slate { - // Was a hardcoded lightened blue (too bright in dark mode); use the palette - // border + muted text so it adapts to light/dark. - border: 2px solid var(--aa-border); - color: var(--aa-text-muted); - @include rounded($skinBorderRadius); - } - div.table_tools { - margin-bottom: 14px; // match the gap between sidebar sections - // The gem shrinks the scope / batch / tool buttons to 0.9em; reset to 1em so - // they match the surrounding base text — the same size the panel headers use. - .scopes a, a.table_tools_button, .dropdown_menu_button { font-size: 1em; } - // Contain the floated buttons so the margin sits below them. The collapsible- - // sidebar gem sets `.table_tools:after { clear: none; padding-bottom: 16px }`, - // which disables the float clearfix and otherwise leaves the box ~16px tall - // with the 29px buttons overflowing into the margin. - display: flow-root; - &::after { padding-bottom: 0; } - // Gap between the batch-actions button and the scopes — only rendered when - // the button is present, so scope-only pages stay flush with the table edge. - div.batch_actions_selector { margin-right: 10px; } - // Baseline for any table_tools button (the index-list view switcher, custom - // tools like "Visible columns", etc.). The base AA style is a light gradient; - // theme it via the --aa-* vars. Scopes override the fill + segmented-control - // geometry below (they reset border-radius and re-round only the ends). - // .dropdown_menu_button is the same control with a different class (the - // "Visible columns" style tools); without it here it keeps ActiveAdmin's - // white gradient pill at 22px next to the themed 30px buttons. - a.table_tools_button, a.dropdown_menu_button { - box-sizing: border-box; - height: $skinTableToolsHeight; - line-height: $skinTableToolsHeight; // = button height: nudges the text to the optical centre (the font otherwise sits high in the line box) - background: var(--aa-elevated); - background-image: none; - border: 1px solid var(--aa-border); - color: var(--aa-text); - @include rounded($skinBorderRadius); - padding: 0 $skinTableToolsPaddingX; - box-shadow: none; - text-shadow: none; - &:hover { background: var(--aa-surface-hover); } - } - div.dropdown_menu { - div.dropdown_menu_list_wrapper { - background-color: var(--aa-elevated); - border: 1px solid var(--aa-border); - background-image: none; - box-shadow: none; - // ActiveAdmin draws the nipple as three stacked triangles: the element - // itself is the outline, :before the inner shadow, :after the fill. - // Painting all three the same colour collapses it into a solid arrow - // that is invisible whenever the panel fill is close to the page — - // which is exactly what a light surface is. Outline it like the panel. - div.dropdown_menu_nipple { - border-color: var(--aa-border) transparent; - &:before, &:after { border-color: var(--aa-elevated) transparent; } - } - ul.dropdown_menu_list { - box-shadow: none; - li { - a { - background-color: var(--aa-surface); - color: var(--aa-text); - border-radius: 0; - &:hover { - // background-image, not just background-color: ActiveAdmin - // paints this hover with a linear-gradient, and a gradient - // sits on top of the background colour — leaving it in place - // made every colour set here invisible. - background-image: none; - background-color: var(--aa-selected); - color: var(--aa-text); - } - } - } - } - } - } - div.batch_actions_selector { - > a { - box-sizing: border-box; - height: $skinTableToolsHeight; - line-height: $skinTableToolsHeight; - background-image: none; - box-shadow: none; - background-color: var(--aa-elevated); - @include rounded($skinBorderRadius); - border: 1px solid var(--aa-border); - padding: 0 $skinTableToolsPaddingX; - color: var(--aa-text); - &:hover { - background-color: var(--aa-surface-hover); - } - } - } - div.dropdown_menu { - div.dropdown_menu_list_wrapper { - background-color: var(--aa-elevated); - border: 1px solid var(--aa-border); - background-image: none; - // ActiveAdmin draws the nipple as three stacked triangles: the element - // itself is the outline, :before the inner shadow, :after the fill. - // Painting all three the same colour collapses it into a solid arrow - // that is invisible whenever the panel fill is close to the page — - // which is exactly what a light surface is. Outline it like the panel. - div.dropdown_menu_nipple { - border-color: var(--aa-border) transparent; - &:before, &:after { border-color: var(--aa-elevated) transparent; } - } - ul.dropdown_menu_list { - box-shadow: none; - li { - a { - background-color: var(--aa-surface); - box-shadow: none; - color: var(--aa-text); - border-radius: 0; - text-shadow: none; - &:hover { - // background-image, not just background-color: ActiveAdmin - // paints this hover with a linear-gradient, and a gradient - // sits on top of the background colour — leaving it in place - // made every colour set here invisible. - background-image: none; - background-color: var(--aa-selected); - color: var(--aa-text); - } - } - } - } - } - } - div.batch_actions_selector { - > a { - box-sizing: border-box; - height: $skinTableToolsHeight; - line-height: $skinTableToolsHeight; - background-image: none; - background-color: var(--aa-elevated); - @include rounded($skinBorderRadius); - border: 1px solid var(--aa-border); - padding: 0 $skinTableToolsPaddingX; - color: var(--aa-text); - text-shadow: none; - box-shadow: none; - &:hover { - background-color: var(--aa-surface-hover); - box-shadow: none; - } - } - } - // ActiveAdmin renders the index view switcher with the same classes as - // the scope bar — ul.indexes.table_tools_segmented_control, anchors - // carrying .table_tools_button — so it needs the same segmented - // geometry. Without it the switcher keeps the generic button radius on - // every segment, the shared borders double up, and the selected view is - // indistinguishable from the others. - ul.scopes, div.scopes > ul, ul.indexes { - li { - a { - background-image: none; - // Reset the generic .table_tools_button radius — scopes are a segmented - // control, so only the first/last child are re-rounded (below). - @include rounded(0); - // Outline the segmented control with the border color (was a white - // right-divider, which is invisible when the scope bar matches the - // page background). Adjacent borders are collapsed below. - border: 1px solid var(--aa-border); - box-shadow: none; - background-color: var(--aa-surface); - padding: 0 $skinTableToolsPaddingX; - color: var(--aa-text); - text-shadow: none; - span { - color: var(--aa-text); - font-size: 11px; - font-weight: bold; - } - } - } - li + li a { margin-left: -1px; } // collapse the shared border between scopes - li:first-child { - a { - // Override AA's base `.scopes li:first-child a { margin-left: 10px }`. - // That gap belongs on the batch-actions button (margin-right below), so - // the scope bar aligns with the table's left edge when there are no - // batch actions. - margin-left: 0; - -webkit-border-top-left-radius: $skinBorderRadius; - -webkit-border-bottom-left-radius: $skinBorderRadius; - -moz-border-radius-topleft: $skinBorderRadius; - -moz-border-radius-bottomleft: $skinBorderRadius; - border-top-left-radius: $skinBorderRadius; - border-bottom-left-radius: $skinBorderRadius; - } - } - li:last-child { - a { - -webkit-border-top-right-radius: $skinBorderRadius; - -webkit-border-bottom-right-radius: $skinBorderRadius; - -moz-border-radius-topright: $skinBorderRadius; - -moz-border-radius-bottomright: $skinBorderRadius; - border-top-right-radius: $skinBorderRadius; - border-bottom-right-radius: $skinBorderRadius; - } - } - li.selected { - a { - // Selected scope: a touch lighter than the neutral fill, plus an accent - // text colour so the active scope's label stands out. - background-color: var(--aa-surface-hover); - color: var(--aa-link); - span { color: var(--aa-link); } - } - } - } - } - } - table.index_table { - background-color: var(--aa-surface); - // Uniform 1px border (no thicker bottom) so the rounded corners are even. - border: 1px solid var(--aa-border); - // The right edge is owned by the last-column cells (below) rather than the - // table itself, so a last-column cell's own border-right doesn't stack - // against the table border 1px further out (a doubled right line). - border-right: 0; - @include rounded($skinBorderRadius); - th { - background-image: none; - border: none; - box-shadow: none; - text-shadow: none; - font-size: 1.0em; - font-weight: bold; - background-color: $skinBorderWindowColor; - padding: $skinTablePadding 12px $skinTablePadding 12px; - // 1px header underline (was 3px) so it matches the other 1px borders. - border-bottom: 1px solid darken( $skinBorderWindowColor, 5% ); - border-right: 1px solid darken( $skinBorderWindowColor, 5% ); - a { - font-size: 1.0em; - font-weight: bold!important; - } - } - // ActiveAdmin puts the sort arrow inside the heading link, on the left, as a - // background image cleared with `padding-left: 13px`. That indents the label - // of every sortable column by 13px while the data below starts at the cell's - // own padding, so a heading never lines up with its column. The link is also - // `display: block`, so moving the image to the right edge would park the - // arrow at the far side of the column instead of beside the text. - // - // Drawn as a pseudo-element instead: it follows the label immediately, the - // link keeps its full width so the whole cell stays clickable, and the - // marker takes currentColor — the stock sprite is a fixed grey PNG that - // cannot follow the text into dark mode. - // The sprite goes from every anchor in a sortable heading, because - // ActiveAdmin sets it on every one — an application link in there would - // otherwise keep the low-res PNG and the 13px indent it needs. - th.sortable a { - padding-left: 0; - background-image: none; - } - // The marker, though, only on ActiveAdmin's own heading link, which always - // carries the order parameter: an application's link is its own business. - th.sortable > a[href*="order="] { - - &:after { - content: ""; - display: inline-block; - width: 9px; - height: 12px; - margin-left: 6px; - vertical-align: middle; - background-color: currentColor; - -webkit-mask: #{$sort-icon-none} center / contain no-repeat; - mask: #{$sort-icon-none} center / contain no-repeat; - // 0.6, not lower: the marker is the only thing separating a sortable - // heading from a plain one, so WCAG 1.4.11 asks 3:1 of it. Against the - // header fill it gives 3.40 light and 4.22 dark; at 0.4 it was 2.13 - // and 2.73. - opacity: 0.6; - } - } - th.sorted-asc > a[href*="order="]:after { - -webkit-mask-image: $sort-icon-asc; - mask-image: $sort-icon-asc; - opacity: 1; - } - th.sorted-desc > a[href*="order="]:after { - -webkit-mask-image: $sort-icon-desc; - mask-image: $sort-icon-desc; - opacity: 1; - } - // Right edge = a single 1px line on the last-column cells (header th, body - // td, footer cells) coloured like the table border, since the table itself - // no longer draws a right border. - thead th:last-child, - tbody td:last-child, - tfoot tr > :last-child { - border-right: 1px solid var(--aa-border); - } - // Drop the bottom row's cell border: with border-collapse: separate it sits - // just inside the table's own bottom border, so the two stack and the bottom - // edge looks thicker than the other sides. The bottom row is the footer's - // last row when there's a totals footer, otherwise the body's last row. - tfoot tr:last-child > td, - tfoot tr:last-child > th { border-bottom: 0; } - &:not(:has(tfoot)) tbody tr:last-child > td { border-bottom: 0; } - // Round the corner cells so their square backgrounds follow the table's - // rounded border instead of cutting square notches into it (separate borders - // don't round cell backgrounds). Nest 1px inside the table's outer radius. - thead th:first-child { border-top-left-radius: inner-radius($skinBorderRadius); } - thead th:last-child { border-top-right-radius: inner-radius($skinBorderRadius); } - tfoot tr:last-child > :first-child { border-bottom-left-radius: inner-radius($skinBorderRadius); } - tfoot tr:last-child > :last-child { border-bottom-right-radius: inner-radius($skinBorderRadius); } - &:not(:has(tfoot)) tbody tr:last-child > td:first-child { border-bottom-left-radius: inner-radius($skinBorderRadius); } - &:not(:has(tfoot)) tbody tr:last-child > td:last-child { border-bottom-right-radius: inner-radius($skinBorderRadius); } - th.col-selectable { - width: 12px; - } - // Center the batch-actions checkboxes (header select-all + per-row): they are - // baseline-aligned and otherwise ride a few px high in the cell. - .col-selectable input[type="checkbox"] { - vertical-align: middle; - } - th.sorted-desc { - background-color: $skinBorderWindowColor; - } - td { - padding: $skinTablePadding 12px $skinTablePadding 12px; - } - } - // active_admin_sidebar: align toggle button with sidebar section (no top margin in theme) - #active_admin_content.collapsible_sidebar .sidebar_toggle_btn { - top: 0; - background-color: $skinMainSecondColor; - &:hover { - background-color: lighten($skinMainSecondColor, 5%); - } - } - - .sidebar_section.panel { - background-image: none; - box-shadow: none; - background-color: var(--aa-surface); - // Uniform 1px border (no thicker bottom) so the rounded corners are even. - border: 1px solid var(--aa-border); - & > h3 { - background-image: none; - box-shadow: none; - text-shadow: none; - background-color: var(--aa-panel-header); - color: var(--aa-panel-header-text); - border: none; - border-bottom: 1px solid var(--aa-border); - padding: $skinPanelHeaderPaddingY 15px; - font-weight: 700; // match the tab label weight - - -webkit-border-top-left-radius: $skinBorderRadius; - -webkit-border-top-right-radius: $skinBorderRadius; - -moz-border-radius-topleft: $skinBorderRadius; - -moz-border-radius-topright: $skinBorderRadius; - border-top-left-radius: $skinBorderRadius; - border-top-right-radius: $skinBorderRadius; - } - .filter_form_field { - label { - color: var(--aa-label-text); - } - input { - border: 1px solid var(--aa-input-border); - background-color: var(--aa-input-bg); - } - input:focus { - border-color: lighten( $skinMainSecondColor, 20% ) ; - box-shadow: none; - } - } - } -} -form fieldset > ol > li fieldset.fragments ol { - margin-left: 20%; -} -form .has_many_container h3 { - color: var(--aa-text-muted); -} -form fieldset > ol > li p.inline-errors, -form fieldset > ol > li ul.errors, -form ul.errors { - color: var(--aa-error); -} -form ul.errors { - background: var(--aa-error-bg); -} -form input[type=submit], form input[type=button], form button, a.button, a:link.button, a:visited.button { - background-image: none; - box-shadow: none; - text-shadow: none; - background-color: var(--aa-button); - @include rounded($skinBorderRadius); - border: none; - padding: 10px 20px; - color: var(--aa-button-text); - &:hover { - background-image: none!important; - background-color: var(--aa-button-hover)!important; - } -} -form fieldset.buttons li.cancel a, form fieldset.actions li.cancel a, form.filter_form a.clear_filters_btn { - background-image: none; - box-shadow: none; - text-shadow: none; - background-color: #ffffff; - @include rounded($skinBorderRadius); - padding: 9px 20px 9px 20px; - border-color: #E4E4E4; - &:hover { - background-image: none!important; - background-color: #F4F4F4!important; - } -} -form fieldset.inputs { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - background-color: var(--aa-surface); - // Uniform 1px border (no thicker bottom) so the rounded corners stay even. - border: 1px solid var(--aa-input-border); - legend { - span { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - background-color: var(--aa-panel-header); - color: var(--aa-panel-header-text); - border: none; - border-bottom: 1px solid var(--aa-border); - padding: $skinPanelHeaderPaddingY 15px; - font-weight: 700; // match the tab label weight - - -webkit-border-top-left-radius: $skinBorderRadius; - -webkit-border-top-right-radius: $skinBorderRadius; - -moz-border-radius-topleft: $skinBorderRadius; - -moz-border-radius-topright: $skinBorderRadius; - border-top-left-radius: $skinBorderRadius; - border-top-right-radius: $skinBorderRadius; - } - } - ol > li { - label { - color: var(--aa-label-text); - } - input, textarea { - border: 1px solid var(--aa-input-border); - background-color: var(--aa-input-bg); - } - input:focus, textarea:focus { - border-color: lighten( $skinMainSecondColor, 20% ) ; - box-shadow: none; - } - } -} -#ui-datepicker-div { - background: none; - background-color: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - div.ui-datepicker-header { - background-image: none; - background-color: $skinMainSecondColor; - border-bottom: none; - div.ui-datepicker-title { - span { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - } - } - } - div.ui-datepicker-header:before { - border-bottom: 10px solid $skinMainSecondColor; - } - table.ui-datepicker-calendar { - background-color: var(--aa-surface); - border-color: $skinMainSecondColor; - thead { - tr { - th { - background-color:lighten( $skinMainSecondColor, 20% ); - } - } - } - tbody { - tr { - td { - a:hover { - background-color: $skinMainSecondColor; - color: #ffffff; - } - } - td.ui-datepicker-today > a { - background-color: $skinMainSecondColor; - color: #ffffff; - } - } - } - } -} -.panel { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - background-color: var(--aa-surface); - // Uniform border width on all sides: a thicker (2px) bottom border combined - // with border-radius renders a distorted, non-uniform bottom corner (the outer - // edge curves but the border's inner edge looks square). 1px all round keeps - // the rounded corners symmetric. - border: 1px solid var(--aa-border); - > h3 { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - background-color: var(--aa-panel-header); - color: var(--aa-panel-header-text); - border: none; - border-bottom: 1px solid var(--aa-border); - padding: $skinPanelHeaderPaddingY 15px; - margin-top: 0; - font-weight: 700; // match the tab label weight - - -webkit-border-top-left-radius: $skinBorderRadius; - -webkit-border-top-right-radius: $skinBorderRadius; - -moz-border-radius-topleft: $skinBorderRadius; - -moz-border-radius-topright: $skinBorderRadius; - border-top-left-radius: $skinBorderRadius; - border-top-right-radius: $skinBorderRadius; - } - .panel_contents { - font-weight: 400; - } -} -.attributes_table table th, .attributes_table table td { - padding: 10px 0; -} -.attributes_table table th { - color: var(--aa-label-text); - // base AA gives attribute labels an emboss text-shadow, which reads as a - // blurry halo. Drop it (the index table headers are already shadow-free). - text-shadow: none; -} -// Index-table column headers: one color for both the sortable links and the -// plain headers (configurable via --aa-table-header-text). !important overrides -// the dark-mode content-link recolor that would otherwise turn sortable headers -// blue. -table.index_table thead th, -table.index_table thead th a { - color: var(--aa-table-header-text) !important; -} - -// Sticky table headers: keep the column labels visible while a long index table -// scrolls vertically. The header/title bar pin only horizontally (no `top`), so -// they scroll away on vertical scroll and this thead pins to the viewport top — -// no overlap. Scoped to .paginated_collection so it only hits real AA index -// listings, not custom report/dashboard tables. -// -// Opting out: AA 3.x hardcodes the index table's class attribute and ignores -// `index class:` options, so .skip-sticky-headers is reachable only on tables -// whose markup you render yourself. For real AA listings, opt out app-wide -// with one CSS line in your own stylesheet: -// .paginated_collection table.index_table thead tr th { position: static; } -// Opt a hand-built table IN with .with-sticky-headers. -// tr:first-child only: with a multi-row thead (group header over column -// header) every row would stick at the same offset and paint over each other — -// per-row offsets need measurements CSS alone can't express. The first row -// pins; any further header rows scroll away normally. -.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr:first-child th, -table.with-sticky-headers thead tr:first-child th { - position: sticky; - top: -1px; - z-index: 98; -} -// Square the corner cells of sticky theads: their inner-radius top corners are -// transparent, so once the header detaches from the table's rounded border the -// rows scrolling beneath flicker through the ~3px notches. At rest the square -// background overpaints the border's rounding by the same ~3px — the lesser, -// static artifact (CSS cannot tell "stuck" from "at rest"). -.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr:first-child th:first-child, -table.with-sticky-headers thead tr:first-child th:first-child { border-top-left-radius: 0; } -.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr:first-child th:last-child, -table.with-sticky-headers thead tr:first-child th:last-child { border-top-right-radius: 0; } - -// Form section header: a interrupts the fieldset's top border, so the -// header bar has no border around it (unlike a panel header). Float the legend -// so it flows as a normal block and the fieldset border wraps around it. -// padding-top: 0 so the header sits flush against the top border (the fieldset's -// 10px top padding would otherwise leave a gap above the floated legend). -form fieldset.inputs { padding-top: 0; } -form fieldset.inputs > legend { float: left; width: 100%; } -form fieldset.inputs > ol { clear: both; } -.comments form.active_admin_comment fieldset.inputs { - border: none -} -// Comment meta column (author / date / "Delete Comment"). AA's base _comments.scss -// caps it at 130px — too narrow for the timestamp (~99px) and the "Delete Comment" -// link (~77px) to share a line, so the link wraps to its own row. Widen the column -// so date + link sit together; keep the body/form offset (base = meta width + 20px) -// in step. -.comments .active_admin_comment { - .active_admin_comment_meta { width: 200px; } - .active_admin_comment_body { margin-left: 220px; } -} -.comments form.active_admin_comment { margin-left: 220px; } -// AA base sets `.pagination_information b { color: #5c6469 }` globally and -// `.comments .pagination_information { color: #000 }` (the "Displaying 1 -// comment" line) — black-on-dark in dark mode. Re-point to the themed text -// color so it reads in both modes; the .comments copy needs a two-class -// selector of its own to outweigh the base one. -.pagination_information, -.pagination_information b, -.comments .pagination_information { color: var(--aa-text); } -.pagination { - span > a { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - border: none; - background-color: $skinBorderWindowColor; - @include rounded($skinBorderRadius); - padding: 5px 10px; - &:hover { - background-image: none!important; - background-color: $skinMainSecondColor!important; - color: #ffffff; - } - } - span.page.current { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - background-color: $skinMainSecondColor; - border: none; - padding: 5px 10px; - @include rounded($skinBorderRadius); - cursor: pointer; - &:hover { - background-image: none!important; - background-color: $skinMainSecondColor!important; - } - } -} -body.logged_out #content_wrapper #active_admin_content { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - @include rounded($skinBorderRadius); - - // The login page uses #content_wrapper (not #wrapper), so the aa-content dark - // rules don't reach it — point the box at the palette vars directly so it - // follows light/dark like everything else. - background-color: var(--aa-surface); - color: var(--aa-text); - border: 1px solid var(--aa-border); - #login { - h2 { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - // Match the regular panel header: receding header color, header text color, - // bottom outline and header height — all from the same vars. - background-color: var(--aa-panel-header); - color: var(--aa-panel-header-text); - border: none; - border-bottom: 1px solid var(--aa-border); - padding: $skinPanelHeaderPaddingY 15px; - font-weight: 700; // match the tab label weight - - -webkit-border-top-left-radius: $skinBorderRadius; - -webkit-border-top-right-radius: $skinBorderRadius; - -moz-border-radius-topleft: $skinBorderRadius; - -moz-border-radius-topright: $skinBorderRadius; - border-top-left-radius: $skinBorderRadius; - border-top-right-radius: $skinBorderRadius; - - } - form { - fieldset { - border: none; - ol { - li { - label { - color: var(--aa-label-text); - } - } - } - } - } - } -} -form input[type=text], form input[type=password], form input[type=email], form input[type=number], form input[type=url], form input[type=tel], form textarea { - border: 1px solid var(--aa-input-border); - background-color: var(--aa-input-bg); - color: var(--aa-text); -} -// Flat form controls: strip the base ActiveAdmin emboss (white gradient fill, -// white emboss text-shadow, inset light box-shadow) from text inputs, selects -// and textareas in BOTH modes. Keeping this mode-agnostic means dark mode only -// has to recolor the controls via the --aa-* vars (no extra dark-only resets). -#active_admin_content { - input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]):not([type=file]):not([type=image]), - textarea, - select { - background-image: none; - text-shadow: none; - box-shadow: none; - } -} -form fieldset > ol > li fieldset ol li { - clear: both; - select { - margin-top: 6px - } -} -// Dropdown (batch-actions / title-bar / table-tools) menu items: keep them on the -// surface text colour, not the global link blue that otherwise leaks in. -.dropdown_menu_list li a { color: var(--aa-text) !important; } -// The list itself and its row dividers were never repainted — ActiveAdmin -// leaves them #FFF and #ebebeb, which in dark mode is a white panel behind -// the items and a bright hairline between every one of them. Same selector -// depth as the base rules, so source order decides and this comes later. -.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list { - background-color: var(--aa-elevated); - border-color: var(--aa-border); -} -.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li { - // Was repainted to --aa-border, but a hairline under every row makes the - // flyout read as a table of cells rather than a menu. Drop it — the hover - // highlight is enough separation. (Same depth as the base rule, later wins.) - border-bottom: none; -} - -// Dropdown-button caret. ActiveAdmin draws it with two stacked triangles on the -// button: :before is a #FFF "shadow", :after the #777 arrow — both hard-coded. -// On the themed blue title-bar action button and on the dark elevated -// table-tools buttons the #777 arrow is ~invisible. Recolor it to the button's -// own text colour and hide the light shadow into the fill. -#titlebar_right .dropdown_menu .dropdown_menu_button { - &:before { border-top-color: var(--aa-button); } - &:after { border-top-color: var(--aa-button-text); } -} -.table_tools .dropdown_menu .dropdown_menu_button, -.batch_actions_selector .dropdown_menu_button { - &:before { border-top-color: var(--aa-elevated); } - &:after { border-top-color: var(--aa-text); } -} - -// Disabled form controls. The theme recolors inputs/selects via --aa-*, but a -// disabled one looked identical to an enabled one (especially in dark). Mute -// the fill, text and cursor so "disabled" reads in both modes. select2 sets its -// control bg with !important, so the disabled override needs it too. -#active_admin_content { - input:disabled, textarea:disabled, select:disabled { - background-color: var(--aa-surface-2) !important; - color: var(--aa-text-muted) !important; - opacity: 0.6; - cursor: not-allowed !important; - } -} -// Both container classes (--default --disabled) so this out-specifies the -// enabled control rule's `--default` fill (which is !important) regardless of -// source order. -.select2-container--default.select2-container--disabled .select2-selection { - background-color: var(--aa-surface-2) !important; - opacity: 0.6; - cursor: not-allowed !important; - .select2-selection__rendered { color: var(--aa-text-muted) !important; } -} - -.ui-dialog { - background-image: none; - box-shadow: none; - text-shadow: none; - background-color: var(--aa-surface); - color: var(--aa-text); - border-bottom: 1px solid var(--aa-border); - // jQuery-UI's default `minHeight: 150` option is applied inline to the content - // box (min-height:~54px), so a one-line confirm leaves a big empty area - // between the message and the buttons. Let the content size to its text - // (the inline min-height needs !important to beat). - .ui-dialog-content { min-height: 0 !important; } - .ui-dialog-titlebar { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - background-color: var(--aa-panel-header); - color: var(--aa-panel-header-text); - border: none; - border-bottom: 1px solid var(--aa-border); - padding: $skinPanelHeaderPaddingY 15px; - font-weight: 700; - font-size: 1em; - - -webkit-border-top-left-radius: $skinBorderRadius; - -webkit-border-top-right-radius: $skinBorderRadius; - -moz-border-radius-topleft: $skinBorderRadius; - -moz-border-radius-topright: $skinBorderRadius; - border-top-left-radius: $skinBorderRadius; - border-top-right-radius: $skinBorderRadius; - } - // jQuery-UI close button: drop the default grey gradient pill so it blends into - // the accent titlebar. - .ui-dialog-titlebar-close { - background: transparent; - background-image: none; - border: none; - box-shadow: none; - color: var(--aa-panel-header-text); - } - form { - ul { - li { - label { - display: block; - margin-bottom: 5px; - } - input[type="text"], input[type="number"], input[type="datepicker"], textarea { - padding: 8px 10px 7px; - border: 1px solid var(--aa-input-border); - background-color: var(--aa-input-bg); - outline: none; - @include rounded($skinBorderRadius); - width: 247px; - &:focus { - border-color: lighten( $skinMainSecondColor, 20% ) ; - box-shadow: none; - } - } - } - } - } - .ui-dialog-buttonpane { - .ui-dialog-buttonset { - button:first-child { - background-image: none; - box-shadow: none; - text-shadow: none; - box-shadow: none; - @include rounded($skinBorderRadius); - background-color: var(--aa-button); - border: none; - color: var(--aa-button-text); - font-weight: 400; - padding: 10px 20px; - &:hover { - background-image: none!important; - background-color: var(--aa-button-hover)!important; - } - } - button:last-child { - background-image: none; - box-shadow: none; - text-shadow: none; - background-color: var(--aa-surface); - color: var(--aa-text); - @include rounded($skinBorderRadius); - padding: 9px 20px 9px 20px; - border: 1px solid var(--aa-border); - &:hover { - background-image: none!important; - background-color: var(--aa-surface-hover)!important; - } - } - } - } -} -.ui-widget-overlay { - background: rgba(0, 0, 0, 0.7); -} - -//becouse of sidebar border 2px -form.filter_form .filter_form_field.filter_date_range .seperator { - width: 10px; -} - -// Date-only range filters (jQuery-UI datepicker, input.datepicker) get no width -// from the base, so the two inputs fall back to their size="12" intrinsic width -// and line up side by side only by luck; the label's float:right "Set range" -// link then leaks into the input band on a longer label and bumps the "To" -// input onto a second line. Lay the field out with flexbox: the label takes a -// full row, the two inputs share the next, immune to the float. -form.filter_form .filter_form_field.filter_date_range { - display: flex; - flex-wrap: wrap; - - > label { flex: 0 0 100%; } - - input.datepicker { - box-sizing: border-box; - flex: 1 1 0; - min-width: 0; - margin: 0; - - & + input.datepicker { margin-left: 6px; } - } -} - -//filter select box border radius -.filter_form_field select { - border-radius: 3px; -} - -//radio button color change -input[type='radio'] { - &:before { - content: ''; - display: block; - width: 50%; - height: 50%; - margin: 20% auto; - border-radius: 50%; - } - &:checked:before { - background: $skinMainSecondColor; - } -} - -// Status tag: solid colour fill with a white label (high visibility), and an -// outline a shade darker than the fill so the edge reads in the tag's own -// colour. All colours are fixed, so a tag looks identical in light and dark. -@mixin status-tag-colors($c) { - background: $c; - border-color: mix($c, #000000, 84%); // = darken($c) — outline in the fill colour - // The fills are dark enough to carry this label: every one of the five is at - // least 4.53 against white. Lighten one and the label needs to go with it. - color: $skinStatusTagTextColor; -} - -//CONTENT (both modes)-----------------------------------------------------------// -// Repaint ActiveAdmin's content onto the semantic palette. Included -// unconditionally; the --aa-* custom properties carry the per-mode values (light -// on :root, dark via aa-dark-palette), so this one rule set themes both modes. -// Only the genuinely-dark overrides (see aa-dark-content, below) stay dark-scoped. -@mixin aa-content { - body.active_admin { color: var(--aa-text); } - - #wrapper #active_admin_content { - color: var(--aa-text); - - // Status tags: solid colour fill + white label (high visibility), an - // in-colour outline, rounded corners and a centered label. Colours are - // mode-independent (same in light and dark). The base ActiveAdmin rule - // supplies uppercase + letter-spacing + font-size; text-indent offsets the - // trailing letter-spacing so the label is horizontally centered. - .status_tag { - display: inline-block; - line-height: 1; - // 4px (not 5px) horizontal padding so the added 1px border doesn't widen - // the tag past ActiveAdmin's original borderless 5px-padded width. 1px less - // bottom padding than top (like the stock AA tag) optically centers the - // uppercase label, which otherwise rides high (no descenders to fill the - // reserved space below the baseline). - padding: 3px 4px 2px; - text-indent: 0.15em; - border-radius: 2px; - border: 1px solid; - - @include status-tag-colors($skinStatusTagNeutralColor); - &.ok, &.published, &.complete, &.completed, &.green, &.yes { @include status-tag-colors($skinStatusTagOkColor); } - &.notice, &.blue { @include status-tag-colors($skinStatusTagNoticeColor); } - &.warn, &.warning, &.orange { @include status-tag-colors($skinStatusTagWarnColor); } - &.error, &.errored, &.red { @include status-tag-colors($skinStatusTagErrorColor); } - &.empty, &.unknown, &.none { - background: none; - border: 0; // placeholder/empty tags stay unobtrusive (no outline) - color: var(--aa-text-muted); - font-weight: bold; - font-size: 0.8em; - text-indent: 0; - } - } - - // Generic content links take the link color. Exclude UI "chrome" links that - // carry their own color — buttons, title-bar actions, scope/batch buttons - // (.table_tools_button), jQuery-UI tab anchors (.ui-tabs-anchor) — and delete - // links (handled by their own rule). Excluding them means those rules win on - // their own, with no need for !important. - // :where() wraps the exclusions so they filter without adding weight. Bare, - // the seven :not()s are worth seven class-level points, and that beat every - // component rule declared later — pagination links and the Clear Filters - // button came out --aa-link instead of --aa-text. - a:where(:not(.button):not(.action_item):not(.table_tools_button):not(.dropdown_menu_button):not(.ui-tabs-anchor):not(.delete_link):not(.delete-link):not([data-method="delete"])) { color: var(--aa-link); } - // `.delete-link` (hyphen) as well as AA's own `.delete_link`: some apps name - // their soft-delete links with the hyphen. - a[data-method="delete"], a.delete_link, a.delete-link { color: var(--aa-delete-link); } - - // Pagination — non-current page chips use a light-grey bg in the theme - .pagination span > a { background-color: var(--aa-surface-2); color: var(--aa-text); } - // The hovered page link keeps its accent fill, so the label has to be - // the colour that reads on the accent — not the link colour. The generic - // link rule above scores (2,6,1) thanks to its six :not() clauses and - // cannot be out-specified by anything sane here, which is why this needs - // !important, like the accent fill one line over in the base pagination - // block. Without it the digit renders at 1.00:1 on its own background. - // The hover fill is the accent ($skinMainSecondColor); $skinMenuPillTextColor - // is a light blue that sits on it at ~1.16:1 (invisible). White reads on the - // accent (matches the base pagination block's own hover colour one file over). - .pagination span > a:hover { color: #fff !important; } - - // Batch-actions / table-tools dropdown menu items are white by default - .table_tools .dropdown_menu_list li a { - background-color: var(--aa-surface); - color: var(--aa-text); - } - - // NB: do NOT give .panel_contents its own background — it's a square-cornered - // child that fills the panel, so a bg on it would paint over the panel's - // rounded inner border edge and make the corners look square. The parent - // .panel background shows through instead. - .panel, - .sidebar_section, - .dashboard_section, - fieldset.inputs { - background: var(--aa-surface); - border-color: var(--aa-border); - color: var(--aa-text); - } - // .index_content wraps the index table, which already paints its own - // (rounded) surface. Giving this square-cornered wrapper a surface bg makes - // it show through the table's rounded corners as white squares, so leave it - // transparent (same reasoning as .panel_contents above). - .index_content { color: var(--aa-text); } - // Blank-slate ("No records") container blends into the page background - // rather than reading as a panel surface. - .blank_slate_container { - background: var(--aa-page-bg); - color: var(--aa-text); - } - // Cancel / clear buttons are white in the theme (a light secondary button) - fieldset.buttons li.cancel a, fieldset.actions li.cancel a, - form.filter_form a.clear_filters_btn { - background-color: var(--aa-surface-2); - color: var(--aa-text); - border-color: var(--aa-input-border); - } - // (Tab styling — colors, the no-bottom-outline inactive tabs, and the z-index - // baseline — lives in the base #wrapper div.tabs block now, so it applies in - // both light and dark via the --aa-* vars.) - - table.index_table, - .attributes_table table, - table { - background: var(--aa-surface); - color: var(--aa-text); - border-color: var(--aa-border); - } - // Column headers (index AND plain content tables, e.g. the CDR "Attempts" - // table) get a distinct header-row shade so they don't blend with the rows. - // Plain table headers are otherwise transparent and read like an odd row. - table thead th { - background: var(--aa-surface-2); - } - table.index_table thead th { background: var(--aa-table-header); } - table.index_table thead th, - .attributes_table th { - border-color: var(--aa-border); - } - table th, table td, - table.index_table td, - .attributes_table td { - border-color: var(--aa-border); - color: var(--aa-text); - } - // Attribute-table labels (the field-name column) use the muted label color; - // the more specific selector wins over the generic `table th` rule above. - .attributes_table th { color: var(--aa-label-text); } - table.index_table tbody tr:nth-child(even) > td, - table.index_table tbody tr.even > td { background: var(--aa-surface-2); } - table.index_table tbody tr:hover > td { background: var(--aa-surface-hover); } - // Selected cells: the zebra and hover rules above outrank ActiveAdmin's own - // `tr.selected td` / `td.selected`, so without this a ticked row (batch - // actions) or a click-highlighted row + column (crisscross) looks unselected. - // The `tr >` in the td.selected selector is load-bearing: the even-stripe - // rule is `...tbody tr:nth-child(even) > td` (specificity 0,0,2,4); a plain - // `tbody td.selected` is only (0,0,2,3) and loses on even rows, breaking the - // cross every other row. Matching the `tr >` ties the specificity, so source - // order wins. The :hover variants keep the selection visible under the pointer. - table.index_table tbody tr.selected > td, - table.index_table tbody tr.selected:hover > td, - table.index_table tbody tr > td.selected, - table.index_table tbody tr:hover > td.selected { background: var(--aa-selected); } - // The clicked cell sits at the centre of the cross; an accent outline marks - // it apart from the row/column tint without a competing fill (outset inward - // so it adds no layout shift). - table.index_table tbody tr > td.selected-cell { - outline: 2px solid var(--aa-accent); - outline-offset: -2px; - } - // Plain content tables (e.g. the CDR show "Attempts" table) are striped by the - // base CSS with light fills; map the odd/even rows onto the dark surfaces too. - table tbody tr.odd > td, table tbody tr.odd > th { background-color: var(--aa-surface); } - table tbody tr.even > td, table tbody tr.even > th { background-color: var(--aa-surface-2); } - // Plain table headers carry a white emboss text-shadow (subtle on a light bg, - // a blurry halo on dark). Drop it — index tables are already shadow-free. - table thead th, table thead td { text-shadow: none; } - // Footer / totals row (theme gives it a light grey bg, which on the dark - // panel reads as a light rectangle poking against the rounded panel border). - table.index_table tfoot tr, - table.index_table tfoot td, - table.index_table tfoot th { - background: var(--aa-surface-2); - color: var(--aa-text); - } - - // Give the content column its own block-formatting context so #index_footer's - // `clear: both` (below) only clears the pagination floats INSIDE the table - // area — not the floated #sidebar. Without this, a tall filter sidebar pushes - // the pagination far below a short table (it clears the sidebar's float too). - #main_content { display: flow-root; } - - // Index footer (per-page selector, pagination, "displaying…" info, download - // links): lay them out in one left-packed, vertically-centred row. The gem - // floats them, so they sat top-aligned and, having different heights, looked - // misaligned. A slightly larger font than the gem default (0.85em) too. - #index_footer { - // ActiveAdmin floats .pagination_top and .paginated_collection_contents - // left; the footer has no clear, so it rode up beside them and rendered - // ABOVE the table (per-page + page numbers + "Displaying…" duplicated on - // top, nothing below). Clear the floats so it sits under the table — one - // pagination on top, one on the bottom, like master. - clear: both; - text-align: left; - font-size: 0.95em; - .pagination_per_page, - .pagination, - .pagination_information, - .download_links { - float: none; - display: inline-block; - vertical-align: middle; - margin: 0 8px 0 0; - } - } - - // Forms & inputs - // :focus is nested rather than written out alongside: the six :not()s count - // as six class-level points, so a sibling `input:focus` (one) loses to the - // resting border every time and the focus indicator never appears at all. - input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]):not([type=file]):not([type=image]), - textarea, - select { - background-color: var(--aa-input-bg); - border-color: var(--aa-input-border); - color: var(--aa-text); - - &:focus { border-color: var(--aa-accent); } - } - input::placeholder, textarea::placeholder { color: var(--aa-text-muted); } - option, optgroup { background-color: var(--aa-input-bg); color: var(--aa-text); } - label, fieldset > legend, .fieldset > legend { color: var(--aa-label-text); } - .inline-hints, .inline_hints, p.inline-hints { color: var(--aa-text-muted); } - } - - // jQuery UI datepicker (appended to , so styled at the mixin level) - // #ui-datepicker-div, not .ui-datepicker.ui-widget: both ActiveAdmin and the - // block higher up this file address it by id, and an id beats any number of - // classes — the class-only form here never applied, leaving the calendar on - // its light fills with #666 day numbers on the dark surface (2.61:1). - #ui-datepicker-div.ui-datepicker { - background: var(--aa-surface); - border-color: var(--aa-border); - color: var(--aa-text); - // The header keeps the accent fill and its white label in both modes — the - // theme paints it that way above, and it reads on either background. Only - // the calendar body needs repointing; a blanket `a` here would turn the - // header's month arrows and title dark on the accent. - border-color: var(--aa-border); - table.ui-datepicker-calendar { background-color: var(--aa-surface); } - table.ui-datepicker-calendar thead th { background-color: var(--aa-surface-2); color: var(--aa-text-muted); } - table.ui-datepicker-calendar tbody td a { color: var(--aa-text); } - table.ui-datepicker-calendar tbody td span { color: var(--aa-text-muted); } - .ui-state-hover, .ui-state-active { background: var(--aa-surface-hover); } - } - - // select2 (used by most ActiveAdmin apps). It ships its own light chrome and - // appends the dropdown to , so it is styled here at the mixin level - // like the datepicker. Drive the control, the dropdown panel, the results - // list and the search field from the --aa-* tokens so it follows both modes; - // without this the dropdown stays a white panel with white/near-invisible - // text in dark mode. !important is required because select2's own stylesheet - // sets these to white with equal specificity and loads after this in the - // bundle — same reason the datetime picker overrides use it. - .select2-container--default { - .select2-selection--single, - .select2-selection--multiple { - background-color: var(--aa-input-bg) !important; - border-color: var(--aa-input-border) !important; - color: var(--aa-text); - } - .select2-selection__rendered { color: var(--aa-text) !important; } - .select2-selection__placeholder { color: var(--aa-text-muted) !important; } - // Clear "×": keep it a small, muted inline glyph that brightens on hover. - // select2 4.1 renders it as a + + <% if current_active_admin_user? %> + + <% if (logout_path = auto_logout_link_path) %> + <%= link_to logout_path, class: "aat-logout inline-flex items-center justify-center w-8 h-8 opacity-80 hover:opacity-100", title: I18n.t("active_admin.logout"), "aria-label": I18n.t("active_admin.logout"), data: { method: :delete } do %> + + <% end %> + <% end %> + <% end %> + diff --git a/app/views/active_admin/_header_leading.html.erb b/app/views/active_admin/_header_leading.html.erb new file mode 100644 index 0000000..4236e10 --- /dev/null +++ b/app/views/active_admin/_header_leading.html.erb @@ -0,0 +1,5 @@ + + +

<%= title %>

diff --git a/app/views/active_admin/_header_navigation.html.erb b/app/views/active_admin/_header_navigation.html.erb new file mode 100644 index 0000000..74ad9d3 --- /dev/null +++ b/app/views/active_admin/_header_navigation.html.erb @@ -0,0 +1,21 @@ + diff --git a/app/views/active_admin/_main_navigation.html.erb b/app/views/active_admin/_main_navigation.html.erb new file mode 100644 index 0000000..e71ad1f --- /dev/null +++ b/app/views/active_admin/_main_navigation.html.erb @@ -0,0 +1,3 @@ + diff --git a/app/views/active_admin/_page_content.html.erb b/app/views/active_admin/_page_content.html.erb new file mode 100644 index 0000000..d6acd79 --- /dev/null +++ b/app/views/active_admin/_page_content.html.erb @@ -0,0 +1,7 @@ +<% has_sidebar = !(skip_sidebar? || sidebar_sections_for_action.blank?) %> +
class="px-2.5 md:px-4 grid grid-cols-1 gap-3 md:gap-4 md:data-aat-has-sidebar:grid-cols-[250px_minmax(0,1fr)] md:data-aat-sidebar-collapsed:grid-cols-1"> + <%= render "active_admin/sidebar" %> +
+ <%= yield %> +
+
diff --git a/app/views/active_admin/_page_heading.html.erb b/app/views/active_admin/_page_heading.html.erb new file mode 100644 index 0000000..d9d841e --- /dev/null +++ b/app/views/active_admin/_page_heading.html.erb @@ -0,0 +1,12 @@ +<% has_sidebar = !(skip_sidebar? || sidebar_sections_for_action.blank?) %> +
+ <% if has_sidebar %> + + <% end %> +
+ <%= render "active_admin/breadcrumbs" %> + <%= render "active_admin/page_title", title: title %> +
+
diff --git a/app/views/active_admin/_page_title.html.erb b/app/views/active_admin/_page_title.html.erb new file mode 100644 index 0000000..ac9c20f --- /dev/null +++ b/app/views/active_admin/_page_title.html.erb @@ -0,0 +1 @@ +

<%= title %>

diff --git a/app/views/active_admin/_sidebar.html.erb b/app/views/active_admin/_sidebar.html.erb new file mode 100644 index 0000000..cb4c045 --- /dev/null +++ b/app/views/active_admin/_sidebar.html.erb @@ -0,0 +1,5 @@ +<% unless skip_sidebar? || sidebar_sections_for_action.blank? %> + +<% end %> diff --git a/app/views/active_admin/resource/_show_default.html.arb b/app/views/active_admin/resource/_show_default.html.arb new file mode 100644 index 0000000..4cd9ae2 --- /dev/null +++ b/app/views/active_admin/resource/_show_default.html.arb @@ -0,0 +1,4 @@ +panel I18n.t("active_admin.details", model: active_admin_config.resource_label) do + attributes_table_for resource, *active_admin_config.resource_columns +end +active_admin_comments_for(resource) if active_admin_config.comments? diff --git a/app/views/active_admin/resource/show.html.arb b/app/views/active_admin/resource/show.html.arb new file mode 100644 index 0000000..b2eed73 --- /dev/null +++ b/app/views/active_admin/resource/show.html.arb @@ -0,0 +1,15 @@ +# Wraps the show page's attributes_table in a panel, as ActiveAdmin 3 did. +def attributes_table(*args, &block) + panel I18n.t("active_admin.details", model: active_admin_config.resource_label) do + attributes_table_for resource, *args, &block + end +end + +div class: "main-content-container" do + if page_presenter.block + # Evaluate the show config from the controller + instance_exec resource, &page_presenter.block + else + render "show_default" + end +end diff --git a/app/views/active_admin/shared/_sidebar_section.html.arb b/app/views/active_admin/shared/_sidebar_section.html.arb new file mode 100644 index 0000000..440aa8e --- /dev/null +++ b/app/views/active_admin/shared/_sidebar_section.html.arb @@ -0,0 +1,10 @@ +# Sidebar content sits in a panel with the section's title, as in ActiveAdmin 3. +title = I18n.t("active_admin.sidebars.#{section.name.to_s.parameterize(separator: '_')}", default: section.name.to_s.titlecase) +panel(title) do + if section.block + result = instance_exec(§ion.block) + text_node result unless result.is_a?(Arbre::Element) + else + render(section.partial_name) + end +end diff --git a/app/views/active_admin_theme/_menu_drawer.html.erb b/app/views/active_admin_theme/_menu_drawer.html.erb new file mode 100644 index 0000000..955634a --- /dev/null +++ b/app/views/active_admin_theme/_menu_drawer.html.erb @@ -0,0 +1,26 @@ +<%# ActiveAdmin's main_menu.js toggles data-open on the item when a [data-menu-button] is clicked. %> +
    "> + <% items.each do |item| %> + <% children = item.items(self).presence %> + <% url = item.url(self) %> + <% link_classes = "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white rounded-md text-sm no-underline #{nested ? 'block py-1.5 px-2' : 'flex items-center w-full p-2 gap-x-2'}" %> + <% selected = "bg-gray-100 dark:bg-white/5 text-gray-900 dark:text-white selected" %> +
  • <%= "data-open" if children && current_menu_item?(item) %>> + <% if children %> +
    + <% if url.present? && url != "#" %> + <%= link_to item.label(self), url, item.html_options.merge(class: "grow #{link_classes} #{selected if current_menu_item?(item, children: false)}") %> + <% else %> + <%= item.label(self) %> + <% end %> + +
    + <%= render "active_admin_theme/menu_drawer", items: children, nested: true %> + <% else %> + <%= link_to item.label(self), url, item.html_options.merge(class: "#{link_classes} #{selected if current_menu_item?(item)}") %> + <% end %> +
  • + <% end %> +
diff --git a/app/views/active_admin_theme/_menu_flyout.html.erb b/app/views/active_admin_theme/_menu_flyout.html.erb new file mode 100644 index 0000000..effdb7a --- /dev/null +++ b/app/views/active_admin_theme/_menu_flyout.html.erb @@ -0,0 +1,20 @@ +
    + <% items.each do |item| %> + <% children = item.items(self).presence %> + <% url = item.url(self) %> + <% classes = "px-3 py-1.5 leading-snug no-underline text-(--aat-menu-text) hover:bg-(--aat-menu-item-hover) hover:text-(--aat-menu-hover-text) #{'bg-(--aat-menu-item-hover) text-(--aat-menu-hover-text) selected' if current_menu_item?(item)}" %> +
  • > + <% if children %> + <%= link_to url.presence || "#", item.html_options.merge(class: "flex items-center gap-2 #{classes}", aria: { haspopup: "true" }) do %> + <%= item.label(self) %> + + <% end %> +
    + <%= render "active_admin_theme/menu_flyout", items: children %> +
    + <% else %> + <%= link_to item.label(self), url, item.html_options.merge(class: "block #{classes}") %> + <% end %> +
  • + <% end %> +
diff --git a/app/views/layouts/active_admin_logged_out.html.erb b/app/views/layouts/active_admin_logged_out.html.erb new file mode 100644 index 0000000..706e39c --- /dev/null +++ b/app/views/layouts/active_admin_logged_out.html.erb @@ -0,0 +1,13 @@ + + + + <%= html_head_site_title %> + <%= render "active_admin/html_head" %> + + +
+ <%= render "active_admin/flash_messages" %> + <%= yield %> +
+ + diff --git a/app/views_compat/active_admin/_html_head.html.erb b/app/views_compat/active_admin/_html_head.html.erb new file mode 100644 index 0000000..949c14a --- /dev/null +++ b/app/views_compat/active_admin/_html_head.html.erb @@ -0,0 +1,14 @@ +<%= stylesheet_link_tag "active_admin" %> + +<%= csrf_meta_tags %> +<%= csp_meta_tag %> +<% # On page load or when changing themes, best to add inline in `head` to avoid FOUC %> +<%= javascript_tag nonce: true do %> + if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } +<% end %> +<%= javascript_importmap_tags "active_admin", importmap: ActiveAdmin.importmap %> +<%= javascript_import_module_tag(*ActiveAdminTheme.javascripts) if ActiveAdminTheme.javascripts.any? %> diff --git a/app/views_compat/active_admin/_page_header.html.erb b/app/views_compat/active_admin/_page_header.html.erb new file mode 100644 index 0000000..df0c8bf --- /dev/null +++ b/app/views_compat/active_admin/_page_header.html.erb @@ -0,0 +1,8 @@ + diff --git a/app/views_compat/active_admin/_site_header.html.erb b/app/views_compat/active_admin/_site_header.html.erb new file mode 100644 index 0000000..1f70706 --- /dev/null +++ b/app/views_compat/active_admin/_site_header.html.erb @@ -0,0 +1,5 @@ + diff --git a/app/views_compat/layouts/active_admin.html.erb b/app/views_compat/layouts/active_admin.html.erb new file mode 100644 index 0000000..43ec1b5 --- /dev/null +++ b/app/views_compat/layouts/active_admin.html.erb @@ -0,0 +1,17 @@ + + + + <%= html_head_site_title %> + <%= render "active_admin/html_head" %> + + + <%= render "active_admin/site_header", title: site_title %> + <%= render "active_admin/main_navigation" %> +
+ <%= render "active_admin/page_header", title: @page_title || page_title %> + <%= render "active_admin/flash_messages" %> + <%= render "active_admin/page_content" %> + <%= render "active_admin/site_footer" %> +
+ + diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..fe38428 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,9 @@ +# Pinned into ActiveAdmin.importmap (the map the AA layout renders), not into +# the host's own importmap. Not preloaded: a modulepreload of an opt-in module +# (searchable_select) also fetches its imports, so a host that skips the module +# would still load Tom Select. +pin_all_from File.expand_path("../app/javascript/active_admin_theme", __dir__), under: "active_admin_theme", preload: false + +# Vendored Tom Select for active_admin_theme/searchable_select (the engine adds +# vendor/javascript to the asset paths). +pin "tom-select", preload: false diff --git a/docs/migrating-from-v3.md b/docs/migrating-from-v3.md new file mode 100644 index 0000000..2cd58f9 --- /dev/null +++ b/docs/migrating-from-v3.md @@ -0,0 +1,81 @@ +# Migrating from theme 3 to theme 4 + +Theme 4 is a rework for ActiveAdmin 4. ActiveAdmin 4 replaced SCSS and Sprockets with Tailwind CSS 4, ERB partials, and import maps, so theme 3 cannot run on it. On screen the result looks almost the same. In your app, a few things change. + +Upgrade ActiveAdmin first, and follow ActiveAdmin's own [upgrade guide](https://github.com/activeadmin/activeadmin/blob/master/UPGRADING.md). Then switch the theme. Theme 3.x stays on the `3.x` branch and keeps getting bug fixes. + +## Checklist + +1. In the `Gemfile`, set `gem "active_admin_theme", "~> 4.0"` and `activeadmin` to 4.0 or newer. Ruby 3.3 or newer is required. +2. Remove the theme 3 lines from your stylesheet and script manifests: + - `@import "wigu/active_admin_theme";` and any `$skin*` variable assignments + - `//= require wigu/all` (or the individual `wigu/*` files) +3. Add the theme CSS to your Tailwind entry file and the gem's files to the Tailwind `content` globs. See the README, "CSS". If you use the gem route, run `bin/rails active_admin_theme:css` in your build and git-ignore the generated file. +4. Move your color settings from Sass variables to CSS custom properties (below). +5. Replace anything that relied on the utility navigation (below). +6. Remove calls to ActiveAdmin 3 DSL that ActiveAdmin 4 dropped (below). +7. Check your own scripts: ActiveAdmin 4 has no jQuery. + +## Colors: `$skin*` variables are now `--aat-*` properties + +Sass variables are gone. Set CSS custom properties instead, light on `:root` and dark under `.dark`: + +| Theme 3 variable | Theme 4 property | +| --- | --- | +| `$skinPageBgColor` / `$skinPageBgColorDark` | `--aat-page-bg` | +| `$skinSurfaceColor` | `--aat-surface` | +| `$skinSurface2Color` | `--aat-surface-2` | +| `$skinSurfaceHoverColor` | `--aat-surface-hover` | +| `$skinSelectedRowColor` | `--aat-selected-row` | +| `$skinTextColor`, `$skinTextMutedColor` | `--aat-text`, `--aat-text-muted` | +| `$skinBorderColor` | `--aat-border` | +| `$skinInputBgColor`, `$skinInputBorderColor` | `--aat-input-bg`, `--aat-input-border` | +| `$skinMainSecondColor` (accent) | `--aat-accent` | +| `$skinLinkColor` | `--aat-link` | +| `$skinButtonColor`, `$skinButtonTextColor` | `--aat-button`, `--aat-button-text` | +| `$skinMainFirstColor` (header) | `--aat-header-bg` | +| `$skinMenu*` | `--aat-menu-*` | + +Sizing variables (header padding, title bar height, and so on) are not ported. Override the Tailwind classes in the partials, or copy the partial into your app. + +## Utility navigation, server clock, and logout + +ActiveAdmin 4 removed `build_menu :utility_navigation` and `add_logout_button_to_menu`. The theme now renders the server clock, the current user, and the logout icon in its header partial, so you no longer add them: + +- Delete any `ActiveAdminTheme.add_current_user = false` line. To hide the user or the clock, override `app/views/active_admin/_header_actions.html.erb`. +- Delete any items you added to the `:utility_navigation` menu. Put the content in `_header_actions.html.erb` instead. +- Logout still follows `config.logout_link_path`; set it to `nil` to remove the icon. + +## Nested menus + +ActiveAdmin 4 dropped menus deeper than one submenu level, but the theme keeps them. `menu parent: ["Customer", "Settings"]` works as it did with ActiveAdmin 3. + +## JavaScript + +- The theme loads as ES modules through `ActiveAdmin.importmap`, not through Sprockets. A pin in your own `config/importmap.rb` does not load on ActiveAdmin pages; pin your scripts into `ActiveAdmin.importmap` (README, "JavaScript"). +- `theme_toggle.js` is gone. The header uses ActiveAdmin 4's dark mode switch. +- `scrollbar_width.js` is gone. The header no longer uses `100vw`, so it is not needed. +- `table_crisscross.js` is rewritten without jQuery as `active_admin_theme/table_crisscross`, and loads by default. The `td.selected` marker is now `td[data-selected]`. +- `server_clock.js` is rewritten as `active_admin_theme/server_clock`. It reads `data-servertime-epoch` and `data-servertime-zone` from `[data-server-clock]`; the old `data-servertime` wall-clock format is not supported. + +## DSL + +| Theme 3 / ActiveAdmin 3 | Theme 4 | +| --- | --- | +| `columns` / `column` | Restored by the theme, with a ratio string and a `mobile:` option. `column span: N` still works. | +| `tabs` / `tab` | Restored by the theme. | +| `panel` around `sidebar` and show `attributes_table` | Restored by the theme. | +| `Panel#header_action` | Removed. | +| `dropdown_menu` in action items | Removed by ActiveAdmin 4. | +| `index_as_blog`, `index_as_block`, `index_as_grid`, `index_column` | Removed by ActiveAdmin 4. | +| Sortable has-many forms | Removed by ActiveAdmin 4. | + +Extensions that depend on ActiveAdmin 3 internals (calendar, table footer, and others) are not part of this gem and may block your migration. + +## Not ported + +- Trix and ActionText theming. +- Pinned bars and sticky table headers. +- The `$skin*` sizing variables. + +Open an issue if you need one of these. diff --git a/lib/active_admin_theme.rb b/lib/active_admin_theme.rb index 5c1ead0..25e88b3 100644 --- a/lib/active_admin_theme.rb +++ b/lib/active_admin_theme.rb @@ -1,134 +1,34 @@ +# ActiveAdmin first: Rails prepends each engine's view path in the order the +# engine classes are defined, so the theme's partials win over ActiveAdmin's +# only when ActiveAdmin::Engine is defined before ActiveAdminTheme::Engine. +require "active_admin" require "active_admin_theme/version" module ActiveAdminTheme - module Rails - class Engine < ::Rails::Engine - # Register the server clock once AA is available. server_clock.js (shipped - # in this gem) ticks li#servertime in #utility_nav and reads the server - # instant from [data-servertime]; here we render both server-side, per - # namespace, so every consumer gets the clock without reimplementing it. - # after :load_config_initializers, or hosts loading AA from a config - # initializer (gem "activeadmin", require: false) fail the defined? - # check and silently get no clock — engine initializers otherwise run - # before the host's config/initializers. - initializer "active_admin_theme.server_clock", after: :load_config_initializers do - if defined?(::ActiveAdmin) - ActiveAdminTheme.install! - else - # Evaluated once; a host requiring activeadmin even later (routes.rb, - # an ENV-gated engine) gets no theme at all — say so instead of - # silently shipping a README-promised feature that never appears. - ::Rails.logger&.warn( - "active_admin_theme: ActiveAdmin was not loaded when the engine " \ - "initialized; the utility-nav clock and logout handling are " \ - "disabled. Require \"active_admin\" no later than config/initializers." - ) - end - end - end + # True when ActiveAdmin has the layout partials the theme overrides + # (activeadmin/activeadmin#9149 and #9150). + def self.layout_hooks? + ActiveAdmin::Engine.root.join("app/views/active_admin/_page_heading.html.erb").exist? end +end - # Opt-out for the restored current-user item. Unlike logout (which AA turns - # off per namespace via logout_link_path = nil), AA has no switch short of - # unsetting current_user_method — which also breaks authorization — and - # Menu has no removal API, so hosts that deliberately run a user-link-free - # header (e.g. display_name would leak an email) set this in an initializer: - # ActiveAdminTheme.add_current_user = false - class << self - attr_accessor :add_current_user - end - self.add_current_user = true +require "active_admin_theme/engine" - # Add a utility-nav clock item (and ensure a logout item) to every AA namespace. - # - # data-servertime must be fresh on every request, but only a menu item's - # :label/:url/:if are evaluated per request — :html_options is captured once at - # build time. So the instant is embedded in the label as html_safe markup - # rather than an html_option. The label is "YYYY MM DD HH MM SS ZONE" (exactly - # what server_clock.js parses) on an inner span; the JS overwrites the visible - # text each tick. Timezone comes from the host app's Time.current/Time.zone. - def self.install! - # after_load fires on every ActiveAdmin.load! (each dev reload), and each - # build_menu call registers a before_build callback that MenuCollection#clear! - # never removes — so register once per namespace or callbacks pile up for - # the life of the process. Namespace objects and their MenuCollection - # survive unload! (it only clears the built menus), so once is enough. - seen = {}.compare_by_identity - register = lambda do |namespace| - next if seen[namespace] - seen[namespace] = true - namespace.build_menu(:utility_navigation) do |menu| - # A consumer item with the same id wins, but NOT through these - # guards: before_build unshifts, so the LAST registration runs - # first, and consumer hooks (config/initializers) subscribe before - # this engine's initializer — on a production first load THIS - # callback runs first against an empty menu and the guards all - # pass. What protects consumer overrides is Menu#_add replacing by - # id when their callback runs afterwards. The guards only matter - # from the second dev reload on, when consumer items are already - # present — do not tighten them into the primary mechanism. - unless menu["servertime"] - menu.add id: "servertime", - priority: 99_990, - url: -> {}, - # same guard both default utility items carry — the nav - # stays empty for anonymous visitors, as it was pre-theme - if: :current_active_admin_user?, - label: lambda { - now = Time.current - data = now.strftime("%Y %m %d %H %M %S %Z") - shown = now.strftime("%Y %b %d %H:%M:%S %Z") - # epoch + IANA zone let the JS re-apply the server - # zone's offset on every tick (DST included); the - # wall-clock attribute stays as the documented contract - # for host-rendered elements and older scripts. - # Time.zone is nil under Time.use_zone(nil) — the - # common around_action(user.time_zone) pattern with a - # nil column — while Time.current still works; skip the - # precise attributes then instead of 500ing the page. - tz = Time.zone - precise = tz ? %( data-servertime-epoch="#{now.to_i}" data-servertime-zone="#{tz.tzinfo.identifier}") : "" - %(#{shown}).html_safe - } - end - # Claiming :utility_navigation stops AA's build_default_utility_nav - # (it returns early once the menu exists), so restore the default - # current-user item it would have added. No-op when the host has no - # current_user_method, exactly like the default nav. - if ActiveAdminTheme.add_current_user && !menu["current_user"] - namespace.add_current_user_to_menu(menu) - end - # Opting out is ActiveAdmin's own per-namespace switch, not a theme - # flag: config.logout_link_path = nil makes add_logout_button_to_menu - # a no-op (AA guards on `if logout_link_path`). Hosts with custom - # auth and no logout route — where rendering the default item would - # public_send a missing helper and 500 — set exactly that. - # The wigu-logout class (lands on the
— AA passes html_options to - # link_to) scopes the stylesheet's icon treatment to THIS item, so a - # consumer-rendered logout with its own label keeps its text. The - # aria-label is the accessible name: the stylesheet hides the link - # text with font-size: 0, which some screen readers prune from the - # accessibility tree. html_options are captured at menu-build time, so - # the label uses the locale active then — good enough for a nav icon. - unless menu["logout"] - namespace.add_logout_button_to_menu( - menu, 99_999, - class: "wigu-logout", "aria-label": I18n.t("active_admin.logout") - ) - end - end - end +module ActiveAdminTheme + # ES modules the layout imports after ActiveAdmin's own entry point. Edit the + # list in an initializer to load fewer scripts or to add the opt-in ones: + # ActiveAdminTheme.javascripts -= %w[active_admin_theme/table_crisscross] + DEFAULT_JAVASCRIPTS = %w[ + active_admin_theme/layout + active_admin_theme/server_clock + active_admin_theme/table_crisscross + ].freeze - ::ActiveAdmin.after_load do |app| - app.namespaces.each(®ister) - end - # Production fires after_load exactly once (load! gates on @@loaded), so a - # namespace created later — e.g. another engine's later-subscribed hook - # registering a resource into a new namespace — would keep AA's stock - # default nav, inconsistently with its siblings. Namespace creation has - # its own notification; theme late arrivals through it. - ActiveSupport::Notifications.subscribe(::ActiveAdmin::Namespace::RegisterEvent) do |*args| - register.call(args.last[:active_admin_namespace]) + class << self + attr_writer :javascripts + + def javascripts + @javascripts ||= DEFAULT_JAVASCRIPTS.dup end end end diff --git a/lib/active_admin_theme/css_import.rb b/lib/active_admin_theme/css_import.rb new file mode 100644 index 0000000..b445b3e --- /dev/null +++ b/lib/active_admin_theme/css_import.rb @@ -0,0 +1,59 @@ +require "pathname" + +module ActiveAdminTheme + # Gives a host's Tailwind entry file the theme CSS from the installed gem. + # A Tailwind @import cannot compute the gem's path, and that path differs + # between machines (a laptop, a Docker image, CI). So the path goes into a + # generated file the host does not commit, and the entry file, which is + # committed, imports that file by a fixed relative name. + module CssImport + GENERATED = "active_admin_theme.generated.css".freeze + BEGIN_MARKER = "/* active_admin_theme:begin */".freeze + END_MARKER = "/* active_admin_theme:end */".freeze + BLOCK = /#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m + ENTRY_BLOCK = "#{BEGIN_MARKER}\n@import \"./#{GENERATED}\";\n#{END_MARKER}\n".freeze + + class << self + def gem_css_path + Pathname.new(File.expand_path("../../app/tailwind/active_admin_theme.css", __dir__)) + end + + def generated_path(entry) + Pathname.new(entry).expand_path.dirname.join(GENERATED) + end + + def generated_content(entry, css: gem_css_path) + relative = css.relative_path_from(Pathname.new(entry).expand_path.dirname) + "/* Generated by `rake active_admin_theme:css`. Machine-specific: do not commit. */\n@import \"#{relative}\";\n" + end + + # Writes the generated file, and adds the fixed import to the entry file + # if it is not there. Returns what changed, as an array of + # [:generated | :entry, :created | :updated]. + def write(entry, css: gem_css_path) + path = Pathname.new(entry) + raise ArgumentError, "#{entry} does not exist" unless path.file? + + changes = [] + generated = generated_path(path) + content = generated_content(path, css: css) + unless generated.file? && generated.read == content + changes << [:generated, generated.file? ? :updated : :created] + generated.write(content) + end + + source = path.read + unless source.include?(ENTRY_BLOCK) + updated = if source.match?(BLOCK) + source.sub(BLOCK) { ENTRY_BLOCK } + else + (source.end_with?("\n") ? source + "\n" : source + "\n\n") + ENTRY_BLOCK + end + path.write(updated) + changes << [:entry, source.match?(BLOCK) ? :updated : :created] + end + changes + end + end + end +end diff --git a/lib/active_admin_theme/engine.rb b/lib/active_admin_theme/engine.rb new file mode 100644 index 0000000..6c368b1 --- /dev/null +++ b/lib/active_admin_theme/engine.rb @@ -0,0 +1,67 @@ +module ActiveAdminTheme + class Engine < ::Rails::Engine + # ActiveAdmin releases without the layout hooks from activeadmin/activeadmin#9149 + # and #9150 get full copies of the views that render those hooks. + paths["app/views"] << "app/views_compat" unless ActiveAdminTheme.layout_hooks? + + # ActiveAdmin keeps its own importmap (ActiveAdmin.importmap) and renders + # it in the layout, so the theme's modules are pinned there. A host with no + # importmap-rails has no such config; it imports the npm package instead. + initializer "active_admin_theme.importmap", after: "active_admin.importmap" do |app| + next unless app.config.respond_to?(:importmap) + + ActiveAdmin.importmap.draw(root.join("config", "importmap.rb")) + if app.config.respond_to?(:assets) + app.config.assets.paths << root.join("app/javascript") + app.config.assets.paths << root.join("vendor/javascript") + end + + if ActiveAdmin.application.respond_to?(:importmap_watches) + ActiveAdmin.application.importmap_watches << root.join("app/javascript") + elsif app.config.importmap.sweep_cache + # Without importmap_watches (activeadmin/activeadmin#9153) each + # cache_sweeper(watches:) call replaces the watcher, so rebuild + # ActiveAdmin's with both directories. + watches = [ActiveAdmin::Engine.root.join("app/javascript"), root.join("app/javascript")].map(&:to_s) + app.config.after_initialize { ActiveAdmin.importmap.cache_sweeper(watches: watches) } + end + end + + # Host initializers edit ActiveAdminTheme.javascripts, so read it once they + # have run. Without javascript_modules (activeadmin/activeadmin#9148) the + # compat _html_head imports the list itself. + config.after_initialize do + if ActiveAdmin.application.respond_to?(:javascript_modules) + ActiveAdmin.application.javascript_modules |= ActiveAdminTheme.javascripts + end + end + + # Before any host initializer, so a host that defines its own + # searchable_select_options includes it later and wins. + initializer "active_admin_theme.resource_dsl" do + require "active_admin_theme/resource_dsl/searchable_select" + end + + # Before any resource registers its menu item. + initializer "active_admin_theme.nested_menu" do + require "active_admin_theme/nested_menu" + end + + rake_tasks do + load File.expand_path("../tasks/active_admin_theme.rake", __dir__) + end + + # Arbre's builder_method defines a method on a shared module, so a host + # component with the same builder_method name replaces the theme's if it + # loads later. The engine's to_prepare callback is registered before the + # host's, so the host's loads later. + config.to_prepare do + # Every file under views/ and inputs/ defines Arbre components or + # formtastic inputs; loading them here keeps their builder methods in + # place before any resource registers. + %w[views inputs].each do |dir| + Dir[File.expand_path("#{dir}/*.rb", __dir__)].sort.each { |file| require file } + end + end + end +end diff --git a/lib/active_admin_theme/helpers/pre_wrap.rb b/lib/active_admin_theme/helpers/pre_wrap.rb new file mode 100644 index 0000000..2c23a86 --- /dev/null +++ b/lib/active_admin_theme/helpers/pre_wrap.rb @@ -0,0 +1,14 @@ +module ActiveAdminTheme + module Helpers + module PreWrap + # pre_wrap(record.raw_payload) + # + # A
 that keeps line breaks and wraps long lines. The text is escaped.
+      def pre_wrap(text)
+        content_tag(:pre, text.to_s, class: "aat-pre-wrap")
+      end
+    end
+  end
+end
+
+ActiveSupport.on_load(:action_view) { include ActiveAdminTheme::Helpers::PreWrap }
diff --git a/lib/active_admin_theme/helpers/tooltip.rb b/lib/active_admin_theme/helpers/tooltip.rb
new file mode 100644
index 0000000..e0a26ac
--- /dev/null
+++ b/lib/active_admin_theme/helpers/tooltip.rb
@@ -0,0 +1,23 @@
+module ActiveAdminTheme
+  module Helpers
+    module Tooltip
+      # with_tooltip("42", "Total orders")
+      # with_tooltip("42", "Total orders", html: true)
+      #
+      # A plain tooltip is a `title` attribute. With `html: true` the tooltip
+      # is sanitized HTML shown by ActiveAdmin's Flowbite tooltip.
+      def with_tooltip(content, tooltip, html: false)
+        return content_tag(:span, content, title: tooltip) unless html
+
+        id = "aat-tooltip-#{SecureRandom.hex(4)}"
+        safe_join([
+          content_tag(:span, content, "data-tooltip-target": id),
+          content_tag(:div, sanitize(tooltip.to_s), id: id, role: "tooltip",
+            class: "absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-xs opacity-0 tooltip dark:bg-gray-700")
+        ])
+      end
+    end
+  end
+end
+
+ActiveSupport.on_load(:action_view) { include ActiveAdminTheme::Helpers::Tooltip }
diff --git a/lib/active_admin_theme/inputs/searchable_select_input.rb b/lib/active_admin_theme/inputs/searchable_select_input.rb
new file mode 100644
index 0000000..2db6bb8
--- /dev/null
+++ b/lib/active_admin_theme/inputs/searchable_select_input.rb
@@ -0,0 +1,104 @@
+module ActiveAdminTheme
+  module Inputs
+    # f.input :customer, as: :searchable_select
+    # filter :customer, as: :searchable_select
+    #
+    # Renders a ) +
-             %()
-      text_node(html.html_safe)
-    end
-  end
-end
diff --git a/test/dummy/app/admin/layout_demo.rb b/test/dummy/app/admin/layout_demo.rb
new file mode 100644
index 0000000..b71cba9
--- /dev/null
+++ b/test/dummy/app/admin/layout_demo.rb
@@ -0,0 +1,27 @@
+# Demo of the columns DSL: ratios, mobile ratio, equal widths, minimum width.
+ActiveAdmin.register_page "Columns" do
+  menu parent: "Demo pages", priority: 5
+
+  content do
+    columns "1:3" do
+      column { panel("1/4") { para "Stacked on mobile." } }
+      column { panel("3/4") { para "Side by side from 768px." } }
+    end
+    columns "1:1", mobile: "1:1" do
+      column { panel("A") { para "Side by side on mobile too." } }
+      column { panel("B") { para "Side by side on mobile too." } }
+    end
+    columns do
+      column { panel("Equal 1") { para "No ratio." } }
+      column { panel("Equal 2") { para "No ratio." } }
+      column { panel("Equal 3") { para "No ratio." } }
+    end
+    columns do
+      column(span: 2) { panel("span 2") { para "column span: 2 counts as weight 2." } }
+      column { panel("span 1") { para "Weight 1." } }
+    end
+    columns "1:1:1:1:1:1" do
+      6.times { |i| column { panel("Narrow #{i + 1}") { para "Wraps when it cannot keep its minimum width." } } }
+    end
+  end
+end
diff --git a/test/dummy/app/admin/nested_menu_demo.rb b/test/dummy/app/admin/nested_menu_demo.rb
new file mode 100644
index 0000000..ac32c60
--- /dev/null
+++ b/test/dummy/app/admin/nested_menu_demo.rb
@@ -0,0 +1,16 @@
+# Menu items three and four levels deep (parent chains).
+ActiveAdmin.register_page "Deep page" do
+  menu parent: ["Demo pages", "More demos"], priority: 1
+
+  content do
+    para "Third menu level."
+  end
+end
+
+ActiveAdmin.register_page "Deeper page" do
+  menu parent: ["Demo pages", "More demos", "Even more"], priority: 2
+
+  content do
+    para "Fourth menu level."
+  end
+end
diff --git a/test/dummy/app/admin/searchable_select_demo.rb b/test/dummy/app/admin/searchable_select_demo.rb
new file mode 100644
index 0000000..45242ba
--- /dev/null
+++ b/test/dummy/app/admin/searchable_select_demo.rb
@@ -0,0 +1,50 @@
+# Demo of the searchable select: the options endpoints, a form and a filter
+# list that use them, paging (71 countries), and a dependent select (city
+# follows country).
+ActiveAdmin.register Customer do
+  searchable_select_options label: :name, search: :name_cont, per_page: 10
+end
+
+ActiveAdmin.register Country do
+  menu false
+  filter :name
+  searchable_select_options per_page: 20
+end
+
+ActiveAdmin.register City do
+  menu false
+  filter :name
+  searchable_select_options per_page: 20
+end
+
+ActiveAdmin.register Order do
+  menu parent: "Demo pages", priority: 6
+  permit_params :reference, :status, :customer_id, :country_id, :city_id
+
+  filter :customer, as: :searchable_select
+  filter :country, as: :searchable_select
+  filter :city, as: :searchable_select, depends_on: :country_id_eq, depends_placeholder: "Select a country first"
+  filter :status, as: :select, collection: Order::STATUSES
+
+  index do
+    selectable_column
+    id_column
+    column :reference
+    column :customer
+    column :country
+    column :city
+    column :status
+    actions
+  end
+
+  form do |f|
+    f.inputs do
+      f.input :reference
+      f.input :customer, as: :searchable_select, placeholder: "Search customers"
+      f.input :country, as: :searchable_select
+      f.input :city, as: :searchable_select, depends_on: :country_id, depends_placeholder: "Select a country first"
+      f.input :status, as: :select, collection: Order::STATUSES
+    end
+    f.actions
+  end
+end
diff --git a/test/dummy/app/admin/table.rb b/test/dummy/app/admin/table.rb
index 1ef7c62..031d096 100644
--- a/test/dummy/app/admin/table.rb
+++ b/test/dummy/app/admin/table.rb
@@ -2,7 +2,7 @@
 # table_crisscross.js) can be exercised and screenshotted: clicking a cell marks
 # its whole row and column with .selected, which the theme paints.
 ActiveAdmin.register_page "Table" do
-  menu priority: 3
+  menu parent: "Demo pages", priority: 3
 
   ROWS = [
     %w[101 Ada\ Lovelace ada@example.com UK Active],
diff --git a/test/dummy/app/admin/wide_table.rb b/test/dummy/app/admin/wide_table.rb
index 79b2c50..781d8e8 100644
--- a/test/dummy/app/admin/wide_table.rb
+++ b/test/dummy/app/admin/wide_table.rb
@@ -2,31 +2,13 @@
 # horizontally — used to show the header + title bar (title on the left, the
 # action button on the right) staying pinned while the table scrolls under them.
 ActiveAdmin.register_page "Wide table" do
-  menu priority: 4
+  menu parent: "Demo pages", priority: 4
 
-  # A bar with several action items, including a dropdown that is NOT the last
-  # one: its panel has to open under its own button. While .dropdown_menu was
-  # unpositioned the panel resolved against #title_bar (sticky) instead and
-  # every menu parked at the bar's right edge, under the wrong button.
+  # Several action items, to check how the title bar lays them out.
   action_item(:edit_record) { link_to "Edit record", "#" }
-
-  action_item(:actions_menu) do
-    dropdown_menu "Actions" do
-      item "First action", "#"
-      item "Second action", "#"
-      item "Third action", "#"
-    end
-  end
-
+  action_item(:export_csv) { link_to "Export CSV", "#" }
   action_item(:new_record) { link_to "New record", "#" }
 
-  action_item(:more_menu) do
-    dropdown_menu "More" do
-      item "Export CSV", "#"
-      item "Export XML", "#"
-    end
-  end
-
   content do
     # Locals, not constants: assignment inside this block is lexically
     # top-level (it lands on Object), and AA re-loads every app/admin file on
diff --git a/test/dummy/app/assets/builds/.keep b/test/dummy/app/assets/builds/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/test/dummy/app/assets/config/manifest.js b/test/dummy/app/assets/config/manifest.js
deleted file mode 100644
index 694c926..0000000
--- a/test/dummy/app/assets/config/manifest.js
+++ /dev/null
@@ -1,2 +0,0 @@
-//= link active_admin.css
-//= link active_admin.js
diff --git a/test/dummy/app/assets/javascripts/active_admin.js b/test/dummy/app/assets/javascripts/active_admin.js
deleted file mode 100644
index 10de081..0000000
--- a/test/dummy/app/assets/javascripts/active_admin.js
+++ /dev/null
@@ -1,6 +0,0 @@
-//= require active_admin/base
-//= require trix
-//= require wigu/server_clock
-//= require wigu/theme_toggle
-//= require wigu/table_crisscross
-//= require wigu/scrollbar_width
diff --git a/test/dummy/app/assets/stylesheets/active_admin.scss b/test/dummy/app/assets/stylesheets/active_admin.scss
deleted file mode 100644
index 6ccb9ca..0000000
--- a/test/dummy/app/assets/stylesheets/active_admin.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-@import "active_admin/mixins";
-@import "active_admin/base";
-@import "trix";
-@import "wigu/active_admin_theme";
diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb
index 09705d1..c729024 100644
--- a/test/dummy/app/controllers/application_controller.rb
+++ b/test/dummy/app/controllers/application_controller.rb
@@ -1,2 +1,10 @@
 class ApplicationController < ActionController::Base
+  DemoUser = Struct.new(:name)
+
+  private
+
+  # Stands in for Devise's current user so the header's user area renders.
+  def current_admin_user
+    DemoUser.new("Demo Admin")
+  end
 end
diff --git a/test/dummy/app/models/city.rb b/test/dummy/app/models/city.rb
new file mode 100644
index 0000000..3e2a5b8
--- /dev/null
+++ b/test/dummy/app/models/city.rb
@@ -0,0 +1,13 @@
+class City < ActiveRecord::Base
+  belongs_to :country
+
+  def self.ransackable_attributes(_auth = nil)
+    %w[name country_id]
+  end
+
+  def self.ransackable_associations(_auth = nil)
+    []
+  end
+
+  def display_name = name
+end
diff --git a/test/dummy/app/models/country.rb b/test/dummy/app/models/country.rb
new file mode 100644
index 0000000..727d9e8
--- /dev/null
+++ b/test/dummy/app/models/country.rb
@@ -0,0 +1,13 @@
+class Country < ActiveRecord::Base
+  has_many :cities
+
+  def self.ransackable_attributes(_auth = nil)
+    %w[name]
+  end
+
+  def self.ransackable_associations(_auth = nil)
+    []
+  end
+
+  def display_name = name
+end
diff --git a/test/dummy/app/models/customer.rb b/test/dummy/app/models/customer.rb
new file mode 100644
index 0000000..5dd03db
--- /dev/null
+++ b/test/dummy/app/models/customer.rb
@@ -0,0 +1,13 @@
+class Customer < ActiveRecord::Base
+  STATUSES = %w[active pending suspended closed].freeze
+
+  def self.ransackable_attributes(_auth = nil)
+    %w[name email country status balance created_at]
+  end
+
+  def self.ransackable_associations(_auth = nil)
+    []
+  end
+
+  def display_name = name
+end
diff --git a/test/dummy/app/models/order.rb b/test/dummy/app/models/order.rb
new file mode 100644
index 0000000..ec409e7
--- /dev/null
+++ b/test/dummy/app/models/order.rb
@@ -0,0 +1,17 @@
+class Order < ActiveRecord::Base
+  STATUSES = %w[new paid shipped].freeze
+
+  belongs_to :customer, optional: true
+  belongs_to :country, optional: true
+  belongs_to :city, optional: true
+
+  def self.ransackable_attributes(_auth = nil)
+    %w[reference status customer_id country_id city_id created_at]
+  end
+
+  def self.ransackable_associations(_auth = nil)
+    %w[customer country city]
+  end
+
+  def display_name = reference
+end
diff --git a/test/dummy/app/tailwind/active_admin.css b/test/dummy/app/tailwind/active_admin.css
new file mode 100644
index 0000000..3d089f4
--- /dev/null
+++ b/test/dummy/app/tailwind/active_admin.css
@@ -0,0 +1,5 @@
+@import "tailwindcss";
+
+@config "../../tailwind-active_admin.config.js";
+
+@import "../../../../app/tailwind/active_admin_theme.css";
diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb
index 7537079..24fb305 100644
--- a/test/dummy/config/application.rb
+++ b/test/dummy/config/application.rb
@@ -1,17 +1,14 @@
-# Minimal ActiveAdmin host app. Its only jobs are to render an AA page with the
-# theme active so the Capybara test can prove the server clock renders + ticks,
-# and to be the source for the neutral README screenshot. Deliberately no DB
-# schema: AA comments are off and there are no resources, so nothing queries.
+# Minimal ActiveAdmin 4 host app (importmap + propshaft, Tailwind CSS built by
+# the npm CLI). It renders AA pages with the theme active for the Capybara
+# tests and the screenshot scripts. No DB schema: AA comments are off.
 require "rails"
 require "active_record/railtie"
 require "action_controller/railtie"
 require "action_view/railtie"
-require "sprockets/railtie"
+require "propshaft"
+require "importmap-rails"
 
-require "active_admin"
-# The gem under test. Requiring it registers ActiveAdminTheme::Rails::Engine,
-# whose initializer calls install! — that injects li#servertime (with the
-# data-servertime instant) and the logout item into every namespace.
+# The gem under test. It requires active_admin first, then registers its engine.
 require "active_admin_theme"
 
 module Dummy
@@ -19,7 +16,7 @@ class Application < Rails::Application
     # Booted by requiring this file directly (not from test/dummy as cwd), so
     # pin the root or Rails looks for config/database.yml in the repo root.
     config.root = File.expand_path("..", __dir__)
-    config.load_defaults 7.1
+    config.load_defaults 8.0
     config.eager_load = false
     config.consider_all_requests_local = true
     config.secret_key_base = "dummy-secret-key-base-for-tests-only"
@@ -28,18 +25,6 @@ class Application < Rails::Application
     config.logger = Logger.new($stdout)
     config.log_level = :warn
 
-    # action_text-trix ships trix.js / trix.css but doesn't add them to the
-    # Sprockets load path on its own, so `//= require trix` / `@import "trix"`
-    # (in active_admin.js / active_admin.scss) can't find them. Add its asset
-    # dirs so the demo editor page gets Trix's base JS + CSS.
-    trix_root = Gem::Specification.find_by_name("action_text-trix").gem_dir
-    config.assets.paths << File.join(trix_root, "app/assets/javascripts")
-    config.assets.paths << File.join(trix_root, "app/assets/stylesheets")
-
-    # Sprockets: compile on the fly, no digests — simplest thing that serves
-    # active_admin.css / active_admin.js in dev/test.
-    config.assets.compile = true
-    config.assets.debug = false
     config.public_file_server.enabled = true
   end
 end
diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml
index 3de036b..5b61d2f 100644
--- a/test/dummy/config/database.yml
+++ b/test/dummy/config/database.yml
@@ -1,7 +1,8 @@
-# In-memory SQLite for every env — there is no schema to persist.
+# A file, rebuilt at every boot by config/environment.rb. Not ":memory:":
+# each Puma thread would get its own empty in-memory database.
 default: &default
   adapter: sqlite3
-  database: ":memory:"
+  database: tmp/dummy.sqlite3
 
 development:
   <<: *default
diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb
index 332a7f4..6129c58 100644
--- a/test/dummy/config/environment.rb
+++ b/test/dummy/config/environment.rb
@@ -1,7 +1,64 @@
 require_relative "application"
 
-# In-memory SQLite: a connection exists if anything asks, but there is no schema
-# to maintain (AA comments are disabled and there are no resources).
-ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
+# SQLite file rebuilt at every boot, so there is nothing to migrate. A file, not
+# ":memory:": each Puma thread would get its own empty in-memory database.
+require "fileutils"
+db_file = File.expand_path("../tmp/dummy.sqlite3", __dir__)
+FileUtils.mkdir_p(File.dirname(db_file))
+FileUtils.rm_f(db_file)
 
 Dummy::Application.initialize!
+
+ActiveRecord::Schema.verbose = false
+ActiveRecord::Schema.define do
+  create_table :customers do |t|
+    t.string :name, :email, :country, :status
+    t.decimal :balance, precision: 12, scale: 2, default: 0
+    t.text :notes
+    t.timestamps
+  end
+  create_table :countries do |t|
+    t.string :name
+  end
+  create_table :cities do |t|
+    t.string :name
+    t.references :country
+  end
+  create_table :orders do |t|
+    t.string :reference, :status
+    t.references :customer, :country, :city
+    t.timestamps
+  end
+end
+
+countries = %w[UK US NL DE FR UA PL]
+30.times do |i|
+  Customer.create!(
+    name: "Customer #{i + 1}",
+    email: "customer#{i + 1}@example.com",
+    country: countries[i % countries.size],
+    status: Customer::STATUSES[i % Customer::STATUSES.size],
+    balance: (i * 137.5) % 5000,
+    notes: "Note for customer #{i + 1}. " * 3
+  )
+end
+
+# 70 countries and 3 cities each: enough rows for the searchable select to page.
+# The last country has markup in its name to check that labels are escaped.
+country_records = Array.new(70) { |i| Country.create!(name: format("Country %02d", i + 1)) }
+country_records << Country.create!(name: %(Italic & "Quoted"))
+country_records.each do |country|
+  3.times { |i| City.create!(name: "#{country.name} City #{i + 1}", country: country) }
+end
+
+customers = Customer.order(:id).to_a
+60.times do |i|
+  country = country_records[i % 70]
+  Order.create!(
+    reference: format("ORD-%03d", i + 1),
+    status: Order::STATUSES[i % Order::STATUSES.size],
+    customer: customers[i % customers.size],
+    country: country,
+    city: country.cities.first
+  )
+end
diff --git a/test/dummy/config/importmap.rb b/test/dummy/config/importmap.rb
new file mode 100644
index 0000000..9b9f938
--- /dev/null
+++ b/test/dummy/config/importmap.rb
@@ -0,0 +1,2 @@
+# The AA layout renders ActiveAdmin.importmap, not this one. Kept so
+# importmap-rails finds the file a host normally has.
diff --git a/test/dummy/config/initializers/active_admin.rb b/test/dummy/config/initializers/active_admin.rb
index 63370d1..922ab33 100644
--- a/test/dummy/config/initializers/active_admin.rb
+++ b/test/dummy/config/initializers/active_admin.rb
@@ -1,23 +1,19 @@
 ActiveAdmin.setup do |config|
   config.site_title = "ActiveAdmin Theme Demo"
 
-  # No devise / no auth: the dummy exists only to render a themed page.
+  # No devise: the dummy only renders themed pages. A fake signed-in user makes
+  # the header show the clock, user name, and logout icon.
   config.authentication_method = false
-  config.current_user_method = false
+  config.current_user_method = :current_admin_user
   config.logout_link_path = "/admin"
 
   # No ActiveRecord-backed comments, so the app needs no schema.
   config.comments = false
 end
 
-# Auth is off (no login wall), but the theme's logout item is guarded by AA's
-# `current_active_admin_user?`, which is false with current_user_method = false.
-# Stub it true in the dummy so the theme-added li#logout actually renders — the
-# whole point being to show the clock and logout sitting together in the nav.
-Rails.application.config.to_prepare do
-  ActiveAdmin::BaseController.class_eval do
-    def current_active_admin_user?
-      true
-    end
-  end
-end
+# The optional modules the demo pages use.
+ActiveAdminTheme.javascripts += %w[
+  active_admin_theme/confirm_dialog
+  active_admin_theme/secret_reveal
+  active_admin_theme/searchable_select
+]
diff --git a/test/dummy/package-lock.json b/test/dummy/package-lock.json
new file mode 100644
index 0000000..0e03c5b
--- /dev/null
+++ b/test/dummy/package-lock.json
@@ -0,0 +1,1309 @@
+{
+  "name": "active_admin_theme_dummy",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "active_admin_theme_dummy",
+      "dependencies": {
+        "@activeadmin/activeadmin": "4.0.0-beta23",
+        "@tailwindcss/cli": "^4.1.0",
+        "tailwindcss": "^4.1.0"
+      }
+    },
+    "node_modules/@activeadmin/activeadmin": {
+      "version": "4.0.0-beta23",
+      "resolved": "https://registry.npmjs.org/@activeadmin/activeadmin/-/activeadmin-4.0.0-beta23.tgz",
+      "integrity": "sha512-7hNiy0T/p1XEPAgYqLWYT9/03+0zBZ8dnjP+B8igr7FNE7KOYa8Mbv1LFJvZuN9P4AKLc1lMHTJzu7BVLY/PZw==",
+      "license": "MIT",
+      "dependencies": {
+        "@rails/ujs": "7.1.600",
+        "flowbite": "3.1.2"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+      "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@parcel/watcher": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
+      "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^2.0.3",
+        "is-glob": "^4.0.3",
+        "node-addon-api": "^7.0.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher-android-arm64": "2.6.0",
+        "@parcel/watcher-darwin-arm64": "2.6.0",
+        "@parcel/watcher-darwin-x64": "2.6.0",
+        "@parcel/watcher-freebsd-x64": "2.6.0",
+        "@parcel/watcher-linux-arm-glibc": "2.6.0",
+        "@parcel/watcher-linux-arm-musl": "2.6.0",
+        "@parcel/watcher-linux-arm64-glibc": "2.6.0",
+        "@parcel/watcher-linux-arm64-musl": "2.6.0",
+        "@parcel/watcher-linux-x64-glibc": "2.6.0",
+        "@parcel/watcher-linux-x64-musl": "2.6.0",
+        "@parcel/watcher-win32-arm64": "2.6.0",
+        "@parcel/watcher-win32-x64": "2.6.0"
+      }
+    },
+    "node_modules/@parcel/watcher-android-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
+      "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
+      "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
+      "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-freebsd-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
+      "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
+      "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
+      "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
+      "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
+      "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-glibc": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
+      "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-musl": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
+      "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-arm64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
+      "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-x64": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
+      "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "version": "2.11.8",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rails/ujs": {
+      "version": "7.1.600",
+      "resolved": "https://registry.npmjs.org/@rails/ujs/-/ujs-7.1.600.tgz",
+      "integrity": "sha512-ntMYJ8++n1zcIJ1g75or7Va9IU3ueCHgp9IJk7Ny7eFEbu0Kx40EHZ2FNhoxoUEjlF5NifCFCbvoK5rDK5HpIQ==",
+      "license": "MIT"
+    },
+    "node_modules/@rollup/plugin-node-resolve": {
+      "version": "15.3.1",
+      "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
+      "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
+      "license": "MIT",
+      "dependencies": {
+        "@rollup/pluginutils": "^5.0.1",
+        "@types/resolve": "1.20.2",
+        "deepmerge": "^4.2.2",
+        "is-module": "^1.0.0",
+        "resolve": "^1.22.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^2.78.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rollup/pluginutils": {
+      "version": "5.5.1",
+      "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.5.1.tgz",
+      "integrity": "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-walker": "^2.0.2",
+        "picomatch": "^4.0.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@tailwindcss/cli": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.4.tgz",
+      "integrity": "sha512-279RsQ1SjT5PcUp6za3F9Qavw933WkMErTOArEKl+gOS4BBgW/2/w9Tn5zLSRy8t/U1Kiy4LmKjuxrFFsbbtAw==",
+      "license": "MIT",
+      "dependencies": {
+        "@parcel/watcher": "2.6.0",
+        "@tailwindcss/node": "4.3.4",
+        "@tailwindcss/oxide": "4.3.4",
+        "enhanced-resolve": "^5.24.5",
+        "mri": "^1.2.0",
+        "picocolors": "^1.1.1",
+        "tailwindcss": "4.3.4"
+      },
+      "bin": {
+        "tailwindcss": "dist/index.mjs"
+      }
+    },
+    "node_modules/@tailwindcss/node": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.4.tgz",
+      "integrity": "sha512-b7QK/QG04v3j04NbpG82XLCedRyulnkuwUDwRa9oK/ypnJIzMapz2Wg+0TltVczjUzDaJJHIa1+jj9aMUOFMTQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.5",
+        "enhanced-resolve": "^5.24.5",
+        "jiti": "^2.7.0",
+        "lightningcss": "1.33.0",
+        "magic-string": "^1.1.0",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.3.4"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.4.tgz",
+      "integrity": "sha512-riqsRw2sLF7B1sNCCkTl3KYXEqvhTiABzZ/4oxxHfZ3c3Td60bCBfOrUuUGOiHagd3E7V+zG46c+XBDWZGFBcg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.3.4",
+        "@tailwindcss/oxide-darwin-arm64": "4.3.4",
+        "@tailwindcss/oxide-darwin-x64": "4.3.4",
+        "@tailwindcss/oxide-freebsd-x64": "4.3.4",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.4",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.3.4",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.3.4",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.3.4",
+        "@tailwindcss/oxide-linux-x64-musl": "4.3.4",
+        "@tailwindcss/oxide-wasm32-wasi": "4.3.4",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.3.4",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.3.4"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-android-arm64": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.4.tgz",
+      "integrity": "sha512-qkjxt+EFocOi3mtsUS3ndlj6yj4SHubwBdvBynLnS8T4mvSzX0Enn0CpypJKmBEtczUAXOHVZKA2zTGzSNU2Sg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-arm64": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.4.tgz",
+      "integrity": "sha512-i/awUA/7mJQAbA3AzCW8aQtUh5goMieUdF1zOoclLaJUCxDvsXQ8VJqpQZY3dfcHS6PJjd3tw25nNneD/mEviQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-x64": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.4.tgz",
+      "integrity": "sha512-q56V9U6AnoYz/yj/alkDLhSQ9AZoXQSEl93f7WETa0+32CudbqM57BXdYXfMYPKudueXKaA+MG6L/0KRseF7Jg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-freebsd-x64": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.4.tgz",
+      "integrity": "sha512-r0W7A+Z9HTHh7bwihJl45E/zZKLjxD50/jToVF/E/RZHSl8+EWO+4yKKJpPMcUooT9hdoip3Zx1uWnbMg4N79g==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.4.tgz",
+      "integrity": "sha512-3aih/sRxzeYfi2Bkh+bGYNf2YU9Llamb2mpA7BNF1obYzdgEeWDAMQzK6BSwzLhs/eTdOaSur92qrGfchagUtQ==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.4.tgz",
+      "integrity": "sha512-ktJSrPGQou4OqL88rDEx5CkeM9BjzrwZvYo8u7B7VL1zkBa2XBm351o4MQrek95qikb2M++26Y7w7vbH6Mgl8g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.4.tgz",
+      "integrity": "sha512-rt3hjUxT7utAtcB+df6wHyaXDyxhE7Zd1x7L/nvB5Jb2qBb8dZwFUoWqeRja1BaF5OlSqkKSjBmVXj9OsevmCQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.4.tgz",
+      "integrity": "sha512-XE3jO2yOOWjttpeGvpwf3/b274NMi3QapQSAI/EzsgrmYNan1Tw8dXT9JQkN7K0OgvuqiC8mcCyNvGzEkpVJPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.4.tgz",
+      "integrity": "sha512-PaAZ2FjJaZk9SQLYtKIfQWiFm/xtl7pX2MKPgCzn0mS/VU54yu+vP457AJdB2XFLxU3gO2abLCt8N7Lg4EbKNg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.4.tgz",
+      "integrity": "sha512-bjO9WmoM687p8AoCIyw1DUqzpAs4JICLvNmIPxIWDmhw8IOsdubtWQLShyJmISnDKtRy1BBApHVOUl6sNFQQJA==",
+      "bundleDependencies": [
+        "@napi-rs/wasm-runtime",
+        "@emnapi/core",
+        "@emnapi/runtime",
+        "@tybys/wasm-util",
+        "@emnapi/wasi-threads",
+        "tslib"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.11.3",
+        "@emnapi/runtime": "^1.11.3",
+        "@emnapi/wasi-threads": "^1.2.3",
+        "@napi-rs/wasm-runtime": "^1.2.2",
+        "@tybys/wasm-util": "^0.10.3",
+        "tslib": "^2.8.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+      "version": "1.11.3",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.3",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+      "version": "1.11.3",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.3",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.2.2",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.3"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
+        "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+      "version": "0.10.3",
+      "inBundle": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+      "version": "2.8.1",
+      "inBundle": true,
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.4.tgz",
+      "integrity": "sha512-jCrwCvWVtt7ruMXp6dFPd5KBNU8o1tWtEdZbTL/w/hvD3lUYZA0E2mmfvTzW4QLHIx5wGQU9HeY7997R1QTrEg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.4.tgz",
+      "integrity": "sha512-cge1wcedn1FrRSXL22lF5sNDYr8c9+VImk7zFYLPs12jkDmchSrZNfzWqOKub+5Hb/aYRdDEqERGGDKONBlOzQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
+      "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/resolve": {
+      "version": "1.20.2",
+      "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+      "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+      "license": "MIT"
+    },
+    "node_modules/deepmerge": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.26.1",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.1.tgz",
+      "integrity": "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==",
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.3.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/flowbite": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-3.1.2.tgz",
+      "integrity": "sha512-MkwSgbbybCYgMC+go6Da5idEKUFfMqc/AmSjm/2ZbdmvoKf5frLPq/eIhXc9P+rC8t9boZtUXzHDgt5whZ6A/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@popperjs/core": "^2.9.3",
+        "flowbite-datepicker": "^1.3.1",
+        "mini-svg-data-uri": "^1.4.3",
+        "postcss": "^8.5.1"
+      }
+    },
+    "node_modules/flowbite-datepicker": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/flowbite-datepicker/-/flowbite-datepicker-1.3.2.tgz",
+      "integrity": "sha512-6Nfm0MCVX3mpaR7YSCjmEO2GO8CDt6CX8ZpQnGdeu03WUCWtEPQ/uy0PUiNtIJjJZWnX0Cm3H55MOhbD1g+E/g==",
+      "license": "MIT",
+      "dependencies": {
+        "@rollup/plugin-node-resolve": "^15.2.3",
+        "flowbite": "^2.0.0"
+      }
+    },
+    "node_modules/flowbite-datepicker/node_modules/flowbite": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-2.5.2.tgz",
+      "integrity": "sha512-kwFD3n8/YW4EG8GlY3Od9IoKND97kitO+/ejISHSqpn3vw2i5K/+ZI8Jm2V+KC4fGdnfi0XZ+TzYqQb4Q1LshA==",
+      "license": "MIT",
+      "dependencies": {
+        "@popperjs/core": "^2.9.3",
+        "flowbite-datepicker": "^1.3.0",
+        "mini-svg-data-uri": "^1.4.3"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "license": "ISC"
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.18.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.18.0.tgz",
+      "integrity": "sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw==",
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-module": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+      "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+      "license": "MIT"
+    },
+    "node_modules/jiti": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+      "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
+      "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.6.0"
+      }
+    },
+    "node_modules/mini-svg-data-uri": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
+      "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
+      "license": "MIT",
+      "bin": {
+        "mini-svg-data-uri": "cli.js"
+      }
+    },
+    "node_modules/mri": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+      "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.20",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+      "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+      "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+      "license": "MIT"
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.32",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
+      "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.20",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.14",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.14.tgz",
+      "integrity": "sha512-1ZjYvR9sbkiwC5+9QHetqW5HmA2pHRMaoupD01t2HlYnV/vydC2zPwLixhsMieSWVZt0JJRg5LVIk0Ow1D/TrA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "is-core-module": "^2.18.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+      "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.4.tgz",
+      "integrity": "sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==",
+      "license": "MIT"
+    },
+    "node_modules/tapable": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+      "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    }
+  }
+}
diff --git a/test/dummy/package.json b/test/dummy/package.json
new file mode 100644
index 0000000..b9b201c
--- /dev/null
+++ b/test/dummy/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "active_admin_theme_dummy",
+  "private": true,
+  "type": "module",
+  "dependencies": {
+    "@activeadmin/activeadmin": "4.0.0-beta23",
+    "@tailwindcss/cli": "^4.1.0",
+    "tailwindcss": "^4.1.0"
+  },
+  "scripts": {
+    "build": "tailwindcss -i ./app/tailwind/active_admin.css -o ./app/assets/builds/active_admin.css",
+    "watch": "tailwindcss -i ./app/tailwind/active_admin.css -o ./app/assets/builds/active_admin.css --watch"
+  }
+}
diff --git a/test/dummy/tailwind-active_admin.config.js b/test/dummy/tailwind-active_admin.config.js
new file mode 100644
index 0000000..e686cff
--- /dev/null
+++ b/test/dummy/tailwind-active_admin.config.js
@@ -0,0 +1,24 @@
+import { execSync } from 'child_process';
+import activeAdminPlugin from '@activeadmin/activeadmin/plugin';
+
+// Last line only: Bundler's DEBUG env prints extra lines.
+const bundleShow = (gem) => execSync(`bundle show ${gem}`, { encoding: 'utf-8' }).trim().split(/\r?\n/).pop();
+const activeAdminPath = bundleShow('activeadmin');
+const themePath = bundleShow('active_admin_theme');
+
+export default {
+  content: [
+    `${activeAdminPath}/vendor/javascript/flowbite.js`,
+    `${activeAdminPath}/plugin.js`,
+    `${activeAdminPath}/app/views/**/*.{arb,erb,html,rb}`,
+    `${themePath}/app/**/*.{arb,erb,html,rb,js}`,
+    `${themePath}/lib/**/*.rb`,
+    './app/admin/**/*.{arb,erb,html,rb}',
+    './app/views/**/*.{arb,erb,html,rb}',
+    './app/javascript/**/*.js'
+  ],
+  darkMode: "selector",
+  plugins: [
+    activeAdminPlugin
+  ]
+}
diff --git a/test/fixtures/active_admin_originals.yml b/test/fixtures/active_admin_originals.yml
new file mode 100644
index 0000000..7211d45
--- /dev/null
+++ b/test/fixtures/active_admin_originals.yml
@@ -0,0 +1,26 @@
+---
+compat:
+  active_admin/_html_head.html.erb: f1b93636518fbc9046c156c108fb7c5f88fc7e42705aa9d5bb00d8eb0cf87ec7
+  active_admin/_main_navigation.html.erb: 7fec1c34275393217395985a103297bf4e14c9f365d84174f641bcfd3dd564a2
+  active_admin/_page_header.html.erb: adb5145b30a091449d069f069d812d4e0cc602070ad6c53f26d0f6b4d5ed55ab
+  active_admin/_sidebar.html.erb: a831f12e82104273d772609e903d67822107663984f319b22129f8f2ddae0415
+  active_admin/_site_header.html.erb: ef48c93275aaa00c17d18067d78d7b177b01c11a64ca8767c939bfb538ba36bc
+  active_admin/resource/_show_default.html.arb: '0348dc93e217df0a48a1d81ff48b96baad790f0dca937980200c588c2b55f5a1'
+  active_admin/resource/show.html.arb: 662d997ced6fb74bff47e5efc7aa42d329af7af39fe346f179b871f8a732b47e
+  active_admin/shared/_sidebar_section.html.arb: a2d0d4fd46b75bd603aba8f66d9724d0eb6322d7688faefc20f0ecab02367a6a
+  layouts/active_admin.html.erb: 97c8e7f31edfa37ec80705fec9676f2621e93762c3cdc4999a687b1639d973c0
+  layouts/active_admin_logged_out.html.erb: bd5c6f0ffcbd49249f57e53206ada4b1dbb1d533e7061d32863a087c7926285f
+hooks:
+  active_admin/_breadcrumbs.html.erb: 523193419c5188650193d654e6eb976fa24abb186b92f69f91397f34cd22512e
+  active_admin/_header_actions.html.erb: 04e14c8fdeeddb028d6b4d5a90a0609a9fb222ca70ecec15965b92cdd60c83f1
+  active_admin/_header_leading.html.erb: 69352e23dee7d6ebf07f445e35a005cd3bd5791b48b4ff25662d8f3c5b52cc66
+  active_admin/_header_navigation.html.erb: a40d376d3b0a4ba43dd03f3c5711981ea5a5c41a4955b8b493a8d32a9f34e5ba
+  active_admin/_main_navigation.html.erb: 952a76484714df66e6e1b4db3ab1ceeea008e46e4fc80e7cdfec04a6bfb1e35f
+  active_admin/_page_content.html.erb: 48e22fd82bd3a5748bfc9485b58209355ded0a2eefeed8906608fbe902ecbaa9
+  active_admin/_page_heading.html.erb: 04e41b3252e724de24104df3de1e8ab93033fc50843902600a9c562ccd2acbc8
+  active_admin/_page_title.html.erb: 30c3e4934319f2cf15460b486399f9a320298444bd6b2a9d93bc3f780fb173f6
+  active_admin/_sidebar.html.erb: a831f12e82104273d772609e903d67822107663984f319b22129f8f2ddae0415
+  active_admin/resource/_show_default.html.arb: '0348dc93e217df0a48a1d81ff48b96baad790f0dca937980200c588c2b55f5a1'
+  active_admin/resource/show.html.arb: 662d997ced6fb74bff47e5efc7aa42d329af7af39fe346f179b871f8a732b47e
+  active_admin/shared/_sidebar_section.html.arb: a2d0d4fd46b75bd603aba8f66d9724d0eb6322d7688faefc20f0ecab02367a6a
+  layouts/active_admin_logged_out.html.erb: bd5c6f0ffcbd49249f57e53206ada4b1dbb1d533e7061d32863a087c7926285f
diff --git a/test/layout_test.rb b/test/layout_test.rb
new file mode 100644
index 0000000..f83e8bc
--- /dev/null
+++ b/test/layout_test.rb
@@ -0,0 +1,164 @@
+# Browser test (headless Chrome): the layout behaves as the visual contract says.
+require_relative "capybara_boot"
+require "minitest/autorun"
+require "capybara/minitest"
+
+class LayoutTest < Minitest::Test
+  include Capybara::DSL
+  include Capybara::Minitest::Assertions
+
+  def setup
+    page.current_window.resize_to(1280, 900)
+  end
+
+  def teardown
+    Capybara.reset_sessions!
+  end
+
+  def test_desktop_has_top_menu_and_no_drawer
+    visit "/admin/customers"
+    assert_selector "#site-header nav a", text: "Customers"
+    assert_no_selector "#main-menu", visible: :visible
+  end
+
+  def test_top_menu_breakpoint_is_768
+    page.current_window.resize_to(768, 900)
+    visit "/admin/customers"
+    assert_selector "#site-header nav a", text: "Customers"
+  end
+
+  def test_menu_group_opens_on_hover
+    visit "/admin/customers"
+    assert_no_selector "#top-menu-demo_pages", visible: :visible
+    find("#site-header nav a", text: "Demo pages").hover
+    assert_selector "#top-menu-demo_pages a", text: "Wide table", visible: :visible
+  end
+
+  def test_nested_submenus_fly_out_on_hover
+    visit "/admin/customers"
+    find("#site-header nav a", text: "Demo pages").hover
+    find("#top-menu-demo_pages a", text: "More demos").hover
+    find("#top-menu-demo_pages a", text: "Even more").hover
+    find("#top-menu-demo_pages a", text: "Deeper page").click
+    assert_text "Fourth menu level."
+    assert_selector "#site-header nav > ul > li > a.selected", text: "Demo pages"
+  end
+
+  def test_drawer_opens_the_branch_of_the_current_page
+    page.current_window.resize_to(390, 800)
+    visit "/admin/deeper_page"
+    find("#site-header button[data-drawer-show='main-menu']").click
+    assert_selector "#main-menu a", text: "Deeper page", visible: :visible
+    find("#main-menu [data-item-id='even_more'] > [data-menu-button] button").click
+    assert_no_selector "#main-menu a", text: "Deeper page", visible: :visible
+  end
+
+  def test_mobile_menu_opens_in_a_drawer
+    page.current_window.resize_to(390, 800)
+    visit "/admin/customers"
+    assert_no_selector "#site-header nav a", text: "Customers"
+    find("#site-header button[data-drawer-show='main-menu']").click
+    assert_selector "#main-menu a", text: "Customers", visible: :visible
+  end
+
+  def test_filters_sit_left_of_the_table_on_desktop_and_above_on_mobile
+    visit "/admin/customers"
+    filters = page.evaluate_script("document.querySelector('[data-aat-sidebar]').getBoundingClientRect().toJSON()")
+    table = page.evaluate_script("document.querySelector('table.data-table').getBoundingClientRect().toJSON()")
+    assert_operator filters["right"], :<=, table["left"]
+
+    page.current_window.resize_to(390, 800)
+    visit "/admin/customers"
+    filters = page.evaluate_script("document.querySelector('[data-aat-sidebar]').getBoundingClientRect().toJSON()")
+    table = page.evaluate_script("document.querySelector('table.data-table').getBoundingClientRect().toJSON()")
+    assert_operator filters["bottom"], :<=, table["top"]
+  end
+
+  def test_filters_collapse_and_the_table_takes_the_width
+    visit "/admin/customers"
+    before = page.evaluate_script("document.querySelector('.paginated-collection').getBoundingClientRect().width")
+    find("[data-aat-sidebar-toggle]").click
+    assert_no_selector "[data-aat-sidebar]", visible: :visible
+    after = page.evaluate_script("document.querySelector('.paginated-collection').getBoundingClientRect().width")
+    assert_operator after, :>, before + 200
+    find("[data-aat-sidebar-toggle]").click
+    assert_selector "[data-aat-sidebar]", visible: :visible
+  end
+
+  def test_wide_index_tables_scroll_inside_their_wrapper_not_the_page
+    page.current_window.resize_to(390, 800)
+    visit "/admin/customers"
+    scroll = page.evaluate_script("document.documentElement.scrollWidth")
+    assert_operator scroll, :<=, 390
+    assert page.evaluate_script("(() => { const w = document.querySelector('.index-as-table'); return w.scrollWidth > w.clientWidth })()")
+  end
+
+  def test_index_cells_stay_on_one_line
+    visit "/admin/customers"
+    assert_equal "nowrap", page.evaluate_script("getComputedStyle(document.querySelector('table.data-table tbody td')).whiteSpace")
+  end
+
+  def test_server_clock_renders_and_ticks
+    visit "/admin/customers"
+    assert_selector "[data-server-clock] .clock-time", text: /\A\d{2}:\d{2}:\d{2}\s+\S+\z/
+    first = find("[data-server-clock] .clock-time").text
+    assert_no_selector "[data-server-clock] .clock-time", text: first, wait: 3
+    data = find("[data-server-clock] [data-servertime-epoch]", visible: :all)
+    assert_equal Time.zone.tzinfo.identifier, data["data-servertime-zone"]
+  end
+
+  def test_header_has_user_and_logout
+    visit "/admin/customers"
+    assert_selector "[data-test-current-user]", text: "Demo Admin"
+    assert_selector "#site-header a.aat-logout"
+  end
+
+  def test_action_items_are_buttons
+    visit "/admin/customers"
+    background = page.evaluate_script("getComputedStyle(document.querySelector('[data-test-action-items] a')).backgroundColor")
+    refute_equal "rgba(0, 0, 0, 0)", background
+  end
+
+  def test_crisscross_marks_row_and_column
+    visit "/admin/customers"
+    cells = all("table.data-table tbody tr:nth-child(2) td")
+    cells[3].click
+    assert_selector "table.data-table td[data-selected-cell]", count: 1
+    assert_selector "table.data-table tbody td[data-selected]", minimum: 30
+    assert_no_selector "table.data-table tbody tr.selected"
+  end
+
+  def test_tabs_switch_panels
+    visit "/admin/customers/1"
+    assert_selector "[role=tab]", count: 2
+    assert_selector "#notes", visible: :visible
+    assert_no_selector "#raw", visible: :visible
+    find("[role=tab]", text: "Raw").click
+    assert_selector "#raw", visible: :visible
+    assert_no_selector "#notes", visible: :visible
+  end
+
+  def test_columns_split_by_ratio_and_stack_on_mobile
+    visit "/admin/columns"
+    widths = page.evaluate_script("[...document.querySelectorAll('.aat-columns')[0].children].map(c => c.getBoundingClientRect().width)")
+    assert_in_delta 3.0, widths[1] / widths[0], 0.4
+    page.current_window.resize_to(390, 800)
+    visit "/admin/columns"
+    tops = page.evaluate_script("[...document.querySelectorAll('.aat-columns')[0].children].map(c => c.getBoundingClientRect().top)")
+    assert_operator tops[1], :>, tops[0]
+    side = page.evaluate_script("[...document.querySelectorAll('.aat-columns')[1].children].map(c => c.getBoundingClientRect().top)")
+    assert_equal side[0], side[1]
+  end
+
+  def test_columns_wrap_instead_of_clipping
+    visit "/admin/columns"
+    lines = page.evaluate_script("new Set([...document.querySelectorAll('.aat-columns')[4].children].map(c => Math.round(c.getBoundingClientRect().top))).size")
+    assert_operator lines, :>, 1
+  end
+
+  def test_show_page_and_sidebar_are_panels
+    visit "/admin/customers/1"
+    assert_selector ".panel .panel-title", text: "Customer Details"
+    assert_selector "[data-aat-sidebar] .panel .panel-title", text: "Help"
+  end
+end
diff --git a/test/screenshot.rb b/test/screenshot.rb
index ec5020b..09e6807 100644
--- a/test/screenshot.rb
+++ b/test/screenshot.rb
@@ -1,20 +1,29 @@
-# Regenerates img/clock.png — the neutral README/PR screenshot of the two-line
-# server clock sitting in the utility nav. Same dummy app and driver the test
-# uses; crops to the AA header so the shot is just the top bar (site title
-# "ActiveAdmin Theme Demo", dashboard menu, clock, logout) — no real content.
+# Screenshots of demo pages at the theme's target widths, light and dark.
+#   bundle exec ruby test/screenshot.rb                       # all defaults
+#   PAGES=/admin/customers WIDTHS=1280 MODES=dark OUT=tmp/shots bundle exec ruby test/screenshot.rb
 require_relative "capybara_boot"
-require "capybara/dsl"
+require "fileutils"
 
-include Capybara::DSL
+# A Session, not Capybara::DSL: top-level DSL methods land on Object, and
+# `page` then collides with Kaminari's scope.
+browser = Capybara::Session.new(:cuprite, Dummy::Application)
 
-OUT = File.expand_path("../img/clock.png", __dir__)
+pages = (ENV["PAGES"] || "/admin/customers,/admin/customers/1,/admin/customers/1/edit").split(",")
+widths = (ENV["WIDTHS"] || "1280,820,390").split(",").map(&:to_i)
+modes = (ENV["MODES"] || "light,dark").split(",")
+out = File.expand_path(ENV["OUT"] || "../tmp/screenshots", __dir__)
+FileUtils.mkdir_p(out)
 
-visit "/admin"
-# Let the clock tick once so the two lines are populated by the JS.
-find("#utility_nav li#servertime .clock-time", wait: 5)
-sleep 1.1
-
-page.driver.save_screenshot(OUT, selector: "#header")
-Capybara.reset_sessions!
-
-puts "wrote #{OUT} (#{File.size(OUT)} bytes)"
+pages.each do |path|
+  widths.each do |width|
+    modes.each do |mode|
+      browser.current_window.resize_to(width, 900)
+      browser.visit path
+      browser.execute_script("document.documentElement.classList.#{mode == 'dark' ? 'add' : 'remove'}('dark')")
+      file = File.join(out, "#{path.delete_prefix('/').tr('/', '_')}-#{width}-#{mode}.png")
+      browser.driver.save_screenshot(file, full: true)
+      puts file
+    end
+  end
+end
+browser.quit
diff --git a/test/searchable_select_test.rb b/test/searchable_select_test.rb
new file mode 100644
index 0000000..c0edc35
--- /dev/null
+++ b/test/searchable_select_test.rb
@@ -0,0 +1,169 @@
+# Browser test (headless Chrome): the searchable select loads, pages, filters
+# and depends on its parent as the form and filter inputs promise.
+require_relative "capybara_boot"
+require "minitest/autorun"
+require "capybara/minitest"
+
+class SearchableSelectTest < Minitest::Test
+  include Capybara::DSL
+  include Capybara::Minitest::Assertions
+
+  OPTION = ".ts-dropdown-content .option[data-value]:not(.loading-more-results)".freeze
+
+  def setup
+    page.current_window.resize_to(1280, 900)
+  end
+
+  def teardown
+    Capybara.reset_sessions!
+  end
+
+  def test_typing_filters_options_through_ajax
+    visit "/admin/orders/new"
+    wrapper = wrapper_for("order[customer_id]")
+    open_dropdown(wrapper)
+    wrapper.assert_selector OPTION, minimum: 10
+    search(wrapper, "Customer 17")
+    wrapper.assert_selector OPTION, count: 1
+    wrapper.assert_selector OPTION, text: "Customer 17"
+  end
+
+  def test_scrolling_the_dropdown_loads_the_next_page
+    visit "/admin/orders/new"
+    wrapper = wrapper_for("order[customer_id]")
+    open_dropdown(wrapper)
+    wrapper.assert_selector OPTION, minimum: 10
+    # Each page holds 10 of the 30 customers; scrolling to the bottom pulls the
+    # next page until none is left.
+    6.times do
+      scroll_dropdown_to_bottom
+      sleep 0.3
+    end
+    wrapper.assert_selector OPTION, count: 30
+  end
+
+  def test_selection_submits_with_the_form
+    visit "/admin/orders/new"
+    fill_in "Reference", with: "ORD-NEW"
+    pick("order[customer_id]", "Customer 17")
+    pick("order[country_id]", "Country 05")
+    pick("order[city_id]", "Country 05 City 2")
+    click_button "Create Order"
+    assert_text "Order was successfully created"
+    assert_selector "td", text: "Customer 17"
+    assert_selector "td", text: "Country 05 City 2"
+  end
+
+  def test_edit_form_shows_the_selected_option_without_loading_the_list
+    visit "/admin/orders/1/edit"
+    wrapper = wrapper_for("order[customer_id]")
+    wrapper.assert_selector ".item", text: "Customer 1"
+    options = page.evaluate_script("document.querySelectorAll(\"select[name='order[customer_id]'] option\").length")
+    assert_operator options, :<=, 2
+  end
+
+  def test_dependent_child_is_disabled_until_the_parent_has_a_value_and_reloads_on_change
+    visit "/admin/orders/new"
+    assert_selector ".ts-wrapper.disabled", count: 1
+    assert_selector ".ts-wrapper.disabled input[placeholder='Select a country first']", visible: :all
+
+    pick("order[country_id]", "Country 05")
+    assert_no_selector ".ts-wrapper.disabled"
+    city = wrapper_for("order[city_id]")
+    open_dropdown(city)
+    city.assert_selector OPTION, count: 3
+    assert_equal ["Country 05 City 1", "Country 05 City 2", "Country 05 City 3"], city.all(OPTION).map(&:text)
+    city.find(OPTION, text: "Country 05 City 2").click
+    city.assert_selector ".item", text: "Country 05 City 2"
+
+    pick("order[country_id]", "Country 06")
+    city = wrapper_for("order[city_id]")
+    city.assert_no_selector ".item"
+    open_dropdown(city)
+    city.assert_selector OPTION, count: 3
+    assert_equal ["Country 06 City 1", "Country 06 City 2", "Country 06 City 3"], city.all(OPTION).map(&:text)
+  end
+
+  def test_works_on_a_phone_sized_screen
+    page.current_window.resize_to(390, 800)
+    visit "/admin/orders/new"
+    wrapper = wrapper_for("order[customer_id]")
+    open_dropdown(wrapper)
+    wrapper.assert_selector OPTION, count: 10
+    right = page.evaluate_script("document.querySelector('.ts-wrapper.dropdown-active .ts-dropdown').getBoundingClientRect().right")
+    assert_operator right, :<=, 390
+    scroll_dropdown_to_bottom
+    wrapper.assert_selector OPTION, count: 20
+    wrapper.find(OPTION, text: "Customer 17").click
+    wrapper.assert_selector ".item", text: "Customer 17"
+    assert_operator page.evaluate_script("document.documentElement.scrollWidth"), :<=, 390
+  end
+
+  def test_filter_input_filters_the_index
+    visit "/admin/orders"
+    assert_selector "table.data-table tbody tr", count: 30
+    pick("q[customer_id_eq]", "Customer 17")
+    click_button "Filter"
+    expected = Order.where(customer_id: 17).count
+    assert_operator expected, :>=, 2
+    assert_selector "table.data-table tbody tr", count: expected
+    assert_selector "table.data-table tbody tr", text: "Customer 17", count: expected
+    wrapper_for("q[customer_id_eq]").assert_selector ".item", text: "Customer 17"
+  end
+
+  def test_dependent_filter_waits_for_its_parent
+    visit "/admin/orders"
+    assert_selector ".ts-wrapper.disabled", count: 1
+    pick("q[country_id_eq]", "Country 05")
+    assert_no_selector ".ts-wrapper.disabled"
+    pick("q[city_id_eq]", "Country 05 City 1")
+    click_button "Filter"
+    assert_selector "table.data-table tbody tr", count: 1
+    assert_selector "table.data-table tbody tr", text: "Country 05 City 1"
+  end
+
+  def test_option_labels_are_escaped
+    visit "/admin/orders/new"
+    wrapper = wrapper_for("order[country_id]")
+    open_dropdown(wrapper)
+    search(wrapper, "Italic")
+    wrapper.assert_selector OPTION, count: 1
+    assert_equal %(Italic & "Quoted"), wrapper.find(OPTION).text
+    assert_no_selector ".ts-dropdown i"
+  end
+
+  private
+
+  # Tom Select inserts its wrapper right after the original ',copyClassesToDropdown:!1,placeholder:null,hidePlaceholder:null,shouldLoad:function(s){return s.length>0},render:{}};function le(s,e){var t=Object.assign({},xe,e),r=t.dataAttr,i=t.labelField,o=t.valueField,n=t.disabledField,l=t.optgroupField,a=t.optgroupLabelField,c=t.optgroupValueField,u=s.tagName.toLowerCase(),p=s.getAttribute("placeholder")||s.getAttribute("data-placeholder");if(!p&&!t.allowEmptyOption){let _=s.querySelector('option[value=""]');_&&(p=_.textContent)}var m={placeholder:p,options:[],optgroups:[],items:[],maxItems:null},b=()=>{var _,f=m.options,h={},x=1;let g=0;var y=C=>{var O=Object.assign({},C.dataset),v=r&&O[r];return typeof v=="string"&&v.length&&(O=Object.assign(O,JSON.parse(v))),O},K=(C,O)=>{var v=k(C.value);if(v!=null&&!(!v&&!t.allowEmptyOption)){if(h.hasOwnProperty(v)){if(O){var I=h[v][l];I?Array.isArray(I)?I.push(O):h[v][l]=[I,O]:h[v][l]=O}}else{var S=y(C);S[i]=S[i]||C.textContent,S[o]=S[o]||v,S[n]=S[n]||C.disabled,S[l]=S[l]||O,S.$option=C,S.$order=S.$order||++g,h[v]=S,f.push(S)}C.selected&&m.items.push(v)}},q=C=>{var O,v;v=y(C),v[a]=v[a]||C.getAttribute("label")||"",v[c]=v[c]||x++,v[n]=v[n]||C.disabled,v.$order=v.$order||++g,m.optgroups.push(v),O=v[c],E(C.children,I=>{K(I,O)})};m.maxItems=s.hasAttribute("multiple")?null:1,E(s.children,C=>{_=C.tagName.toLowerCase(),_==="optgroup"?q(C):_==="option"&&K(C)})},d=()=>{var _,f;let h=s.getAttribute(r);if(h)m.options=JSON.parse(h),E(m.options,g=>{m.items.push(g[o])});else{var x=(f=(_=s?.value)===null||_===void 0?void 0:_.trim())!==null&&f!==void 0?f:"";if(!t.allowEmptyOption&&!x.length)return;let g=x.split(t.delimiter);E(g,y=>{let K={};K[i]=y,K[o]=y,m.options.push(K)}),m.items=g}};return u==="select"?b():d(),Object.assign({},xe,m,e)}var Me=0,L=class extends ue(B){constructor(e,t){super(),this.order=0,this.isOpen=!1,this.isDisabled=!1,this.isReadOnly=!1,this.isInvalid=!1,this.isValid=!0,this.isLocked=!1,this.isFocused=!1,this.isInputHidden=!1,this.isSetup=!1,this.isDropdownContentStale=!0,this.ignoreFocus=!1,this.ignoreHover=!1,this.hasOptions=!1,this.lastValue="",this.caretPos=0,this.loading=0,this.loadedSearches={},this.activeOption=null,this.activeItems=[],this.optgroups={},this.options={},this.userOptions={},this.items=[],this.refreshTimeout=null,Me++;var r,i=D(e);if(i.tomselect)throw new Error("Tom Select already initialized on this element");i.tomselect=this;var o=window.getComputedStyle&&window.getComputedStyle(i,null);r=o.getPropertyValue("direction");let n=le(i,t);this.settings=n,this.input=i,this.tabIndex=i.tabIndex||0,this.is_select_tag=i.tagName.toLowerCase()==="select",this.rtl=/rtl/i.test(r),this.inputId=ie(i,"tomselect-"+Me),this.isRequired=i.required,this.sifter=new se(this.options,{diacritics:n.diacritics}),n.mode=n.mode||(n.maxItems===1?"single":"multi"),typeof n.hideSelected!="boolean"&&(n.hideSelected=n.mode==="multi"),typeof n.hidePlaceholder!="boolean"&&(n.hidePlaceholder=n.mode!=="multi");var l=n.createFilter;typeof l!="function"&&(typeof l=="string"&&(l=new RegExp(l)),l instanceof RegExp?n.createFilter=f=>l.test(f):n.createFilter=f=>this.settings.duplicates||!this.options[f]),this.initializePlugins(n.plugins),this.setupCallbacks(),this.setupTemplates();let a=D("
"),c=D("
"),u=this._render("dropdown"),p=D('
'),m=this.input.getAttribute("class")||"",b=n.mode;var d;if(P(a,n.wrapperClass,m,b),P(c,n.controlClass),$(a,c),P(u,n.dropdownClass,b),n.copyClassesToDropdown&&P(u,m),P(p,n.dropdownContentClass),$(u,p),D(n.dropdownParent||a).appendChild(u),ve(n.controlInput)){d=D(n.controlInput);var _=["autocorrect","autocapitalize","autocomplete","spellcheck","aria-label"];E(_,f=>{i.getAttribute(f)&&w(d,{[f]:i.getAttribute(f)})}),d.tabIndex=-1,c.appendChild(d),this.focus_node=d}else n.controlInput?(d=D(n.controlInput),this.focus_node=d):(d=D(""),this.focus_node=c);this.wrapper=a,this.dropdown=u,this.dropdown_content=p,this.control=c,this.control_input=d,this.setup()}setup(){let e=this,t=e.settings,r=e.control_input,i=e.dropdown,o=e.dropdown_content,n=e.wrapper,l=e.control,a=e.input,c=e.focus_node,u={passive:!0},p=e.inputId+"-ts-dropdown";w(o,{id:p}),w(c,{role:"combobox","aria-haspopup":"listbox","aria-expanded":"false","aria-controls":p});let m=ie(c,e.inputId+"-ts-control"),b="label[for='"+ke(e.inputId)+"']",d=document.querySelector(b),_=e.focus.bind(e);if(d){F(d,"click",_),w(d,{for:m});let g=ie(d,e.inputId+"-ts-label");w(c,{"aria-labelledby":g}),w(o,{"aria-labelledby":g})}if(n.style.width=a.style.width,n.style.minWidth=a.style.minWidth,n.style.maxWidth=a.style.maxWidth,e.plugins.names.length){let g="plugin-"+e.plugins.names.join(" plugin-");P([n,i],g)}(t.maxItems===null||t.maxItems>1)&&e.is_select_tag&&w(a,{multiple:"multiple"}),t.placeholder&&w(r,{placeholder:t.placeholder}),!t.splitOn&&t.delimiter&&(t.splitOn=new RegExp("\\s*"+N(t.delimiter)+"+\\s*")),t.load&&t.loadThrottle&&(t.load=Fe(t.load,t.loadThrottle)),F(i,"mousemove",()=>{e.ignoreHover=!1}),F(i,"mouseenter",g=>{var y=oe(g.target,"[data-selectable]",i);y&&e.onOptionHover(g,y)},{capture:!0}),F(i,"click",g=>{let y=oe(g.target,"[data-selectable]");y&&(e.onOptionSelect(g,y),A(g,!0))}),F(l,"click",g=>{var y=oe(g.target,"[data-ts-item]",l);if(y&&e.onItemSelect(g,y)){A(g,!0);return}r.value==""&&(e.onClick(),A(g,!0))}),F(c,"keydown",g=>e.onKeyDown(g)),F(r,"keypress",g=>e.onKeyPress(g)),F(r,"input",g=>e.onInput(g)),F(c,"blur",g=>e.onBlur(g)),F(c,"focus",g=>e.onFocus(g)),F(r,"paste",g=>e.onPaste(g));let f=g=>{let y=g.composedPath()[0];if(!n.contains(y)&&!i.contains(y)){e.isFocused&&e.blur(),e.inputState();return}y==r&&e.isOpen?g.stopPropagation():A(g,!0)},h=()=>{e.isOpen&&e.positionDropdown()},x=()=>{e.isValid&&(e.isValid=!1,e.isInvalid=!0,e.refreshState())};F(a,"invalid",x),F(document,"mousedown",f),F(window,"scroll",h,u),F(window,"resize",h,u),this._destroy=()=>{a.removeEventListener("invalid",x),document.removeEventListener("mousedown",f),window.removeEventListener("scroll",h),window.removeEventListener("resize",h),d&&d.removeEventListener("click",_)},this.revertSettings={innerHTML:a.innerHTML,tabIndex:a.tabIndex},a.tabIndex=-1,a.insertAdjacentElement("afterend",e.wrapper),e.sync(!1),t.items=[],delete t.optgroups,delete t.options,e.refreshItems(),e.close(!1),e.inputState(),e.isSetup=!0,e.on("change",this.onChange),P(a,"tomselected","ts-hidden-accessible"),e.trigger("initialize"),t.preload===!0&&e.preload()}setupOptions(e=[],t=[]){this.addOptions(e),E(t,r=>{this.registerOptionGroup(r)})}setupTemplates(){var e=this,t=e.settings.labelField,r=e.settings.optgroupLabelField,i={optgroup:o=>{let n=document.createElement("div");return n.className="optgroup",n.appendChild(o.options),n},optgroup_header:(o,n)=>'
'+n(o[r])+"
",option:(o,n)=>"
"+n(o[t])+"
",item:(o,n)=>"
"+n(o[t])+"
",option_create:(o,n)=>'
Add '+n(o.input)+"…
",no_results:()=>'
No results found
',loading:()=>'
',not_loading:()=>{},dropdown:()=>"
"};e.settings.render=Object.assign({},i,e.settings.render)}setupCallbacks(){var e,t,r={initialize:"onInitialize",change:"onChange",item_add:"onItemAdd",item_remove:"onItemRemove",item_select:"onItemSelect",clear:"onClear",option_add:"onOptionAdd",option_remove:"onOptionRemove",option_clear:"onOptionClear",optgroup_add:"onOptionGroupAdd",optgroup_remove:"onOptionGroupRemove",optgroup_clear:"onOptionGroupClear",dropdown_open:"onDropdownOpen",dropdown_close:"onDropdownClose",type:"onType",load:"onLoad",focus:"onFocus",blur:"onBlur"};for(e in r)t=this.settings[r[e]],t&&this.on(e,t)}sync(e=!0){let t=this,r=e?le(t.input,{delimiter:t.settings.delimiter,allowEmptyOption:t.settings.allowEmptyOption}):t.settings;t.setupOptions(r.options,r.optgroups),t.setValue(r.items||[],!0),t.input.disabled?t.disable():t.input.readOnly?t.setReadOnly(!0):t.enable(),t.lastQuery=null}onClick(){var e=this;if(e.activeItems.length>0){e.clearActiveItems(),e.focus();return}e.isFocused&&e.isOpen?e.blur():e.focus()}onMouseDown(){}onChange(){ne(this.input,"input"),ne(this.input,"change")}onPaste(e){var t=this;if(t.isInputHidden||t.isLocked){A(e);return}t.settings.splitOn&&setTimeout(()=>{var r=t.inputValue();if(r.match(t.settings.splitOn)){var i=r.trim().split(t.settings.splitOn);E(i,o=>{k(o)&&(this.options[o]?t.addItem(o):t.createItem(o))})}},0)}onKeyPress(e){var t=this;if(t.isLocked){A(e);return}var r=String.fromCharCode(e.keyCode||e.which);if(t.settings.create&&t.settings.mode==="multi"&&r===t.settings.delimiter){t.createItem(),A(e);return}}onKeyDown(e){var t=this;if(t.ignoreHover=!0,t.isLocked){e.keyCode!==9&&A(e);return}switch(e.keyCode){case 65:if(V(Q,e)&&t.control_input.value==""){A(e),t.selectAll();return}break;case 27:t.isOpen&&(A(e,!0),t.close()),t.clearActiveItems();return;case 40:if(!t.isOpen&&t.hasOptions)t.open();else if(t.activeOption){let r=t.getAdjacent(t.activeOption,1);r&&t.setActiveOption(r)}A(e);return;case 38:if(t.activeOption){let r=t.getAdjacent(t.activeOption,-1);r&&t.setActiveOption(r)}A(e);return;case 13:t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),A(e)):t.settings.create&&t.createItem()?A(e):document.activeElement==t.control_input&&t.isOpen&&A(e);return;case 37:t.advanceSelection(-1,e);return;case 39:t.advanceSelection(1,e);return;case 9:t.settings.selectOnTab&&(t.canSelect(t.activeOption)?(t.onOptionSelect(e,t.activeOption),A(e)):t.settings.create&&t.createItem()&&A(e));return;case 8:case 46:t.deleteSelection(e);return}t.isInputHidden&&!V(Q,e)&&A(e)}onInput(e){if(this.isLocked)return;let t=this.inputValue();if(this.lastValue!==t){if(this.lastValue=t,t==""){this._onInput();return}this.refreshTimeout&&window.clearTimeout(this.refreshTimeout),this.refreshTimeout=Le(()=>{this.refreshTimeout=null,this._onInput()},this.settings.refreshThrottle)}}_onInput(){let e=this.lastValue;this.settings.shouldLoad.call(this,e)&&this.load(e),this.refreshOptions(),this.trigger("type",e)}onOptionHover(e,t){this.ignoreHover||this.setActiveOption(t,!1)}onFocus(e){var t=this,r=t.isFocused;if(t.isDisabled||t.isReadOnly){t.blur(),A(e);return}t.ignoreFocus||(t.isFocused=!0,t.settings.preload==="focus"&&t.preload(),r||t.trigger("focus"),t.activeItems.length||(t.inputState(),t.refreshOptions(!!t.settings.openOnFocus)),t.refreshState())}onBlur(e){if(document.hasFocus()!==!1){var t=this;if(t.isFocused){t.isFocused=!1,t.ignoreFocus=!1;var r=()=>{t.close(),t.setActiveItem(),t.setCaret(t.items.length),t.trigger("blur")};t.settings.create&&t.settings.createOnBlur?t.createItem(null,r):r()}}}onOptionSelect(e,t){var r,i=this;t.parentElement&&t.parentElement.matches("[data-disabled]")||(t.classList.contains("create")?i.createItem(null,()=>{i.settings.closeAfterSelect?i.close():i.settings.clearAfterSelect&&i.setTextboxValue()}):(r=t.dataset.value,typeof r<"u"&&(i.isDropdownContentStale=i.settings.hideSelected,i.addItem(r),i.settings.closeAfterSelect?i.close():i.settings.clearAfterSelect&&i.setTextboxValue(),!i.settings.hideSelected&&e.type&&/click/.test(e.type)&&i.setActiveOption(t))))}canSelect(e){return!!(this.isOpen&&e&&this.dropdown_content.contains(e))}onItemSelect(e,t){var r=this;return!r.isLocked&&r.settings.mode==="multi"?(A(e),r.setActiveItem(t,e),!0):!1}canLoad(e){return!(!this.settings.load||this.loadedSearches.hasOwnProperty(e))}load(e){let t=this;if(!t.canLoad(e))return;P(t.wrapper,t.settings.loadingClass),t.loading++;let r=t.loadCallback.bind(t);t.settings.load.call(t,e,r)}loadCallback(e,t){let r=this;r.loading=Math.max(r.loading-1,0),r.isDropdownContentStale=!0,r.clearActiveOption(),r.setupOptions(e,t),r.refreshOptions(r.isFocused&&!r.isInputHidden),r.loading||H(r.wrapper,r.settings.loadingClass),r.trigger("load",e,t)}preload(){var e=this.wrapper.classList;e.contains("preloaded")||(e.add("preloaded"),this.load(""))}setTextboxValue(e=""){var t=this.control_input,r=t.value!==e;r&&(t.value=e,ne(t,"update"),this.lastValue=e)}getValue(){return this.is_select_tag&&this.input.hasAttribute("multiple")?this.items:this.items.join(this.settings.delimiter)}setValue(e,t){var r=t?[]:["change"];ge(this,r,()=>{this.clear(t),this.addItems(e,t)})}setMaxItems(e){e===0&&(e=null),this.settings.maxItems=e,this.refreshState()}setActiveItem(e,t){var r=this,i,o,n,l,a,c;if(r.settings.mode!=="single"){if(!e){r.clearActiveItems(),r.isFocused&&r.inputState();return}if(i=t&&t.type.toLowerCase(),i==="click"&&V("shiftKey",t)&&r.activeItems.length){for(c=r.getLastActive(),n=Array.prototype.indexOf.call(r.control.children,c),l=Array.prototype.indexOf.call(r.control.children,e),n>l&&(a=n,n=l,l=a),o=n;o<=l;o++)e=r.control.children[o],r.activeItems.indexOf(e)===-1&&r.setActiveItemClass(e);A(t)}else i==="click"&&V(Q,t)||i==="keydown"&&V("shiftKey",t)?e.classList.contains("active")?r.removeActiveItem(e):r.setActiveItemClass(e):(r.clearActiveItems(),r.setActiveItemClass(e));r.inputState(),r.isFocused||r.focus()}}setActiveItemClass(e){let t=this,r=t.control.querySelector(".last-active");r&&H(r,"last-active"),P(e,"active last-active"),t.trigger("item_select",e),t.activeItems.indexOf(e)==-1&&t.activeItems.push(e)}removeActiveItem(e){var t=this.activeItems.indexOf(e);this.activeItems.splice(t,1),H(e,"active")}clearActiveItems(){H(this.activeItems,"active"),this.activeItems=[]}setActiveOption(e,t=!0){e!==this.activeOption&&(this.clearActiveOption(),e&&(this.activeOption=e,w(this.focus_node,{"aria-activedescendant":e.getAttribute("id")}),w(e,{"aria-selected":"true"}),P(e,"active"),t&&this.scrollToOption(e)))}scrollToOption(e,t){if(!e)return;let r=this.dropdown_content,i=r.clientHeight,o=r.scrollTop||0,n=e.offsetHeight,l=e.getBoundingClientRect().top-r.getBoundingClientRect().top+o;l+n>i+o?this.scroll(l-i+n,t):l{e.setActiveItemClass(r)}))}inputState(){var e=this;e.control.contains(e.control_input)&&(w(e.control_input,{placeholder:e.settings.placeholder}),e.activeItems.length>0||!e.isFocused&&e.settings.hidePlaceholder&&e.items.length>0?(e.setTextboxValue(),e.isInputHidden=!0):(e.settings.hidePlaceholder&&e.items.length>0&&w(e.control_input,{placeholder:""}),e.isInputHidden=!1),e.wrapper.classList.toggle("input-hidden",e.isInputHidden))}inputValue(){return this.control_input.value.trim()}focus(){var e=this;if(e.isDisabled||e.isReadOnly)return;e.ignoreFocus=!0;let t=this.control_input.offsetWidth?this.control_input:this.focus_node;t.focus(),setTimeout(()=>{e.ignoreFocus=!1,t.getRootNode().activeElement===t&&this.onFocus()},0)}blur(){this.focus_node.blur(),this.onBlur()}getScoreFunction(e){return this.sifter.getScoreFunction(e,this.getSearchOptions())}getSearchOptions(){var e=this.settings,t=e.sortField;return typeof e.sortField=="string"&&(t=[{field:e.sortField}]),{fields:e.searchField,conjunction:e.searchConjunction,sort:t,nesting:e.nesting}}search(e){var t,r,i=this,o=this.getSearchOptions();if(i.settings.score&&(r=i.settings.score.call(i,e),typeof r!="function"))throw new Error('Tom Select "score" setting must be a function that returns a function');return i.isDropdownContentStale||e!==i.lastQuery?(i.lastQuery=e,/(.)\1{15,}/.test(e)&&(e=""),t=i.sifter.search(e,Object.assign(o,{score:r})),i.currentResults=t):t=Object.assign({},i.currentResults),i.settings.hideSelected&&(t.items=t.items.filter(n=>{let l=k(n.id);return!(l!==null&&i.items.indexOf(l)!==-1)})),t}refreshOptions(e=!0){var t,r,i,o,n,l,a,c,u,p;let m={},b=[];var d=this,_=d.inputValue();let f=_===d.lastQuery||_==""&&d.lastQuery==null;var h=d.search(_),x=null,g=d.settings.shouldOpen||!1,y=d.dropdown_content;f&&(x=d.activeOption,x&&(u=x.closest("[data-group]"))),o=h.items.length,typeof d.settings.maxOptions=="number"&&(o=Math.min(o,d.settings.maxOptions)),o>0&&(g=!0);let K=(O,v)=>{let I=m[O];if(I!==void 0){let T=b[I];if(T!==void 0)return[I,T.fragment]}let S=document.createDocumentFragment();return I=b.length,b.push({fragment:S,order:v,optgroup:O}),[I,S]};for(t=0;t0&&(T=T.cloneNode(!0),w(T,{id:I.$id+"-clone-"+r,"aria-selected":null}),T.classList.add("ts-cloned"),H(T,"active"),d.activeOption&&d.activeOption.dataset.value==v&&u&&u.dataset.group===n.toString()&&(x=T)),lt.appendChild(T),n!=""&&(m[n]=ot)}}d.settings.lockOptgroupOrder&&b.sort((O,v)=>O.order-v.order),a=document.createDocumentFragment(),E(b,O=>{let v=O.fragment,I=O.optgroup;if(!v||!v.children.length)return;let S=d.optgroups[I];if(S!==void 0){let T=document.createDocumentFragment(),J=d.render("optgroup_header",S);$(T,J),$(T,v);let M=d.render("optgroup",{group:S,options:T});$(a,M)}else $(a,v)}),y.innerHTML="",$(y,a),d.isDropdownContentStale=!1,d.settings.highlight&&(Ne(y),h.query.length&&h.tokens.length&&E(h.tokens,O=>{Ke(y,O.regex)}));var C=O=>{let v=d.render(O,{input:_});return v&&(g=!0,y.insertBefore(v,y.firstChild)),v};if(d.loading?C("loading"):d.settings.shouldLoad.call(d,_)?h.items.length===0&&C("no_results"):C("not_loading"),c=d.canCreate(_),c&&(p=C("option_create")),d.hasOptions=h.items.length>0||c,g){if(h.items.length>0){if(!x&&d.settings.mode==="single"&&d.items[0]!=null&&(x=d.getOption(d.items[0])),!y.contains(x)){let O=0;p&&!d.settings.addPrecedence&&(O=1),x=d.selectable()[O]}}else p&&(x=p);e&&!d.isOpen&&(d.open(),d.scrollToOption(x,"auto")),d.setActiveOption(x)}else d.clearActiveOption(),e&&d.isOpen&&d.close(!1)}selectable(){return this.dropdown_content.querySelectorAll("[data-selectable]")}addOption(e,t=!1){let r=this;if(Array.isArray(e))return r.addOptions(e,t),!1;let i=k(e[r.settings.valueField]);return i===null||r.options.hasOwnProperty(i)?(r.updateOption(e[r.settings.valueField],e),!1):(e.$order=e.$order||++r.order,e.$id=r.inputId+"-opt-"+e.$order,r.options[i]=e,r.isDropdownContentStale=!0,t&&(r.userOptions[i]=t,r.trigger("option_add",i,e)),i)}addOptions(e,t=!1){E(e,r=>{this.addOption(r,t)})}registerOption(e){return this.addOption(e)}registerOptionGroup(e){var t=k(e[this.settings.optgroupValueField]);return t===null?!1:(e.$order=e.$order||++this.order,this.optgroups[t]=e,t)}addOptionGroup(e,t){var r;t[this.settings.optgroupValueField]=e,(r=this.registerOptionGroup(t))&&this.trigger("optgroup_add",r,t)}removeOptionGroup(e){this.optgroups.hasOwnProperty(e)&&(delete this.optgroups[e],this.clearCache(),this.trigger("optgroup_remove",e))}clearOptionGroups(){this.optgroups={},this.clearCache(),this.trigger("optgroup_clear")}updateOption(e,t){let r=this;var i,o;let n=k(e),l=k(t[r.settings.valueField]);if(n===null)return;let a=r.options[n];if(a==null)return;if(typeof l!="string")throw new Error("Value must be set in option data");let c=r.getOption(n),u=r.getItem(n);if(t.$order=t.$order||a.$order,delete r.options[n],r.uncacheValue(l),r.options[l]=t,c){if(r.dropdown_content.contains(c)){let p=r._render("option",t);W(c,p),r.activeOption===c&&r.setActiveOption(p)}c.remove()}u&&(o=r.items.indexOf(n),o!==-1&&r.items.splice(o,1,l),i=r._render("item",t),u.classList.contains("active")&&P(i,"active"),W(u,i)),r.isDropdownContentStale=!0}removeOption(e,t){let r=this;e=U(e),r.uncacheValue(e),delete r.userOptions[e],delete r.options[e],r.isDropdownContentStale=!0,r.trigger("option_remove",e),r.removeItem(e,t)}clearOptions(e){let t=(e||this.clearFilter).bind(this);this.loadedSearches={},this.userOptions={},this.clearCache();let r={};E(this.options,(i,o)=>{t(i,o)&&(r[o]=i)}),this.options=this.sifter.items=r,this.isDropdownContentStale=!0,this.trigger("option_clear")}clearFilter(e,t){return this.items.indexOf(t)>=0}getOption(e,t=!1){let r=k(e);if(r===null)return null;let i=this.options[r];if(i!=null){if(i.$div)return i.$div;if(t)return this._render("option",i)}return null}getAdjacent(e,t,r="option"){var i=this,o;if(!e)return null;r=="item"?o=i.controlChildren():o=i.dropdown_content.querySelectorAll("[data-selectable]");for(let n=0;n0?o[n+1]:o[n-1];return null}getItem(e){if(typeof e=="object")return e;var t=k(e);return t!==null?this.control.querySelector(`[data-value="${me(t)}"]`):null}addItems(e,t){var r=this,i=Array.isArray(e)?e:[e];i=i.filter(n=>r.items.indexOf(n)===-1);let o=i[i.length-1];i.forEach(n=>{r.isPending=n!==o,r.addItem(n,t)})}addItem(e,t){var r=t?[]:["change","dropdown_close"];ge(this,r,()=>{var i,o;let n=this,l=n.settings.mode,a=k(e);if(!(a&&n.items.indexOf(a)!==-1&&(l==="single"&&n.close(),l==="single"||!n.settings.duplicates))&&!(a===null||!n.options.hasOwnProperty(a))&&(l==="single"&&n.clear(t),!(l==="multi"&&n.isFull()))){if(i=n._render("item",n.options[a]),n.control.contains(i)&&(i=i.cloneNode(!0)),o=n.isFull(),n.items.splice(n.caretPos,0,a),n.insertAtCaret(i),n.isSetup){if(!n.isPending&&n.settings.hideSelected){let c=n.getOption(a),u=n.getAdjacent(c,1);u&&n.setActiveOption(u)}n.settings.clearAfterSelect&&n.setTextboxValue(),!n.isPending&&!n.settings.closeAfterSelect&&n.refreshOptions(n.isFocused&&l!=="single"),n.settings.closeAfterSelect!=!1&&n.isFull()?n.close():n.isPending||n.positionDropdown(),n.trigger("item_add",a,i),n.isPending||n.updateOriginalInput({silent:t})}(!n.isPending||!o&&n.isFull())&&(n.inputState(),n.refreshState())}})}removeItem(e=null,t){let r=this;if(e=r.getItem(e),!e)return;var i,o;let n=e.dataset.value;i=Oe(e),e.remove(),e.classList.contains("active")&&(o=r.activeItems.indexOf(e),r.activeItems.splice(o,1),H(e,"active")),r.items.splice(i,1),r.isDropdownContentStale=!0,!r.settings.persist&&r.userOptions.hasOwnProperty(n)&&r.removeOption(n,t),i{}){arguments.length===3&&(t=arguments[2]),typeof t!="function"&&(t=()=>{});var r=this,i=r.caretPos,o;if(e=e||r.inputValue(),!r.canCreate(e))return k(e)&&this.options[e]&&r.addItem(e),t(),!1;r.lock();var n=!1,l=a=>{if(r.unlock(),!a||typeof a!="object")return t();var c=k(a[r.settings.valueField]);if(typeof c!="string")return t();r.setTextboxValue(),r.addOption(a,!0),r.setCaret(i),r.addItem(c),t(a),n=!0};return typeof r.settings.create=="function"?o=r.settings.create.call(this,e,l):o={[r.settings.labelField]:e,[r.settings.valueField]:e},n||l(o),!0}refreshItems(){var e=this;e.isDropdownContentStale=!0,e.isSetup&&e.addItems(e.items),e.updateOriginalInput(),e.refreshState()}refreshState(){let e=this;e.refreshValidityState();let t=e.isFull(),r=e.isLocked;e.wrapper.classList.toggle("rtl",e.rtl);let i=e.wrapper.classList;i.toggle("focus",e.isFocused),i.toggle("disabled",e.isDisabled),i.toggle("readonly",e.isReadOnly),i.toggle("required",e.isRequired),i.toggle("invalid",!e.isValid),i.toggle("locked",r),i.toggle("full",t),i.toggle("input-active",e.isFocused&&!e.isInputHidden),i.toggle("dropdown-active",e.isOpen),i.toggle("has-options",He(e.options)),i.toggle("has-items",e.items.length>0)}refreshValidityState(){var e=this;e.input.validity&&(e.isValid=e.input.validity.valid,e.isInvalid=!e.isValid)}isFull(){return this.settings.maxItems!==null&&this.items.length>=this.settings.maxItems}updateOriginalInput(e={}){let t=this;var r,i;let o=t.input.querySelector('option[value=""]');if(t.is_select_tag){let a=function(c,u,p){return c||(c=D('")),c!=o&&t.input.append(c),n.push(c),(c!=o||l>0||t.settings.mode=="multi")&&(c.selected=!0),c},n=[],l=t.input.querySelectorAll("option:checked").length;t.input.querySelectorAll("option:checked").forEach(c=>{c.selected=!1}),t.items.length==0&&t.settings.mode=="single"?a(o,"",""):t.items.forEach(c=>{if(r=t.options[c],i=r[t.settings.labelField]||"",n.includes(r.$option)){let u=t.input.querySelector(`option[value="${me(c)}"]:not(:checked)`);a(u,c,i)}else r.$option=a(r.$option,c,i)})}else t.input.value=t.getValue();t.isSetup&&(e.silent||t.trigger("change",t.getValue()))}open(){var e=this;e.isLocked||e.isOpen||e.settings.mode==="multi"&&e.isFull()||(e.isOpen=!0,w(e.focus_node,{"aria-expanded":"true"}),e.refreshState(),G(e.dropdown,{visibility:"hidden",display:"block"}),e.positionDropdown(),G(e.dropdown,{visibility:"visible",display:"block"}),e.focus(),e.trigger("dropdown_open",e.dropdown))}close(e=!0){var t=this,r=t.isOpen;e&&(t.setTextboxValue(),t.settings.mode==="single"&&t.items.length&&t.inputState()),t.isOpen=!1,w(t.focus_node,{"aria-expanded":"false"}),G(t.dropdown,{display:"none"}),t.settings.hideSelected&&t.clearActiveOption(),t.refreshState(),r&&t.trigger("dropdown_close",t.dropdown)}positionDropdown(){if(this.settings.dropdownParent==="body"){var e=this.control,t=e.getBoundingClientRect(),r=e.offsetHeight+t.top+window.scrollY,i=t.left+window.scrollX;G(this.dropdown,{width:t.width+"px",top:r+"px",left:i+"px"})}}clear(e){var t=this;if(t.items.length){var r=t.controlChildren();E(r,i=>{t.removeItem(i,!0)}),t.inputState(),e||t.updateOriginalInput(),t.trigger("clear")}}insertAtCaret(e){let t=this,r=t.caretPos,i=t.control;i.insertBefore(e,i.children[r]||null),t.setCaret(r+1)}deleteSelection(e){var t,r,i,o,n=this;t=e&&e.keyCode===8?-1:1,r=Te(n.control_input);let l=[];if(n.activeItems.length)o=_e(n.activeItems,t),i=Oe(o),t>0&&i++,E(n.activeItems,a=>l.push(a));else if((n.isFocused||n.settings.mode==="single")&&n.items.length){let a=n.controlChildren(),c;t<0&&r.start===0&&r.length===0?c=a[n.caretPos-1]:t>0&&r.start===n.inputValue().length&&(c=a[n.caretPos]),c!==void 0&&l.push(c)}if(!n.shouldDelete(l,e))return!1;for(A(e,!0),typeof i<"u"&&n.setCaret(i);l.length;)n.removeItem(l.pop());return n.inputState(),n.positionDropdown(),n.refreshOptions(!1),!0}shouldDelete(e,t){let r=e.map(i=>i.dataset.value);return!(!r.length||typeof this.settings.onDelete=="function"&&this.settings.onDelete.call(this,r,t)===!1)}advanceSelection(e,t){var r,i,o=this;o.rtl&&(e*=-1),!o.inputValue().length&&(V(Q,t)||V("shiftKey",t)?(r=o.getLastActive(e),r?r.classList.contains("active")?i=o.getAdjacent(r,e,"item"):i=r:e>0?i=o.control_input.nextElementSibling:i=o.control_input.previousElementSibling,i&&(i.classList.contains("active")&&o.removeActiveItem(r),o.setActiveItemClass(i))):o.moveCaret(e))}moveCaret(e){}getLastActive(e){let t=this.control.querySelector(".last-active");if(t)return t;var r=this.control.querySelectorAll(".active");if(r)return _e(r,e)}setCaret(e){this.caretPos=this.items.length}controlChildren(){return Array.from(this.control.querySelectorAll("[data-ts-item]"))}lock(){this.setLocked(!0)}unlock(){this.setLocked(!1)}setLocked(e=this.isReadOnly||this.isDisabled){this.isLocked=e,this.refreshState()}disable(){this.setDisabled(!0),this.close()}enable(){this.setDisabled(!1)}setDisabled(e){this.focus_node.tabIndex=e?-1:this.tabIndex,this.isDisabled=e,this.input.disabled=e,this.control_input.disabled=e,this.setLocked()}setReadOnly(e){this.isReadOnly=e,this.input.readOnly=e,this.control_input.readOnly=e,this.setLocked()}destroy(){var e=this,t=e.revertSettings;e.trigger("destroy"),e.off(),e.wrapper.remove(),e.dropdown.remove(),e.input.innerHTML=t.innerHTML,e.input.tabIndex=t.tabIndex,H(e.input,"tomselected","ts-hidden-accessible"),e._destroy(),delete e.input.tomselect}render(e,t){var r,i;let o=this;if(typeof this.settings.render[e]!="function"||(i=o.settings.render[e].call(this,t,re),!i))return null;if(i=D(i),e==="option"||e==="option_create"?t[o.settings.disabledField]?w(i,{"aria-disabled":"true"}):w(i,{"data-selectable":""}):e==="optgroup"&&(r=t.group[o.settings.optgroupValueField],w(i,{"data-group":r}),t.group[o.settings.disabledField]&&w(i,{"data-disabled":""})),e==="option"||e==="item"){let n=U(t[o.settings.valueField]);w(i,{"data-value":n}),e==="item"?(P(i,o.settings.itemClass),w(i,{"data-ts-item":""})):(P(i,o.settings.optionClass),w(i,{role:"option",id:t.$id}),t.$div=i,o.options[n]=t)}return i}_render(e,t){let r=this.render(e,t);if(r==null)throw"HTMLElement expected";return r}clearCache(){E(this.options,e=>{e.$div&&(e.$div.remove(),delete e.$div)})}uncacheValue(e){let t=this.getOption(e);t&&t.remove()}canCreate(e){return this.settings.create&&e.length>0&&this.settings.createFilter.call(this,e)}hook(e,t,r){var i=this,o=i[t];i[t]=function(){var n,l;return e==="after"&&(n=o.apply(i,arguments)),l=r.apply(i,arguments),e==="instead"?l:(e==="before"&&(n=o.apply(i,arguments)),n)}}};var Pt=(s,e,t,r)=>{s.addEventListener(e,t,r)};function Re(){Pt(this.input,"change",()=>{this.sync()})}var Dt=s=>typeof s>"u"||s===null?null:Ht(s),Ht=s=>typeof s=="boolean"?s?"1":"0":s+"",Be=(s,e=!1)=>{s&&(s.preventDefault(),e&&s.stopPropagation())},Kt=s=>{if(s.jquery)return s[0];if(s instanceof HTMLElement)return s;if(Nt(s)){var e=document.createElement("template");return e.innerHTML=s.trim(),e.content.firstChild}return document.querySelector(s)},Nt=s=>typeof s=="string"&&s.indexOf("<")>-1;function Ye(s){var e=this,t=e.onOptionSelect;e.settings.hideSelected=!1;let r=Object.assign({className:"tomselect-checkbox",checkedClassNames:void 0,uncheckedClassNames:void 0},s);var i=function(l,a){a?(l.checked=!0,r.uncheckedClassNames&&l.classList.remove(...r.uncheckedClassNames),r.checkedClassNames&&l.classList.add(...r.checkedClassNames)):(l.checked=!1,r.checkedClassNames&&l.classList.remove(...r.checkedClassNames),r.uncheckedClassNames&&l.classList.add(...r.uncheckedClassNames))},o=function(l){setTimeout(()=>{var a=l.querySelector("input."+r.className);a instanceof HTMLInputElement&&i(a,l.classList.contains("selected"))},1)};e.hook("after","setupTemplates",()=>{var n=e.settings.render.option;e.settings.render.option=(l,a)=>{var c=Kt(n.call(e,l,a)),u=document.createElement("input");r.className&&u.classList.add(r.className),u.addEventListener("click",function(m){Be(m)}),u.type="checkbox";let p=Dt(l[e.settings.valueField]);return i(u,!!(p&&e.items.indexOf(p)>-1)),c.prepend(u),c}}),e.on("item_remove",n=>{var l=e.getOption(n);l&&(l.classList.remove("selected"),o(l))}),e.on("item_add",n=>{var l=e.getOption(n);l&&o(l)}),e.hook("instead","onOptionSelect",(n,l)=>{if(l.classList.contains("selected")){l.classList.remove("selected"),e.removeItem(l.dataset.value),e.refreshOptions(),Be(n,!0);return}t.call(e,n,l),o(l)})}var Vt=s=>{if(s.jquery)return s[0];if(s instanceof HTMLElement)return s;if($t(s)){var e=document.createElement("template");return e.innerHTML=s.trim(),e.content.firstChild}return document.querySelector(s)},$t=s=>typeof s=="string"&&s.indexOf("<")>-1;function je(s){let e=this,t=Object.assign({className:"clear-button",title:"Clear All",role:"button",tabindex:0,html:r=>`
×
`},s);e.on("initialize",()=>{var r=Vt(t.html(t));r.addEventListener("click",i=>{e.isLocked||(e.clear(),e.settings.mode==="single"&&e.settings.allowEmptyOption&&e.addItem(""),e.refreshOptions(!1),i.preventDefault(),i.stopPropagation())}),e.control.appendChild(r)})}var Mt=(s,e=!1)=>{s&&(s.preventDefault(),e&&s.stopPropagation())},R=(s,e,t,r)=>{s.addEventListener(e,t,r)},Rt=(s,e)=>{if(Array.isArray(s))s.forEach(e);else for(var t in s)s.hasOwnProperty(t)&&e(s[t],t)},Bt=s=>{if(s.jquery)return s[0];if(s instanceof HTMLElement)return s;if(Yt(s)){var e=document.createElement("template");return e.innerHTML=s.trim(),e.content.firstChild}return document.querySelector(s)},Yt=s=>typeof s=="string"&&s.indexOf("<")>-1,jt=(s,e)=>{Rt(e,(t,r)=>{t==null?s.removeAttribute(r):s.setAttribute(r,""+t)})},zt=(s,e)=>{var t;(t=s.parentNode)==null||t.insertBefore(e,s.nextSibling)},Ut=(s,e)=>{var t;(t=s.parentNode)==null||t.insertBefore(e,s)},Gt=(s,e)=>{do{var t;if(e=(t=e)==null?void 0:t.previousElementSibling,s==e)return!0}while(e&&e.previousElementSibling);return!1};function ze(){var s=this;if(s.settings.mode!=="multi")return;var e=s.lock,t=s.unlock;let r=!0,i;s.hook("after","setupTemplates",()=>{var o=s.settings.render.item;s.settings.render.item=(n,l)=>{let a=Bt(o.call(s,n,l));jt(a,{draggable:"true"});let c=_=>{r||Mt(_),_.stopPropagation()},u=_=>{i=a,setTimeout(()=>{a.classList.add("ts-dragging")},0)},p=_=>{_.preventDefault(),a.classList.add("ts-drag-over"),b(a,i)},m=()=>{a.classList.remove("ts-drag-over")},b=(_,f)=>{f!==void 0&&(Gt(f,a)?zt(_,f):Ut(_,f))},d=()=>{var _;document.querySelectorAll(".ts-drag-over").forEach(h=>h.classList.remove("ts-drag-over")),(_=i)==null||_.classList.remove("ts-dragging"),i=void 0;var f=[];s.control.querySelectorAll("[data-value]").forEach(h=>{if(h.dataset.value){let x=h.dataset.value;x&&f.push(x)}}),s.setValue(f)};return R(a,"mousedown",c),R(a,"dragstart",u),R(a,"dragenter",p),R(a,"dragover",p),R(a,"dragleave",m),R(a,"dragend",d),a}}),s.hook("instead","lock",()=>(r=!1,e.call(s))),s.hook("instead","unlock",()=>(r=!0,t.call(s)))}var Wt=(s,e=!1)=>{s&&(s.preventDefault(),e&&s.stopPropagation())},Qt=s=>{if(s.jquery)return s[0];if(s instanceof HTMLElement)return s;if(qt(s)){var e=document.createElement("template");return e.innerHTML=s.trim(),e.content.firstChild}return document.querySelector(s)},qt=s=>typeof s=="string"&&s.indexOf("<")>-1;function Ue(s){let e=this,t=Object.assign({title:"Untitled",headerClass:"dropdown-header",titleRowClass:"dropdown-header-title",labelClass:"dropdown-header-label",closeClass:"dropdown-header-close",html:r=>'
'},s);e.on("initialize",()=>{var r=Qt(t.html(t)),i=r.querySelector("."+t.closeClass);i&&i.addEventListener("click",o=>{Wt(o,!0),e.close()}),e.dropdown.insertBefore(r,e.dropdown.firstChild)})}var Jt=(s,e)=>{if(Array.isArray(s))s.forEach(e);else for(var t in s)s.hasOwnProperty(t)&&e(s[t],t)},Xt=(s,...e)=>{var t=Zt(e);s=es(s),s.map(r=>{t.map(i=>{r.classList.remove(i)})})},Zt=s=>{var e=[];return Jt(s,t=>{typeof t=="string"&&(t=t.trim().split(/[\t\n\f\r\s]/)),Array.isArray(t)&&(e=e.concat(t))}),e.filter(Boolean)},es=s=>(Array.isArray(s)||(s=[s]),s),ts=(s,e)=>{if(!s)return-1;e=e||s.nodeName;for(var t=0;s=s.previousElementSibling;)s.matches(e)&&t++;return t};function Ge(){var s=this;s.hook("instead","setCaret",e=>{s.settings.mode==="single"||!s.control.contains(s.control_input)?e=s.items.length:(e=Math.max(0,Math.min(s.items.length,e)),e!=s.caretPos&&!s.isPending&&s.controlChildren().forEach((t,r)=>{r{if(!s.isFocused)return;let t=s.getLastActive(e);if(t){let r=ts(t);s.setCaret(e>0?r+1:r),s.setActiveItem(),Xt(t,"last-active")}else s.setCaret(s.caretPos+e)})}var ss=(s,e=!1)=>{s&&(s.preventDefault(),e&&s.stopPropagation())},rs=(s,e,t,r)=>{s.addEventListener(e,t,r)},is=(s,e)=>{if(Array.isArray(s))s.forEach(e);else for(var t in s)s.hasOwnProperty(t)&&e(s[t],t)},We=s=>{if(s.jquery)return s[0];if(s instanceof HTMLElement)return s;if(ns(s)){var e=document.createElement("template");return e.innerHTML=s.trim(),e.content.firstChild}return document.querySelector(s)},ns=s=>typeof s=="string"&&s.indexOf("<")>-1,os=(s,...e)=>{var t=ls(e);s=as(s),s.map(r=>{t.map(i=>{r.classList.add(i)})})},ls=s=>{var e=[];return is(s,t=>{typeof t=="string"&&(t=t.trim().split(/[\t\n\f\r\s]/)),Array.isArray(t)&&(e=e.concat(t))}),e.filter(Boolean)},as=s=>(Array.isArray(s)||(s=[s]),s);function Qe(){let s=this;s.settings.shouldOpen=!0,s.hook("before","setup",()=>{var e;s.focus_node=s.control,os(s.control_input,"dropdown-input");let t=We('