CoreCP

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. Hairlines, not boxes. Surfaces are flat, separated by a single pixel. Shadows appear only under things that genuinely float.
  4. Dense, and readable. 13px for the interface, 14px for text you read, numbers always tabular so a column lines up.
  5. 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/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 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=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 768pxbottom bar of four + drawercards, 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 columnslide-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.

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

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

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.