(modals). */ /** * ⭐ THE THREE TOOLTIPS JOINED ON 2026-08-21 — his catch, on the Light * scheme: "also the tooltip". They are ink surfaces by the same test as * everything else here — each paints `background: var(--ar-ink)` with its * text in --ar-surface — and they were the only ones left out, so a bubble * came up in the app's own near-black beside a button wearing the scheme's * colour. That was true on every scheme, not just his; the Light room is * simply where a near-black bubble on a grey page is impossible to miss. * ⚠️ The carets are CHILDREN (.ar-act-tip__caret, .ar-act-uatip__caret) and * paint from the same token, so they follow without being named. * ⛔ .ar__mark, .ar-wiz__welcome-mark--brand and .ar-about-snippet are ink * grounds too and stay OUT, each for its own written reason: the two marks * are chrome that must not change with the mode (schemes.css restates them), * and the snippet is dressed per-scheme by hand. */ const SCHEME_SCOPE = '.ar-btn,.ar-tags__chip,.ar-rail-card.ar-rail-card--readiness,.ar-srcpick__btn.is-on,.ar-act-tip,.ar-act-uatip,.ar-tip'; /** @var Settings */ private $settings; /** * @param Settings $settings Settings store. */ public function __construct( Settings $settings ) { $this->settings = $settings; } /** * Hook the menu, assets and the plugin-list shortcut. */ public function register() { add_action( 'admin_menu', array( $this, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'menu_icon_style' ) ); add_filter( 'plugin_action_links_' . plugin_basename( AGENTIMUS_FILE ), array( $this, 'action_links' ) ); add_action( 'admin_init', array( $this, 'maybe_activation_redirect' ) ); // Quick-access node in the toolbar (front-end + admin), with its icon. add_action( 'admin_bar_menu', array( $this, 'admin_bar_node' ), 80 ); add_action( 'wp_enqueue_scripts', array( $this, 'admin_bar_style' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'admin_bar_style' ) ); // Native admin-footer text + version, on our own screens ONLY (the // scoped WordPress convention WooCommerce and others follow — we never // touch the global admin footer or any unrelated screen). add_filter( 'admin_body_class', array( $this, 'scheme_body_class' ) ); add_filter( 'admin_footer_text', array( $this, 'admin_footer_text' ) ); add_filter( 'update_footer', array( $this, 'admin_footer_version' ), 15 ); // Keep our own screen clean: WordPress prints every other plugin's // admin_notices on every admin page. On the Agentimus screen ONLY, clear // those queues before they render (same scoped convention as above). add_action( 'in_admin_header', array( $this, 'suppress_foreign_notices' ), 1 ); } /** * On the Agentimus admin screen, remove all queued admin notices so other * plugins' banners don't intrude on our app-like UI. Runs on `in_admin_header`, * before WordPress prints the notices, and is a no-op on every other screen. * Agentimus registers no admin_notices of its own, so nothing of ours is lost. */ public function suppress_foreign_notices() { if ( ! function_exists( 'get_current_screen' ) ) { return; } $screen = get_current_screen(); if ( ! $screen || 'toplevel_page_' . self::SLUG !== $screen->id ) { return; } remove_all_actions( 'admin_notices' ); remove_all_actions( 'all_admin_notices' ); remove_all_actions( 'user_admin_notices' ); remove_all_actions( 'network_admin_notices' ); } /** * Register the top-level menu. */ public function menu() { add_menu_page( __( 'Agentimus', 'agentimus' ), // The menu title carries the review-queue count bubble, so the queue is // visible from every admin screen — not only when inside Agentimus. ReviewBadge::decorate( __( 'Agentimus', 'agentimus' ), $this->settings ), 'manage_options', self::SLUG, array( $this, 'render' ), $this->menu_icon_uri(), /** * Where the menu sits. Just above Posts — his call: this is a screen * an owner opens daily, and at 81 it sat below Settings where nobody * looks twice. * * ⚠️ A STRING, and fractional on purpose. Menu positions are array * keys: a plain integer that another plugin already used REPLACES it, * and the loser is whichever registered second — a silent way to * delete somebody else's menu. A fraction nobody else is likely to * pick avoids the collision entirely. * * @param string $position add_menu_page position. */ (string) apply_filters( 'agentimus_menu_position', '4.9127' ) ); } /** * The brand "A" monogram as a single-colour SVG data URI for the admin-menu * icon — the same mark as the in-app logo and the wp.org icon, in the line form * a menu icon needs. The stroke colour is a sensible idle fallback; menu_icon_style() * recolours it per state (idle grey -> white on hover/current) via a CSS mask. * * @return string */ private function menu_icon_uri() { // ⚠️ Stroke 2.1, not 1.7. Its neighbours in a real sidebar are SOLID // glyphs, and a hairline outline beside them has roughly a third of the // ink — it reads as disabled rather than as quiet, whatever colour it is // given (his catch, 2026-08-18, with FluentCart sitting above it). // // ⛔ The crossbar is NOT in here. This shape is masked, so it can only // ever be one colour; the gold bar is painted separately by // {@see menu_icon_style()} so the brand keeps one note of its own. $svg = ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * The brand's gold crossbar, painted over the masked mark. * * One colour that survives every scheme and every state, so the item is * findable at a glance without opting out of the sidebar's own language. * * @return string */ private function menu_bar_uri() { $svg = ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * A meta-box title wearing the brand tile. Delegate — {@see Brand::title()}. * * @param string $text The plain-text title (already translated). * @return string */ public static function brand_title( $text ) { return Brand::title( $text ); } /** * The Agentimus mark as a standalone SVG. Delegate — {@see Brand::icon()}. * * @param int $size Pixel size (square). * @param string $style Optional inline style for the root element. * @return string */ public static function brand_icon( $size = 16, $style = '' ) { return Brand::icon( $size, $style ); } /** * Recolour the SVG menu icon to match a native dashicon: masked by the "A", filled * with the menu's per-state icon colour (idle grey, white when hovered/current), * so it adapts across admin colour schemes instead of being a fixed-colour image. * * Attached to a src-less registered handle via wp_add_inline_style() — the menu is * present on every admin screen, so this runs on all of them, independent of the * plugin app enqueued in assets(). The mask URL is a static, plugin-generated SVG * data URI (no user input), so it is safe to inline verbatim. */ public function menu_icon_style() { $uri = $this->menu_icon_uri(); $sel = '#adminmenu #toplevel_page_' . self::SLUG; $handle = self::HANDLE . '-menu'; $bar = $this->menu_bar_uri(); // ⚠️⚠️ `currentColor`, NEVER a literal. This used to hardcode // rgba(240,246,252,.6) at rest and #fff on hover — two values invented // for the default scheme and applied to all nine. On a light admin scheme // a 60%-white icon is very nearly invisible, and on every scheme the // hover/current transition was ours rather than WordPress's. // // WordPress already sets `color` on this exact pseudo-element, per scheme // AND per state, for its own dashicons. Inheriting it means the mark // dims, brightens and recolours in step with every other icon in the // sidebar for free — which is what makes it look native rather than // switched off. ⛔ Do not "fix" this back to a colour value. $css = $sel . ' .wp-menu-image{background-image:none!important;position:relative}' . $sel . ' .wp-menu-image::before{content:"";position:absolute;inset:0;background-color:currentColor;-webkit-mask:url("' . $uri . '") center/21px no-repeat;mask:url("' . $uri . '") center/21px no-repeat}' // The gold bar rides on top, painted rather than masked, so it keeps // its colour while everything under it follows the scheme. . $sel . ' .wp-menu-image::after{content:"";position:absolute;inset:0;background:url("' . $bar . '") center/21px no-repeat}'; wp_register_style( $handle, false, array(), AGENTIMUS_VERSION ); wp_enqueue_style( $handle ); wp_add_inline_style( $handle, $css ); } /** * Quick-access node in the WordPress toolbar so Agentimus is one click away * from anywhere — the front-end bar and every other admin screen. * * Hidden on the plugin's own screen (you're already there) and shown only to * users who can actually open it. The child nodes deep-link into the SPA's * tabs, which route off the URL hash (see action_links()). * * Placed in the right-hand `top-secondary` group; at this hook priority (80) * it falls between the account node (priority 0) and the search node (9999), * so the float:right layout renders it immediately to the left of "Howdy". * * @param \WP_Admin_Bar $bar The toolbar being built. */ public function admin_bar_node( $bar ) { if ( ! current_user_can( 'manage_options' ) || $this->is_plugin_screen() ) { return; } $base = admin_url( 'admin.php?page=' . self::SLUG ); $bar->add_node( array( 'id' => self::SLUG, 'parent' => 'top-secondary', 'title' => '' . esc_html__( 'Agentimus', 'agentimus' ), 'href' => esc_url( $base ), 'meta' => array( 'title' => esc_attr__( 'Open Agentimus', 'agentimus' ) ), ) ); $tabs = array( 'dashboard' => __( 'Dashboard', 'agentimus' ), 'settings' => __( 'Settings', 'agentimus' ), 'readiness' => __( 'Readiness', 'agentimus' ), 'discovery' => __( 'Discovery', 'agentimus' ), ); foreach ( $tabs as $tab => $label ) { $bar->add_node( array( 'parent' => self::SLUG, 'id' => self::SLUG . '-' . $tab, 'title' => $label, 'href' => esc_url( $base . '#' . $tab ), ) ); } } /** * Whether the current request is the Agentimus admin screen — used to hide * the toolbar shortcut when it would just point at the page you're on. * * @return bool */ private function is_plugin_screen() { if ( ! is_admin() ) { return false; } if ( function_exists( 'get_current_screen' ) ) { $screen = get_current_screen(); if ( $screen && 'toplevel_page_' . self::SLUG === $screen->id ) { return true; } } // Fallback for hooks that fire before the screen object is set. return isset( $_GET['page'] ) && self::SLUG === sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, no state change. } /** * Left admin-footer text on our own screens: a polite, optional rating * request. Off our screens we return WordPress's default text untouched, so * the global admin experience is never altered (a wp.org guideline). The * name and the star link are pre-escaped; the translatable string carries * only placeholders. * * @param string $text Default footer text. * @return string */ public function admin_footer_text( $text ) { if ( ! $this->is_plugin_screen() ) { return $text; } // Once the Dashboard's review ask has been answered for good, this line // stops asking too — "never asks again" holds everywhere, and someone // who already reviewed isn't asked on every screen forever. Core's // default footer text stands in. if ( Review::closed() ) { return $text; } $name = '' . esc_html__( 'Agentimus', 'agentimus' ) . ''; $stars = sprintf( '★★★★★', esc_url( 'https://wordpress.org/support/plugin/agentimus/reviews/?rate=5#new-post' ), esc_attr__( 'Rate Agentimus five stars on WordPress.org (opens in a new tab)', 'agentimus' ) ); return sprintf( /* translators: 1: plugin name (bold), 2: five-star rating link. */ __( 'If you like %1$s please give it a %2$s rating. A huge thanks in advance!', 'agentimus' ), $name, $stars ); } /** * Right admin-footer text on our own screens: the plugin version alongside the * running WordPress version (priority 15 so it wins over core's WP-version line). * Off our screens core's default version/update text is left intact. * * @param string $text Default upgrade/version text. * @return string */ public function admin_footer_version( $text ) { if ( ! $this->is_plugin_screen() ) { return $text; } // The "|" is a decorative, letterpress-styled separator (see .agentimus-footer-sep // in app.css); aria-hidden so screen readers skip the glyph. The version values are // escaped; the span is our own static markup, and update_footer output is not // auto-escaped by core (it renders HTML, as the star-rating link above does). $sep = ''; return sprintf( /* translators: 1: Agentimus plugin version, 2: separator glyph, 3: WordPress core version. */ esc_html__( 'Agentimus - %1$s %2$s WordPress - %3$s', 'agentimus' ), esc_html( AGENTIMUS_VERSION ), $sep, esc_html( get_bloginfo( 'version' ) ) ); } /** * Style the toolbar node's brand monogram. Reuses the menu icon's SVG as a * CSS mask filled with `currentColor`, so the "A" tracks the toolbar's own * text colour in every admin scheme and on the front-end bar alike. Enqueued * on both front and admin because the toolbar shows in both. */ public function admin_bar_style() { if ( ! is_admin_bar_showing() || ! current_user_can( 'manage_options' ) ) { return; } $uri = $this->menu_icon_uri(); $sel = '#wpadminbar #wp-admin-bar-' . self::SLUG; $handle = self::HANDLE . '-adminbar'; $css = $sel . ' .ab-icon::before{content:"";display:inline-block;width:16px;height:16px;' . 'margin:0 2px 0 0;vertical-align:middle;background-color:currentColor;' . '-webkit-mask:url("' . $uri . '") center/contain no-repeat;' . 'mask:url("' . $uri . '") center/contain no-repeat}'; wp_register_style( $handle, false, array(), AGENTIMUS_VERSION ); wp_enqueue_style( $handle ); wp_add_inline_style( $handle, $css ); } /** * "Settings" link on the Plugins screen. * * @param array $links Existing links. * @return array */ public function action_links( $links ) { // Deep-link to the Settings tab: the SPA reads the URL hash on load, so // "#settings" lands there instead of the default Dashboard. $url = admin_url( 'admin.php?page=' . self::SLUG ) . '#settings'; array_unshift( $links, '' . esc_html__( 'Settings', 'agentimus' ) . '' ); return $links; } /** * One-time redirect to the plugin screen right after a fresh activation, so a * non-technical admin lands on the setup wizard instead of the Plugins list. * Guarded against AJAX, network admin and bulk activation; the transient is * one-shot. */ public function maybe_activation_redirect() { if ( ! get_transient( 'agentimus_activation_redirect' ) ) { return; } delete_transient( 'agentimus_activation_redirect' ); // Never hijack a bulk activation or a network-admin context. if ( wp_doing_ajax() || is_network_admin() || isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading WordPress's own bulk-activation marker, no state change. return; } if ( ! current_user_can( 'manage_options' ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG ) ); exit; } /** * Whether the setup wizard is done — or shouldn't appear because the install * is clearly already configured. The explicit flag is set when the wizard is * finished or skipped (and on activation for pre-existing installs). The * heuristic additionally suppresses the wizard for installs that updated via * wordpress.org without re-running the activation hook: any sign of prior * configuration — a profile sentence, or a content selection that differs * from the fresh-install default — counts as onboarded. * * @return bool */ private function is_onboarded() { if ( false !== get_option( 'agentimus_onboarded', false ) ) { return true; } if ( '' !== trim( (string) $this->settings->identity( 'about', '' ) ) ) { return true; } $selected = (array) $this->settings->get( 'post_types', array() ); $default = Settings::default_post_types(); sort( $selected ); sort( $default ); return $selected !== $default; } /** * Enqueue the built Vue app on our screen only. * * @param string $hook Current admin page hook. */ public function assets( $hook ) { if ( 'toplevel_page_' . self::SLUG !== $hook ) { return; } // One plugin-screen visit, for the review ask's "has it earned it yet" // gates (a week present, 3+ visits, 1+ real action) — see Review::eligible(). Review::touch(); // The media modal, for the "Default share image" picker in Search basics — // our screen only (the gate above), so no other admin page pays for it. wp_enqueue_media(); $js = AGENTIMUS_DIR . 'assets/admin/app.js'; $css = AGENTIMUS_DIR . 'assets/admin/app.css'; if ( is_readable( $js ) ) { wp_enqueue_script( self::HANDLE, AGENTIMUS_URL . 'assets/admin/app.js', array(), $this->asset_version( $js ), true ); wp_localize_script( self::HANDLE, 'AgentimusData', $this->bootstrap_data() ); } if ( is_readable( $css ) ) { wp_enqueue_style( self::HANDLE, AGENTIMUS_URL . 'assets/admin/app.css', array(), $this->asset_version( $css ) ); // Gold, underline-free stars for the admin-footer rating link (the // footer sits outside the Vue app, so it needs a global selector). wp_add_inline_style( self::HANDLE, '.agentimus-rating-link{color:#e0a32e;text-decoration:none}.agentimus-rating-link:hover,.agentimus-rating-link:focus{color:#c8881d}' ); $scheme_css = $this->scheme_css(); if ( '' !== $scheme_css ) { wp_add_inline_style( self::HANDLE, $scheme_css ); } } } /** * Inline CSS giving the app's dark surfaces the admin colour scheme's * flavour — the score card, the buttons and the editable chips (see * SCHEME_SCOPE), nothing else. Those all paint with var(--ar-ink), so * re-declaring that token ON JUST THOSE SELECTORS re-inks them while the * rest of the app (headings, toggles, links, the teal accent, the * green/amber status tones) keeps the designed palette. Ink values come * from the curated SCHEME_INKS map for the core schemes and the * {@see scheme_ink()} derivation for third-party ones. * * ⭐ THE STOCK "fresh" SCHEME LENDS BUT DOES NOT ADOPT (his call 2026-08-21): * it emits an ink like any other scheme — #262d31, the designed night card — * and nothing else, because the designed palette IS the fresh look and has * nothing to take from it. See the $accent gate below. * * @return string CSS custom-property rule, or '' (no scheme / opted out). */ /** * Stamp the active colour scheme's OWN surface colour onto
, as * `agentimus-scheme-363b3f`. * * A dark dialect in schemes.css can then key off the colour WordPress * actually serves instead of off a slug that outlives its palette. The * owner has both Midnights in front of him: heera.it's older WordPress * registers #363b3f, WP 7.1 registers #333c42, and the right dark for one * is the wrong dark for the other. * * A dialect that no longer matches simply stops applying and the base dark * takes over — which is the correct outcome for an unknown palette, and * means a future retune degrades quietly instead of painting a night that * was mixed for a colour nobody is wearing any more. * * @param string $classes Space-separated classes from core. * @return string */ public function scheme_body_class( $classes ) { $hex = SchemeInk::active_surface(); return '' === $hex ? $classes : trim( $classes . ' agentimus-scheme-' . substr( $hex, 1 ) ); } private function scheme_css() { /** * Filter whether the app adopts the admin colour scheme. * * @param bool $match Default true. */ if ( ! apply_filters( 'agentimus_match_admin_scheme', true ) ) { return ''; } $scheme = get_user_option( 'admin_color' ); if ( ! is_string( $scheme ) ) { return ''; } // ⚠️ colors[0] — the FIRST swatch, used purely as a stable key. Verified // against WordPress's own colors.scss: the menu is colors[1] for six core // schemes, colors[2] for classic blue and colors[0] for light and modern, // so there is no slot that always holds the menu. colors[0] is the slot // that is stable and unique per scheme-and-generation. // ⛔ Not active_surface(): that answers "which colour is this scheme // wearing" for the body stamp, and on classic blue it returns #4796b3, // which is neither the menu nor a key here. global $_wp_admin_css_colors; $base = isset( $_wp_admin_css_colors[ $scheme ]->colors[0] ) ? (string) $_wp_admin_css_colors[ $scheme ]->colors[0] : ''; $ink = self::card_ink_for( $scheme, $base ); if ( '' === $ink ) { return ''; } // ⭐⭐ colors[2] — WORDPRESS'S OWN HIGHLIGHT, and his idea, 2026-08-20. He // pointed at the admin menu's CURRENT item — the one WordPress paints to // say "you are here" — and asked for that colour in the app's links, tab // underlines, numbers and everywhere a green used to be. It is the right // source: every other value here is derived, and this one is the scheme's // author saying which colour means "this one". // // ⚠️ Only where it can carry TEXT on the page. Seven of the nine core // schemes clear 4.5:1 on the cream card — ectoplasm 5.50, fresh 5.08, // coffee 4.88, ocean 4.83, light 4.82, sunrise 4.51, blue 4.50 — and two // do not: midnight's #69a8bb is 2.61:1 and modern's #7b90ff 2.84:1, both // mixed to sit on a DARK menu, not on paper. Those keep the derived ink, // which is what they have today, so the fallback is not a downgrade. // ⛔ Not on a bright-menu scheme. There the accent is the menu colour by // his earlier call, and a third colour would undo it. $highlight = isset( $_wp_admin_css_colors[ $scheme ]->colors[2] ) ? (string) $_wp_admin_css_colors[ $scheme ]->colors[2] : ''; $light = ':root:not([data-ar-theme="dark"]) '; $scope = $light . implode( ',' . $light, explode( ',', self::SCHEME_SCOPE ) ); // ⛔ #e8e8e8 AND NOT #ffffff, for two reasons that are really one. The // score block is a button — the gauge and the band word jump to the // readiness report — and app.css announces that with // `filter: brightness(1.08)`. brightness() MULTIPLIES, so on pure white it // is a no-op: 255 x 1.08 clips back to 255 and the whole row reacts to the // pointer by doing nothing. His catch. // ⭐ #e8e8e8 lifts to #fbfbfb — a 1.18:1 change, which reads — and is the // calmer face he asked for in the same breath. A multiplicative filter // needs headroom, and a value at the end of its range has none. $face = '#e8e8e8'; // ⚠️ AND --rail-read:100%. The rungs are drawn as --rail-face behind a 74% // veil, and that veil was HIERARCHY while the kicker and the band word // were a green: two colours, one of them quieter. Once those went to // the same face, the veil stopped separating anything and just made // the rung rows look dimmed beside every other line on the card — his // catch. With one face for the card, the rungs read at full strength. // ⛔ AND THE HOVER NEEDS SOMEWHERE TO GO. app.css lifts a rung on hover by // dropping its veil — `color: var(--rail-face)` — which only reads while // the resting state IS veiled. --rail-read:100% makes the resting state // already --rail-face, so that rule resolves to the colour it started // from and the row stops answering the pointer. A value at the end of // its range has no headroom left — the same lesson as the pure-white // face. ⭐ So the lift goes ABOVE the face, to white. // ⭐ AND THE SCORE BLOCK WITH THEM — his call, 2026-08-20: "make sure // rails each line use same bright hover color including the Excellent // line". That row lifted by `filter: brightness(1.08)` instead, which // took #e8e8e8 to #fbfbfb — a different white from the rungs' #ffffff, // on the same card, under the same pointer. Naming the colour makes // every line answer identically; the filter still runs underneath and // is a no-op on a value already at 255. // ⛔ The gold is deliberately NOT in here. "N to fix" is the one thing on // the card that means something, and it stays its own colour whether or // not the row is under the pointer — his words: it "should remain // bright". // ⚠️ (0,6,0) — app.css's own hover rule is (0,5,0). $rug_hover = $light . '.ar-rail-card--readiness' . ' .ar-rung__btn:not(.ar-rung__btn--static):hover :is(.ar-rung__name,.ar-rung__count),' . $light . '.ar-rail-card--readiness' . ' .ar-rail-readiness--link:hover :is(.ar-rail-tier__name,.ar-rail-gauge__num)'; // ⭐ HIS PATTERN, 2026-08-20, after watching me chase a card border, then a // chart bar, then a picker, one at a time: they all read --ar-accent. // Re-key the TOKEN and every one of them follows — 234 references in // app.css, plus --ar-accent-wash and --ar-accent-soft, which are mixed // from it. Stop patching elements. // // ⚠️ LIGHT MODE ONLY, and that is what makes it safe. In light, // --ar-good / --ar-warn / --ar-bad carry their OWN values (#2f7a4c, // #ad7b18, #b93c2b), so success stays green and a warning stays gold. // At night they are ALIASED to --ar-accent, and doing this there would // turn every PASS badge and every ring the scheme's colour. // // ⚠️ THE INK on a dark menu, THE SURFACE on a bright one — $accent_hue // below. His call, 2026-08-20, and it replaced mine: I had both blues // taking the deep ink so the accent would read as text, and the result // was a bright button beside a dark link with nothing to explain the // difference. One colour per scheme, on the surfaces AND the accent, // is what he asked for: "same as classic blue theme in light mode". // ⚠️ The number, on the table and overruled twice: #52accc is 2.54:1 // on the cream card, under the 4.5 floor for text and the 3 for a // graphic. What it buys is one colour where there were two. // ⚠️ Declared at :root, not on the app. --ar-accent-wash and // --ar-accent-soft are color-mixes of --ar-accent written in the :root // block, so they resolve against :root's value: overriding the accent // further down left the washes teal while everything else went blue. // Re-keying at the same level fixes all three in one declaration. // ⚠️ AND the two washes, explicitly. In light mode app.css writes them as // hand-picked teal LITERALS (#f0f6f4 / #e3efec), not as mixes of the // accent — only the dark blocks derive them — so re-keying the accent // alone left every wash teal while its own colour went blue. Same 12% // and 20% formulas the dark blocks use, against whatever surface is in // force. // The colour every accent-driven thing takes: WordPress's own highlight // where that reads on paper, and the ink otherwise — see $highlight above. // ⚠️ THERE USED TO BE A THIRD CASE HERE, the "bright menu" surface, and it // is gone with the branch that consumed it (2026-08-21). See the note on // the return below. $accent_hue = SchemeInk::carries_text( $highlight ) ? $highlight : $ink; // ⭐⭐ THE GREEN ITSELF, not a list of the things wearing it. His call, // 2026-08-20: "just apply this color where a green color was before, // including All registrations are valid text." // // This replaces a $verdict block that named six selectors — the rule // beside a check, the group rung and its count, the totals dot and its // number, the rail's registration card — and still missed the up-delta // arrows, the live dot and the PASS pills, which he could see and I // could not until he pointed. Every one of them paints --ar-good. Re-key // the token and the list stops existing. His own law, from earlier the // same day: stop patching elements. // // ⛔ ONLY WHERE THE COLOUR CAN CARRY THE MEANING — verdict_can_take() is // unchanged and is what makes this safe. Sunrise's highlight is hue 29, // eleven degrees from --ar-warn; light's is hue 20; coffee's 27. On // those a passing check would paint as a warning, so they keep the // designed green. Ocean's #567958 is hue 123 and 17% saturated: a green // that means what green means. // ⚠️ AND THE TWO MIXES WITH IT. --ar-good-wash and --ar-good-tint are the // pill's ground and border; left behind they would frame the new colour // in the old one. Same 10% / 26% formulas the dark block uses. // ⛔ --ar-good-strong and --ar-good-deep are deliberately NOT re-keyed: // they are emphasis steps picked by hand against the designed green, // and deriving them here would be inventing two values to fix a problem // nobody has reported. Worth his eye if a screen ever shows all four. $good = SchemeInk::verdict_can_take( $accent_hue ) ? '--ar-good:' . $accent_hue . ';' . '--ar-good-wash:color-mix(in srgb,' . $accent_hue . ' 10%,var(--ar-surface));' . '--ar-good-tint:color-mix(in srgb,' . $accent_hue . ' 26%,var(--ar-surface));' : ''; // ⛔ AND NOTHING IS RE-KEYED IF IT CANNOT CARRY TEXT. His catch 2026-08-20 // on the Light scheme: its highlight (#c64606) misses the floor, so the // line above fell back to $ink — and on THIS scheme the ink is a near // white. The app was emitting --ar-accent:#e5e5e5, an accent nothing // could read, on links, tab underlines, numbers and chart bars. // ⭐ The fallback is to emit nothing at all, which leaves app.css's own // #146b64 standing — his value for this scheme, and the designed teal // every unmeasured scheme already gets. A guard, not a special case: // any future scheme whose ink is bright lands the same way. // ⚠️ $good keeps its own gate (verdict_can_take) and is inside this // string, so it falls with it — correct, since a verdict mark nothing // can read is worse than a green one. // ⛔ AND NOT ON A SCHEME WHOSE INK IS BRIGHT — the Light scheme, which // schemes.css dresses by hand and which already exempts itself from the // hover re-key below for the same reason. Its highlight is #c64606, a // strong orange that DOES carry text, so the rule above would have // keyed the whole app to it: links, tab underlines, numbers, chart // bars. His call 2026-08-20: this scheme wears the designed teal // #146b64 in both modes, the value its night block already restates, // and the one thing its mode changes is the rug. // ⛔ AND NOTHING IS RE-KEYED IF IT IS A GREY. His catch 2026-08-21, on the // DEFAULT scheme — the one most people never change. `modern` registers // #1e1e1e; its highlight (#7b90ff) misses the text floor, so the line // above fell back to $ink, and the app emitted --ar-accent:#1e1e1e. The // traffic chart's bars, the links, the tab underlines, the numbers and // the readiness ring all went BLACK, and the app lost its colour on the // scheme that ships switched on. // ⚠️ The guard beside this one could not catch it, and the reason is worth // keeping: carries_text() asks whether a colour can be READ, and a // near-black on cream reads at 18:1. It passed the test PRECISELY // because it has no colour in it. Readable and coloured are two // questions, and the accent needs both answered yes. // ⭐ Emitting nothing leaves app.css's designed teal standing — the same // fallback the Light scheme takes, and the right one here: a scheme // whose own colour is a grey has no flavour to lend, so it lends none. // ⚠️ This also takes MIDNIGHT back to the teal (its ink is 12.6% // saturated, under the floor his ocean tuning set). Both generations of // it, by the same reasoning. // ⛔ AND NOT ON FRESH, WHICH LENDS BUT DOES NOT ADOPT — his call 2026-08-21, // when he gave that scheme a light ink (#262d31) and nothing else. // ⭐ It is not a special case, it is the base case: the teal, the green // and the cream were all mixed ON fresh's charcoal. A scheme has // nothing to adopt from the palette that was designed against it, so // fresh lends its slabs a depth and takes no colour back. // ⚠️ WITHOUT THIS the app would go WordPress blue on the default-until- // 7.1 scheme: fresh's highlight #2271b1 clears the text floor at // 5.08:1 AND carries colour, so it would key --ar-accent, --ar-good // and both washes, and $rug_white would follow it. Measured, not // assumed. He asked for an ink; this keeps the ask to an ink. // ⛔ Keyed by SLUG on purpose, against this file's usual rule. Every // other lookup here asks "what colour is this scheme", which slugs // go stale about; this one asks "is this the scheme the palette was // drawn on", which is an identity and cannot be retuned away. // ⛔⛔ AND NOT ON "light" EITHER, BY NAME — and this replaced a booby trap. // That scheme was held out by `! is_bright( $ink )` alone, which was // true only while its ink happened to sit above 0.42. On 2026-08-21 he // asked for #656363 (0.3922) and the guard would have silently opened: // its highlight is #c64606, a strong orange that DOES carry text, so // the whole app would have keyed to it the moment the ink went a shade // deeper. ⭐ A brightness number was never the right question. "This is // the scheme schemes.css dresses by hand, in both modes" is an // IDENTITY — the same reasoning as fresh's above — so it is stated as // one and the ink is free to be any depth he likes. // ⚠️ is_bright() stays in the condition: it still guards any FUTURE // scheme whose ink is too bright to carry these, which is a different // question from this one. $accent = ( 'fresh' !== $scheme && 'light' !== $scheme && SchemeInk::carries_text( $accent_hue ) && SchemeInk::carries_colour( $accent_hue ) && ! self::is_bright( $ink ) ) ? ':root:not([data-ar-theme="dark"]){' . $good . '--ar-accent:' . $accent_hue . ';' . '--ar-accent-wash:color-mix(in srgb,' . $accent_hue . ' 12%,var(--ar-surface));' . '--ar-accent-soft:color-mix(in srgb,' . $accent_hue . ' 20%,var(--ar-surface))' . '}' : ''; // ⭐ THE RUG WEARS WHITE ON EVERY SCHEME-COLOURED GROUND — his call, // 2026-08-20, pointing at the classic ocean's rug: "apply this straight, // use same color on lines and same hover effect." // // This landed in three steps and the middle one was mine to get wrong. // It began as SCHEME_RAIL_WHITE, a named list holding both blues. I read // his next screenshot as "make the classic ones green" and deleted the // list; he meant the opposite — that the treatment the brighter rooms // had should reach the classic ones too. So: no list, no branch, no // ground test. A rug that has taken a scheme's colour wears white. // ⛔ The ONE rug that keeps the green is the default's, because "fresh" // returns before any of this and never reaches a re-key at all. // // ⚠️ --rail-read:100% IS THE HALF THAT IS EASY TO MISS. A rug's rung names // are drawn as --rail-face behind a 76% veil, mixed for a DARK card. // Painting the face white and leaving the veil on composites it back // down: on ocean's brighter ground the names came out at 2.84:1 while // the band word beside them sat at full strength — one colour at two // brightnesses, which reads as "dimmed" rather than as hierarchy. At // 100% the whole card speaks once. His words: "use same color on lines". // // ⛔ #e8e8e8 AND NOT #ffffff, for the reason he caught on blue: the score // block announces itself with `filter: brightness(1.08)`, which // MULTIPLIES, so on pure white it is a no-op and the row stops answering // the pointer. #e8e8e8 lifts to #fbfbfb and still reads as white. // // ⚠️ What it measures. On the dark inks it is comfortable — 8.2:1 and up. // On a bright surface it is not: 2.87:1 on sunrise's #cf4944, where the // green it replaces could not reach a floor either. Raised with him with // the numbers; his call. (Ocean's #738e96 and blue's #52accc measured // 3.48 and 2.58 here before 2026-08-20 retired both.) // ⛔ AND NONE OF IT ON A BRIGHT INK — his catch 2026-08-20, hovering // "Findable" on the Light scheme: the row went WHITE and vanished. Both // halves above are drawn for a rug that is DARK. The face re-key is // already outranked by that scheme's own block, but the hover is not — // it names elements app.css alone would have handled, and #ffffff on a // #ebeaea slab is 1.1:1. // ⭐ Emitting nothing restores app.css's own rung hover, `color: // var(--rail-face)`, which drops the 82% veil to the full #1e1e1e — // the same "come forward" gesture, correct on a light ground because // the face it lifts to is dark. The score block above it already // deepens by filter for the same reason. // ⛔ AND THE RUG FOLLOWS THE ACCENT, not the brightness of the ink. His // note above says it plainly — "a rug that has taken a SCHEME'S COLOUR // wears white" — and the test for that is whether the scheme lent one. // On the Default scheme it did not (grey, see $accent), yet this fired // anyway and washed the ring, the band word and the rungs to #e8e8e8: // a card with its green taken away and nothing put in its place. The // exemption he wrote for "fresh" — "the ONE rug that keeps the green is // the default's" — was keyed to a slug, and WordPress 7.1 moved the // default to `modern`, which walks straight past that early return. // ⭐ Keyed to the accent instead, it holds for whichever scheme is the // default, this generation or the next. ⚠️ It subsumes the is_bright() // test it replaces: a bright ink is the Light scheme, whose $accent is // already empty for its own reason, so that rug is untouched as before. $rug_white = '' === $accent ? '' : $light . '.ar-rail-card.ar-rail-card--readiness' . '{--rail-face:' . $face . ';--rail-good:' . $face . ';--rail-read:100%}' . $rug_hover . '{color:#ffffff}'; // ⛔ THERE IS NO SECOND BRANCH ANY MORE. Until 2026-08-21 this returned // early and ~60 lines below dressed a "bright menu" scheme in its own menu // colour instead of a dark ink. That path died on 2026-08-20 when he // collapsed blue, ocean and sunrise onto their 7.1 bars and SCHEME_SURFACES // emptied — `card_surface_for()` could only ever answer '', so the test // guarding the branch was always true and the code under it unreachable. // It stood for a day with a comment saying so; this is that deletion. // ⚠️ WHAT IT COST, so nobody has to reconstruct it from git: on a bright // menu the ink surfaces AND the accent both took the menu's own colour // (his call: "same as classic blue theme in light mode"), the rug's // "N to fix" took a #f8c350 gold that survives a mid-tone ground, the // GHOST button kept the dark ink because it paints on the PAGE, and the // button hover DEEPENED instead of lifting. ⛔ Restoring any of that means // restoring the branch too — adding a row to SCHEME_SURFACES alone would // now do nothing, which is exactly why that table went with it. $css = $scope . '{--ar-ink:' . $ink . '}' . $accent; // ⛔ …but NOT app.css's hover. It dives to --ar-ink-hard, which is // #000 by day, so a navy or brown button turns BLACK under the // pointer and throws its hue away — his catch on the dark blue: // "changes button to black, which is also not in sync". // ⭐ A dark surface LIGHTENS instead. 88% toward white is a 1.35–1.46:1 // shift across every ink we emit — the same gesture, read the same way. // ⚠️ Skipped when the ink is itself bright: that is the Light scheme, // which schemes.css already dresses by hand, and this would // override it. // ⚠️ AND NOT FOR "light", whose own day hover DEEPENS (schemes.css) — // emitting a lighten here would be a rule that only ever loses. if ( 'light' !== $scheme && ! self::is_bright( $ink ) ) { $css .= $light . '.ar-btn:not(.ar-btn--ghost):not(.ar-btn--danger):hover:not(:disabled)' . '{background:color-mix(in srgb,' . $ink . ' 88%,#fff);box-shadow:none}'; } return $css . $rug_white; } /** * Is a colour light enough that a hover should DEEPEN it rather than lift it? * * @param string $hex Colour. * @return bool */ private static function is_bright( $hex ) { $hex = ltrim( strtolower( trim( (string) $hex ) ), '#' ); if ( ! preg_match( '/\A[0-9a-f]{6}\z/', $hex ) ) { return false; } $rgb = array_map( 'hexdec', str_split( $hex, 2 ) ); return ( ( max( $rgb ) + min( $rgb ) ) / 2 / 255 ) > SchemeInk::BRIGHT_MENU_L; } /** * The card surface for a scheme. Delegate — {@see SchemeInk::card_ink_for()}. * * @param string $scheme Scheme slug from the user's profile, e.g. "coffee". * @param string $base The scheme's registered base colour (colors[0]). * @return string Card-depth hex, or '' (default scheme / nothing usable). */ public static function card_ink_for( $scheme, $base ) { return SchemeInk::card_ink_for( $scheme, $base ); } /** * Turn a scheme colour into a card-surface tint. Delegate — {@see SchemeInk::scheme_ink()}. * * @param string $hex Scheme colour, e.g. "#096484" (also accepts #abc). * @return string Card-depth 6-digit hex, or '' when $hex isn't parseable. */ public static function scheme_ink( $hex ) { return SchemeInk::scheme_ink( $hex ); } /** * Data handed to the Vue app at boot. * * @return array */ /** * The AEO/GEO score, computed fail-open. It samples posts (HTML parsing) and reads * the visibility store — more moving parts than the rest of the boot payload — so a * throw here must degrade to "no score card" (null), never take down the whole admin * screen. The rail's card is guarded on this value, so null just hides it. * * @param array' . esc_html__( 'The plugin’s admin screens are missing from this install — usually a broken or partial upload. Re-install the plugin from a fresh zip. (Developers running from source: npm install && npm run build.)', 'agentimus' ) . '