diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 01d8f04..b451c68 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,3 +19,15 @@ jobs:
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
+ with:
+ ruby-version: '3.4'
+ bundler-cache: true
+ - run: bundle exec rake test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ce9cfc3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/Gemfile.lock
+/pkg/
+/src/
+/test/dummy/tmp/
+/test/dummy/log/
diff --git a/Gemfile b/Gemfile
index b02e4a8..49e2c88 100644
--- a/Gemfile
+++ b/Gemfile
@@ -7,3 +7,20 @@ 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
+end
diff --git a/README.md b/README.md
index 00f5399..1b7149b 100644
--- a/README.md
+++ b/README.md
@@ -174,6 +174,52 @@ 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.
+### Disabling dark mode
+
+To ship light-only, two manual steps (there is no build flag):
+
+- 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.
+
+### Server clock
+
+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.
+
+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:
+
+```js
+// app/assets/javascripts/active_admin.js
+//= require wigu/server_clock
+
+// or, as an npm module
+import "@activeadmin-plugins/active_admin_theme/src/server_clock";
+```
+
+[](./img/clock.png)
+
+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:
+
+```ruby
+# config/initializers/active_admin.rb
+config.logout_link_path = nil
+```
+
+The restored current-user item has a theme switch (ActiveAdmin offers none
+short of unsetting `current_user_method`, which breaks authorization):
+
+```ruby
+ActiveAdminTheme.add_current_user = false
+```
+
### Upgrading
#### 3.1.0
diff --git a/Rakefile b/Rakefile
index 6ae6529..b090a1d 100644
--- a/Rakefile
+++ b/Rakefile
@@ -5,4 +5,17 @@ task :css do
ruby "test/css_check.rb"
end
+desc "Capybara integration test: the server clock renders + ticks (needs Chrome)"
+task :test do
+ ruby "test/clock_test.rb"
+end
+
+desc "Regenerate the neutral 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
diff --git a/app/assets/javascripts/wigu/server_clock.js b/app/assets/javascripts/wigu/server_clock.js
new file mode 100644
index 0000000..b7af4a5
--- /dev/null
+++ b/app/assets/javascripts/wigu/server_clock.js
@@ -0,0 +1,171 @@
+// 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/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss
index 60b11b5..d6194dc 100644
--- a/app/assets/stylesheets/wigu/active_admin_theme.scss
+++ b/app/assets/stylesheets/wigu/active_admin_theme.scss
@@ -249,6 +249,11 @@ $theme-icon-auto: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s
$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
@@ -435,6 +440,17 @@ body.active_admin {
border: none;
text-shadow: none;
background-image: none;
+
+ // A wide index table scrolls the WHOLE page sideways (#wrapper is
+ // display:table, min-width:100%). Keep the header pinned on screen and at the
+ // viewport width while that happens, so it doesn't stretch to the table's
+ // width (which spread the menu out under the utility nav and overlapped it).
+ // The header keeps ActiveAdmin's natural layout, so it still grows to fit a
+ // wrapped menu.
+ position: sticky;
+ left: 0;
+ width: 100vw;
+ box-sizing: border-box;
h1.site_title {
color: $skinMainSecondColor;
font-size: 16px;
@@ -465,16 +481,34 @@ body.active_admin {
// 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 {
- // Pin to the viewport's right edge so the clock / theme toggle / logout
- // stay reachable while a wide table scrolls the page sideways.
- position: sticky;
- right: 0;
+ // 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
@@ -511,6 +545,35 @@ body.active_admin {
-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 {
@@ -1716,6 +1779,12 @@ input[type='radio'] {
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
diff --git a/img/clock.png b/img/clock.png
new file mode 100644
index 0000000..332099b
Binary files /dev/null and b/img/clock.png differ
diff --git a/lib/active_admin_theme.rb b/lib/active_admin_theme.rb
index aca44b7..5c1ead0 100644
--- a/lib/active_admin_theme.rb
+++ b/lib/active_admin_theme.rb
@@ -3,6 +3,132 @@
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
-end
\ No newline at end of file
+
+ # 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
+
+ # 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])
+ end
+ end
+end
diff --git a/package.json b/package.json
index d53db33..0183ed2 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,6 @@
"src/**/*"
],
"scripts": {
- "prepublishOnly": "rm -rf src && cp -R app/assets/stylesheets/wigu/ src && cp app/assets/javascripts/wigu/theme_toggle.js src/"
+ "prepare": "node -e \"const fs=require('fs');fs.rmSync('src',{recursive:true,force:true});fs.cpSync('app/assets/stylesheets/wigu','src',{recursive:true});for(const f of fs.readdirSync('app/assets/javascripts/wigu'))fs.copyFileSync('app/assets/javascripts/wigu/'+f,'src/'+f)\""
}
}
diff --git a/test/capybara_boot.rb b/test/capybara_boot.rb
new file mode 100644
index 0000000..90a0de6
--- /dev/null
+++ b/test/capybara_boot.rb
@@ -0,0 +1,35 @@
+# Shared Capybara + cuprite setup for the clock integration test and the
+# screenshot task. Boots the dummy ActiveAdmin app (test/dummy) and points
+# Capybara at it, driven by headless system Chrome via cuprite/ferrum.
+ENV["RAILS_ENV"] ||= "test"
+
+require_relative "dummy/config/environment"
+require "capybara"
+require "capybara/cuprite"
+
+Capybara.app = Dummy::Application
+Capybara.server = :puma, { Silent: true }
+Capybara.default_max_wait_time = 5
+
+Capybara.register_driver(:cuprite) do |app|
+ Capybara::Cuprite::Driver.new(
+ app,
+ window_size: [1200, 300],
+ headless: true,
+ # CI runners need --no-sandbox; their small /dev/shm makes Chrome crash on
+ # launch unless --disable-dev-shm-usage is set, which surfaces as ferrum's
+ # "Browser did not produce websocket url within N seconds". --disable-gpu is
+ # the usual headless companion. All harmless locally. Ferrum finds the system
+ # Chrome on its own (macOS app bundle or the ubuntu-latest preinstall).
+ browser_options: {
+ "no-sandbox" => nil,
+ "disable-dev-shm-usage" => nil,
+ "disable-gpu" => nil
+ },
+ # First Chrome launch on a cold CI runner can exceed ferrum's 10s default.
+ process_timeout: 30
+ )
+end
+
+Capybara.default_driver = :cuprite
+Capybara.javascript_driver = :cuprite
diff --git a/test/clock_test.rb b/test/clock_test.rb
new file mode 100644
index 0000000..fa9bec0
--- /dev/null
+++ b/test/clock_test.rb
@@ -0,0 +1,53 @@
+# Integration test: the server clock renders as two ticking lines in the
+# utility nav, and the logout item sits beside it. Proves the gem's JS
+# (server_clock.js) + Ruby backend (install!) work end to end in a
+# real browser against a real ActiveAdmin page.
+require_relative "capybara_boot"
+require "minitest/autorun"
+require "capybara/minitest"
+
+class ClockTest < Minitest::Test
+ include Capybara::DSL
+ include Capybara::Minitest::Assertions
+
+ def teardown
+ Capybara.reset_sessions!
+ end
+
+ def test_clock_renders_two_ticking_lines_and_logout
+ visit "/admin"
+
+ # 1. Two-line structure: li#servertime with a .clock-time and a .clock-date.
+ assert_selector "#utility_nav li#servertime", wait: 5
+ assert_selector "#utility_nav li#servertime .clock-time", wait: 5
+ assert_selector "#utility_nav li#servertime .clock-date", wait: 5
+
+ time_text = find("#utility_nav li#servertime .clock-time").text
+ date_text = find("#utility_nav li#servertime .clock-date").text
+
+ # Shapes: "HH:MM:SS " over "YYYY DD".
+ assert_match(/\A\d{2}:\d{2}:\d{2}\s+\S+\z/, time_text,
+ "clock-time should read 'HH:MM:SS ', got #{time_text.inspect}")
+ assert_match(/\A\d{4}\s+[A-Z][a-z]{2}\s+\d{2}\z/, date_text,
+ "clock-date should read 'YYYY DD', got #{date_text.inspect}")
+
+ # 2. Ticking: a retrying Capybara assertion, not a one-shot sample — a busy
+ # CI runner can delay the 1s interval past any fixed sleep, and tick()
+ # replaces the node each second, so re-querying until the text changes is
+ # the only race-free check.
+ assert_no_selector "#utility_nav li#servertime .clock-time", text: time_text, wait: 3
+
+ # 3. Logout lives in the utility nav too — specifically the THEME's item
+ # (a.wigu-logout), not AA's default, which would also appear if install!
+ # never ran.
+ assert_selector "#utility_nav li#logout > a.wigu-logout"
+
+ # 4. The displayed clock is the SERVER's. Every assertion above would also
+ # pass against a client-side fallback (same selectors, same shapes, same
+ # ticking), so pin the data span the backend renders: the app's own zone
+ # and a fresh epoch. visible: :all — the JS hides it once mounted.
+ data = find("#utility_nav li#servertime [data-servertime-epoch]", visible: :all)
+ assert_equal Time.zone.tzinfo.identifier, data["data-servertime-zone"]
+ assert_in_delta Time.now.to_i, data["data-servertime-epoch"].to_i, 60
+ end
+end
diff --git a/test/dummy/app/admin/dashboard.rb b/test/dummy/app/admin/dashboard.rb
new file mode 100644
index 0000000..93beae6
--- /dev/null
+++ b/test/dummy/app/admin/dashboard.rb
@@ -0,0 +1,7 @@
+ActiveAdmin.register_page "Dashboard" do
+ menu priority: 1, label: proc { I18n.t("active_admin.dashboard") }
+
+ content title: proc { I18n.t("active_admin.dashboard") } do
+ para "Welcome to the ActiveAdmin Theme Demo."
+ end
+end
diff --git a/test/dummy/app/assets/config/manifest.js b/test/dummy/app/assets/config/manifest.js
new file mode 100644
index 0000000..694c926
--- /dev/null
+++ b/test/dummy/app/assets/config/manifest.js
@@ -0,0 +1,2 @@
+//= 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
new file mode 100644
index 0000000..6780be4
--- /dev/null
+++ b/test/dummy/app/assets/javascripts/active_admin.js
@@ -0,0 +1,3 @@
+//= require active_admin/base
+//= require wigu/server_clock
+//= require wigu/theme_toggle
diff --git a/test/dummy/app/assets/stylesheets/active_admin.scss b/test/dummy/app/assets/stylesheets/active_admin.scss
new file mode 100644
index 0000000..c0252fb
--- /dev/null
+++ b/test/dummy/app/assets/stylesheets/active_admin.scss
@@ -0,0 +1,3 @@
+@import "active_admin/mixins";
+@import "active_admin/base";
+@import "wigu/active_admin_theme";
diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb
new file mode 100644
index 0000000..09705d1
--- /dev/null
+++ b/test/dummy/app/controllers/application_controller.rb
@@ -0,0 +1,2 @@
+class ApplicationController < ActionController::Base
+end
diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb
new file mode 100644
index 0000000..513502b
--- /dev/null
+++ b/test/dummy/config/application.rb
@@ -0,0 +1,37 @@
+# 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.
+require "rails"
+require "active_record/railtie"
+require "action_controller/railtie"
+require "action_view/railtie"
+require "sprockets/railtie"
+
+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.
+require "active_admin_theme"
+
+module Dummy
+ 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.eager_load = false
+ config.consider_all_requests_local = true
+ config.secret_key_base = "dummy-secret-key-base-for-tests-only"
+ config.hosts.clear
+
+ config.logger = Logger.new($stdout)
+ config.log_level = :warn
+
+ # 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
new file mode 100644
index 0000000..3de036b
--- /dev/null
+++ b/test/dummy/config/database.yml
@@ -0,0 +1,11 @@
+# In-memory SQLite for every env — there is no schema to persist.
+default: &default
+ adapter: sqlite3
+ database: ":memory:"
+
+development:
+ <<: *default
+test:
+ <<: *default
+production:
+ <<: *default
diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb
new file mode 100644
index 0000000..332a7f4
--- /dev/null
+++ b/test/dummy/config/environment.rb
@@ -0,0 +1,7 @@
+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:")
+
+Dummy::Application.initialize!
diff --git a/test/dummy/config/initializers/active_admin.rb b/test/dummy/config/initializers/active_admin.rb
new file mode 100644
index 0000000..63370d1
--- /dev/null
+++ b/test/dummy/config/initializers/active_admin.rb
@@ -0,0 +1,23 @@
+ActiveAdmin.setup do |config|
+ config.site_title = "ActiveAdmin Theme Demo"
+
+ # No devise / no auth: the dummy exists only to render a themed page.
+ config.authentication_method = false
+ config.current_user_method = false
+ 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
diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb
new file mode 100644
index 0000000..e6277d6
--- /dev/null
+++ b/test/dummy/config/routes.rb
@@ -0,0 +1,4 @@
+Rails.application.routes.draw do
+ ActiveAdmin.routes(self)
+ root to: redirect("/admin")
+end
diff --git a/test/screenshot.rb b/test/screenshot.rb
new file mode 100644
index 0000000..ec5020b
--- /dev/null
+++ b/test/screenshot.rb
@@ -0,0 +1,20 @@
+# 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.
+require_relative "capybara_boot"
+require "capybara/dsl"
+
+include Capybara::DSL
+
+OUT = File.expand_path("../img/clock.png", __dir__)
+
+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)"