CoreCP

Het CoreCP-ontwerpsysteem

Alles wat je in het paneel ziet, is opgebouwd uit één kleine set onderdelen. Deze pagina vertelt welke dat zijn, wat het ontwerp voor je beslist, en hoe je er een nieuw scherm mee bouwt.

Geschreven voor: Beheerder

Alles wat je in het paneel ziet, is opgebouwd uit één kleine set onderdelen. Deze pagina vertelt welke dat zijn, wat het ontwerp voor je beslist, en hoe je er een nieuw scherm mee bouwt.

De levende versie van deze pagina is het paneel zelf: /styleguide toont elk onderdeel op één scherm, in licht en in donker. Open die voordat je iets bouwt.

De ontwerptaal in vijf zinnen

  1. Een licht getint, bijna-neutraal doek. Nooit zuiver wit, nooit zuiver zwart — de grijzen hebben een spoor violet, zodat het hele paneel als één familie leest.
  2. Eén accent: iris. Het is geen blauw, want elke browser kleurt links blauw, en een accent dat overal "klikbaar" betekent, betekent het nergens. Groen, amber en rood zijn voorbehouden aan toestanden en zijn nooit versiering.
  3. Haarlijnen, geen dozen. Vlakken zijn plat en worden gescheiden door één pixel. Schaduw hoort alleen onder iets dat echt zweeft.
  4. Dicht, en leesbaar. 13px voor de interface, 14px voor tekst die je leest, cijfers altijd tabellarisch zodat een kolom uitlijnt.
  5. Glas alleen op zwevende lagen — een slide-over, een dialoog, het ⌘K-palet. Nooit op een tabel: een gegevensvlak moet leesbaar zijn, niet sfeervol.

Beide thema's worden door een script tegen WCAG AA gehouden, niet op het oog (npm run check:ui, 50 kleurparen, contrast berekend uit de tokens zelf).

Waar het staat

corecp-panel/web/src/
  ui/tokens.css       elke kleur, maat, radius, schaduw en duur
  ui/icons/           de eigen inline-SVG-iconenset (24px-raster, streek 1,5)
  ui/*.tsx            de primitieven — de enige bouwstenen die een pagina mag gebruiken
  shell/nav.ts        één navigatieconfiguratie, gefilterd op rol
  shell/AppShell.tsx  het frame: sidebar, bovenbalk, onderbalk, lade
  pages/              één bestand per scherm, gebouwd uit src/ui

De regel voor pagina's

Een pagina importeert visueel alleen uit ../ui. Geen hex-kleuren, geen style={{ color: … }}, geen zelfverzonnen spatiëring.

Dat is afgedwongen, niet gevraagd:

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)

Een pagina die haar eigen kleur schildert, laat de build vallen:

$ 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-…))

De dertig schermen uit ronde 1 zijn ouder dan de primitieven en staan in ui-consistency-baseline.json met het aantal overtredingen dat elk nog heeft. Ze mogen beter worden en niet slechter; sessies A2-A5 zetten ze om, en zodra een bestand op nul staat gaat het uit de baseline:

npm run check:ui:baseline      # opnieuw vastleggen na een migratie
git diff ui-consistency-baseline.json

Is een overtreding écht onvermijdelijk — een xterm-kleurenschema, de twee kleuren van een QR-code, de stalen van een kleurkiezer — zet de reden op de regel:

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

Een scherm bouwen

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')} />}
      />
    </>
  )
}

Vier dingen die je gratis krijgt en niet opnieuw hoort te maken:

Wat je nodig hebtGebruikNooit
Een tabel op een telefoonDataView (tabel + kaarten uit één kolomlijst)een tweede component
Detail van een rijDetailPanel (slide-over / bottom-sheet)een aparte pagina per rij
"Het is gelukt"useToast().toast(…), met undo waar het omkeerbaar iseen banner in de pagina
"Weet je het zeker?"ConfirmDialog, of TypeToConfirm in een DangerZonewindow.confirm

Wat het ontwerp al beslist heeft

  • Rijklik opent een slide-over op een desktop en een bottom-sheet op een telefoon (DetailPanel). De lijst blijft staan waar hij stond.
  • Validatie beloont vroeg en straft laat: niets terwijl een veld wordt ingevuld, een controle bij blur, en zodra het fout is een controle bij elke toets. useFieldValidation is precies dat en niets meer.
  • Meer dan tien opties is een Combobox, geen Select.
  • Wachtwoorden kun je altijd tonen, genereren en kopiëren, en plakken wordt nooit geblokkeerd.
  • De frictie bij verwijderen past bij de schade: omkeerbaar → gewoon doen met Ongedaan maken; herstelbaar → ConfirmDialog; catastrofaal → TypeToConfirm.
  • Lege toestanden hebben precies één actie.
  • Skeletons bij een lading tussen één en tien seconden, in de vorm van wat ze vervangen.
  • Beweging duurt 110-260 ms en valt weg onder prefers-reduced-motion; niets hangt af van een animatie die afloopt.

Eén configuratie in src/shell/nav.ts, gefilterd op niveau. Hoogstens zeven items op het eerste niveau, in twee secties, en kinderen alleen zichtbaar in het deel waar je bent. Een nieuw scherm is een regel in dat bestand:

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

minLevel bepaalt wat je ziet. Het bepaalt nooit wat mag — de server weigert wat een gebruiker niet mag, wat de browser ook denkt.

Elke bestemming in dat bestand wordt vanzelf een ⌘K-opdracht. Een pagina voegt haar eigen acties toe zolang ze op het scherm staat:

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

Thema's en white label

Licht, donker en systeem. De keuze wordt vóór de eerste paint op <html data-theme> gezet, dus er is geen flits. Voor een schermafdruk of een test kun je hem uit de adresbalk forceren — dat wordt één keer gelezen en nooit bewaard:

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

De merkkleur van een reseller landt op de accenthelft van de tokenlaag: de primaire knop, het actieve menu-item, de focusring en links volgen hem; de grijsschaal, de radii en de typografie niet. Eén kleur per thema is alles wat een reseller hoeft te kiezen; de hoverkleur, de zachte tint en de ring worden afgeleid.

Ernaar kijken

De stijlgids wordt zonder inloggen geserveerd (er staan geen gegevens op) en staat voor beheerders in het menu:

# vanuit de repository, op een ontwikkelmachine
cd corecp-panel/web && npm run dev
# → http://127.0.0.1:5173/styleguide

# schermafdrukken van beide thema's, in .wolf/designqc-captures/
cd ~ && openwolf designqc --url http://127.0.0.1:5173 \
  --routes "/styleguide?theme=light" --quality 72 --max-width 1280

De volledige acceptatie van het ontwerpsysteem — contrast, de consistentiecheck (inclusief een expres kapot bestand), de build op panel1 en schermafdrukken van beide thema's — is één commando:

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

Het frame: uitgeklapt, ingeklapt, en op een telefoon

Het zijmenu heeft een eigen ondergrond — het accent bijna helemaal in de basiskleur van het thema gemengd, met het merkteken erachter als watermerk — en die taal loopt door tot in de onderbalk en de lade van een telefoon. De drie toestanden van een menu-item (rust, hover, actief) zijn één kleur in drie sterktes, dezelfde kleur als de focusring:

/* 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);

Ingeklapt is een eigen ontwerp en niet hetzelfde menu met minder breedte: elk element is hetzelfde vierkant van 36px, gecentreerd, met een tooltip die ook met het toetsenbord bereikbaar is; het watermerk verdwijnt in plaats van te worden afgeknipt. De keuze wordt onthouden per browser:

localStorage.getItem('corecp.sidebar')     # → "collapsed"
# en, alleen voor schermafdrukken, eenmalig via de adresbalk:
#   /?sidebar=collapsed        /?assistant=1

Het frame is precies één beeldscherm hoog (h-dvh) en alleen de contentkolom scrolt. Het zijmenu houdt daardoor zijn voettekst — help, de gebruiker, de inklapknop — in beeld, en het assistentpaneel houdt zijn invoerbalk onderaan het scherm in plaats van onderaan de pagina. dvh en niet vh: op iOS is 100vh de grootst mogelijke viewport, waardoor de onderste rij onder de adresbalk van de browser verdwijnt.

Drie apparaten, en het element dat elk ervan verdient

Het paneel onderscheidt een telefoon, een tablet en een bureau, en de regel (spec §A6) is dat elke breedte een eigen element krijgt — nooit een desktop-element dat is platgeknepen tot het past.

breedtenavigatieeen lijstrij aanklikkenhet palet
telefoononder 768pxonderbalk van vier + ladekaarten, 2–4 velden en een onthullingbottom sheetschermvullend
tablet768–1279pxicoonrail (onthouden zodra je hem wijzigt)tabel, zonder de prioriteit‑3-kolommenslide-overzwevend
bureau1280px en meervolledig zijmenutabel, alle kolommenslide-overzwevend

Waarom een tablet een eigen laag is: een iPad staand is 768 CSS-pixels. Haal het zijmenu en de paginamarges eraf en een tabel houdt ongeveer 480 over — precies waar een vloottabel van negen kolommen ophoudt een tabel te zijn en een horizontale schuifbalk met een kop wordt. De tabel laat daarom zijn minst belangrijke kolommen vallen in plaats van te krimpen, en alles wat hij laat vallen staat één rijklik verderop in de slide-over.

Een kolom zegt zelf wat hij waard is, en één vlag haalt een bedieningselement eruit — een vinkje dat een bestand selecteert is geen feit dat kan wachten:

// web/src/pages/Files.tsx
{ key: 'mode',   header: t('files.mode'), priority: 3 },              // valt weg op tablet
{ key: 'select', header: t('files.select'), priority: 3, always: true } // valt nooit weg

De dichte tabel is overal beschikbaar

Kaarten zijn de standaard op een telefoon, maar de tabel is één druk verderop en die keuze geldt meteen voor élke lijst — wie tweehonderd domeinen afspeurt naar het ene dat fout staat, wil het raster en geen vier velden met een pijltje:

localStorage.getItem('corecp.data.density')   # → "dense"
# en, alleen voor schermafdrukken, eenmalig uit de adresbalk:
#   /accounts?density=dense

Vaste balken en de rand van het scherm

Elke balk die aan een schermrand vastzit — de onderbalk, de toastkolom, de voet van een bottom sheet, de invoerbalk van de assistent — haalt zijn padding uit één groep tokens en nergens anders vandaan:

/* 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));

Ze staan op nul in een browsertab, en dat klopt: er ís geen inkeping om rekening mee te houden zolang het paneel geen geïnstalleerde app is. De sessie die dat wél maakt, vult vier waarden in op één plek in plaats van env(safe-area-inset-*) door een stuk of tien componenten te jagen.

Hoe je het controleert

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 loopt elk scherm langs op 390 en 834 px en stelt drie vragen die een bureaucontrole niet kan stellen: is er een bedieningselement kleiner dan 24×24 CSS-pixels met een buur binnen 24 (WCAG 2.2 §2.5.8, mét de uitzonderingen die de norm zelf geeft voor een link ín een zin en voor een element met vrije ruimte eromheen); heeft deze breedte zijn eigen vorm gekregen; en eindigt de laatste rij van de pagina bóven de vaste balk. De hele set draait met bash scripts/e2e-r2-ui-qa.sh.

De bel: wat hij zegt, en waarover hij zwijgt

De bel draagt een ongelezen-teller, opent als popover waar een muis is en als bottom sheet op een telefoon, en groepeert alles wat hij weet in vijf categorieën — servers, accounts, beveiliging, migraties, systeem — die je stuk voor stuk uit kunt zetten. Uitzetten dempt de bel en de toast; het gooit nooit iets weg. De volledige historie blijft op /notifications staan, waar dezelfde schakelaars staan en het filter ook de gedempte groepen nog vindt.

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

Een toast is het moment dat er iets gebeurde; een melding is het bewijs dat het gebeurd is. Alles waar je op terug wilt komen is een melding, en alleen een mislukking die binnenkomt terwijl je kijkt, is óók een toast.

⌘K kent dingen, niet alleen plekken

Het palet toont elke bestemming die het rolniveau mag zien, de handvol acties die overal bereikbaar horen te zijn, en élk benoemd ding in het paneel: accounts, servers, klanten, groepen, pakketten en migraties. Er wordt niets opgehaald tot het palet voor het eerst opengaat, en de lijsten verdwijnen bij een contextwissel. Het is ⌘K (Ctrl+K) op een toetsenbord, de zoekbalk in de bovenbalk met een muis, en schermvullend op een telefoon — waar een zwevende kaart op 12vh het toetsenbord over de resultaten heen zou laten vallen die hij juist moest tonen.

Tabbladen: twee varianten, één regel

De Tabs-primitief scrolt uitsluitend horizontaal — overflow-x: auto alleen is daar niet genoeg, want CSS zet de andere as op auto zodra één as niet meer visible is, en dan krijgt de balk een verticale scrollbar.

VariantWat het isWaar
compact (standaard)een rij woorden met een streep onder de actieveoveral
prominenteen groot duotoon-icoon met de titel eronderde hoofdsecties van een pagina, maximaal één balk per pagina

Binnen kaarten, slide-overs en subsecties altijd compact, hoe belangrijk die sectie ook voelt: twee prominente balken op één scherm zeggen dat geen van beide de hoofdindeling is. Beide staan op /styleguide.

Het merkteken in de browsertab

cd corecp-panel/web
npm run gen:favicons     # de PNG-fallbacks, uit public/favicon-src.svg
npm run check:ui         # faalt als ze ontbreken

public/favicon.svg is het merkteken zelf en volgt het licht/donker-thema van de lezer. Op het hostname van een reseller vervangt het paneel álle icoon-links door het logo van die reseller; een merk zonder eigen logo houdt het ingebouwde teken, want dat is de standaard en geen uitspraak over wie het paneel draait.