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 explicittw: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>needstw: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>inherittext-decoration: none, so it shows no underline untilhover:underlinesays 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 withtw:btn-primary/tw:btn-secondary/tw:btn-smdoesn't needtw:linkeither — those three already carrytw:no-underlinethemselves, 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-cardunder 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 atw: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:
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-primaryfor a form's primary submit (<button type="submit">, notsubmit_button()— that WordPress helper predates this vocabulary and doesn't carry it).tw:btn-secondaryfor 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-50for 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-50for a small destructive action (Delete, Remove, Cancel product);tw:btn-primaryis never destructive-coloured — a destructive action never looks like the page's main call to action.Save Changesfor saves. Not "Save", not "Update".Add Xfor creates. Not "Create X".Removeas 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">(thattw:w-40 tw:py-2 tw:pr-4 tw:text-left tw:align-top tw:font-medium tw:text-gray-700,tdattw:py-2 tw:align-top) inside atw:card.tw:w-40is the label column on every one of them — it wasw-48on some pages andw-32on 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 — whichtw:align-topalready handles, and which is the price of the column being in the same place on every screen. And notw: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-textfields, stacked in atw:grid tw:grid-cols-1 tw:gap-4, inside atw:cardper 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 ownrequiredattribute 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
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 addsis-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.
9. Headings and back links¶
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 owntw:mt-4is its top margin. - A heading sits
tw:mb-4above what it heads. - A heading with a description drops to
tw:mb-1, and the description carries thetw:mb-4instead —<p class="tw:mb-4 tw:text-sm tw:text-gray-500">, nottw:help-text(that utility adds atw:mt-1meant 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-bunder 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.phpusesName, notPlayer— 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 usingdata-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'spartial-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 callCPM_Platform_View's helpers directly. They are outside these conventions entirely.admin-dashboard-widget.php(the WordPress Dashboard's "Club at a glance" widget) andadmin-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 acpm-*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-*classesassets/css/admin.csskeeps 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.