Styling the administration: one component stylesheet, tokens, no inline CSS
Read this page before you write or review a template or stylesheet of an administration design (admin, admin3,
admin4, admin4l) or of an extension's admin design (some extensions still carry an admin2 folder). It holds the rules, the shared component and token
API, how the stylesheets are loaded, and the checklist for code review. Since 2026-10-14.
Why
The admin4 module pages had grown one <style> block each: 55 templates under design/admin4/templates/ (7,831
lines), 58 more in design/admin, and style="" attributes in 53 files. Each block copied the same utilities
(screen-reader text in 27 files, a box-sizing reset in 29, the focus ring in 29, button rules in 41) under its own
scope class (.exp-roles, .exp-rad, .nf-, .cb-, .shop- ...) and its own custom properties (35 prefixes such
as --rl-, --rad-). The copies drifted: four accent colours (#c2410c 27 times, #2d6cdf 12, the brand
#f26a21 3, #ff5500 1) and two radii. 46 of the 55 blocks hard-coded white with no dark rules, so admin4's dark
mode showed white cards. Inline CSS is not packed or cached, and extensions had no shared classes to build on.
The rules
- No
<style>block and nostyle=""attribute in a template of an administration design. All CSS lives in the design'sstylesheets/directory and is loaded throughdesign.ini[StylesheetSettings], so the ezjscore packer bundles and caches it. The few documented exceptions are listed below.- A value that really is per element (a bar's height, a percentage) is a class (
.a4-pct-0to.a4-pct-100) or adata-*attribute that a stylesheet or the page's script reads, neverstyle="".
- A value that really is per element (a bar's height, a percentage) is a class (
- Use the shared component classes first (
stylesheets/components.css, prefix.a4-). Add a new shared component centrally only if nothing fits; never copy one into a module or an extension. - Colours, radius, spacing and fonts come only from the
--a4-*tokens. No hard-coded values (#fff,#c2410c,12px,.6em) and no local aliases (--rl-ink,--rad-card). There is one accent and one component radius; do not add another. - Every colour token has a value for
html[data-a4-theme="dark"], added in the same commit as the token. - Module CSS is the exception: only what the components cannot do, in
stylesheets/modules/<group>.css, as.a4-<module>-*classes, built from tokens. One prefix (.a4-) for the whole administration; no per-module scope classes. - Check every new or changed page in light and dark mode, at 1440 px (scale 1) and at 960 px with scale 2 (a browser at 200% zoom): no light card on the dark page, text contrast at least 4.5:1, no horizontal overflow, no console errors.
- CSS reaches the browser through the packer. Deploy with
exp:velocity deploy --packer(it clears the template-block cache that holds the packed link). Never add?v=or another version query to a link; a browser that still shows the old file needs a hard reload.
These rules apply to every administration design and to the admin designs of extensions (extension/*/design/admin*,
exp_adminui and the rest).
How the stylesheets are loaded
settings/design.ini:
[StylesheetSettings]
AdminComponentCSSFileList[]
AdminComponentCSSFileList[]=components.css
AdminComponentCSSFileList[]=modules/setup.css
AdminComponentCSSFileList[]=modules/content.css
AdminComponentCSSFileList[]=modules/people.css
AdminComponentCSSFileList[]=modules/structure.css
AdminComponentCSSFileList[]=modules/workflow.csspage_head_style.tpl of admin, admin3 and admin4 loads the list after the design's own stylesheets (and, in
admin4, after base.css, debug.css and admin4.css) and before BackendCSSFileList, so an extension still has the last word on its own
markup. Every file is named in the INI: every admin page gets the same bundle, packed and cached once, instead of a
bundle per module.
| File | Holds |
|---|---|
components.css | the tokens (light and dark) and the .a4-* components; the copy in design/admin is identical (the test checks it) |
modules/setup.css | setup/* |
modules/content.css | content/* |
modules/people.css | role, section, state, user, notification, collaboration |
modules/structure.css | class, pdf, rss, package |
modules/workflow.css | workflow, trigger, audit, shop, settings, infocollector, search, url, visual, contentfinder |
Each module file exists in design/admin4/stylesheets/modules/ and design/admin/stylesheets/modules/ (admin3
finds the design/admin files through its fallback). admin4.css stays the frame of admin4 (header,
columns, dashboard, sign-in page).
base.css is admin4's own base: the element defaults, the frame layout, the content classes, the rounded theme, the
data tables and the modal window. Until October 2026 these were six files inherited from admin3 (core.css,
pagelayout.css, content.css, theme/rounded.css, theme/admin_datatable.css, theme/modalwindow.css); they are
one file in the same cascade order, and a declaration that a later rule of base.css, admin4.css or components.css
sets again for the same selector was removed. core.css and theme/modalwindow.css remain as files in
design/admin4/stylesheets/ only because other designs resolve them through the design chain (the standard popup,
print and redirect layouts name core.css; exp_adminui loads theme/modalwindow.css); admin4's own pages do not
load them.
Extensions keep their stylesheet in their own design folder and add it to BackendCSSFileList[], not to
AdminComponentCSSFileList. In admin4 they can use the .a4-* classes and the --a4-* tokens; their own classes
take their own prefix and the same rules (tokens only, a dark value for every colour).
Tokens
All var(--a4-...), defined in components.css.
| Group | Tokens |
|---|---|
| Accent | --a4-accent (#f26a21), --a4-accent-hover, --a4-accent-strong (filled controls with white text, 5.2:1), --a4-accent-press (their hovered fill), --a4-accent-text (the accent as text or link), --a4-accent-soft (tint), --a4-on-accent, --a4-on-accent-hover (text on the -press fill), --a4-ring (focus ring, opaque: 3:1 on every surface) |
| Neutral | --a4-ink, --a4-muted, --a4-line, --a4-line-strong, --a4-soft, --a4-card, --a4-surface, --a4-zebra (the even rows of data tables), --a4-head-bg (the header row of data tables), --a4-field, --a4-field-line, --a4-disabled, --a4-overlay, --a4-code-bg, --a4-code-ink |
| States | --a4-ok, --a4-ok-bg, --a4-ok-line; --a4-warn, --a4-warn-bg, --a4-warn-line; --a4-bad, --a4-bad-bg, --a4-bad-line, --a4-bad-hover; --a4-info, --a4-info-bg, --a4-info-line |
| Charts | --a4-chart-1 to --a4-chart-8, a categorical palette in this order |
| Depth | --a4-shadow-s, --a4-shadow-m, --a4-shadow-up |
| Radius | --a4-radius-s (9 px): the one component radius. --a4-radius (18 px) is the frame's three columns only |
| Spacing | --a4-space-1 to --a4-space-7 (4, 8, 12, 16, 24, 32, 48 px); a length between two steps takes the nearer one (3 and 5 → 4, 6 to 9 → 8, 10 to 14 → 12, 15 to 20 → 16, 22 to 28 → 24) |
| Controls | --a4-control-h (32 px): one height for every control: .a4-btn (.a4-btn-large 44 px, .a4-btn-small 30 px aside), .a4-input and the fields of .a4-field, .a4-tabs, .a4-chip, the pager sizes, the legacy buttons and fields, the node's actions and the sub-items toolbar. Field text is --a4-text-m (14 px), button text --a4-text-s (13 px) |
| Layout | --a4-content-max (1680 px): the widest the content column grows on a very wide window, so a page's actions stay near what they act on; --a4-gutter (24 px): the one gap between the frame's cards (side columns and content, under the header, at the bottom); the window's edges keep 24 px for the side columns' tabs |
| Spacing of lists and cards | --a4-list-pad (the cells of table.list: 8 px above and below like .a4-table, 6 px a side so a list of ten columns still fits a 944 px content column; the old base stylesheet had .4em), --a4-cell-pad-y/-x (8/12 px, .a4-table), --a4-card-pad-y/-x (12/16 px, .a4-card). admin4 has one density: the compact view and its header switch were removed in October 2026 (AdminDensityTest keeps them out) |
| Type | --a4-font, --a4-mono, --a4-text-xs, -s, -m, -l, -xl, -2xl, -3xl (12, 13, 14, 16, 22, 26, 32 px; nothing below 12); --a4-text-body (the body text: --a4-text-s up to a 1920px window, growing to --a4-text-m at 2560px, a clamp between two steps of the scale, so the em sizes of the base stylesheets never fall below 12px), --a4-leading (1.5, every block that can wrap), --a4-leading-tight (1.25, headings and single-line controls that may wrap), --a4-leading-none (1: icons, badges, a box whose height is the font size) |
| Weight | --a4-weight-medium (500), --a4-weight-strong (600: controls, buttons, navigation), --a4-weight-heading (650: headings, table heads, labels of facts), --a4-weight-bold (700: figures, counts, emphasis); 400 is the keyword normal |
| Motion | --a4-duration (.15s: every hover and focus feedback), --a4-duration-slow (.3s: the phone menu opening and closing); a progress bar that grows with real progress (.a4-content-job-bar, .a4-setup-progress) keeps its own .4s |
| Tracking | --a4-tracking (.04em: uppercase labels); a negative tracking of a large heading stays a value of its own |
Every colour, chart and depth token has a dark value; the radius, spacing, control, type, weight, motion and tracking tokens are the same in both modes.
Weights, durations, letter spacing and line heights come from these tokens, as colours and radii do: no
font-weight: 600, no transition: ... .15s, no letter-spacing: .05em, no border-radius: 4px in any stylesheet of admin4,
the classic admin, admin3 or the admin stylesheets of the extensions in this repository (AdminTokenUsageTest; 0, 50% and 999px as a radius, a negative
tracking and the two progress bars are the exceptions). A line height is one of the three --a4-leading* tokens unless a
fixed pixel height depends on it.
Tables. Every data table is striped the same way: table.list, the .a4-table component and the sub-items table
(exp/datatable, through --exp-dt-row-alt) paint every second data row with --a4-zebra, the hovered row with
--a4-info-bg, a selected row (.a4-table tr.is-selected) with --a4-accent-soft; a row header (th scope="row")
takes its row's colour. Only rows with data cells count (tr:where(:nth-child(even of :has(> td):not([hidden])))): a header
row of th inside tbody or a row with the hidden attribute is skipped, so the first data row is always plain. The
bglight/bgdark classes the templates still write are ignored for the colour. A row hidden by a script
(display: none) still counts; that is a known limit of CSS striping. A table used for layout carries none of these
classes and stays plain; a form laid out as a table.list takes a4-plain as well (shop/editvatrule.tpl,
shop/editcurrency.tpl). Text on a zebra row and on a hovered row keeps 4.5:1 (AdminTokenContrastTest), and no
admin4 stylesheet paints rows in a colour of its own (AdminStylingRulesTest::testDataTablesShareOneZebra).
Cells of a data table sit in the middle of their row (.a4-plain form tables keep their labels at the top). A row
whose selection checkbox (a checkbox straight in one of its cells) is ticked takes --a4-accent-soft without a script
(tr:has(> td > input[type="checkbox"]:checked)), over stripe and hover. List headers are not sticky: almost every
table sits in a horizontally scrolling wrapper (base.css, div:has(> table)), and a sticky header binds to
that wrapper, not to the page. An empty list says so in .a4-empty, never in a plain paragraph
(AdminStylingRulesTest::testEmptyListsUseTheEmptyState).
--a4-orange, --a4-orange-dark and --a4-orange-soft are the accent's former names, kept for the extensions that
use them; new code uses the --a4-accent* names.
The accent is the brand orange #f26a21, as on admin4's header (the selected menu pill) and its default button.
White text on it is only 3:1, so filled controls with white text use its darker shade --a4-accent-strong
(#c2410c, 5.2:1) and accent-coloured text uses --a4-accent-text (#ad3809 light, 5.4:1 also on a hovered table row and 4.8:1 for an is-accent badge on one; #ff9a5c dark).
Stacking layers
Stacking order comes from one scale in components.css; a stylesheet writes z-index: var(--a4-z-…), never a
number (0, 1 and -1 for order inside one component excepted). Theme-independent, no dark value.
| Token | Value | For |
|---|---|---|
--a4-z-raised | 2 | an item over its neighbours (a hovered thumbnail, a drop marker) |
--a4-z-sticky | 5 | sticky toolbars and notices in the content |
--a4-z-frame-grip | 9 | the classic frame's width grip, under its show/hide links |
--a4-z-frame-control | 11 | the classic frame's show/hide links and width handler |
--a4-z-hover-preview | 50 | a thumbnail enlarged on hover, over sticky bars and grips |
--a4-z-popover | 60 | a pane inside the header or the control bar (search scope) |
--a4-z-controlbar-top | 60 | the sticky top control bar of the edit pages |
--a4-z-scroll-top | 100 | the back-to-top button |
--a4-z-navbar | 110 | the phone-width menu |
--a4-z-sidebar | 120 | side columns open over the page |
--a4-z-sidebar-handle | 140 | the left column's width grip |
--a4-z-controlbar | 200 | the fixed control bar of edit pages |
--a4-z-drag-target | 999 | the drop target when a table column is dragged |
--a4-z-header | 1020 | the fixed header |
--a4-z-sidebar-toggle | 1030 | the tabs on the window edges (inside the fixed header: ordered among its children) |
--a4-z-menu / --a4-z-menu-sub | 1040 / 1050 | context menus and their submenus, above the header |
--a4-z-overlay / --a4-z-modal | 1060 / 1070 | a dialog's mask and the dialog (also the base rule of the modal window in base.css) |
--a4-z-toast | 1080 | short-lived notices |
--a4-z-drag / --a4-z-drag-mask | 9000 / 9500 | column drag and resize proxies of the data tables |
--a4-z-skip-link | 10000 | the skip link while it is focused, over everything |
--a4-z-card-veil / --a4-z-card-spinner | 2 / 3 | the busy veil of a card and its spinner, inside the card |
Inside a parent with its own stacking context (the fixed header, a transformed card) a token only orders against that
parent's children. AdminStylingRulesTest::testStackingComesFromTheLayerTokens fails on any other z-index and on a
change of the order sticky < grip < frame controls < hover preview < popover < header < tabs < menus < mask < dialog <
toasts, and on a var(--a4-z-…) that components.css does not define (a typo would fall back to auto).
Components
| Component | Classes |
|---|---|
| Page scope | .a4-page wraps a module page: the box-sizing reset, font, [hidden], links, the focus ring on :focus-visible. .a4-standalone: a page drawn without the main card |
| Utilities | .a4-sr-only, .a4-skip-link (the frame's skip link, visible only while focused), .a4-focus, .a4-muted, .a4-meta, .a4-help, .a4-intro, .a4-num, .a4-nowrap, .a4-wrap, .a4-plain, .a4-code, .a4-ok-text, .a4-warn-text, .a4-bad-text, .a4-middle, .a4-flag, .a4-end, .a4-inset, .a4-overflow-visible |
| Headings | .a4-h2, .a4-h3 |
| Layout | .a4-section, .a4-section-head, .a4-title-row, .a4-row (.is-between), .a4-stack, .a4-grid (.is-narrow, .is-wide) |
| Page header | .a4-pagehead > .a4-pagehead-main (.a4-pagehead-pre, .a4-pagehead-title, .a4-pagehead-sub) + .a4-pagehead-actions (right, under the title on narrow windows); the one title style of every page (h1.context-title, the node view and the dashboard take the same size) |
| Breadcrumb | ol.a4-crumbs (role="list") > li (.is-middle, .is-fold: the middle steps fold into "..." up to 600px, visually only, a focused step shows again), separators from the stylesheet, the current step aria-current="page", inside the #path navigation landmark |
| Card | .a4-card (.is-soft, .is-selected, .is-attention, .is-warn, .is-bad, .is-ok, .is-info, .is-flush, .is-link, .is-busy), .a4-cards, .a4-card-head (title, a <p> as subtitle, .a4-card-actions at its end), .a4-card-title, .a4-card-body, .a4-card-foot (the card's actions on a strip at its bottom edge), .a4-pick (the selection checkbox) |
| Figures, facts | .a4-figures > .a4-figure (.is-attention, .is-bad, .is-ok, .is-warn, .is-info, .is-current); dl.a4-facts (.is-rows) |
| Tag, badge, status | .a4-tag, .a4-badge (.is-ok, .is-warn, .is-bad, .is-info, .is-accent, .is-mono), .a4-badges; .a4-status (.is-ok, .is-running, .is-warn, .is-bad) with .a4-dot (a status with its text); .a4-mark (.is-visible, .is-hidden, .is-hidden-by-parent, .is-disabled, .is-locked): a 10 px mark whose shape tells the state, named by role="img" and aria-label when it stands alone, aria-hidden beside its own text (design:parts/visibility_status.tpl). Two classes: the mark never styles .a4-status |
| Buttons | .a4-btn, .a4-btn-primary, .a4-btn-danger, .a4-btn-outline-danger, .a4-btn-ghost, .a4-btn-small, .a4-btn-large, .a4-btn-icon; .a4-actions (a row of them) |
| Toolbar | .a4-toolbar (an action bar), .is-bottom (under a list), .is-sticky (stays at the window's bottom) |
| Filter bar | .a4-filterbar (a grid of fields above a list), .a4-filter-count |
| Forms | .a4-field, .a4-field-wide, .a4-label, .a4-input (.is-auto), .a4-field-error, .a4-form (.is-wide), .a4-formrow (label beside control), .a4-searchrow, .a4-check, .a4-radios, .a4-chips > .a4-chip, .a4-inline-label (a label around text that was plain before: an inline radio or checkbox with its text, or the words in front of a control in a table cell; drawn like the text around it) |
| Forms | .a4-field, .a4-field-wide, .a4-label, .a4-input (.is-auto), .a4-field-error, .a4-required-mark + .a4-required-hint (required fields), .a4-error-summary (the summary of a failed store), .a4-form (.is-wide), .a4-formrow (label beside control), .a4-searchrow, .a4-check, .a4-radios, .a4-chips > .a4-chip |
| Tables | .a4-table-wrap > table.a4-table (.is-compact), tr.is-selected |
| Under a list | .a4-listfoot, .a4-sizes, .a4-pager; .a4-tabs (links, buttons, span.current, a.current, .is-current; .is-segmented: one pill at --a4-control-h around every choice, the current one tinted in the accent with an accent edge; a pressed button is aria-pressed="true", a current link span.current, a.current or .is-current (with aria-current); Left/Right and Home/End move between the choices (admin4.js), each keeps its Tab stop; .is-icons: round icon buttons, each named by .a4-sr-only text and its title), .a4-count |
| Empty state, notices | .a4-empty; .a4-notice (.is-ok, .is-warn, .is-bad, .is-info: each with its icon left of the text, .no-icon leaves it out; .a4-notice-title for a bold first line) |
| Toasts | .a4-toasts (the two live regions of the frame, #a4-toasts polite and #a4-toasts-alert assertive) and .a4-toast (.is-ok, .is-warn, .is-bad); added only by window.a4Toast( message, kind, opener, delay ) from admin4.js; an action in a toast (Undo) is a .a4-toast-action button before the close button, and its toast stays delay ms |
| Folding | details.a4-fold > summary + .a4-fold-body (.is-bad, .is-warn); details.a4-confirm (a confirmation that opens in place) |
| Steps | ol.a4-steps > li.is-done / li.is-current |
| Timeline | ol.a4-timeline > li (.is-ok, .is-warn, .is-bad, .is-info) > time.a4-timeline-time (with datetime) + .a4-timeline-body (.a4-timeline-meta, a state badge); for short chronological lists without actions per row, newest first; the dot repeats the state a badge says in words. Audit events: parts/audit_timeline.tpl (events, show_reason) |
| Code | .a4-pre (.is-tall), .a4-kbd |
| Amounts | .a4-meter > span, .a4-bars > children, sized by .a4-pct-0 ... .a4-pct-100 (--a4-pct) |
| Language | .a4-lang (a content language's short code, from design:parts/language_badge.tpl; .is-current for the one shown), .a4-langs (several in a row) |
| Icons | .a4-icon with one .a4-bi-<name> (a Bootstrap icon of share/icons/bootstrap-icons/svg/, in currentColor; .is-small, .is-medium, .is-large); .a4-icon-btn (a link or button that is only an icon) |
| Other | .a4-grip (drag handle), .is-dragging |
A card opens with .a4-card-head (not .a4-section-head), puts the rest in .a4-card-body and its buttons in
.a4-card-foot. The foot and the flush head read the card's padding from --a4-card-pad-x/--a4-card-pad-y (with
the spacing scale, --a4-space-4/--a4-space-3, as their fallback). .is-flush lets a table
run to the card's edges (head and foot keep their padding, the table's corner cells take the card's radius).
.is-link makes the whole card follow the link in its .a4-card-title: links, buttons and fields in the body, foot
and head actions stay clickable, but plain text under the link layer cannot be selected, so a link card holds no text
people copy; where the browser has :has() the focus ring goes around the card, elsewhere around the title link.
.is-busy works together with aria-busy="true" on the same element and veils the card with a spinner while its
content loads or is saved; the veil stops only the pointer, so the script that sets it also sets inert on the card
(or disables its controls) and removes both afterwards. aria-busy alone announces nothing: use
window.a4CardBusy( card, on, message ) (admin4.js), which sets and removes aria-busy and inert together and
puts the message ("Saving...", then the result) in a polite live region outside the card. The title of a link card
holds exactly one link, whose text is the card's accessible name; never wrap a card in <a>.
tests/tests/design/AdminCardsTest.php checks the parts and
that no admin4 card opens with a section head.
Inside .a4-page a card list reads as a table: each li.a4-card of a ul.a4-cards is a row with a rule, zebra and
hover, the facts of a row are one line ("ID: 2 Policies: 1"), and from 701 px the rows are subgrids of the list,
so name, buttons and facts line up in columns. The row actions View, Edit, Open, Assign and Copy (.a4-btn-small
found by their link target) are drawn as icon buttons; their text stays for screen readers, and a template gives
such a button a title (its label) as tooltip. Tables (.a4-table)
take the same padding from 601 px, the facts of every card that is not a list row (.a4-card > dl.a4-facts, also in
its .a4-card-body) are one line of "Label: value", and the attributes of class/view (ol.a4-cards) are rows. On
touch screens the row buttons keep their full size. Left as cards: the sources form of rss/edit_export
(ul.a4-cards.a4-card-body) and the action tiles of user/edit. The rules are the block "List rows" of
components.css; tests/tests/design/AdminListRowsTest.php checks it. A module list that should stay a grid of
cards uses .a4-grid instead of ul.a4-cards.
States are .is-* classes on the component. Each component is one class plus at most an element or a state, so a
module rule with one more class always wins.
Example, a list page:
<div class="a4-page">
<div class="a4-toolbar"><p class="a4-meta">12 roles</p>
<div class="a4-actions"><a class="a4-btn a4-btn-primary" href="...">New role</a></div></div>
<ul class="a4-cards">
<li class="a4-card"><div class="a4-card-head"><div class="a4-card-title"><h3 class="a4-h3">Editor</h3>
<span class="a4-badge is-ok">assigned</span></div></div></li>
</ul>
<p class="a4-empty">No roles match.</p>
</div>Window widths
admin4 is checked at 390, 820, 1180, 1440, 1920 and 2560 px (AdminResponsiveTest). Below 576px the top menu is a
hamburger panel in --a4-card under the header's row, at most 100dvh less the header (100vh as the fallback),
the only scroller (overscroll-behavior: contain) while the page behind it is locked; the side columns' tabs are
hidden while it is open, and the header is one row of 56px. Inside: the search first, the tabs as a grid of tiles
(at least 44px each), the user's row, a Close button. Tab stays in the header while it is open; Escape, Close, a tap
outside and a window widened past 575px close it (AdminPhoneMenuTest). The site menu's panel (.a4-site-menu)
opens under the globe on --a4-z-popover; on a phone it is fixed under the header on --a4-z-menu, the window's
width less the gutters. From 576px the header has two rows (48px for the logo and the tools, 40px for the menu),
from 1280px one row of 56px (logo, menu, search, light/dark, user menu, logout); at every width from 576px the menu
stays on one line, and the tabs that do not fit go into its "More" disclosure (admin4.js; without the script the row
scrolls sideways). Logout is an icon button beside the user menu (parts/a4_header_user.tpl), and "/" puts the focus
in the header search. --header-height is set on the root: the drawn heights as defaults, the measured one by
admin4.js (AdminHeaderTest). From 1024px the left column takes room beside the
content, the right one only from 1280px (below that it opens over the content as a drawer). ezadmin_menubar.js uses the same breakpoints: a column below its breakpoint starts closed, opens alone (a column beside the content steps aside meanwhile), closes on Escape with the focus back on its tab, and is never saved as the wide-window choice (AdminSidebarDrawerTest, which runs the script in node). The content column stops
at --a4-content-max. On a phone the node's tabs take their own row and scroll, the tabs switch stands under them,
and a wide table in a tab scrolls inside it. On touch screens (pointer: coarse) the small controls of the frame
(the tree's node icons and open/close arrows, the search scope, the copy buttons, the side columns' checkboxes) get a 24px target. Search results show each result's path under its name, and on a phone the type stacks under them too. On very wide windows the body text grows from 13 to 14px (--a4-text-body); the control and label sizes keep their tokens.
The right column's drawer:
| Phone (below 576px) | Tablet-sized window (576 to 1279px, also 960px at 200% zoom) | Wide window (1280px and up) | |
|---|---|---|---|
| Shown as | drawer over the page | drawer over the page | column beside the content |
| On a new page | closed | as the user left it: eZPreference admin_right_drawer (1 open, 0 or empty closed) | eZPreference admin_right_sidebar |
| Saved by | nothing | a click on its tab, Escape, or a click on the left tab that closes it | a click on its tab |
Keyboard: opening a drawer with its tab moves the focus to the drawer's first control; while it is open, Tab and
Shift+Tab cycle through the drawer's controls and its tab (focus trap); Escape closes it and puts the focus back on
the tab; the tab reports the state in aria-expanded. A page that opens with the drawer restored does not move the
focus. For a signed-in user every one of these choices is an eZPreference written through window.a4Pref (and on
<html> by parts/a4_preference_attributes.tpl, so it follows the user to another browser); an anonymous page keeps
it in localStorage. The left column's drawer (below 1024px) is not remembered.
Resize grips
admin4 has four grips that change a size by hand: the left column's width (.leftmenu-resize-handle,
ezadmin_menubar.js), the node view's tab box height (.a4-tabs-height-grip, parts/a4_tabs_height.tpl, admin4.js),
the debug panel's height (expdebugbar.js) and the details panel of exp_adminui. They behave alike, and a new one
does the same:
- a focusable separator (
role="separator",tabindex="0",aria-orientation,aria-valuenow,aria-valueminandaria-valuemaxin pixels, updated on a window resize), with a name and a title that says how to use it; - dragged with pointer events (
touch-action: none, pointer capture), so the mouse, a finger and a pen all work; - the arrow keys move the edge 16px, Shift+Arrow 64px, Home and End go to the smallest and largest size, and a double-click gives the default back (the left column has its size buttons for that instead);
- held between limits that follow the window, so a size saved on a large screen still fits a small one;
- saved once, at the end of a drag or a short rest after the last key, through
window.a4Pref(an eZPreference for a signed-in user, read on<html>outside every cache-block, so the page is drawn at the saved size without a jump); going back to the default empties the preference instead of storing the default; - the size reaches the CSS as a custom property on
<html>set by the script (--left-sidebar-width,--a4-node-tabs-height), never as astyle=""in a template.
The node view's tab box: the eZPreference admin_node_preview_height (pixels, one height for every node), applied from
576px only, between 10em and 60em and at most 85% of the window. The "Default size" button beside the grip (icon
arrow-counterclockwise) shows only while a height of the user's own is set. AdminNodeTabsHeightTest checks it.
Legacy controls and the side columns
The kernel's module templates still write input.button, input.defaultbutton, input.button-disabled,
button.button, and selects and text fields without a class. Inside #page, admin4.css gives them the size of the
.a4-btn components and of the node's action row: min-height: var(--a4-control-h) (32px), the
type scale's small step and the space tokens; a select gets the field's edge and a chevron drawn in --a4-muted, and
forced colors get the native select back. The selectors keep the base rules' weight (element plus class, the scope
inside :where()), so a rule with one more class (the node's action row, the edit bars, .a4-input, an extension's
own control) always wins; a select or field with a class of its own is not touched. New markup still uses .a4-btn
and .a4-field.
Side columns: blocks are separated by space, not by a line: a heading that follows a block stands --a4-gutter (24
px) below it, whatever the menu template nests (no admin4 stylesheet draws a line above a .box-header); the right
column is --a4-right-width (16rem) wide, so its card keeps 208px beside the 24px edge and the gutter; block titles have one size (1.25em of the column's font, hyphenated); link lists have no bullets. Every left
menu of admin4 ends with design:parts/left_width_control.tpl (three icon buttons in a POST form, the current width
pressed) and a tree menu shows its trash link through design:parts/left_trash.tpl: include them, never copy their
markup (AdminLegacyControlsTest).
The User preferences block of the right column (toolbar/full/admin_preferences.tpl) shows each on/off preference as
its name and the design's tabs (ul.tabs), not as a segmented button switch. The two states are the buttons of a
POST form to user/preferences/set (Key, Value, RedirectURIAfterSet), like the left column's width switch, so a
preference is written only from this site (ezformtoken adds its token) and never by a GET link: the current state is
the pressed button (<li class="selected"><button ... aria-pressed="true">), drawn like the node view's current tab
(the accent tint with the bar under it), the other one a quiet button. A screen reader says the preference's name,
the state and "pressed". A <div class="a4-pref-tabs" role="group" aria-labelledby="..."> around the list names it
after the preference's label (.a4-pref-label); the list keeps its list role. In the column the tabs sit side by
side, at least 24px high, and under forced colors an outline in Highlight marks the current one (for every
ul.tabs). A new on/off preference in the right column is one more entry of the template's list
(AdminLegacyControlsTest).
The node view's Upload button
The node view's actions have an Upload button between Edit and Move (since 2026-10-15). It is a link,
<a class="a4-btn a4-node-upload"> with the Bootstrap Icon .a4-bi-upload and the text "Upload" (ger-DE
"Hochladen"), to ezmultiupload's upload page for the node, ezmultiupload/upload/<node id>. One template draws it,
node/view/upload_button.tpl, in identical copies in design/admin and design/admin4 (admin4 is self-contained;
admin4l falls back to admin4), included by node/view/full.tpl and node/view/a4_full_body.tpl; the icon is on
design.ini [AdminDesignSettings] IconList[]. exp_adminui puts the same link first in its "..."
menu (window_preview_toolbar.tpl).
It shows only when uploading is possible there for the current user: the module ezmultiupload/upload is enabled and
reachable in the siteaccess (ezmodule()), and fetch( 'ezmultiupload', 'can_upload', hash( 'node_id', $node.node_id ) )
is true. That fetch asks expMultiuploadService::canUploadAt(), the one check for links to the upload page: the
ezmultiupload/upload policy, read and content/create access at the node, and a container (or a class or node of
ezmultiupload.ini [MultiUploadSettings] AvailableClasses[] / AvailableSubtreeNode[]). Per-class create rights for
each file are checked by the upload page itself. A draft or archived object never shows the button, and nor do the
edit pages, which do not include the node view. The node view is view-cached per role set, like Edit and Move.
admin4 sizes the link like the other actions (--a4-control-h) and takes it into the narrow-width rules of
.a4-node-actions (admin4.css); the classic bar of design/admin keeps it as high as its legacy buttons
(modules/content.css). Tests: tests/tests/design/AdminNodeUploadButtonTest.php.
Version preview
content/view/versionview.tpl draws the device switch of the preview with the components (.a4-tabs.is-segmented,
aria-pressed buttons, .a4-icon) and modules/content.css adds what they cannot do:
| Class | Holds |
|---|---|
.a4-content-preview-bar | the device pill, the landscape switch and .a4-content-preview-size (the size and scale); a container: below 640 px the names (.a4-content-preview-device-name) are visually hidden and the buttons are round icons |
.a4-content-preview-stage | the .mainobject-window around the iframe; .is-device (set by the script) centres the frame on --a4-soft |
.a4-content-preview-device | the frame: the script sets its size (the device's size times the scale, box-sizing: content-box), the iframe inside has the device's size and transform: scale() |
The behaviour is a part of admin4.js (it starts when the page has #a4-content-preview-bar and
#a4-content-preview-stage); the device and the orientation are the eZPreferences admin_preview_device and
admin_preview_landscape, kept through window.a4Pref (AdminPreviewDevicesTest, AdminPreferencesTest).
A site design that overrides versionview.tpl keeps its own preview; to take the switch, copy the bar and the stage
from admin4's template.
Edit forms
The datatype edit templates live in design/standard and still write the classic markup: input.box,
input.button, input.button-disabled, bare checkboxes, <label> above a field, <fieldset> and <legend>. Scripts
look for some of these classes (the AJAX uploader and the relation list swap button and button-disabled), so
they stay. modules/content.css draws them as the components instead, inside two scopes:
| Scope | Set by | Holds |
|---|---|---|
.a4-content-attr | content/edit_attribute.tpl, on every attribute (content/edit, translate, multiedit, user/register, the template look editor) | the attribute's controls |
.a4-content-edit | the <form id="editform"> of content/edit.tpl | the left column and the relations box of the edit page (buttons only) |
| Markup | Drawn as |
|---|---|
| a text field, select or textarea (any class) | .a4-input: --a4-control-h, --a4-text-m, --a4-field-line, --a4-radius-s, the accent edge and ring on focus; a one-line select gets the chevron of the legacy selects; a field with a size keeps its width, .box is full width, .halfbox keeps its half width |
input.button, input.button-disabled, button.button | .a4-btn: outlined, the accent edge and text on hover, faded at opacity: .55 when disabled (no hatch) |
input.defaultbutton | .a4-btn-primary |
| a checkbox or radio | as in .a4-check: 18 px in --a4-accent-strong; a <label> around one and its text is a row, not a bold block |
input[type=file] | its button as .a4-btn, its text muted |
a <label> of a part (Year, URL, Username ...) | .a4-label (12 px, semibold, above its field) |
The attribute's own name is a label or legend of the class .a4-content-attr-label (13 px, semibold, no trailing
colon), and a grouped input's fieldset has the class .a4-content-attr-group: no edge, background or padding of its
own. The selectors are the scope class plus the element (.a4-content-attr :is(input, select, textarea)), so they
beat base.css (input.box, label, fieldset) by loading later, while the invalid edge
(.a4-content-attr input[aria-invalid="true"]), the sticky button bars (#controlbar-top ...) and any rule with one
more class still win. An extension's datatype template that wants its own look gives its control such a class.
admin4 carries the edit templates of ezdate, ezdatetime, eztime, ezurl, ezprice, ezimage,
ezbinaryfile, ezoption, ezuser, ezmedia, ezmultioption, ezrangeoption, ezmultiprice, ezinisetting,
ezpackage and ezobjectrelationlist (in templates/content/datatype/edit/): the parts of an attribute stand in a
row (.a4-content-parts, the date and the time of ezdatetime in .a4-content-datetime), each with its label above
the field and without a colon; a heading without a control of its own is a p.a4-label; the calendar is a
button.a4-btn.a4-btn-icon with .a4-icon.a4-bi-calendar3; no div.element floats, div.break or spacing.
Their lists name their fields by their column headers (th scope="col" with an id, aria-labelledby), a remove
checkbox by its row's object or by the action, a single control without a label of its own by the attribute's name
(aria-labelledby="ezcca-name-<id>" from edit_attribute.tpl); radio and checkbox lists of the relation list are
.a4-check rows, its invert-selection control is an .a4-icon-btn, and empty lists are .a4-empty. Each copy posts
the same field names and ids and keeps the template blocks and table structure of design/standard's
(AdminContentEditFormsTest), so a fix there has to be carried over by hand. The other datatypes take the look
through the stylesheet alone. admin4's base.css no longer styles .datepicker-icon (the calendar is an
.a4-btn now); the class stays in design/admin, whose calendar is still the image.
tests/tests/design/AdminContentEditFormsTest.php checks the rules and the templates.
ezoe's engine switch below a rich text field (ezxmltext_ezoe_engine_switch.tpl, EngineSwitch=enabled) writes
neutral markup: div.ezoe-engine-switch (role="group", named by its label span.ezoe-engine-switch-label
"Editor"), the engine in use as the pressed toggle button.ezoe-engine-switch-option.current (type="button", aria-pressed="true"), every other
engine as a button.button.ezoe-engine-switch-option (aria-pressed="false") that posts CustomActionButton[<attribute id>_switch_engine_<id>].
The engines' span.ezoe-engine-switch-options also carries a4-tabs is-segmented, so every administration design
draws it as the one segmented pill of components.css, as the left menu's width switch: the track, the engine in use
tinted in the accent with an accent edge, the focus ring, forced colors, and the arrow keys of admin4.js.
modules/content.css only places it (the label muted beside the pill on the same centre line, left-aligned under the
field like its other controls and its label, --a4-space-2 below the editor's status bar; on a narrow screen the
pill moves under the label as one piece) and keeps the pill's hover over the stronger legacy button hover of that
file (AdminContentEditFormsTest).
The rich text editors (TinyMCE 3 and 8) draw a field's text in an iframe with ezoe's light content stylesheet. The
edit form names components.css and editor_content.css in data-a4-editor-css; admin4.js adds both to each
editor's document and copies html[data-a4-theme] there when the editor is ready and when the mode changes, so the
text, links, literal blocks, custom tags and embeds take the tokens in both modes. Every rule of
editor_content.css is scoped to html[data-a4-theme] and does nothing without the script. The editor's toolbar
(the TinyMCE skin) is not part of this.
Every admin design
The rules and tokens on this page apply to every administration design, not only to admin4:
| Design | Stylesheets of its own | Tokens | Dark mode |
|---|---|---|---|
admin4 | base.css, admin4.css, components.css, modules/*.css (and core.css, theme/modalwindow.css for other designs' chains) | components.css | yes |
admin4l (the layout-driven admin4) | none: templates only, it loads admin4's stylesheets | admin4's | yes |
editor (the editor siteaccess) | none: design/editor falls back to admin4l or admin3, whichever the siteaccess names after it | that design's | as that design |
admin (classic) | core.css, pagelayout.css, content.css, theme/rounded.css, theme/admin_datatable.css, theme/modalwindow.css, ezinfo.css, packagecompare.css, visual-templateview.css; components.css and modules/*.css are admin4's copies | components.css (the copy) | no: light only |
admin3 | pagelayout.css, theme/rounded.css, responsive.css; the rest from design/admin | design/admin's | no: light only |
The same contrast values everywhere. The classic admin and admin3 read the same tokens as admin4's light mode, so a value there is a value everywhere:
| Token | Light | Dark (admin4) | Reaches |
|---|---|---|---|
--a4-ink | #1f2430 | #e8ebf0 | 4.5:1 on every surface |
--a4-muted | #545c6a | #a3abb9 | 4.5:1 on card, soft, zebra, head and every state tint |
--a4-line | #c9cfd8 | #2e3644 | a divider, no contrast requirement |
--a4-line-strong | #aab2bd | #434d5f | column separators |
--a4-soft | #eaedf1 | #222936 | soft surfaces |
--a4-zebra | #eceff3 | #212734 | the even data rows |
--a4-head-bg | #dce1e8 | #283041 | the header row of data tables |
--a4-field-line | #6e7588 | #6f7a93 | 3:1 on every surface and on admin4's page colour |
--a4-accent-text | #ad3809 | #ff9a5c | 4.5:1 as text, also on a hovered row |
In the classic admin and admin3 the content area takes these tokens; the dark frame of those designs (header, top menu, footer, the textured page and the sign-in page) keeps its own colours, which are part of their look.
- List tables in every design: the header row on
--a4-head-bgwith--a4-inktext (the classic grey bar with#eeetext was 4.4:1), separators in--a4-line-strongbetween the columns and none before the first, rounded top corners (--a4-radius-s), even data rows on--a4-zebra, the hovered row on--a4-info-bg. The same for the datatable theme (.admin-dt) and for expui's tables (--exp-dt-*, mapped in each design's frame stylesheet). - Section headings: the
.box-headerof adiv.context-blockhas a solid--a4-lineunder it. - Greys: text greys are
--a4-inkor--a4-muted, lines--a4-lineor--a4-line-strong, light surfaces--a4-cardor--a4-soft. A raw text colour left in the content area of the classic designs (a brand blue, a state colour on its own fill) reaches 4.5:1 on its background. - Weights, durations, tracking, radii come from the tokens in every design's stylesheets, as in admin4. A file
that is also loaded without
components.css(core.css: the standard popup, print and setup layouts resolve it through the design chain) names the old value as the fallback:var(--a4-weight-bold, bold). - The two layout fixes reached the classic admin where its templates have the same markup: the content edit
page's Locations table scrolls inside its column (
.a4-content-locations-scroll,modules/content.css). The node view's tabs switch of admin4 (a grid row of its own) has no counterpart in the classic window controls, whose switch is the small triangle before the tabs.
One base stylesheet is admin4's rule: a design that draws its own frame keeps its base styles in one base.css.
The classic admin and admin3 keep their split files: the files are shared down the design chains (admin3, admin2 and
the editor resolve design/admin's core.css, content.css, theme/admin_datatable.css and theme/modalwindow.css
one by one, the admin_theme preference names a theme file, design/standard's popup and print layouts load
core.css), so merging them would either copy the same rules into several files or break those chains. The packer
bundles them into one request either way.
Dark mode
admin4's mode is html[data-a4-theme="light"|"dark"], set before the page is drawn by the script at the top of
page_head_style.tpl from the user's choice (window.a4Pref: the eZPreference admin_color_mode, the browser on an
anonymous page); the header's toggle switches it. Every remembered choice of the frame (mode, side
columns, menu groups) goes through window.a4Pref; never write localStorage from a feature script
(AdminPreferencesTest).
Since 2026-10-14 the frame's columns are dark in dark mode too (--a4-card-bg follows --a4-surface), so anything
that paints its own background must take it from a token. A component or module rule never needs a
html[data-a4-theme="dark"] rule of its own: the tokens switch.
Exceptions
Inline CSS that stays, each documented where it is:
| Where | Why |
|---|---|
admin4 page_head_style.tpl: the inline script at the top | sets data-a4-theme before anything is drawn (a page drawn light and then switched would flash), and the left column's hand-dragged width from data-a4-left-width on <html>. A script, not CSS. |
admin3 pagelayout.tpl: a two-line script | the hand-dragged left column width, as above (admin3 has no theme script) |
design/admin/templates/pagelayout.tpl: one <style> block | the column widths of the editor's preferences, in em from menu.ini [LeftMenuSettings] MenuWidth or a dragged width, which a stylesheet cannot know, needed before the page is drawn |
The left column's preset sizes (small, medium, large) are html[data-a4-left-size] rules in base.css. They
set the width only (--left-sidebar-width): the column's text keeps the body's size at every width
(AdminLeftColumnFontTest).
Checked in CI
tests/tests/design/AdminStylingRulesTest.php (testsuite design, no database) fails when:
- a template of
admin4,admin4l,admin3oradmin(intemplates/oroverride/templates/) has a<style>block or astyle=""attribute (template comments are not read), outside the exceptions above and theALLOWLISTof templates still being migrated. The allowlist only shrinks: a listed file without inline CSS fails until it is taken off. It is empty when the migration is complete. A design on the test's list that does not exist fails the test, rather than being skipped; - a rule in
stylesheets/modules/*.css(admin4 and admin) has a colour literal, a radius that is not a token,0or50%, a spacing length that is not a token (0,1px,2px,autoandcalc()of tokens are allowed), a font family or pixel font size of its own, a custom property of its own, or a selector without an.a4-class; - a colour token in
components.csshas no dark value, or the admin and admin4 copies differ; admin4.cssor one of admin4's page stylesheets (ezinfo.css,packagecompare.css,visual-templateview.css) has a colour literal outside a token definition;- admin4's base stylesheet (
base.css) has a colour literal outside a token definition. A legacy literal kept on purpose would go intoBASE_COLOUR_ALLOWLIST, which only shrinks; it is empty. A rule admin4.css overrides today still takes a token, so the next selector that slips past the override cannot show an old colour; - a
:focusor:focus-visiblerule of an admin4 stylesheet removes the outline (none,hiddenor a width of0, in any order of the shorthand;:focus:not(:focus-visible)and:focus-withinaside). The global ring (outline: 3px solid var(--a4-ring), offset 2px) is the focus indicator. A frame field that draws its ring as abox-shadow(outside.a4-page) setsoutline: 2px solid transparentinstead ofnone, which forced-colors mode paints where it drops the shadow; inside.a4-pagethe components' own ring applies anyway. The check reads only rules whose selector names the focus: a plain rule that outranks the global ring (an id such as#logintext { outline: none }) is not seen, so give such a rule a:focusrule that restores the outline; components.cssloses thecolor-schemeof a theme, its forced-colors or touch block,admin4.cssitsprefers-reduced-motionrule, or an admin4 stylesheet transitionsall(name the properties instead);- a state of a module stylesheet (
.is-current,.is-selected,.is-moved,.is-dirty,[aria-pressed="true"],:checked) that differs only by border, background or box-shadow has nooutline: 2px solid Highlightin the file's@media (forced-colors: active)block: forced colors drop all three (AdminBaseStylesheetsTest::testModuleStatesKeepAnOutlineInForcedColors); - a base stylesheet has a rule for
html[data-a4-theme](the tokens switch; a focus ring isvar(--a4-ring)in both modes) or a vendor-prefixed or IE-only leftover (-moz-/-ms-/-o-transitions, shadows and gradients,filter: alpha(),zoom: 1,linear-gradient(top, …)) (AdminBaseStylesheetsTest); components.cssoradmin4.csssets a pixel font size, or a spacing length that is not a--a4-space-*token (1pxand2pxaside; a layout length is a token of its own);- a file named in
AdminComponentCSSFileListis missing.
No text is smaller than 12px on an admin4 page. The base and page stylesheets (base.css,
the Info, package and template pages) size text in em of the body, which admin4.css sets to var(--a4-text-body) (13px, growing to 14px on a very wide window);
an em size that would land below 12px is var(--a4-text-xs) instead
(AdminStylingRulesTest::testBaseAndPageStylesheetsDrawNoTextBelowTwelvePixels).
Targets are at least 24 x 24px (WCAG 2.5.8). Where the drawn control stays smaller, an invisible ::after around
it takes the click (the search scope switch); where that would cover a neighbour (the content tree's open/close,
20 x 20px) or the control is a native checkbox (18px), the spacing exception of 2.5.8 applies: keep rows at least
24px apart. A pixel length that is a layout length rather than spacing (the password field's room for its toggle) is a token of
its own with a dark value (--a4-pw-toggle-room); an offset is a calc() of the space tokens. The test keeps no
exception list.
The node view's tabs switch (.tab-block .show-hide-tabs, the ids #maincontent-show / #maincontent-hide, or
aria-pressed on a button) is a 36 x 24px switch with the visible label .a4-tabs-switch-label ("Tabs"); both float
at the right end of the tab row. Style it by those hooks, not by the element, so link and button look the same.
Modes the browser asks for: each theme sets color-scheme (scrollbars, select popups and date pickers follow);
forced colors (Windows high contrast) get edges, the current and selected states and the focus in system colours,
and data drawn as fills (dots, meters, bars) keeps its colours (forced-color-adjust: none);
prefers-reduced-motion: reduce ends every transition and animation at once; on a touch screen (pointer: coarse)
fields use --a4-text-l (16px) so a phone does not zoom in on focus.
tests/tests/design/AdminTokenContrastTest.php computes the WCAG contrast of the token pairs in both modes: text
tokens on card, surface and soft 4.5:1, each state's text on its own tint 4.5:1, the text of filled buttons (also
hovered) 4.5:1, the ring and the field edge 3:1 on every surface and the page, the chart colours 3:1 on a card, a
meter's fill on its track 3:1 (.a4-meter fills with --a4-accent-text: --a4-accent was 2.9:1 on the light track,
--a4-accent-strong 2.8:1 on the dark one). It also checks every admin design: the classic admin's components.css
carries admin4's tokens unchanged, the list tables of admin4, the classic admin and admin3 take --a4-head-bg and
--a4-ink with separators, rounded corners, zebra and hover rows and the section heading's line (the datatable
theme and expui's tables as well), and no raw text colour in the content area of the classic admin or admin3 falls
below 4.5:1 on its background.
Printing: admin4.css has an @media print block (the content on the full width, no frame, no shadows), and the
head script switches html[data-a4-theme] to light before printing, so a print style needs no colours of its own.
Never weaken the test or add an allowlist entry to make a change pass; move the CSS.
php vendor/bin/phpunit tests/tests/design/AdminStylingRulesTest.phpAfter a stylesheet change
./console exp:velocity deploy --packer --allow-root-userNew templates also need the template-override cache cleared (the deploy does it). A browser that still shows the old look needs a hard reload (Ctrl+Shift+R); the packed file's name carries the modification time.
Code review checklist
For every template or stylesheet change in an administration design or an extension's admin design:
- To do: No
<style>block and nostyle=""attribute was added (also not in a JavaScript string that writes markup). - To do: The page uses the shared
.a4-*components; nothing in it copies a component under another name. - To do: New CSS is in
stylesheets/modules/<group>.css(core) or the extension's own stylesheet, with.a4-<module>-*(or the extension's) class names, and holds only what the components cannot do. - To do: Icons are
.a4-iconwith a.a4-bi-*class: no GIF or PNG icon throughezimage, no inline<svg>, no<input type="image">(tests/tests/design/AdminIconsTest.phpchecks admin4). - To do: A popup or menu reuses the context menu (
ezpopupmenu.js,div.popupmenu): it gets the menu roles and the keyboard handling there; nothing opens a menu that only a mouse reaches (AdminContextMenuTest). - To do: A control that has no visible text has a name (
aria-label, or.a4-sr-onlytext inside it); a header cell hasscope; a new frame column keeps its landmark role (tests/tests/design/AdminLandmarksTest.php). - To do: Every form field has a name: a
<label for>on its id, a<label>around it, oraria-labelledbyon text that is shown (a column header, the row's name, a card heading);aria-labelonly where no text is shown. Atitleor aplaceholderis not a name. Around plain text use<label class="a4-inline-label">, which keeps the text's look. A field with a label that has text gets noaria-labelbeside it: it would replace the label's words (WCAG 2.5.3) (tests/tests/design/AdminFormLabelsTest.phpchecks admin4). - To do: Colours, radius, spacing and fonts are
var(--a4-*)tokens: no#hex,rgb(), named colour, px radius or spacing literal, and no local custom properties. - To do: A new token is in
components.csswith a light and a dark value, and documented on this page. - To do: A z-index is a
--a4-z-*token (or 0/1/-1 inside one component). - To do: A state shown only by border, background or shadow (current, selected, pressed) has an
outline: 2px solid Highlightin the file's@media (forced-colors: active)block. - To do: A required field shows
.a4-required-mark(aria-hidden) with the word in.a4-sr-only, and its control isaria-required; an invalid field shows its message (.a4-field-errorwith an id) and its control isaria-invalidwith that id inaria-describedby(tests/tests/design/AdminFormErrorsTest.php). - To do: A new data table is a
table.listor an.a4-table(zebra rows, hover); controls in one row share--a4-control-h. - To do: A notice that reports the result of an action has a role:
role="alert"for an error (.a4-notice.is-bad,div.message-error),role="status"for the rest (a status region already in the page is usually not announced on load, but screen readers differ); a notice that explains a standing state has none. No hand-written glyph ("!", a tick) at the start of a notice: the state class draws the icon. Feedback of an action done in the page without reload (copied, stored by Ajax) is a toast (window.a4Toast), not a notice that appears; it never takes the focus (AdminNoticesTest). - To do: A new left-menu link (menu.ini
Links[]of a section withGroups[]) is placed in a group (GroupLinks_<group>[], in the extension's own menu.ini.append.php for an extension's link:GroupLinks_ai[],GroupLinks_updates[], ...); otherwise it shows under "More" (AdminLeftMenuGroupsTestchecks Leftmenu_setup). - To do: A new module stylesheet is listed in
AdminComponentCSSFileListand exists indesign/admin4anddesign/admin. - To do: The page was checked in light and dark mode at 1440 px and at 960 px with scale 2: no light card on the dark page, text contrast at least 4.5:1, no horizontal overflow, no console errors.
- To do:
php vendor/bin/phpunit tests/tests/design/AdminStylingRulesTest.phppasses, and the allowlist only shrank. - To do: Deployed with
--packer; no version query added to a link.