Skip to content

Admin UI conventions

The rules the plugins' ~50 admin templates follow, and the helpers that enforce them.

Read this before writing or editing anything in templates/. These conventions were applied across every admin screen in one pass; the value is entirely in them staying applied. One screen that invents its own badge colour or its own "no results" wording puts us back to where we started.

These rules bind every plugin in the repo, not just Club Player Manager — an admin screen should not announce which plugin drew it. Paths below (templates/, assets/css/framework/, includes/…) are relative to a plugin directory under plugins/; the helpers named here live in Club Player Manager's platform layer (CPM_Platform_View, CPM_Admin) and are shared by every satellite plugin — see the monorepo notes in the repository and docs/PLATFORM.md.

If a rule genuinely doesn't fit what you're building, that's worth knowing — change the rule here and apply it everywhere, rather than making a local exception. Where an exception is correct, leave a comment at the call site saying why (see Deliberate exceptions).

The rules a pattern match can decide are enforced, by bin/check-admin-ui.sh — composer lint:ui, and a CI gate. It covers the type scale, the two surfaces, tw:border-collapse, inline styling and handlers, and hand-rolled badges and stat figures. Everything it checks was already a rule here, in prose, and drifted across ~50 templates anyway, because nothing checked it. Run it before you push; when it fails it names the section above and the pattern you want. Rules that need judgement — is this heading a section or a card title, is this empty state the right wording — stay in review, deliberately: a check that guesses only teaches people to work around it.


0. The Tailwind system

Every admin template in every plugin renders with plain Tailwind utility classes plus this repo's own small @utility component vocabulary — btn-primary, btn-secondary, btn-sm, input, field-label, help-text, card, link — not a component library (Bootstrap, Bulma, daisyUI and Flowbite were all tried and rejected; Flowbite got the furthest, and a full migration onto it was itself reverted before this one — see docs/ADMIN-UI-FRAMEWORK-POC.md and the framework README's git history for the trace). See plugins/club-management/assets/css/framework/README.md for how the CSS is built. templates/admin-ui-poc.php (behind the UI Reference menu item, manage_options only) is a living reference for the vocabulary.

Every page body must open the scope. Wrap it in CPM_Platform_View::begin_scope() / end_scope(), inside the existing <div class="wrap"> — that div stays; it is wp-admin's own layout against the sidebar and admin bar, and has nothing to do with the scope:

<div class="wrap cpm-wrap">
    <h1 class="wp-heading-inline">Title</h1>
    <hr class="wp-header-end">

    <?php CPM_Platform_View::begin_scope(); ?>
    <div class="tw:mt-4">
        …page content…
    </div>
    <?php CPM_Platform_View::end_scope(); ?>
</div>

This is not optional, and not a style choice: the compiled CSS is scoped — every rule is a descendant of the element begin_scope() prints, since Tailwind's own Preflight reset is stripped here to avoid repainting wp-admin's own chrome (see the framework README). A tw: class on a page that never opens the scope matches no compiled rule at all — it renders as nothing, with no error. CPM_Platform_View's helpers (badge(), dash(), render_notice(), render_empty(), back_link(), required_mark(), sort_link(), render_pagination()) all depend on this: they are for wp-admin screens only, and must never be called from a plugin's public-facing output (a shortcode, a generated front-end page) — see docs/PLATFORM.md on why, and EFA_View's frontend_badge() / frontend_dash() / frontend_outcome_badge() / frontend_kickoff() for the pattern a plugin needing the same look on the front end follows instead (fixed, legacy-only markup that does not depend on the admin's Tailwind scope at all).

Every tw:-prefixed class used anywhere it's scanned from needs the CSS rebuilt to include it — run composer css and commit the regenerated tailwind.scoped.css alongside the template. A class that "looks right" in the markup but isn't in the compiled CSS renders as nothing; there is no error, just an unstyled element. See the framework README's trap list — particularly that Tailwind's prefix goes before any variant (tw:hover:bg-blue-800, never hover:tw:bg-blue-800) — before trusting a page that "looks styled" on first read.

data-cpm-confirm and the delegated behaviours in §10 cover most interactivity (confirm dialogs, file pickers, submit-on-change) with no JS of a template's own. render_notice() deliberately keeps the notice/is-dismissible classes alongside its Tailwind ones, so WordPress core's own JS keeps injecting the dismiss button, rather than reimplementing dismissal.

A genuinely interactive component (currently: the squad detail page's Payments modal) is a dozen or so lines added to assets/js/admin-common.js — already loaded on every plugin admin screen for the other delegated data-cpm-* behaviours, not a new enqueue — rather than a vendored library. data-cpm-modal-open="id" on a trigger shows #id; data-cpm-modal-close="id" on a backdrop or close button (or the Escape key) hides it. It toggles exactly one class, tw:hidden, via classList — never the native hidden attribute, which is what broke the very first version of this modal (a wrapper carrying both a permanent tw:hidden utility class and a native attribute the JS toggled independently — removing the attribute did nothing, since the class rule still applied). The backdrop is authored HTML inside the scope, a normal child element, not a JS-injected sibling of it under <body> — see assets/js/admin-common.js's own docblock and the framework README for the full reasoning, including why page-scroll locking sets document.body.style.overflow directly rather than through a tw: class (<body> is an ancestor of the scope, so no scoped class could reach it — see the README's scoping section).

No dark: variants — they'd flip a page to a dark palette on any admin whose OS is in dark mode, while the rest of wp-admin stayed light.

Default to vanilla Tailwind unless told otherwise, and specifically watch for the two gaps stripping Preflight (Tailwind's element reset — see the framework README on why it's stripped) leaves that a genuinely vanilla Tailwind+Preflight build would have closed automatically:

  • Every <table> needs an explicit tw:border-collapse. Preflight normally sets this; without it a table falls back to the browser's separate-borders default — visible grid lines between cells, easy to miss in review because nothing errors.
  • Every <a> needs tw:link (tw:no-underline tw:hover:underline — pair with whatever text colour fits, e.g. tw:text-blue-600). Preflight normally makes a bare <a> inherit text-decoration: none, so it shows no underline until hover:underline says so; without it a link falls back to the browser's own always-underlined default, while a <button> styled to look like a link needs nothing extra (buttons never had a native underline to begin with). An <a> styled as a button with tw:btn-primary/tw:btn-secondary/tw:btn-sm doesn't need tw:link either — those three already carry tw:no-underline themselves, for the same reason.

Beyond those two, hold to the plain, unstyled-HTML defaults rather than inventing a variant (different padding, different striping, a different width treatment): a list table is tw:w-full inside a tw:table-card (§0.3) with uniform (not striped) rows and tw:px-6 tw:py-3/tw:py-4 cell padding — or, when a table's content should size itself rather than stretch (every narrow key-value detail table), no tw:w-full at all, letting the browser's own table-layout: auto size columns to content. Badges always render through badge() (one badge) or badge_group() (more than one together) rather than hand-rolled markup — both already carry tw:whitespace-nowrap so a badge's own text never wraps, and badge_group() lets multiple badges in one place wrap onto a new line as a group (stacking) when there isn't room for all of them, which is the default behaviour multiple badges should have. Deviate from any of this only for a specific, stated reason — and note that reason at the point of deviation, the way admin-overview.php's outline-style destructive Delete button does — so a later "reset to default" pass has something to check against instead of guessing which deviations were deliberate.


0.1 The type scale

Five sizes, one job each. Nothing else is a text size in the admin.

Class Job
tw:text-3xl tw:font-bold The figure in a stat card — counts and money alike
tw:text-lg tw:font-semibold Every <h2>: a card's title, or a heading above a table
tw:text-base tw:font-semibold Every <h3> / <h4>, one level under an <h2>
tw:text-sm Body, table cells, form fields, help text — the default
tw:text-xs Badge and small-button text, and it comes from badge() / btn-sm

tw:text-xl, tw:text-2xl and tw:text-4xl are not in the scale, and bin/check-admin-ui.sh fails a build that reintroduces one. A figure that wants to be bigger than its neighbours is the same figure on another page: this is exactly how the payments dashboard's money row ended up a size smaller than the player row directly above it, and how a <h3> on one screen came out larger than the <h2> it sat under. An icon that needs to be bigger uses tw:icon-lg, which sizes the glyph and its box together — a dashicon enlarged with a tw:text-* class is borrowing the type scale for something that is not text, and clips.

The stat card is a helper, not a shape you copy. It is the one component the whole scale turns on, so no call site picks its size:

echo CPM_Admin::stat_grid(
    array(
        array( 'value' => esc_html( number_format_i18n( $players ) ), 'label' => __( 'Players', 'cpm' ) ),
        array( 'value' => CPM_Admin::format_amount( $collected, $currency ), 'label' => __( 'Collected', 'cpm' ), 'variant' => 'success' ),
    )
);

Both return escaped markup — echo them directly, don't wrap in esc_html(), exactly as for badge() and format_amount(). 'value' is printed as given and must already be escaped — number_format_i18n() through esc_html(), or format_amount(), which returns escaped markup. 'variant' is a meaning (success / warning / danger / neutral), never a colour, for the same reason a badge's is. 'url' links the whole tile; 'footnote' is the slot the "N confirmed · N not confirmed" line hangs in (see §1 on why that split is under the number rather than beside it). 'columns' on stat_grid() takes 2, 3, 4 (the default) or 5 — pass one only where the row is genuinely a different shape, and say why at the call site.

Satellite plugins call EFA_Admin::stat_grid(), which forwards to the same helper, so a figure is the same size on an English FA screen as on a Club Player Manager one.


0.2 Colour roles

Grey is a ramp of four roles, not a preference:

Class Role
tw:text-gray-900 Headings, stat figures, a row's emphasised value
tw:text-gray-700 Key-value labels (a detail table's th), neutral button text
tw:text-gray-600 List-table body text, back links
tw:text-gray-500 Help text, stat-card labels, section descriptions, empty states
tw:text-gray-400 The dash() placeholder, and nothing else

Colour is never decoration. Every non-grey in the admin is one of four things: a link (tw:text-blue-600, always with tw:link), the primary button (tw:bg-blue-700), a destructive action (tw:text-red-600 / tw:border-red-600), or a status — and a status is always a badge (§1), never coloured text. The one exception is a stat card's figure, which takes its colour from a stat_card() variant, by the same rule and through the same kind of map.

One fill and one edge. tw:bg-gray-50 is the only background besides white — a table head, and an input's resting state. tw:border-gray-200 is every surface edge and every row rule; tw:border-gray-300 is for form-control borders only.


0.3 Surfaces and nesting

There are exactly two surfaces, they are drawn identically, and they are alternatives.

Utility Holds Its heading
tw:card Form fields, a key-value detail table, prose Inside, as the card's first child
tw:table-card A list table, and nothing else Above it, in the page flow

Both are tw:rounded-lg tw:border tw:border-gray-200 tw:bg-white tw:shadow-sm. table-card adds the tw:relative tw:overflow-x-auto scroll container a list table always needs. They match on purpose: for as long as the table wrapper was hand-written at each call site it drifted to rounded-xl and shadow-xs, so a card and a table side by side were drawn with different radii and different shadows and read as two design systems.

A list table is never inside a tw:card. This is the rule, and it has no exceptions:

A list of records is always a heading in the page flow with a tw:table-card under it.

Not sometimes a titled card with a table in it and sometimes a bare heading over a table — that was the state this replaced, and it meant the same table (the waiting list, shared by the squad page and the Waiting List page from one partial) was drawn boxed on one screen and unboxed on the next. bin/check-admin-ui.sh counts the nesting and fails a build that reintroduces it.

So the distinction is what a surface holds, not whether it happens to have a title:

  • Fields, a key-value table, or prose → tw:card, with its <h2> inside. A settings section of form fields is a card. A player's "Player details" is a card.
  • A list of records → an <h2> (or none, where the page's <h1> already names it, as on All Players) and a tw:table-card. A settings section that lists club admins is not a card, even though every section around it is: it is a list, and it looks like every other list in the admin.

Tabs split one list, they do not group several. The Match screen's registered players are one list in four states — uninvited, invited, available, not available — and every player is in exactly one, so the four tab counts add up to the figure above them. That is what makes a tab row readable at a glance. Do not reach for tabs to put two different lists on one screen: those are two headings and two tw:table-cards, the way every other screen does it. The tab row itself sits directly under the section's description, above the panels, and the panel holds the tw:table-card — the tab is not a surface.

A tw:card with no heading at all, wrapping a list table, is the shape to watch for — it is a table-card with p-5 of dead padding and a doubled border, and it is what the English FA Teams and Team pages had.

Padding is tw:p-5 on a card, and tw:p-4 only on a stat card — the one card with no title, and it comes from the helper anyway. table-card carries no padding: the table's own tw:px-6 tw:py-3/tw:py-4 cells are its padding.

A partial-*.php follows all of this too. It is included into an admin page and draws the same tables; the checker scans partials for exactly that reason. A partial that draws a list table draws its own tw:table-card, and the page including it supplies the heading — so the same partial cannot come out boxed on one page and bare on another.


1. Status badges

Every status in the admin is a badge, and every badge comes from CPM_Admin. No call site picks a colour.

There are six semantic variants, mapped to Tailwind classes once in CPM_Platform_View::badge():

Variant Use for
success Done, active, approved, paid
info Done, but worth distinguishing (e.g. paid offline vs. active Direct Debit)
warning In progress, awaiting someone, declined-by-admin
danger Failed, cancelled, bounced, suspended
neutral Nothing set yet
default A plain label with no status meaning

Use the typed wrapper for the thing you're showing; each one picks the variant:

CPM_Admin::status_badge( $reg->registration_status );        // FA registration (free text → substring match)
CPM_Admin::registration_status_badge( $player->status );     // season approval
CPM_Admin::waiting_list_status_badge( $entry->status );      // Pending / Converted / Archived
CPM_Admin::payment_status_badge( $player_product_row );      // player-product payment status
CPM_Admin::registration_fee_badge( $row->registration_status ); // the one-off reg fee
CPM_Admin::photo_permission_badge( $permission );
CPM_Admin::flag_badge( (bool) $player->suspended, 'danger' ); // Yes/No
CPM_Admin::badge( $label, 'success' );                        // anything else

Don't:

// ✗ inline colour
echo '<span class="tw:inline-flex tw:rounded-sm tw:px-3 tw:py-1" style="background:#00a32a">Active</span>';
// ✗ status as plain coloured text
echo '<span class="tw:font-semibold tw:text-red-600">Declined</span>';
// ✗ a bespoke colour combination per status, bypassing badge()
echo '<span class="tw:inline-flex tw:rounded-sm tw:bg-purple-100 tw:px-3 tw:py-1 tw:text-purple-800">…</span>';

Need a colour that isn't one of the six? Add a variant to the $colours map in CPM_Platform_View::badge() and give it a semantic name — not a per-status one. A colour keyed parent-approved is a status name; success is a meaning, and eight other statuses can share it.

More than one badge in the same place goes through badge_group(), never a bare space or a hand-rolled wrapper — see §0 on why.

status_badge() substring-matches because the FA sends registration status as free text we don't control. Anything we do control gets a proper switch — that's why waiting-list statuses have their own helper rather than borrowing status_badge().

Payment status has one source of truth

CPM_Admin_Payments::PAYMENT_STATUSES is it. Each entry carries the badge variant and the traffic-light bucket, and labels come from payment_status_label(). Four presentations read from it:

  • the badge — CPM_Admin::payment_status_badge() → row_status_meta()
  • the squad page's coloured dot — row_status_meta()['bucket']
  • the filter dropdowns — payment_status_options(), on the Payments list and on Email Guardians
  • the payments dashboard's counts — row_status_meta()['key'] to tally by status, payment_group_labels() for the columns they land in

Adding or renaming a payment status means editing that one const. Don't add a parallel map. PaymentBadgeTest asserts they stay in sync.

Bucket precedence is payment_group_labels() key order — settled, then in progress, then stopped, then never started. best_status_bucket() is the one implementation; the squad page's dot (season_payment_state()) and the dashboard's per-player counts both go through it, so "the furthest this player has got" can't mean two different things on two screens.

Filtering by payment status goes through rows_match_payment_status() — one rule, so the Payments list and the Email Guardians filter can never disagree about who is in a status. A player matches when any of their products is; a player with no product rows at all is in 'none', which is why player_ids_with_payment_status() walks every player rather than only the ones with rows. It cannot be a WHERE clause: status is derived, not stored.

waived is settled by decision rather than by money. The club has written one product's fee off for one player, so nothing is owed and nothing is chased: green bucket, its own badge, and out of 'none' — which is the whole point, since the alternatives were to record a payment that never happened or to leave the family in the unpaid pile for good. It reads first in payment_status_key() and in CPM_Payment_Link::row_payment_state(), ahead of every GoCardless signal, because a waiver is a statement about the player and no leftover billing-request state may speak over it. It is worth £0 everywhere money is counted — row_money() reports nothing on all three figures and the export writes it no money line — which is the one way it differs from offline, whose whole assertion is that the full price was paid outside GoCardless. Nothing about a waiver emails anyone: CPM_Payment_Product_Email::send() refuses on CPM_Payment_Link::waived_payment() at the single point a Billing Request is created, and both admin send handlers ask the same question up front so the refusal lands on the page.

Two pairs share a bucket without being the same status, and both exist for the same reason: the bucket is the coarse view, and the difference is what tells an admin what to do. cancelled and cancelled_paid are both dead red, but only the second left a guardian who has paid with no live plan — which is the club's to fix, and worth being able to filter for. CPM_GoCardless::row_fee_collected() decides which, and CPM_Payment_Link::row_payment_state() withholds its self-serve link on the same call, so a guardian is never offered a link that would charge them the registration fee twice. (failed is deliberately left whole — splitting it wasn't asked for. If it ever is, it splits on the same call, not a new rule.)

A void status is hidden everywhere but one page

cancelled carries 'void' => true in PAYMENT_STATUSES, and it is the only status that does. A row cancelled before it collected anything is effectively deleted: no money changed hands, no plan survives it, and re-sending starts a fresh row rather than reusing it, so every abandoned request leaves one behind for good.

CPM_Admin_Payments::live_rows() is the one place the exclusion is made. Screens filter through it; they do not test the status themselves. It is already applied inside payment_status_keys() and best_status_bucket(), so every derived status — the filter rule, the squad dot, the dashboard's player counts — is void-free without its caller doing anything. What a caller must still do is filter the rows it is about to list, because listing a row a derived status has stopped counting is the contradiction this is meant to remove. Three do: the Payments list (CPM_Admin_Payments::page_payments()), the squad page's Payments expander (CPM_Admin_Seasons::attach_season_payments()), and the dashboard, which filters at the point it reads its rows so its row counts and its money agree.

payment_status_options() leaves void statuses out and is the allowlist a requested pay_status is validated against, so a stale ?pay_status=cancelled falls back to no filter rather than an empty page. The badge is not withheld — row_status_meta() answers for whatever row it is handed.

The exception is the player's own payment page, which lists void rows in a collapsed <details> below the rest via void_rows(). That is the whole point of keeping them, so group_rows_by_status() must go on returning every row it is given — the handler decides what to hand it. CPM_Payment_Link::row_payment_state() already called these rows inactive; this is the admin side of the same fact, not a second rule.

sent and pending are both "waiting", and only one of them is being set up. "Awaiting bank" is a Direct Debit GoCardless is already establishing — amber, mid-setup, and chasing achieves nothing. "Awaiting guardian" is a Billing Request the guardian triggered or was emailed and then left: nothing is authorised, nothing is scheduled, and only chasing moves it. That is the same job the club has for a player nobody has sent anything to, so it is bucketed neutral and counted under Not set up — on the dashboard, on the Not Set Up screen, and as a red dot on the squad page. Don't read its amber badge as a bucket. It keeps a badge of its own precisely because "a link is out with them" and "we have sent them nothing" are different pieces of chasing; bucket is the coarse answer and variant is the useful one, exactly as for "Paid offline" and "Waived". Which of the two a waiting row is in comes from CPM_GoCardless::row_flow_started(), which CPM_Payment_Link::row_payment_state() also splits its resume from its in_progress on — one call, so the Billing Requests page and the payment status can't disagree about who has turned up.

The "Not set up" figure is split, and the split must never be a third count. Both the payments dashboard and the Not Set Up screen break it into confirmed / not confirmed for the season, because the two halves are different jobs — get an answer, or get them paying. It is printed under the number by CPM_Admin::render_confirmation_split(), not as cards or columns of its own: the dashboard's three player cards add up to Players, and a fourth figure beside them would read as though it belonged in that sum. The halves come from CPM_Admin_Payments_Dashboard::split_ids_by_confirmation() (which reads unconfirmed as the complement of confirmed, the callers having already dropped the players who declined), so they always total the figure they sit under. Confirmation is per-season, so on All seasons the split is withheld rather than shown as zeroes.

A sent row does not stay open forever. CPM_Billing_Request_Expiry runs daily and closes any that is more than EXPIRY_DAYS (7) old, doing exactly what the Billing Requests page's Cancel Selected does: cancel at GoCardless, then mandate_status = 'cancelled' locally, which makes it a void row. It selects through CPM_Admin_Payments::unactioned_billing_requests() rather than restating which requests are open, so the sweep and the page can never disagree — add the clock there, not a second rule. It uses the strict cancel_unstarted_billing_request() (which re-reads the request's real status at GoCardless first) rather than the page's lenient one, because nobody is watching a cron run.

A row reaches one of those statuses through payment_status_key(), and what it reads depends on what the product is. A product with instalments is reported by its Direct Debit mandate. A one-off product — a registration fee with no instalments — sets up no mandate at all, so its mandate_status stays 'none' for life and the registration fee's own status is what the row reports. Don't treat a missing mandate as "not set up": 'none' means "no mandate expected here", not "nothing happened". CPM_Payment_Link::row_payment_state() and CPM_Payment_Product_Email::open_row_for_product() read a one-off by the same signal.


2. Column headers

One word per concept, named after the entity the column lists:

Use Not
Player ~~Name~~ (in a list of players)
Date of birth ~~DOB~~, ~~Date of Birth~~
Guardian ~~Parent / Guardian~~, ~~Parent / Carer~~
Date registered ~~Date Registered~~
Active registrations ~~Active Registrations~~

Sentence case for multi-word headers, matching WordPress core.

Name is still right for a field label ("Name" of an emergency contact, a product, a squad) — the rule is about columns in a list of records.


3. Empty states

One helper, one look — muted, centred text where a list has nothing in it:

CPM_Admin::render_empty( __( 'No players yet.', 'cpm' ) );

Two wordings, chosen by why the list is empty — this distinction is deliberate, don't collapse it:

  • nothing exists yet → "No X yet." — the user should go and create something
  • filters excluded everything → "No X match the current filters." — the user should widen their filters

Telling someone "No players yet" when they've filtered to surname=zzz sends them off to import players they already have.

For an empty state that needs a link, write the <p class="tw:py-6 tw:text-center tw:text-sm tw:text-gray-500"> inline rather than squeezing markup through the helper.


4. Buttons

  • tw:btn-primary for a form's primary submit (<button type="submit">, not submit_button() — that WordPress helper predates this vocabulary and doesn't carry it).
  • tw:btn-secondary for a form's secondary action (Cancel, a "View X" link that isn't the primary action) and for standalone action buttons in a header row.
  • tw:btn-sm tw:border tw:border-gray-300 tw:bg-white tw:text-gray-700 tw:hover:bg-gray-50 for a small, low-emphasis action inside a table row (Edit, Sync now, Resend).
  • tw:btn-sm tw:border tw:border-red-600 tw:bg-white tw:text-red-600 tw:hover:bg-red-50 for a small destructive action (Delete, Remove, Cancel product); tw:btn-primary is never destructive-coloured — a destructive action never looks like the page's main call to action.
  • Save Changes for saves. Not "Save", not "Update".
  • Add X for creates. Not "Create X".
  • Remove as a word, never a bare ×.

btn-primary/btn-secondary/btn-sm are shape only (padding, radius, weight) — colour comes from the tw:bg-*/tw:text-*/tw:border-* classes alongside them, which is what lets the same btn-sm shape serve both a neutral row action and a destructive one.

Match the button to what it stands next to. btn-sm is 24px tall and input is 46px, so a btn-sm beside a form control reads as broken rather than as compact — that is what put a stunted Filter button next to full-height selects on every list screen. A button in a row of form controls (a filter bar, a bulk-action bar) takes btn-primary/btn-secondary, which are the same height as input; btn-sm belongs in a table cell, where the compact size is the whole point. btn-primary carries a transparent border for the same reason — without it, it sat 2px shorter than the bordered controls beside it.


5. Row actions

A record's name is the link to its detail page. So the Actions column never repeats it — no "View", no "Manage" button next to a name that already links there.

<td><a href="<?php echo esc_url( $url ); ?>" class="tw:link tw:font-medium"><?php echo esc_html( $name ); ?></a></td>

No colour class on it. tw:link already carries the framework's link colour, and a record's name is a link — forcing it to tw:text-gray-900 (or to a literal tw:text-blue-600) only made one table disagree with the next about whether links look like links. Weight is the emphasis a row's primary link gets; colour comes from tw:link alone.

Actions go in a <div class="tw:flex tw:flex-wrap tw:items-center tw:gap-2"> so they lay out consistently.

Delete confirmations name the record and state the consequence:

Delete Jess Apps? Their guardians, registrations and payment history go with them. This cannot be undone.

Not "Are you sure?", not "Delete this guardian?". The user should be able to tell from the dialog alone whether they've clicked the right row.


6. Forms

  • Record CRUD (player, guardian, squad, registration) and read-only detail views → a narrow two-column key-value <table class="tw:border-collapse tw:text-sm"> (th at tw:w-40 tw:py-2 tw:pr-4 tw:text-left tw:align-top tw:font-medium tw:text-gray-700, td at tw:py-2 tw:align-top) inside a tw:card. tw:w-40 is the label column on every one of them — it was w-48 on some pages and w-32 on others, which is why "Date of birth" used to land in a different place on each detail screen. It is the narrower of the two widths that were in use, so the handful of long labels ("Registration and payment emails" is the longest in the admin) wrap onto a second line — which tw:align-top already handles, and which is the price of the column being in the same place on every screen. And no tw:w-full: these tables size to their content (§0), so a two-word label is not stretched across half a card.
  • Settings and config → tw:field-label / tw:input / tw:help-text fields, stacked in a tw:grid tw:grid-cols-1 tw:gap-4, inside a tw:card per logical section.
  • Required fields are marked in both, with CPM_Admin::required_mark() after the label text. The marker is decorative (aria-hidden); the field's own required attribute is what screen readers announce.

Use the shared formatters rather than reformatting inline:

CPM_Admin::format_date( $reg->registration_date );          // d/m/Y, or the em-dash placeholder
CPM_Admin::format_dob( $player->date_of_birth );            // the same thing, named for a date of birth
CPM_Admin::format_dob_range( $squad->dob_start, $squad->dob_end );
CPM_Admin::age_display( $player->date_of_birth );           // already-escaped markup — echo directly
CPM_Admin::format_amount( $product->reg_amount, $currency );// 4000 → "£40.00"
CPM_Database::player_full_name( $player );                  // never `$p->first_names . ' ' . $p->surname`

format_date() is the general one and format_dob() delegates to it; use whichever names what the call site is showing, so a registration date doesn't have to be printed by a function called format_dob. Both render a blank the same way, which is the point — a date cell should not look different for being empty on one screen and another.

format_date(), format_dob(), age_display() and format_amount() return escaped markup. Echo them directly — don't wrap in esc_html(), or the placeholder <span> will show as literal tags.

Every amount is stored in pence and printed through format_amount(). A club treasurer reconciles the product list, a player's ledger and the payments dashboard against each other, so £40.00 has to be £40.00 on all three — this used to be a number_format() in one template and a different one in another, which is how GBP 40.00 and £40.00 ended up on adjacent screens.


7. Feedback notices

One shape everywhere: $notice = array( 'type' => …, 'message' => … ).

Handler builds it, template renders it:

// includes/admin/class-admin-thing.php
$notice = CPM_Admin::request_notice();   // reads the cpm_saved / cpm_error redirect flags
// templates/admin-thing.php
<?php CPM_Admin::render_notice( $notice ); ?>

Redirect after a successful action with CPM_Admin_Handler::redirect_saved() / redirect_error() and request_notice() picks it up. Where a handler needs richer wording than a flag carries, it stores the same array shape in a transient (see CPM_Admin_Payments).

"Saved." is the success wording. Not "Saved successfully.", not "Settings saved.".

Dismissible or not is a real distinction, not a style choice:

  • Action feedback ("Saved.", "3 players added") → render_notice(), which adds is-dismissible. It reports something that just happened, so dismissing it is correct.
  • Standing state warnings ("No GoCardless access token configured", "Sending in progress") → written inline in the template (native <div class="notice notice-warning inline">, no Tailwind classes needed — it's WordPress core's own box), not dismissible. They describe how the page is; dismissing would hide live state that's still true.

8. Filters and pagination

Filter bars submit via their Filter button. No onchange="this.form.submit()" — mixing auto-submitting selects with a search box that needs Enter is the inconsistency this replaced.

This is about filter bars, not every select. A lone dropdown that is the action — the squad page's per-row season status — auto-submits via data-cpm-submit-on-change (§10); there is nothing else in its form to fill in first, so a second click to confirm the choice you just made is the friction, not the safeguard.

<form method="get" class="tw:mb-4 tw:flex tw:flex-wrap tw:items-center tw:gap-3">
    <input type="hidden" name="page" value="cpm-thing">
    …inputs, each `tw:input tw:max-w-xs`…
    <button type="submit" class="tw:btn-secondary"><?php esc_html_e( 'Filter', 'cpm' ); ?></button>
    <?php if ( $has_filters ) : ?>
        <a href="<?php echo esc_url( $base_url ); ?>" class="tw:btn-secondary"><?php esc_html_e( 'Clear', 'cpm' ); ?></a>
    <?php endif; ?>
</form>

The button says "Filter", even when the only control is a search box.

Filter and Clear are btn-secondary, not btn-sm — they stand in a row of tw:input controls and have to be the same height as them (§4).

A filter bar on one section of a multi-section page puts that section's anchor in the form's action — admin.php?page=cpm-settings#email-log, as the Email Log's recipient filter does. A GET submit replaces the action URL's query string and leaves its fragment alone, so the results land on the section the admin was reading rather than the top of the page. The Clear link points at the same anchored URL, for the same reason. Without it, every filter click scrolls them back up to the first card on the page.

Pagination is one helper, called above and below the table, and it carries the record count:

CPM_Admin::render_pagination( $count_text, (int) $args['page'], (int) $total_pages, $filtered_url, 'top' );

$filtered_url must carry the active filters, or paging past page 1 drops them. $position ('top'/'bottom') controls which side of the block the gap separating it from the table sits on — pass the right one rather than defaulting both calls to 'top'.

Sortable column headers are one helper too — it renders the label, the ▲/▼ on the active column, and a URL that flips the direction:

<th><?php echo CPM_Admin::sort_link( 'surname', __( 'Player', 'cpm' ), $args, $filtered_url ); ?></th>

It reads the sort state from $args['orderby'] / $args['order'], which every list page already builds from the query string. Same $filtered_url as the pagination, for the same reason.

The season detail page filters client-side over an unpaginated table — a different feature, kept deliberately. It still uses the same filter-bar layout so it looks the same.


Every page:

<div class="wrap cpm-wrap">
    <h1 class="wp-heading-inline">Title</h1>
    <a href="…" class="page-title-action">Add Thing</a>        <!-- optional -->
    <?php echo CPM_Admin::back_link( $back_url, __( 'Back to Things', 'cpm' ) ); ?>
    <hr class="wp-header-end">

page-title-action is for actions that create something. Back navigation uses CPM_Admin::back_link(), which renders a quiet grey link (tw:link tw:text-gray-600 tw:hover:text-gray-900), not a button — so "Add Player" and "← Back to Players" don't look like the same kind of thing.

A detail page's other actions — the ones that act on the record rather than create a new one (Delete Player, Left the Club, Convert to Player) — go in a header row after the notice and before the content:

<hr class="wp-header-end">
<?php CPM_Platform_View::begin_scope(); ?>
<div class="tw:mt-4">
    <?php CPM_Admin::render_notice( $notice ); ?>
    <div class="tw:mb-4 tw:flex tw:flex-wrap tw:items-center tw:gap-2">…forms…</div>
    <div class="tw:grid tw:grid-cols-1 tw:gap-4 tw:md:grid-cols-2">…cards…</div>
</div>

It is a flex row with a gap, never a float — a grid of cards below it does not clear floats, so a floated row lands on top of the first row of cards rather than pushing it down.

The same row is how a section carries its own actions, under its own heading and above the section's table — the Not Set Up page's per-squad "Email Team Secretary" button sits in one. Same layout, because it is the same thing one level down: a row of things you can do to what is below it. Don't invent a different one for it.

Rhythm: three measurements, and no others

  • A section heading that follows content gets tw:mt-6. The first one on a page gets nothing — the page container's own tw:mt-4 is its top margin.
  • A heading sits tw:mb-4 above what it heads.
  • A heading with a description drops to tw:mb-1, and the description carries the tw:mb-4 instead — <p class="tw:mb-4 tw:text-sm tw:text-gray-500">, not tw:help-text (that utility adds a tw:mt-1 meant for a form field's hint, not a section's).
  • An action row is tw:mb-4, the same gap, whether it is the page's or a section's.
  • No heading is underlined. A tw:pb-3 tw:border-b under an <h2> appeared on one plugin's settings page and nowhere else; the surface below a heading is the separation.

Write the margins in that order — tw:mt-6 tw:mb-4, never tw:mb-4 tw:mt-6 — so two headings doing the same thing read the same in a diff.

A heading inside a flex row (a title with an action beside it) carries no margin of its own: the row does, and the heading takes tw:mr-auto or the row takes tw:justify-between.


10. No inline styling, no inline handlers

No style= attributes in admin templates. Everything is a tw: utility class — there is one for every plain CSS property this would otherwise reach for (spacing, colour, width, alignment); reach for the vocabulary in §0 rather than an inline style, and add a new @utility to tailwind.css only for a genuinely repeated shape (a button, a card), never for a one-off value.

Missing values render one way: CPM_Admin::dash(). Never a bare —, never <span style="color:#c3c4c7">—</span>.

No onclick / onchange / onsubmit attributes. assets/js/admin-common.js is loaded on every plugin screen and delegates from document:

Attribute On Does
data-cpm-confirm="…" <form>, <button>, <a> Confirms before submit/navigation. On a button, only when that button submits.
data-cpm-require-file="#id" <form> Blocks submit until the file input has a file (message from data-cpm-require-file-message)
data-cpm-file-name="#id" file <input> Writes the chosen filename into the target
data-cpm-select-on-focus <input> Selects the value on click, for read-only copy-me fields
data-cpm-submit-on-change <select> Submits the form as soon as a different option is chosen
data-cpm-check-all="input.thing" header <input type=checkbox> Ticks/unticks every matching checkbox, stays ticked only while they all are, indeterminate part-way. Give each table's rows their own class — the selector is what scopes it
data-cpm-modal-open="id" / data-cpm-modal-close="id" trigger / backdrop or close button Shows/hides #id by toggling tw:hidden — see §0
data-efa-tab="panel-id" / data-efa-tab-panel="panel-id" role="tab" <button> / its panel Shows one panel of a role="tablist" group and hides its siblings, toggling tw:hidden. Marks the chosen tab with aria-selected only, so its styling hangs off Tailwind's aria-selected: variant and the JS never has to know which classes a template picked. English FA only so far (club-english-fa/assets/js/admin.js) — lift it into admin-common.js as data-cpm-tab the first time a Club Player Manager screen wants tabs, rather than writing a second implementation

Messages are trimmed when read, so you can format the PHP across lines inside the attribute.

Elements that start hidden use the hidden attribute, not style="display:none", and JS toggles el.hidden. admin.css carries [hidden] { display: none !important; } so it beats the plugin's own display rules — this one rule is not part of the Tailwind migration and stays regardless.


Helper reference

All on CPM_Admin unless noted.

Helper Returns
CPM_Platform_View::begin_scope() / end_scope() Opens/closes the Tailwind-scoped page body — see §0
badge( $label, $variant ) Escaped badge markup
badge_group( $badges ) Wraps several already-rendered badge()s so they stack as a group
stat_card( $value, $label, $args ) One figure-and-label tile — see §0.1
stat_grid( $cards, $args ) A row of them in the one grid they use — echo directly
dash() The em-dash placeholder
status_badge() / registration_status_badge() / waiting_list_status_badge() / payment_status_badge() / registration_fee_badge() / photo_permission_badge() / flag_badge() Typed badges
registration_status_meta( $status ) ['label','variant'] — mirrored by admin-registration.js
request_notice( $saved_message = '' ) ['type','message'] or null
render_notice( $notice ) Prints a dismissible notice
render_empty( $message ) Prints the empty-state paragraph
back_link( $url, $label ) ← Label link
required_mark() The * marker
render_pagination( $count, $paged, $total_pages, $url, $position ) Prints the pagination block
sort_link( $col, $label, $args, $url ) Sortable column header link
CPM_Platform_View::confirmation_split( $confirmed, $not_confirmed ) (CPM_Admin::render_confirmation_split() forwards to it) Prints the "N confirmed · N not confirmed" line under a "Not set up" figure
format_date() / format_dob() / format_dob_range() / age_display() / calculate_age() Date + age display
format_amount( $pence, $currency ) Escaped money, from minor units
CPM_Database::player_full_name( $player ) Trimmed full name
CPM_Admin_Payments::row_status_meta( $row ) ['key','label','variant','bucket']
CPM_Admin_Payments::best_status_bucket( $rows ) The furthest bucket a player's rows reach
CPM_Admin_Payments::payment_status_options() Filter options

Deliberate exceptions

Documented so nobody "fixes" them:

  • templates/admin-player-duplicates.php uses Name, not Player — its rows are players or waiting-list applicants, which is exactly what its Type column says.
  • The season detail page keeps its own "select all" (admin-registration.js) rather than using data-cpm-check-all. Its box is "select all visible": the page filters rows client-side, and the box must tick only what the filter is currently showing — plus it drives a live selected count. That's a different feature wearing the same checkbox, not a duplicate of the delegated one. The three pages that were duplicates (Payments, All Players, Waiting List) now use the attribute.
  • templates/frontend-*.php (and any plugin's own public-facing shortcode output, e.g. club-english-fa's partial-matches.php / partial-team-links.php) keep their own, separate styling. They render inside whatever theme the site runs, not wp-admin, and they never open the Tailwind scope — see §0 on why they must not call CPM_Platform_View's helpers directly. They are outside these conventions entirely.
  • admin-dashboard-widget.php (the WordPress Dashboard's "Club at a glance" widget) and admin-waiting-list-import-forms.php (a temporary, self-contained one-off import tool — see its own file header) are outside these conventions too, and outside the Tailwind scope. The Dashboard widget renders inside a WP Dashboard metabox with its own chrome and constraints, not a cpm-* admin page; the import tool is scheduled for deletion in one go once its one-off job is done, so converting it would be wasted effort. Both still use the handful of .cpm-* classes assets/css/admin.css keeps for exactly this reason — see that file's own header comment.
  • Standing state warnings aren't dismissible — see §7.
  • The season detail page filters client-side — see §8.