From 2c9957365f991cba72274f9d07ae5fc4aa597b3c Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Wed, 4 Nov 2026 21:14:35 +0200 Subject: [PATCH 1/3] Theme v4 for ActiveAdmin 4 Rebuilds the theme on ActiveAdmin 4's Tailwind layout. Theme 3 stays on the 3.x branch. The theme copies ten ActiveAdmin views, so test/aa_overrides_test.rb fails when ActiveAdmin changes one of them. Co-Authored-By: Clanker --- .github/workflows/ci.yml | 19 +- .gitignore | 4 +- Gemfile | 28 +- README.md | 611 ++--- Rakefile | 24 +- active_admin_theme.gemspec | 8 +- app/assets/javascripts/wigu/all.js | 10 - .../javascripts/wigu/scrollbar_width.js | 72 - app/assets/javascripts/wigu/server_clock.js | 171 -- .../javascripts/wigu/table_crisscross.js | 49 - app/assets/javascripts/wigu/theme_toggle.js | 140 -- .../stylesheets/wigu/active_admin_theme.scss | 2216 ----------------- .../active_admin_theme/confirm_dialog.js | 66 + app/javascript/active_admin_theme/layout.js | 33 + .../active_admin_theme/searchable_select.js | 149 ++ .../active_admin_theme/secret_reveal.js | 48 + .../active_admin_theme/server_clock.js | 53 + .../active_admin_theme/table_crisscross.js | 39 + app/tailwind/active_admin_theme.css | 247 ++ app/tailwind/components/confirm_dialog.css | 143 ++ app/tailwind/components/pre_wrap.css | 9 + app/tailwind/components/searchable_select.css | 266 ++ app/tailwind/components/secret_reveal.css | 38 + app/tailwind/components/status_tag.css | 48 + app/tailwind/components/truncate_text.css | 9 + app/views/active_admin/_html_head.html.erb | 14 + .../active_admin/_main_navigation.html.erb | 45 + app/views/active_admin/_page_header.html.erb | 31 + app/views/active_admin/_sidebar.html.erb | 5 + app/views/active_admin/_site_header.html.erb | 38 + .../active_admin/_top_navigation.html.erb | 27 + .../resource/_show_default.html.arb | 4 + app/views/active_admin/resource/show.html.arb | 15 + .../shared/_sidebar_section.html.arb | 10 + app/views/layouts/active_admin.html.erb | 23 + .../layouts/active_admin_logged_out.html.erb | 13 + config/importmap.rb | 9 + docs/migrating-from-v3.md | 77 + lib/active_admin_theme.rb | 141 +- lib/active_admin_theme/css_import.rb | 59 + lib/active_admin_theme/engine.rb | 33 + lib/active_admin_theme/helpers/pre_wrap.rb | 14 + lib/active_admin_theme/helpers/tooltip.rb | 23 + .../inputs/searchable_select_input.rb | 104 + .../resource_dsl/searchable_select.rb | 114 + lib/active_admin_theme/version.rb | 2 +- lib/active_admin_theme/views/columns.rb | 70 + .../views/confirm_dialog.rb | 113 + lib/active_admin_theme/views/pre_wrap.rb | 1 + .../views/searchable_select.rb | 1 + lib/active_admin_theme/views/secret_reveal.rb | 39 + lib/active_admin_theme/views/status_tag.rb | 44 + lib/active_admin_theme/views/tabs.rb | 60 + lib/active_admin_theme/views/truncate_text.rb | 40 + lib/active_admin_theme/views/with_tooltip.rb | 1 + lib/tasks/active_admin_theme.rake | 13 + package.json | 17 +- test/aa_overrides_test.rb | 44 + test/capybara_boot.rb | 16 +- test/clock_test.rb | 66 - test/components_test.rb | 113 + test/confirm_dialog_test.rb | 112 + test/crisscross_screenshot.rb | 23 - test/css_check.rb | 366 --- test/css_import_test.rb | 56 + test/dummy/app/admin/components_demo.rb | 53 + test/dummy/app/admin/confirm_dialog_demo.rb | 35 + test/dummy/app/admin/customers.rb | 64 + test/dummy/app/admin/editor.rb | 24 - test/dummy/app/admin/layout_demo.rb | 27 + .../dummy/app/admin/searchable_select_demo.rb | 50 + test/dummy/app/admin/table.rb | 2 +- test/dummy/app/admin/wide_table.rb | 24 +- test/dummy/app/assets/builds/.keep | 0 test/dummy/app/assets/config/manifest.js | 2 - .../app/assets/javascripts/active_admin.js | 6 - .../app/assets/stylesheets/active_admin.scss | 4 - .../app/controllers/application_controller.rb | 8 + test/dummy/app/models/city.rb | 13 + test/dummy/app/models/country.rb | 13 + test/dummy/app/models/customer.rb | 13 + test/dummy/app/models/order.rb | 17 + test/dummy/app/tailwind/active_admin.css | 5 + test/dummy/config/application.rb | 29 +- test/dummy/config/database.yml | 5 +- test/dummy/config/environment.rb | 63 +- test/dummy/config/importmap.rb | 2 + .../dummy/config/initializers/active_admin.rb | 22 +- test/dummy/package-lock.json | 1309 ++++++++++ test/dummy/package.json | 14 + test/dummy/tailwind-active_admin.config.js | 24 + test/fixtures/active_admin_originals.yml | 13 + test/layout_test.rb | 145 ++ test/screenshot.rb | 41 +- test/searchable_select_test.rb | 169 ++ test/smoke_test.rb | 25 + test/trix_screenshot.rb | 32 - vendor/javascript/tom-select.js | 2 + 98 files changed, 4910 insertions(+), 3841 deletions(-) delete mode 100644 app/assets/javascripts/wigu/all.js delete mode 100644 app/assets/javascripts/wigu/scrollbar_width.js delete mode 100644 app/assets/javascripts/wigu/server_clock.js delete mode 100644 app/assets/javascripts/wigu/table_crisscross.js delete mode 100644 app/assets/javascripts/wigu/theme_toggle.js delete mode 100644 app/assets/stylesheets/wigu/active_admin_theme.scss create mode 100644 app/javascript/active_admin_theme/confirm_dialog.js create mode 100644 app/javascript/active_admin_theme/layout.js create mode 100644 app/javascript/active_admin_theme/searchable_select.js create mode 100644 app/javascript/active_admin_theme/secret_reveal.js create mode 100644 app/javascript/active_admin_theme/server_clock.js create mode 100644 app/javascript/active_admin_theme/table_crisscross.js create mode 100644 app/tailwind/active_admin_theme.css create mode 100644 app/tailwind/components/confirm_dialog.css create mode 100644 app/tailwind/components/pre_wrap.css create mode 100644 app/tailwind/components/searchable_select.css create mode 100644 app/tailwind/components/secret_reveal.css create mode 100644 app/tailwind/components/status_tag.css create mode 100644 app/tailwind/components/truncate_text.css create mode 100644 app/views/active_admin/_html_head.html.erb create mode 100644 app/views/active_admin/_main_navigation.html.erb create mode 100644 app/views/active_admin/_page_header.html.erb create mode 100644 app/views/active_admin/_sidebar.html.erb create mode 100644 app/views/active_admin/_site_header.html.erb create mode 100644 app/views/active_admin/_top_navigation.html.erb create mode 100644 app/views/active_admin/resource/_show_default.html.arb create mode 100644 app/views/active_admin/resource/show.html.arb create mode 100644 app/views/active_admin/shared/_sidebar_section.html.arb create mode 100644 app/views/layouts/active_admin.html.erb create mode 100644 app/views/layouts/active_admin_logged_out.html.erb create mode 100644 config/importmap.rb create mode 100644 docs/migrating-from-v3.md create mode 100644 lib/active_admin_theme/css_import.rb create mode 100644 lib/active_admin_theme/engine.rb create mode 100644 lib/active_admin_theme/helpers/pre_wrap.rb create mode 100644 lib/active_admin_theme/helpers/tooltip.rb create mode 100644 lib/active_admin_theme/inputs/searchable_select_input.rb create mode 100644 lib/active_admin_theme/resource_dsl/searchable_select.rb create mode 100644 lib/active_admin_theme/views/columns.rb create mode 100644 lib/active_admin_theme/views/confirm_dialog.rb create mode 100644 lib/active_admin_theme/views/pre_wrap.rb create mode 100644 lib/active_admin_theme/views/searchable_select.rb create mode 100644 lib/active_admin_theme/views/secret_reveal.rb create mode 100644 lib/active_admin_theme/views/status_tag.rb create mode 100644 lib/active_admin_theme/views/tabs.rb create mode 100644 lib/active_admin_theme/views/truncate_text.rb create mode 100644 lib/active_admin_theme/views/with_tooltip.rb create mode 100644 lib/tasks/active_admin_theme.rake create mode 100644 test/aa_overrides_test.rb delete mode 100644 test/clock_test.rb create mode 100644 test/components_test.rb create mode 100644 test/confirm_dialog_test.rb delete mode 100644 test/crisscross_screenshot.rb delete mode 100644 test/css_check.rb create mode 100644 test/css_import_test.rb create mode 100644 test/dummy/app/admin/components_demo.rb create mode 100644 test/dummy/app/admin/confirm_dialog_demo.rb create mode 100644 test/dummy/app/admin/customers.rb delete mode 100644 test/dummy/app/admin/editor.rb create mode 100644 test/dummy/app/admin/layout_demo.rb create mode 100644 test/dummy/app/admin/searchable_select_demo.rb create mode 100644 test/dummy/app/assets/builds/.keep delete mode 100644 test/dummy/app/assets/config/manifest.js delete mode 100644 test/dummy/app/assets/javascripts/active_admin.js delete mode 100644 test/dummy/app/assets/stylesheets/active_admin.scss create mode 100644 test/dummy/app/models/city.rb create mode 100644 test/dummy/app/models/country.rb create mode 100644 test/dummy/app/models/customer.rb create mode 100644 test/dummy/app/models/order.rb create mode 100644 test/dummy/app/tailwind/active_admin.css create mode 100644 test/dummy/config/importmap.rb create mode 100644 test/dummy/package-lock.json create mode 100644 test/dummy/package.json create mode 100644 test/dummy/tailwind-active_admin.config.js create mode 100644 test/fixtures/active_admin_originals.yml create mode 100644 test/layout_test.rb create mode 100644 test/searchable_select_test.rb create mode 100644 test/smoke_test.rb delete mode 100644 test/trix_screenshot.rb create mode 100644 vendor/javascript/tom-select.js 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..6b87012 100644 --- a/Gemfile +++ b/Gemfile @@ -3,24 +3,16 @@ source 'https://rubygems.org' # Specify your gem's dependencies in active_admin_theme.gemspec gemspec +# 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 'sassc' -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 + 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..31d7061 100644 --- a/README.md +++ b/README.md @@ -1,486 +1,309 @@ # 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 +``` + +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: + +```sh +bin/rails active_admin_theme:css && npx tailwindcss -i app/assets/stylesheets/active_admin.css -o app/assets/builds/active_admin.css ``` -and execute: - $ yarn +If that is awkward for your build, use the npm route. -## Usage +**From the npm package.** This avoids a machine-specific path and a build step, and fits hosts that already install `@activeadmin/activeadmin` from npm: -In your base stylesheet entry point `active_admin.scss` (as example), add line: +```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 + +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. -Set any of the variables below *above* the import line: +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`: -```scss -$skinMainFirstColor: #A5A7AA; -$skinMainSecondColor: #0066CC; -$skinBorderWindowColor: #B8BABE; +```ruby +ActiveAdmin.importmap.pin "my_admin_script", to: "my_admin_script.js" +``` -@import 'wigu/active_admin_theme'; +The theme's layout imports the modules listed in `ActiveAdminTheme.javascripts`. The default list is: + +```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 partials are `_site_header`, `_top_navigation`, `_main_navigation` (the mobile drawer), `_page_header`, `_sidebar`, `_html_head`, and `layouts/active_admin.html.erb`. -### Switching themes +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. -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: +## Colors -```scss -// app/assets/javascripts/active_admin.js -//= require wigu/theme_toggle +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. + +### `panel` -[![Theme switch](./img/switch.png)](./img/switch.png) +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. -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. +## Components -`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. +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 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. +### `status_tag` -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. +`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`): -### Disabling dark mode +```ruby +status_tag "Shipped" # uses --aat-status-shipped, if you define it +status_tag "Late", color: "#b91c1c" +``` -To ship light-only, two manual steps (there is no build flag): +### `truncate_text` -- 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. +One line of text with an ellipsis and the full value in the `title` attribute: + +```ruby +truncate_text(order.note, length: 40, max_width: "12rem") +``` -### Server clock +### `with_tooltip` -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. +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. -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: +```ruby +text_node with_tooltip("42", "Total orders") +text_node with_tooltip("42", "Total orders", html: true) +``` -```js -// app/assets/javascripts/active_admin.js -//= require wigu/server_clock +### `pre_wrap` -// or, as an npm module -import "@activeadmin-plugins/active_admin_theme/src/server_clock"; +Preformatted text that wraps long lines: + +```ruby +text_node pre_wrap(response.body) ``` -[![Two-line server clock in the utility nav](./img/clock.png)](./img/clock.png) +### `secret_reveal` -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: +A masked value that is fetched and shown on click. The URL must return `{"value": "..."}`. Needs the `active_admin_theme/secret_reveal` script. ```ruby -# config/initializers/active_admin.rb -config.logout_link_path = nil +secret_reveal url: secret_admin_account_path(account), mask: "••••••••" ``` -The restored current-user item has a theme switch (ActiveAdmin offers none -short of unsetting `current_user_method`, which breaks authorization): +### `confirm_dialog` + +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. ```ruby -ActiveAdminTheme.add_current_user = false +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} + ] ``` -### Crisscross table highlighting +Field types are `:string`, `:text`, `:select`, and `:boolean`. -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. +### Searchable select -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: +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: -```js -// app/assets/javascripts/active_admin.js -//= require wigu/table_crisscross +```ruby +ActiveAdminTheme.javascripts += %w[active_admin_theme/searchable_select] +``` + +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: -// or, as an npm module -import "@activeadmin-plugins/active_admin_theme/src/table_crisscross"; +```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: +## Header -```js -// app/assets/javascripts/active_admin.js -//= require wigu/scrollbar_width +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 `_site_header.html.erb`. -// or, as an npm module -import "@activeadmin-plugins/active_admin_theme/src/scrollbar_width"; +## 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 ``` -Without the script the CSS falls back to plain `100vw` — nothing breaks, you -just keep the small far-right shift. +`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. -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 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. After you raise the ActiveAdmin version, diff each reported file against the theme's copy in `app/views`, port what applies, then refresh the record: -```scss -.paginated_collection table.index_table thead tr th { position: static; } +```sh +UPDATE_AA_DIGESTS=1 bundle exec ruby test/aa_overrides_test.rb ``` -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 +## 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 + <% elsif url.present? && url != "#" %> +
+ <%= label %> + +
+ <% end %> + + <% else %> + <%= label %> + <% end %> + + <% end %> + + diff --git a/app/views/active_admin/_page_header.html.erb b/app/views/active_admin/_page_header.html.erb new file mode 100644 index 0000000..5d69277 --- /dev/null +++ b/app/views/active_admin/_page_header.html.erb @@ -0,0 +1,31 @@ +<% has_sidebar = !(skip_sidebar? || sidebar_sections_for_action.blank?) %> +
+
+ <% if has_sidebar %> + + <% end %> +
+ <% breadcrumb_links = build_breadcrumb_links(request.path, class: "no-underline text-(--aat-text-muted) hover:text-(--aat-text)") %> + <% if breadcrumb_links.present? %> + + <% end %> +

<%= title %>

+
+
+ <% if action_items_for_action.present? %> +
+ <%= render "active_admin/shared/action_items" %> +
+ <% end %> +
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/_site_header.html.erb b/app/views/active_admin/_site_header.html.erb new file mode 100644 index 0000000..46b0b24 --- /dev/null +++ b/app/views/active_admin/_site_header.html.erb @@ -0,0 +1,38 @@ +
+ + +

<%= title %>

+ + <%= render "active_admin/top_navigation" %> + +
+ +
+ <% if current_active_admin_user? %> + <% now = Time.current %> + <% zone = Time.zone %> + + <% end %> + + + + <% 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/_top_navigation.html.erb b/app/views/active_admin/_top_navigation.html.erb new file mode 100644 index 0000000..6888f07 --- /dev/null +++ b/app/views/active_admin/_top_navigation.html.erb @@ -0,0 +1,27 @@ + 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/layouts/active_admin.html.erb b/app/views/layouts/active_admin.html.erb new file mode 100644 index 0000000..3902249 --- /dev/null +++ b/app/views/layouts/active_admin.html.erb @@ -0,0 +1,23 @@ + + + + <%= 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" %> + <% 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 %> +
+
+ <%= render "active_admin/site_footer" %> +
+ + 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/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..77f706b --- /dev/null +++ b/docs/migrating-from-v3.md @@ -0,0 +1,77 @@ +# 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/_site_header.html.erb`. +- Delete any items you added to the `:utility_navigation` menu. Put the content in the header partial instead. +- Logout still follows `config.logout_link_path`; set it to `nil` to remove the icon. + +## 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..8391487 100644 --- a/lib/active_admin_theme.rb +++ b/lib/active_admin_theme.rb @@ -1,134 +1,25 @@ +# 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" +require "active_admin_theme/engine" 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 - end + # 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 - # 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 + attr_writer :javascripts - # 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 - - ::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]) + 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..9b78c18 --- /dev/null +++ b/lib/active_admin_theme/engine.rb @@ -0,0 +1,33 @@ +module ActiveAdminTheme + class Engine < ::Rails::Engine + # 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 + 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/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..2ad157c
--- /dev/null
+++ b/test/fixtures/active_admin_originals.yml
@@ -0,0 +1,13 @@
+---
+activeadmin: 4.0.0.beta23
+files:
+  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
diff --git a/test/layout_test.rb b/test/layout_test.rb
new file mode 100644
index 0000000..6f2ad19
--- /dev/null
+++ b/test/layout_test.rb
@@ -0,0 +1,145 @@
+# 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 "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 "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("header nav a", text: "Demo pages").hover
+    assert_selector "#top-menu-demo_pages a", text: "Wide table", visible: :visible
+  end
+
+  def test_mobile_menu_opens_in_a_drawer
+    page.current_window.resize_to(390, 800)
+    visit "/admin/customers"
+    assert_no_selector "header nav a", text: "Customers"
+    find("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 "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('