Skip to the content
Documentation
Help Start
Reading in:
  • German: 9,059 of 9,059 pages
About the help in other languages
All help pages

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…

Download as PDF

Address: https://doc.exponential.earth/exphelp/viewer/bc/6.0/admin4-styling.md

On this page

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

  1. No <style> block and no style="" attribute in a template of an administration design. All CSS lives in the design's stylesheets/ directory and is loaded through design.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-0 to .a4-pct-100) or a data-* attribute that a stylesheet or the page's script reads, never style="".
  2. 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.
  3. 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.
  4. Every colour token has a value for html[data-a4-theme="dark"], added in the same commit as the token.
  5. 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.
  6. 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.
  7. 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.css

page_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.

FileHolds
components.cssthe tokens (light and dark) and the .a4-* components; the copy in design/admin is identical (the test checks it)
modules/setup.csssetup/*
modules/content.csscontent/*
modules/people.cssrole, section, state, user, notification, collaboration
modules/structure.cssclass, pdf, rss, package
modules/workflow.cssworkflow, 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.

GroupTokens
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.

TokenValueFor
--a4-z-raised2an item over its neighbours (a hovered thumbnail, a drop marker)
--a4-z-sticky5sticky toolbars and notices in the content
--a4-z-frame-grip9the classic frame's width grip, under its show/hide links
--a4-z-frame-control11the classic frame's show/hide links and width handler
--a4-z-hover-preview50a thumbnail enlarged on hover, over sticky bars and grips
--a4-z-popover60a pane inside the header or the control bar (search scope)
--a4-z-controlbar-top60the sticky top control bar of the edit pages
--a4-z-scroll-top100the back-to-top button
--a4-z-navbar110the phone-width menu
--a4-z-sidebar120side columns open over the page
--a4-z-sidebar-handle140the left column's width grip
--a4-z-controlbar200the fixed control bar of edit pages
--a4-z-drag-target999the drop target when a table column is dragged
--a4-z-header1020the fixed header
--a4-z-sidebar-toggle1030the tabs on the window edges (inside the fixed header: ordered among its children)
--a4-z-menu / --a4-z-menu-sub1040 / 1050context menus and their submenus, above the header
--a4-z-overlay / --a4-z-modal1060 / 1070a dialog's mask and the dialog (also the base rule of the modal window in base.css)
--a4-z-toast1080short-lived notices
--a4-z-drag / --a4-z-drag-mask9000 / 9500column drag and resize proxies of the data tables
--a4-z-skip-link10000the skip link while it is focused, over everything
--a4-z-card-veil / --a4-z-card-spinner2 / 3the 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

ComponentClasses
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)
Breadcrumbol.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
Foldingdetails.a4-fold > summary + .a4-fold-body (.is-bad, .is-warn); details.a4-confirm (a confirmation that opens in place)
Stepsol.a4-steps > li.is-done / li.is-current
Timelineol.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 asdrawer over the pagedrawer over the pagecolumn beside the content
On a new pageclosedas the user left it: eZPreference admin_right_drawer (1 open, 0 or empty closed)eZPreference admin_right_sidebar
Saved bynothinga click on its tab, Escape, or a click on the left tab that closes ita 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-valuemin and aria-valuemax in 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 a style="" 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:

ClassHolds
.a4-content-preview-barthe 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-stagethe .mainobject-window around the iframe; .is-device (set by the script) centres the frame on --a4-soft
.a4-content-preview-devicethe 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:

ScopeSet byHolds
.a4-content-attrcontent/edit_attribute.tpl, on every attribute (content/edit, translate, multiedit, user/register, the template look editor)the attribute's controls
.a4-content-editthe <form id="editform"> of content/edit.tplthe left column and the relations box of the edit page (buttons only)
MarkupDrawn 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 radioas 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 &nbsp; 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:

DesignStylesheets of its ownTokensDark mode
admin4base.css, admin4.css, components.css, modules/*.css (and core.css, theme/modalwindow.css for other designs' chains)components.cssyes
admin4l (the layout-driven admin4)none: templates only, it loads admin4's stylesheetsadmin4'syes
editor (the editor siteaccess)none: design/editor falls back to admin4l or admin3, whichever the siteaccess names after itthat design'sas 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 copiescomponents.css (the copy)no: light only
admin3pagelayout.css, theme/rounded.css, responsive.css; the rest from design/admindesign/admin'sno: 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:

TokenLightDark (admin4)Reaches
--a4-ink#1f2430#e8ebf04.5:1 on every surface
--a4-muted#545c6a#a3abb94.5:1 on card, soft, zebra, head and every state tint
--a4-line#c9cfd8#2e3644a divider, no contrast requirement
--a4-line-strong#aab2bd#434d5fcolumn separators
--a4-soft#eaedf1#222936soft surfaces
--a4-zebra#eceff3#212734the even data rows
--a4-head-bg#dce1e8#283041the header row of data tables
--a4-field-line#6e7588#6f7a933:1 on every surface and on admin4's page colour
--a4-accent-text#ad3809#ff9a5c4.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-bg with --a4-ink text (the classic grey bar with #eee text was 4.4:1), separators in --a4-line-strong between 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-header of a div.context-block has a solid --a4-line under it.
  • Greys: text greys are --a4-ink or --a4-muted, lines --a4-line or --a4-line-strong, light surfaces --a4-card or --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:

WhereWhy
admin4 page_head_style.tpl: the inline script at the topsets 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 scriptthe hand-dragged left column width, as above (admin3 has no theme script)
design/admin/templates/pagelayout.tpl: one <style> blockthe 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, admin3 or admin (in templates/ or override/templates/) has a <style> block or a style="" attribute (template comments are not read), outside the exceptions above and the ALLOWLIST of 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, 0 or 50%, a spacing length that is not a token (0, 1px, 2px, auto and calc() 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.css has no dark value, or the admin and admin4 copies differ;
  • admin4.css or 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 into BASE_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 :focus or :focus-visible rule of an admin4 stylesheet removes the outline (none, hidden or a width of 0, in any order of the shorthand; :focus:not(:focus-visible) and :focus-within aside). The global ring (outline: 3px solid var(--a4-ring), offset 2px) is the focus indicator. A frame field that draws its ring as a box-shadow (outside .a4-page) sets outline: 2px solid transparent instead of none, which forced-colors mode paints where it drops the shadow; inside .a4-page the 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 :focus rule that restores the outline;
  • components.css loses the color-scheme of a theme, its forced-colors or touch block, admin4.css its prefers-reduced-motion rule, or an admin4 stylesheet transitions all (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 no outline: 2px solid Highlight in 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 is var(--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.css or admin4.css sets a pixel font size, or a spacing length that is not a --a4-space-* token (1px and 2px aside; a layout length is a token of its own);
  • a file named in AdminComponentCSSFileList is 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.php

After a stylesheet change

./console exp:velocity deploy --packer --allow-root-user

New 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 no style="" 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-icon with a .a4-bi-* class: no GIF or PNG icon through ezimage, no inline <svg>, no <input type="image"> (tests/tests/design/AdminIconsTest.php checks 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-only text inside it); a header cell has scope; 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, or aria-labelledby on text that is shown (a column header, the row's name, a card heading); aria-label only where no text is shown. A title or a placeholder is 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 no aria-label beside it: it would replace the label's words (WCAG 2.5.3) (tests/tests/design/AdminFormLabelsTest.php checks 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.css with 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 Highlight in 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 is aria-required; an invalid field shows its message (.a4-field-error with an id) and its control is aria-invalid with that id in aria-describedby (tests/tests/design/AdminFormErrorsTest.php).
  • To do: A new data table is a table.list or 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 with Groups[]) 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" (AdminLeftMenuGroupsTest checks Leftmenu_setup).
  • To do: A new module stylesheet is listed in AdminComponentCSSFileList and exists in design/admin4 and design/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.php passes, and the allowlist only shrank.
  • To do: Deployed with --packer; no version query added to a link.

Something wrong on this page? Report an error

Documentation license

This documentation is free documentation, licensed under the GNU Free Documentation License 1.3 or later.

Exponential Copyright © 1998-2026 Exponential Foundation and 7x