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
- 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.
- 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.
- 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.
- Dense, and readable. 13px for the interface, 14px for text you read, numbers always tabular so a column lines up.
- 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/uiThe rule for pages
A page imports from../uiand from nowhere else for anything visual. No hex colours, nostyle={{ 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.jsonIf 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 need | Use | Never |
|---|---|---|
| A table on a phone | DataView (table + cards from one column list) | a second component |
| Detail of a row | DetailPanel (slide-over / bottom sheet) | a new page per row |
| "It worked" | useToast().toast(…), with undo where it is reversible | an inline banner |
| "Are you sure?" | ConfirmDialog, or TypeToConfirm in a DangerZone | window.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.
useFieldValidationis that and nothing more. - More than ten options is a
Combobox, not aSelect. - 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.
Navigation
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=darkA 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 1280The 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
# greenThe 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=1The 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.
| width | navigation | a list | a row click | the palette | |
|---|---|---|---|---|---|
| phone | under 768px | capsule (active label) + more sheet | cards, 2–4 fields and a disclosure | bottom sheet | full screen |
| tablet | 768–1279px | icon rail (remembered once you change it) | table, without its priority‑3 columns | slide-over | floating |
| desk | 1280px and up | full sidebar | table, every column that fits its box | slide-over | floating |
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 shedsHow 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).
| declaration | what the column does | for |
|---|---|---|
width: 'min' | as wide as its content, never folded | a badge, a checkbox, a button |
width: 'grow' | takes the slack, ellipses with …, one per table | the 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: true | may fold, with the same ceiling | a 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=denseFixed 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:5199ui-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:
| Colour | What it says | Example |
|---|---|---|
| grey | normal, nothing to do | SCHEDULED |
| iris (purple) | our own accent: a property, not an alarm | WEB on a server |
| green | something you were waiting for succeeded | ACTIVE |
| amber | pay attention, not broken yet | MAINTENANCE |
| red | broken, or something is being lost | FAILED |
| cyan | for information | POOL |
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:
| Screen | Where |
|---|---|
| Servers → a server → Settings | beside each service (nginx, MariaDB, Rspamd, Redis, PowerDNS) |
| Servers → a server → Tools | beside Git, Composer, Node.js, Python, Redis, Docker, WP-CLI |
| Servers → a server | at web server and PHP in the facts |
| An account → Databases | on the engine chip above the list |
| An account → WordPress | in 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.
| Variant | What it is | Where |
|---|---|---|
compact (default) | a row of words with an underline under the current one | everywhere |
prominent | a large duotone icon with the title under it | a 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 missingpublic/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).
| State | What it means | How it is drawn |
|---|---|---|
| Dimmed | not yet — the section exists, something it needs does not | quieter label, still in the bar, still clickable |
| Hidden | not here — the plan does not sell it, or the role never has it | absent 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 mailTabItem 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 failedWhen 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 account | The screen |
|---|---|
| has websites | the section itself |
| has none | AccountSectionEmpty: why this section needs a website, and Add website |
| does not exist | AccountMissing: 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.