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.
- Hairlines, not boxes. Surfaces are flat, separated by a single pixel. Shadows appear only under things that genuinely float.
- Dense, and readable. 13px for the interface, 14px for text you read, numbers always tabular so a column lines up.
- Glass on floating layers only — a slide-over, a dialog, the ⌘K palette. Never on a table: a data surface has to be legible, not atmospheric.
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, bottom bar, drawer
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 bottom bar and drawer. 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 | bottom bar of four + drawer | 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 | 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.
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 shedsThe 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 bar pinned to a viewport edge — the bottom navigation, 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.
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.