Files
kristianwindandClaude Opus 5 8143d146e4 1.6.1: shared-menu 1.2.3, and render the dashboard to check the pill
The status pill on the shared dashboard is white 11px text over a light
overlay. SEO measured it two ways, arithmetic and a canvas read-back in
headless Chromium: this plugin's #1a7a52 gave 3.50:1 against a 4.5 minimum,
and six of eight family colours failed along with the no-colour fallback.

Not fixable by choosing a different card colour. The header is a direct pair
and passes at 5.32:1; the pill is a COMPOSITE, and 22% white is a floor — a
card has to reach about 7.7:1 before the pill clears 4.5, which is above the
whole family band. So it is the shared file or seven plugins giving up the
colour the cards exist to carry. 1.2.3 makes the overlay rgba(0,0,0,.18) and
this plugin's pill goes to 7.15:1.

Verified before copying: the hash, and php -w over both files to confirm the
executable code really changed — the diff is the version constant and that one
CSS value, nothing else.

The new assertion renders the dashboard and reads the pill out of what came
back, and it is built around the trap SEO warned about: their first harness
printed "no problem" over a code path it never entered, because it stubbed
wpmgr_cards instead of wpmgr_dashboard_cards and the renderer took its early
return. So the card's own title is asserted first — if the renderer bailed,
nothing after it means anything — and the OLD rgba is asserted ABSENT, because
a check that cannot tell the two versions apart cannot testify about either.

Both proven: against 471b0a2a it fails naming "the old 22% white", and with the
card filter removed it fails at 81 bytes rendered. That 81 is the same number
SEO reported from their own broken harness, which is independent confirmation
it is the same code path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 17:21:44 +02:00

413 lines
17 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* The one admin menu the manager plugins share.
*
* ┌──────────────────────────────────────────────────────────────────────────┐
* │ THIS FILE IS BYTE-IDENTICAL IN EVERY MANAGER PLUGIN. │
* │ Do not edit it to add something for one plugin. Extend it through the │
* │ `wpmgr_dashboard_cards` filter, which is what it is for. │
* │ Contract version 1.1.0. See "Why it is frozen" below — and note that │
* │ the two changes it has had were both paid for in load-order confusion. │
* └──────────────────────────────────────────────────────────────────────────┘
*
* The problem it solves: a family of plugins each adding a top-level menu is
* a handful of entries in a sidebar that already has fifteen. So they share
* one parent and each adds a submenu under it. Six of them as of this
* writing, which is why the count is not stated anywhere it would have to be
* maintained.
*
* Three things about how it does that are load-bearing, and none is obvious.
*
* **1. Every declaration is guarded.** Each plugin `require_once`s its own
* copy of this file, in one request. `require_once` only deduplicates by
* resolved path, and these are different paths — so without the
* `function_exists()` guard, activating the second plugin is
* `Fatal error: Cannot redeclare wpmgr_ensure_menu()`. A white screen on a
* shop, caused by nothing except owning two of these plugins. It is the same
* shape as the update-hook collision: a fault that exists only when there is
* more than one, which means no repository's own tests can find it.
*
* **2. Whichever copy loads first wins, and that order is arbitrary** — it is
* plugin activation order, effectively alphabetical by directory. PHP cannot
* un-declare a function, so there is no honest way for a newer copy to replace
* an older one that has already been loaded. The only stable answer is that
* the file never changes. Hence: **frozen**. The parent slug, the capability,
* the position and the filter name are a contract between every repository in
* the family, and anything new arrives through the filter.
*
* 🔴 It has moved twice, and both times the cost was the same: a half-adopted
* change means the menu is whatever the alphabetically-first plugin's copy
* says, and every repository's own hash check goes green on its own side
* while the family disagrees. Nolimit SEO measured exactly that today — two
* plugins on 1.1.0 and three on 1.0.0, so a shop got the infinity glyph or
* the old cog depending on activation order. If this file has to change,
* every plugin takes it in the same hour or the change is not made.
*
* **3. The dashboard is built from a filter, not owned by a plugin.** If the
* plugin that happened to register the menu is deactivated, the menu is
* re-registered by the next one on the following request and the page still
* renders — because the page's content comes from whoever is installed, not
* from whoever created it. Registering a callback that lives in one plugin
* would mean deactivating that plugin takes the other four's screens with it.
*
* Each plugin calls `wpmgr_ensure_menu()` at the top of its own `admin_menu`
* callback, before its own `add_submenu_page( 'wpmgr', … )`. Not on some other
* hook: `add_submenu_page` against a parent that does not exist yet silently
* fails, and whose `admin_menu` runs first is, again, arbitrary.
*
* The package below is the family, not any one plugin: this file is
* byte-identical in five repositories, and a tag naming whichever of them it
* was first written in reads as a mistake in the other four — which invites
* somebody to "fix" it, in one repository, which is the exact edit the freeze
* exists to prevent.
*
* @package WordPress_Manager_Family
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
define( 'WPMGR_MENU_VERSION', '1.2.3' );
}
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
define( 'WPMGR_MENU_SLUG', 'wpmgr' );
}
if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
/**
* Register the shared parent menu, unless somebody already has.
*
* Call this first thing in your own `admin_menu` callback.
*/
function wpmgr_ensure_menu() {
global $admin_page_hooks;
if ( isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ) ) {
return;
}
/**
* The label on the shared parent menu.
*
* Left untranslated here on purpose: this file is byte-identical in
* five plugins, so it cannot carry any one of their text domains, and
* a string with the wrong domain is a string that is silently never
* translated. Any plugin may translate it in its own domain through
* this filter.
*
* @param string $title The menu title.
*/
$title = apply_filters( 'wpmgr_menu_title', 'Manager' );
/**
* The glyph on the shared parent menu.
*
* An infinity sign, because the family is called Nolimit. A cog said
* nothing, and there is no infinity dashicon.
*
* Drawn as TEXT rather than as an SVG passed to add_menu_page(), and
* the reason is colour. WordPress renders a data-URI SVG as a CSS
* background-image, and a background-image cannot inherit a colour —
* so it stays one fixed grey while every other icon lightens on hover
* and turns white on the current item. It looks subtly broken under
* every admin colour scheme, including the default. A `::before`
* glyph is text: it takes the colour it is given and follows all of
* those states for free, with no image request.
*
* Filterable so that changing it later needs no edit to this file.
* This file is byte-identical across the family and hash-frozen in
* each plugin's CI, so an edit here is a coordinated release for all
* of them; a filter is one line in one plugin.
*
* @param string $glyph A single character, or '' for the old cog.
*/
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
add_menu_page(
$title,
$title,
'manage_options',
WPMGR_MENU_SLUG,
'wpmgr_render_dashboard',
// 'none' makes WordPress emit the icon wrapper with no dashicon
// class on it, which is the hook the style below needs. A dashicon
// name here would win instead, because its class carries its own
// ::before content.
'' === $glyph ? 'dashicons-admin-generic' : 'none',
// Just below WooCommerce (55.x) and above Appearance (60). The
// fractional position is not decoration: an integer collides with
// whatever core or another plugin put there, and the loser is
// simply dropped.
58.9
);
if ( '' !== $glyph ) {
add_action( 'admin_head', 'wpmgr_menu_glyph_style' );
}
}
/**
* The one style rule the glyph needs.
*
* Printed in the head on every admin page, because the menu is on every
* admin page — a stylesheet enqueued only on this family's own screens
* would leave a blank square everywhere else.
*
* `font-family` has to be overridden: WordPress sets the icon wrapper to
* the Dashicons font, and Dashicons has no infinity glyph, so inheriting
* it renders nothing at all.
*
* 🔴 The box is WordPress's own, measured rather than guessed, and the
* glyph is centred in it geometrically rather than nudged.
*
* wp-admin/css/admin-menu.css sets `div.wp-menu-image:before` to
* `padding: 7px 0` — exactly 7 + 20 + 7 for a 20px dashicon on
* `line-height: 1` in a 34px box. The first version of this rule set
* `line-height: 1.4` and inherited that padding, making the line box 28px
* and the whole pseudo-element 42px inside a 34px parent. Kristian
* reported the result: the glyph sitting above centre.
*
* So: drop the padding that only existed to position a 20px line, and
* centre with flex. That matters more for an arbitrary glyph than for a
* dashicon — U+221E sits on the maths axis rather than filling its em box,
* so any rule that positions by line-height and padding is positioning the
* box and hoping about the glyph. `align-items: center` centres what is
* actually drawn, which is also what keeps working if anyone returns
* something else from `wpmgr_menu_glyph`.
*
* 🔴 `100%%`, not `100%`. This is a printf format string, so a literal
* per-cent sign has to be doubled — and the first version of the
* percentage fix did not, which is a **fatal on every admin page**:
* "Uncaught ValueError: Unknown format specifier". Three sessions agreed
* on that file after reading it, linting it and running PHPCS over it, and
* all three are blind to this: the syntax is valid and the standard has
* nothing to say. Only running the function finds it, and it was the site
* test that did. The fixed-pixel version could not have had this fault,
* so it arrived with the improvement.
*
* 🔴 The size is `100%`, not the 36x34 the desktop menu happens to be.
* That stylesheet declares the parent THREE times and they disagree:
* `#adminmenu div.wp-menu-image` is 36x34, `.folded #adminmenu
* div.wp-menu-image` is 35x30, and under `@media (max-width: 960px)`
* `.auto-fold #adminmenu div.wp-menu-image` is 34x30. A pseudo-element
* fixed at 36x34 is therefore correct on a wide screen with the menu open
* and 4px too tall in both of the other states — which are the states a
* laptop is in. Percentages resolve against whichever parent is in force,
* so there is no number here to be right about. Measured by reading that
* stylesheet, not by eye.
*/
function wpmgr_menu_glyph_style() {
$glyph = apply_filters( 'wpmgr_menu_glyph', '∞' );
if ( '' === $glyph ) {
return;
}
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the slug is reduced to [a-z0-9_-] and the glyph to CSS numeric escapes; both are escaped for the context they are printed in, which is CSS rather than HTML.
printf(
'<style>#adminmenu .toplevel_page_%1$s .wp-menu-image::before{content:"%2$s";font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:20px;font-weight:400;display:flex;align-items:center;justify-content:center;width:100%%;height:100%%;padding:0;line-height:1}</style>',
preg_replace( '/[^a-z0-9_-]/', '', strtolower( WPMGR_MENU_SLUG ) ),
wpmgr_css_escape( $glyph )
);
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* A string that is safe inside a CSS string literal, whatever it was.
*
* 🔴 esc_attr() is the wrong tool here and it protected by accident.
* Nolimit Discounts measured it: a glyph of `x"} body{display:none}` comes
* out as `x&quot;} …`, the CSS string never closes, and nothing breaks
* out — but only because the entity happens to contain no quote. The CSS
* parser does not decode HTML entities, so it was never escaping in this
* context; it was a substitution that coincidentally removed the one
* character that mattered. Two things follow from that: a glyph containing
* a quote renders as the literal text `&quot;` in the menu, and a single
* backslash — which esc_attr() leaves alone, and which IS an escape to the
* CSS parser — swallows the closing quote and the rest of the rule with
* it. Measured, not reasoned: the string then runs on to the quote in
* "Segoe UI" and the declaration block is corrupt.
*
* So do not filter the input; change its alphabet. Every character becomes
* a CSS numeric escape, so the emitted value contains nothing but
* backslashes, hex digits and spaces and cannot leave the string for any
* input at all. No whitelist to keep honest, no character class to get
* wrong, and an emoji still works.
*
* The decoder is written out rather than using mb_ord() because WordPress
* supports installs without mbstring, and ord() on a multi-byte character
* silently yields the first byte — which would render our own ∞ as
* mojibake on exactly those installs and on no machine any of us test on.
*
* @param string $text Text from a filter, i.e. arbitrary.
* @return string CSS escapes, safe between double quotes.
*/
function wpmgr_css_escape( $text ) {
$out = '';
$len = strlen( $text );
for ( $i = 0; $i < $len; $i++ ) {
$byte = ord( $text[ $i ] );
if ( $byte < 0x80 ) {
$code = $byte;
$tail = 0;
} elseif ( $byte >= 0xF0 ) {
$code = $byte & 0x07;
$tail = 3;
} elseif ( $byte >= 0xE0 ) {
$code = $byte & 0x0F;
$tail = 2;
} elseif ( $byte >= 0xC0 ) {
$code = $byte & 0x1F;
$tail = 1;
} else {
continue; // A stray continuation byte is not a character.
}
while ( $tail > 0 && $i + 1 < $len && 0x80 === ( ord( $text[ $i + 1 ] ) & 0xC0 ) ) {
++$i;
$code = ( $code << 6 ) | ( ord( $text[ $i ] ) & 0x3F );
--$tail;
}
if ( $tail > 0 ) {
continue; // Truncated sequence.
}
// The trailing space terminates the escape, so a following hex
// digit cannot be read as part of it.
$out .= sprintf( '\\%X ', $code );
}
return $out;
}
/**
* The shared landing page.
*
* Nothing here knows which plugins exist. Every card comes from the
* filter, so the page is correct whichever of them registered the menu and
* whichever of them have since been deactivated.
*/
function wpmgr_render_dashboard() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html( 'You do not have permission to view this page.' ) );
}
/**
* Cards on the shared dashboard, one per installed manager plugin.
*
* Each entry:
* title string Plugin name, already translated by its owner.
* status string 'ok' | 'warn' | 'fail' — what the check said.
* headline string One short line: the number that matters.
* lines array label => value, a handful at most.
* url string Where "Open" goes.
* colour string The plugin's own colour, as #rrggbb.
*
* Implementations must not measure anything here. This filter runs on
* an admin page load; a card that walks the disk or makes an HTTP
* request turns somebody else's dashboard into a slow page, and they
* will not know why.
*
* @param array $cards The cards so far.
*/
$cards = apply_filters( 'wpmgr_dashboard_cards', array() );
echo '<div class="wrap wpmgr-dashboard">';
echo '<h1>' . esc_html( apply_filters( 'wpmgr_menu_title', 'Manager' ) ) . '</h1>';
if ( empty( $cards ) ) {
echo '<p>Nothing to report.</p></div>';
return;
}
echo '<style>'
. '.wpmgr-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-top:20px}'
. '.wpmgr-card{background:#fff;border:1px solid #dcdcde;border-radius:8px;padding:0;overflow:hidden}'
. '.wpmgr-card header{padding:14px 18px;color:#fff;display:flex;justify-content:space-between;align-items:center}'
. '.wpmgr-card header h2{margin:0;font-size:14px;color:inherit}'
. '.wpmgr-card .wpmgr-body{padding:16px 18px}'
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
// 🔴 A DARK overlay, not a light one. White 11px text on
// rgba(255,255,255,.22) over a card colour is 3.45:1 on this
// plugin's green and 3.42:1 on a blue — under the 4.5:1 that small
// text needs, on every family colour dark enough to need white
// text in the first place. rgba(0,0,0,.18) takes the same four
// colours to 6.94–9.37:1, because darkening a background that
// already carries white text can only help.
//
// Measured rather than reasoned: the composite was read back out
// of a canvas in headless Chromium, not computed — #50977f for the
// old rule, #19644b for this one.
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
. 'background:rgba(0,0,0,.18);padding:2px 8px;border-radius:10px}'
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
. '</style>';
echo '<div class="wpmgr-cards">';
$states = array(
'ok' => 'OK',
'warn' => 'Attention',
'fail' => 'Action needed',
);
foreach ( $cards as $card ) {
if ( empty( $card['title'] ) ) {
continue;
}
$colour = isset( $card['colour'] ) && preg_match( '/^#[0-9a-f]{6}$/i', $card['colour'] )
? $card['colour']
: '#50575e';
$status = isset( $card['status'] ) && isset( $states[ $card['status'] ] ) ? $card['status'] : 'ok';
echo '<section class="wpmgr-card">';
printf(
'<header style="background:%s"><h2>%s</h2><span class="wpmgr-state">%s</span></header>',
esc_attr( $colour ),
esc_html( $card['title'] ),
esc_html( $states[ $status ] )
);
echo '<div class="wpmgr-body">';
if ( ! empty( $card['headline'] ) ) {
echo '<p class="wpmgr-headline">' . esc_html( $card['headline'] ) . '</p>';
}
if ( ! empty( $card['lines'] ) && is_array( $card['lines'] ) ) {
echo '<dl>';
foreach ( $card['lines'] as $label => $value ) {
echo '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';
}
echo '</dl>';
}
echo '</div>';
if ( ! empty( $card['url'] ) ) {
printf(
'<footer><a class="button" href="%s">%s</a></footer>',
esc_url( $card['url'] ),
esc_html( isset( $card['action'] ) ? $card['action'] : 'Open' )
);
}
echo '</section>';
}
echo '</div></div>';
}
}