@@PRODUCT@@

The CoreCP design system

Everything you see in the panel is built out of one small set of parts. This page says what those parts are, what the design decides for you, and how a new screen is put together with them.

Written for: Administrator

Everything you see in the panel is built out of one small set of parts. This page says what those parts are, what the design decides for you, and how a new screen is put together with them.

The living version of this page is the panel itself: /styleguide shows every part on one screen, in light and in dark. Open it before you build anything.

The design language, in five sentences

  1. A tinted near-neutral canvas. Never pure white, never pure black — the greys carry a trace of violet so the whole panel reads as one family.
  2. One accent: iris. It is not blue, because every browser paints links blue and an accent that means "clickable" everywhere means it nowhere. Green, amber and red are reserved for states and are never decoration.
  3. The data plane is quiet and drawn with hairlines. Pages, tables, cards and forms are flat, separated by a single pixel, and a shadow appears only under a card. Depth means layer, never decoration.
  4. Dense, and readable. 13px for the interface, 14px for text you read, numbers always tabular so a column lines up.
  5. Everything that floats above the data plane is glass — a phone's capsule and its more sheet, a slide-over, a dialog, a popover, the ⌘K palette, and the top bar the moment content slides under it. Never on a table: a data surface has to be legible, not atmospheric. Glass carries navigation and controls, never figures you want to compare.

Both themes are checked against WCAG AA by a script, not by eye (npm run check:ui, 50 colour pairs, contrast computed from the tokens themselves).

Where things live

corecp-panel/web/src/
  ui/tokens.css       every colour, size, radius, shadow and duration
  ui/icons/           the inline-SVG icon set (24px grid, 1.5 stroke)
  ui/*.tsx            the primitives — the only building blocks a page may use
  shell/nav.ts        one navigation config, filtered by role
  shell/AppShell.tsx  the frame: sidebar, top bar, capsule, more sheet
  pages/              one file per screen, built out of src/ui

The rule for pages

A page imports from ../ui and from nowhere else for anything visual. No hex colours, no style={{ color: … }}, no invented spacing.

This is enforced, not requested:

cd corecp-panel/web
npm run check:ui
# contrast: 50 pairs pass WCAG AA in both themes (0 not literal, skipped)
# ui-consistency: clean (310 baselined violations left in 40 pre-round-2 files)

A page that paints its own colour fails the build:

$ npm run check:ui
src/pages/Example.tsx: 1 violations, baseline allows 0 — new violations
  src/pages/Example.tsx:42  [hex-colour] #ff0000
      hex colour — use a token (className="text-danger", var(--cp-…))

The thirty screens from round 1 predate the primitives and are listed in ui-consistency-baseline.json with the number of violations each still has. They may get better and may not get worse; sessions A2-A5 migrate them, and when a file reaches zero it is removed from the baseline:

npm run check:ui:baseline      # regenerate after a migration
git diff ui-consistency-baseline.json

If a violation is genuinely unavoidable — an xterm colour scheme, a QR code's two colours, a colour picker's own swatches — put the reason on the line:

// ui-allow: xterm needs literal colours, it does not read CSS variables
const theme = { background: '#0b0d10', foreground: '#d6dde6' }

Building a screen

import { PageHeader, Section, DataView, EmptyState, Button, Badge } from '../ui'
import { IconServers, IconPlus } from '../ui/icons'

export function Nodes() {
  const t = useT()
  return (
    <>
      <PageHeader
        title={t('nodes.title')}
        actions={<Button variant="primary" icon={IconPlus}>{t('nodes.add')}</Button>}
      />
      <DataView
        caption={t('nodes.caption')}
        rows={rows}
        columns={columns}
        getKey={(r) => r.fqdn}
        onRowClick={(r) => setSelected(r)}
        empty={<EmptyState icon={IconServers} title={t('nodes.empty')} />}
      />
    </>
  )
}

Four things that come for free and must not be re-implemented:

You needUseNever
A table on a phoneDataView (table + cards from one column list)a second component
Detail of a rowDetailPanel (slide-over / bottom sheet)a new page per row
"It worked"useToast().toast(…), with undo where it is reversiblean inline banner
"Are you sure?"ConfirmDialog, or TypeToConfirm in a DangerZonewindow.confirm

The patterns the design has already decided

  • Row click opens a slide-over on a desktop and a bottom sheet on a phone (DetailPanel). The list stays where it was.
  • Validation rewards early and punishes late: nothing while a field is being filled in, a check on blur, and once it is wrong, a check on every keystroke. useFieldValidation is that and nothing more.
  • More than ten options is a Combobox, not a Select.
  • Passwords always show/hide, generate and copy, and never block paste.
  • Destructive friction matches the damage: reversible → do it and offer Undo; recoverable → ConfirmDialog; catastrophic → TypeToConfirm.
  • Empty states have exactly one call to action.
  • Skeletons for a load between one and ten seconds, shaped like the thing they replace.
  • Motion is 110-260ms and collapses to nothing under prefers-reduced-motion; nothing depends on an animation finishing.

One configuration in src/shell/nav.ts, filtered by level. Seven top-level entries at most, in two sections, and children shown only for the part you are in. A new screen is a line in that file:

{ to: '/backups', key: 'nav.backups', icon: IconBackup, minLevel: 'reseller' }

minLevel decides what is shown. It is never what decides what is allowed — the server refuses what the user may not do regardless.

Every destination in the file automatically becomes a ⌘K command. A page adds its own actions while it is on screen:

useCommands('nodes', useMemo(() => [{
  id: 'nodes.add', label: t('nodes.add'), group: t('ui.palette.group.actions'),
  icon: IconPlus, perform: () => setAdding(true),
}], [t]))

Themes and white label

Light, dark and system. The choice is stamped on <html data-theme> before the first paint, so there is no flash. For a screenshot or a test you can force it from the address bar — this is read once and never stored:

https://panel1.corecp.dev/styleguide?theme=dark

A reseller's brand colour maps onto the accent half of the token layer: the primary button, the active navigation item, the focus ring and links follow it, and the neutral scale, the radii and the type do not. One colour per theme is all a reseller is asked for; the hover shade, the soft tint and the ring are derived.

Looking at it

The style guide is served without a login (it holds no data) and is linked in the navigation for administrators:

# from the repository, on a development machine
cd corecp-panel/web && npm run dev
# → http://127.0.0.1:5173/styleguide

# screenshots of both themes, into .wolf/designqc-captures/
cd ~ && openwolf designqc --url http://127.0.0.1:5173 \
  --routes "/styleguide?theme=light" --quality 72 --max-width 1280

The full acceptance run of the design system — contrast, the consistency check (including a deliberately broken file), the build on panel1 and screenshots of both themes — is one command:

bash scripts/e2e-r2-ui-foundation.sh
#   35 passed, 0 failed
#   green

The frame: expanded, collapsed, and on a phone

The sidebar has a ground of its own — the accent mixed almost all the way into the theme's base colour, with the mark behind it as a watermark — and that language carries through to a phone's capsule and its more sheet.

On a phone the navigation is no longer a bar pinned to the bottom edge with a drawer behind it. It is one floating glass capsule on the bottom edge, carrying the label of the destination you are on, and a more sheet that rises from below for everything that does not fit in it. That is not a second design but the two-plane rule carried through: the capsule floats above a page that runs on underneath it, and what floats is glass. The page itself stays flat and hairline-drawn, capsule or no capsule.

The capsule has exactly one active destination on every screen. On a screen the capsule carries itself (Overview, Accounts, …) that button wears the accent pill and its name. On a screen that lives behind More — People, Audit log, Tasks, Settings and the rest — the More button wears the accent pill and the word "More", so the bar still tells you that you are somewhere. On a screen outside the navigation, such as your profile, nothing is active.

The three states of a menu item (rest, hover, active) are one colour at three strengths, and it is the colour of the focus ring:

/* web/src/ui/tokens.css */
--cp-nav-hover:  color-mix(in oklch, var(--cp-primary) 10%, transparent);
--cp-nav-active: color-mix(in oklch, var(--cp-primary) 20%, transparent);

Collapsed is a design of its own and not the same menu with less width: every control is the same 36px square, centred, with a tooltip that keyboard focus reaches, and the watermark fades out rather than being cropped. The choice is remembered per browser:

localStorage.getItem('corecp.sidebar')     # → "collapsed"
# and, for screenshots only, once from the address bar:
#   /?sidebar=collapsed        /?assistant=1

The frame is exactly one viewport tall (h-dvh) and only the content column scrolls, so the sidebar keeps its footer — help, the person, the collapse toggle — on screen, and the assistant keeps its composer at the bottom of the screen rather than at the bottom of the page. dvh and not vh: on iOS 100vh is the largest possible viewport, which parks that bottom row under the browser's own URL bar.

Three devices, and the element each of them deserves

The panel distinguishes a phone, a tablet and a desk, and the rule (spec §A6) is that each width gets its own element — never a desktop element squeezed until it fits.

widthnavigationa lista row clickthe palette
phoneunder 768pxcapsule (active label) + more sheetcards, 2–4 fields and a disclosurebottom sheetfull screen
tablet768–1279pxicon rail (remembered once you change it)table, without its priority‑3 columnsslide-overfloating
desk1280px and upfull sidebartable, every column that fits its boxslide-overfloating

Why a tablet is a tier of its own: an iPad in portrait is 768 CSS pixels. Take the sidebar and the page gutters away and a table has roughly 480 left, which is where a nine-column fleet list stops being a table and becomes a horizontal scrollbar with a header on it. So the table sheds its least important columns instead of shrinking, and everything it sheds is one row-click away in the slide-over.

Who checks this, and what is really enforced since September 2026

Until 2 September 2026 the tablet rule was an agreement with no check behind it. The device audit (corecp-panel/web/scripts/ui-device-audit.mjs) walked 84 of the 146 addresses — the other routes simply carried no device flag, and "nobody decided" looked exactly like "deliberately left out" in the result. And the tablet assertion itself was an empty shell: it compared nothing, so it could never go red.

Both are closed. Every route that renders now carries a device decision — it is walked at 390 and 834 pixels, or the reason it is not is written down, and the compiler refuses a route that says neither. The audit walks 146 addresses because of it. And the tablet rule is really measured: if a single priority‑3 column is laid out below 1280 pixels, the audit fails.

One more thing worth knowing, because it is honest and it matters when you lay out a table: shedding columns is not always enough. On 2 September 2026 all 128 record tables were measured at tablet width; twenty of them are wider than the box they sit in and therefore scroll inside it. The heaviest is the account list: nine columns asking for 1065 pixels in a 728-pixel box, after 27 priority‑3 cells have already been shed. The page itself does not move — only the table's own box — but the column that says which row this is scrolls out of sight first. The full list is in docs/research/run5/evidence/tablet-tabelbreedte-834.md, and whether a pinned name column is built for it is an open design choice.

Decided on 26 September 2026. A wide table now does two things:

  • It sheds columns on a large screen too when its box is too narrow. Below 1280 pixels the least important columns always go; above that the table measures for itself whether everything fits. If it does not, it drops the same columns, and when you make the window wider they come back on their own. What it drops is still one row-click away in the side panel. A table whose rows open nothing drops nothing above 1280 — there is no side panel to keep it — and scrolls instead. On a 1280-pixel laptop 6 of the 136 tables still scroll, where 17 did.
  • The name and the row actions stay put. When a table does scroll sideways, the first column — the name that says which row this is — stays pinned on the left, and the row-actions button on the right. A thin rule with a soft shadow shows that something is scrolling underneath the pinned column; on the side where there is nothing more to see, the rule goes away. So you never have to scroll to the end to open or change a row.

A correction from 8 September 2026, because that sentence was not quite true. "The page itself does not move" held for the table and failed for four screens: on an 834-pixel tablet the whole account list could be dragged 272 pixels sideways, over an empty strip. The cause was not the columns but the invisible column headings a table carries for a screen reader: they floated outside the table's box and stretched the page with them. That is closed, and so is the check that should have seen it — its page half compared two numbers that are always equal under device emulation, so that assertion could never go red. All 156 addresses are now still at 390, 768 and 834 pixels.

Since that day three more things are watched that had no check before, on every screen and at three widths (corecp-panel/web/scripts/ui-polish-audit.mjs):

  • the type sizes. The system has ten of them; everything a screen paints is one of the ten. The fleet diagram drew two that do not exist — an SVG carries its font size as an attribute, and the source check cannot see one.
  • the name of every control, as the browser hands it to a screen reader. Seven filters turned out to be nameless: you heard "All servers, combo box" without hearing what you were filtering. A combo box may not take its name from its own text, so the text on screen said nothing about what a screen reader was given.
  • one page title per screen. The white-label page had five, because its preview — a picture of the sign-in screen — drew its titles with real heading elements.

A column says how much it is worth, and one flag opts a control out — a checkbox that selects a file is not a fact that can wait for the slide-over:

// web/src/pages/Files.tsx
{ key: 'mode',   header: t('files.mode'), priority: 3 },              // sheds on a tablet
{ key: 'select', header: t('files.select'), priority: 3, always: true } // never sheds

How a table divides its columns

There are three kinds of column, and exactly one of them may be elastic. That is not taste but a measurement: two elastic columns in one table made the browser resolve the second to 24 pixels with 118 pixels of text outside it, and that text painted over the neighbouring column — the "NAASERVER" and "WAARDTEL" in the owner's screenshots (owner-fixes #60).

declarationwhat the column doesfor
width: 'min'as wide as its content, never foldeda badge, a checkbox, a button
width: 'grow'takes the slack, ellipses with …, one per tablethe column you scan: a name, a path, an address
(nothing)one line with a 22rem ceiling, then …dates, versions, counts — anything that is a value
wrap: truemay fold, with the same ceilinga column carrying a sentence

The elastic column also has a floor (min-w-40): without it the account name was 24 pixels wide while the Websites column took half a screen. When the whole does not fit, the table scrolls inside its own frame — that is what the frame is for.

// web/src/pages/Accounts.tsx — the name is elastic, the rest are values
{ key: 'username', header: t('accounts.username'), width: 'grow', priority: 1 }
{ key: 'websites', header: t('accounts.websites'), priority: 2 }
{ key: 'state',    header: t('accounts.state'),    width: 'min', priority: 2 }

It is measured rather than hoped:

node corecp-panel/web/scripts/check-tables.mjs --url=http://127.0.0.1:5199
#   67 table(s) measured, 0 finding(s)

The check walks every route in the route source and fails when two cell boxes intersect, when a cell's content leaves its own box, when a cell declared single-line renders on two, or when a row's selection label ("select <name>") becomes visible text instead of existing only for the screen reader.

The dense table is available everywhere

Cards are the phone's default, but the grid is one press away and the choice is remembered for every list at once — somebody who scans two hundred domains for the one that is wrong wants the grid, not four fields and a chevron:

localStorage.getItem('corecp.data.density')   # → "dense"
# and, for screenshots only, once from the address bar:
#   /accounts?density=dense

Fixed bars and the edge of the screen

Every element pinned to a viewport edge — the capsule, the toast column, a bottom sheet's footer, the assistant's composer — takes its padding from one group of tokens and from nothing else:

/* web/src/ui/tokens.css */
--cp-safe-t: 0px;  --cp-safe-r: 0px;  --cp-safe-b: 0px;  --cp-safe-l: 0px;
--cp-bar-pad-b: calc(var(--cp-bar-gutter) + var(--cp-safe-b));
--cp-bottomnav-total: calc(var(--cp-bottomnav-h) + var(--cp-safe-b));

They are zero in a browser tab, which is correct: there is no inset to respect until the panel is installed as an app. The session that makes it one has four values to fill in, in one block, instead of hunting env(safe-area-inset-*) through a dozen components.

Checking it

cd corecp-panel/web
npm run build && npm run preview -- --port 5199 &
node scripts/no-hscroll.mjs      --url=http://127.0.0.1:5199 --width=390
node scripts/no-hscroll.mjs      --url=http://127.0.0.1:5199 --width=834
node scripts/ui-device-audit.mjs --url=http://127.0.0.1:5199

ui-device-audit.mjs walks every screen at 390 and 834 and asks three questions a desktop pass cannot: is any target under 24×24 CSS pixels with a neighbour inside 24 (WCAG 2.2 §2.5.8, with the standard's own exemptions for a link inside a sentence and for a control with clear space around it); did this width get its own shape; and does the last row of the page end above the fixed bar. The whole set runs from bash scripts/e2e-r2-ui-qa.sh.

The bell: what it says, and what it stops saying

The bell carries an unread count, opens as a popover where there is a pointer and as a bottom sheet on a phone, and groups everything it knows into five categories — servers, accounts, security, migrations, system — each of which can be switched off. Switching one off quiets the bell and the toast; it never deletes anything. The whole history stays on /notifications, where the switches are repeated and the filter still finds the groups you silenced.

localStorage.getItem('corecp.notify.muted')   # → "fleet,migration"

A toast is the moment something happened; a notification is the record that it did. Anything worth coming back to is a notification, and only a failure that arrives while you are looking is also a toast.

⌘K knows things, not only places

The palette lists every destination the role can see, the handful of actions worth reaching from anywhere, and every named thing in the panel: accounts, servers, customers, groups, packages and migrations. Nothing is fetched until the palette is opened for the first time, and the lists are discarded on a context switch. It is ⌘K (Ctrl+K) on a keyboard, the search field in the top bar with a pointer, and full screen on a phone — where a floating card at 12vh would leave the keyboard covering the results it exists to show.

Badges: how to read a state

A badge is one word that says a state. You recognise it by its shape: CAPITALS, openly spaced, smaller and lighter than the text around it. That is deliberate — a badge should tell you what is going on, not be the heaviest thing on the screen.

The colour says what the state means, and nothing else:

ColourWhat it saysExample
greynormal, nothing to doSCHEDULED
iris (purple)our own accent: a property, not an alarmWEB on a server
greensomething you were waiting for succeededACTIVE
amberpay attention, not broken yetMAINTENANCE
redbroken, or something is being lostFAILED
cyanfor informationPOOL

Green never means "normal". If every row is green, no row says anything.

The dot that beats

Some badges have a dot that sends a faint ring outwards once every two seconds. It always means the same thing: this is true right now — the service is answering at this moment, the task is running at this moment. It is never used for a setting; "switched on" is not a heartbeat.

The movement lasts 200 milliseconds of every two-second cycle. If you have asked your system to reduce motion, the ring is not drawn at all — the dot and the word stay, so you lose no information. To turn that on:

  • Windows — Settings → Accessibility → Visual effects → Animation effects off.
  • macOS / iOS — System Settings → Accessibility → Display → Reduce motion on.
  • Android — Settings → Accessibility → Remove animations.

Every badge is checked for legibility in both themes before anything is released (WCAG AA, 4.5:1). To see them all side by side, open Settings → Style guide; they are under Badges, the pulsing variant included.

Technology icons: PHP, MariaDB, nginx and eleven more

Wherever the panel names a product — a server's services, the tools installed on it, the database server, your WordPress sites — a small glyph sits in front of the name. Fourteen of them: PHP, MariaDB/MySQL, nginx, Apache, LiteSpeed, Redis, Node.js, Python, Git, Composer, WordPress, Docker, Rspamd and PowerDNS.

They are recognisable but drawn by us, and that is a deliberate choice: somebody else's trademark in a control panel is a licence question, and a logo drawn for a readme badge never fits a set made of 1.5px lines. So what you see is the shape you know, in our hand — never the actual logo. One glyph stands for MariaDB and MySQL: same protocol, same settings file, same row in the panel.

Where you meet them:

ScreenWhere
Servers → a server → Settingsbeside each service (nginx, MariaDB, Rspamd, Redis, PowerDNS)
Servers → a server → Toolsbeside Git, Composer, Node.js, Python, Redis, Docker, WP-CLI
Servers → a serverat web server and PHP in the facts
An account → Databaseson the engine chip above the list
An account → WordPressin the page header

If a name has no glyph, we deliberately have none for it: nothing beats a picture of something else. The whole set is shown with its names under Settings → Style guide → Icons.

Tabs: two variants, one rule

The Tabs primitive scrolls horizontally and only horizontally — overflow-x: auto alone is not that, because CSS computes the other axis to auto as soon as one axis stops being visible, and the bar earns a vertical scrollbar.

VariantWhat it isWhere
compact (default)a row of words with an underline under the current oneeverywhere
prominenta large duotone icon with the title under ita page's own main sections, at most one bar per page

Inside a card, a slide-over or a subsection it is always compact, however important that section feels: two prominent bars on one screen say that neither is the main division of the page. Both are on /styleguide.

The mark in the browser tab

cd corecp-panel/web
npm run gen:favicons     # the PNG fallbacks, from public/favicon-src.svg
npm run check:ui         # …and fails when they are missing

public/favicon.svg is the mark itself and follows the reader's light/dark scheme. On a reseller's hostname the panel replaces every icon link with that reseller's own logo; a brand with no logo keeps the built-in mark, because it is the default rather than a claim about who runs the panel.

Dimmed, hidden, and the difference between them

Two things can be true about a part of the panel you cannot use, and they are not the same thing, so they do not look the same (owner decisions r2c-empty-states and r2b-features-v1).

StateWhat it meansHow it is drawn
Dimmednot yet — the section exists, something it needs does notquieter label, still in the bar, still clickable
Hiddennot here — the plan does not sell it, or the role never has itabsent from the bar, and the API refuses the route

The one case of "not yet" today is an account without a website: SSL, Mail and DNS are about a domain, so on an account that has none the three tabs are dimmed and the pages behind them say why, with one button — Add website — to the websites section. They stay pressable on purpose: hiding them teaches nobody that the panel can do mail, and a hover tooltip carrying the reason does not exist on a phone. The destination explains itself.

"Not here" is the capability atoms of r2b-features-v1. A plan without FTP has no FTP tab at all, because there is nothing to arrive at: the same nine atoms make the API answer 403 feature_disabled for the same routes. That is permanent irrelevance rather than a missing step, and permanent irrelevance is worth no space in a navigation bar.

In code the two are separate properties of a section:

// web/src/components/AccountNav.tsx
{ id: 'mail', label: 'nav.mail', icon: IconMail,
  perDomain: true,        // carries the domain switcher
  requiresDomain: true,   // dimmed until the account has a website
  feature: 'mail' }       // hidden when the plan does not sell mail

TabItem takes a dimmed flag and marks the trigger with data-cp-tab-dimmed. The label keeps --cp-text-faint, which check-tokens holds at 3:1 against the surface — WCAG exempts disabled text from contrast, and this is not disabled. There is no pointer-events: none anywhere in it.

cd corecp-panel/web
node scripts/ui-flow-account-empty.mjs --url=http://127.0.0.1:5219
#   71 passed, 0 failed

When a page has nothing to be about

Three states, three different screens — never a skeleton that keeps turning (the research is in docs/research/r2b/account-empty-states.md):

The accountThe screen
has websitesthe section itself
has noneAccountSectionEmpty: why this section needs a website, and Add website
does not existAccountMissing: what happened, and one button back to the list

Both live in web/src/components/AccountEmptyState.tsx, so the three per-domain sections share one sentence pattern instead of three dialects of it. A page reads them off the domain scope, which reports empty and missing alongside loading:

const scope = useDomainScope(account)
if (scope.missing) return <AccountMissing />
if (scope.empty) return <AccountSectionEmpty account={account} section="mail" … />

A page's own loading state must follow scope.loading when there is no domain. Closing it only inside an effect that returns early on !domain is what made mail and SSL turn a skeleton forever on an account without a website.