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
- 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.
- 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.
- Het datavlak is stil en getekend met haarlijnen. Pagina's, tabellen, kaarten en formulieren zijn plat en worden gescheiden door één pixel; schaduw hoort alleen onder een kaart. Diepte betekent laag, nooit versiering.
- Dicht, en leesbaar. 13px voor de interface, 14px voor tekst die je leest, cijfers altijd tabellarisch zodat een kolom uitlijnt.
- Alles wat boven het datavlak zweeft is glas — de capsule en de meer-sheet van een telefoon, een slide-over, een dialoog, een popover, het ⌘K-palet, en de bovenbalk zodra er inhoud onderdoor schuift. Nooit op een tabel: een gegevensvlak moet leesbaar zijn, niet sfeervol. Glas draagt navigatie en bediening, nooit cijfers die je wilt vergelijken.
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, capsule, meer-sheet
pages/ één bestand per scherm, gebouwd uit src/uiDe regel voor pagina's
Een pagina importeert visueel alleen uit../ui. Geen hex-kleuren, geenstyle={{ 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.jsonIs 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 hebt | Gebruik | Nooit |
|---|---|---|
| Een tabel op een telefoon | DataView (tabel + kaarten uit één kolomlijst) | een tweede component |
| Detail van een rij | DetailPanel (slide-over / bottom-sheet) | een aparte pagina per rij |
| "Het is gelukt" | useToast().toast(…), met undo waar het omkeerbaar is | een banner in de pagina |
| "Weet je het zeker?" | ConfirmDialog, of TypeToConfirm in een DangerZone | window.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.
useFieldValidationis precies dat en niets meer. - Meer dan tien opties is een
Combobox, geenSelect. - 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.
Navigatie
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=darkDe 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 1280De 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
# greenHet 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 capsule en de meer-sheet van een telefoon.
Op een telefoon is de navigatie geen balk meer die aan de onderrand vastzit met een lade erachter. Het is één zwevende glascapsule op de onderrand, met het label van de bestemming waar je bent, en een meer-sheet die van onderen omhoog komt voor alles wat er niet in past. Dat is geen tweede ontwerp maar de tweevlakkenregel doorgevoerd: de capsule zweeft boven de pagina die eronder doorloopt, en wat zweeft is glas. De pagina zelf blijft plat en met haarlijnen getekend, ook onder de capsule.
De capsule heeft op elk scherm precies één actieve bestemming. Sta je op een scherm dat in de capsule zelf staat (Overzicht, Accounts, …), dan draagt die knop de accentpil en zijn naam. Sta je op een scherm dat achter Meer leeft — Mensen, Auditlog, Taken, Instellingen en de rest — dan draagt de knop Meer de accentpil en het woord "Meer", zodat je ook daar ziet dat je in het paneel ergens bent. Op een scherm dat niet in de navigatie staat, zoals je profiel, is niets actief.
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=1Het 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.
| breedte | navigatie | een lijst | rij aanklikken | het palet | |
|---|---|---|---|---|---|
| telefoon | onder 768px | capsule (actief label) + meer-sheet | kaarten, 2–4 velden en een onthulling | bottom sheet | schermvullend |
| tablet | 768–1279px | icoonrail (onthouden zodra je hem wijzigt) | tabel, zonder de prioriteit‑3-kolommen | slide-over | zwevend |
| bureau | 1280px en meer | volledig zijmenu | tabel, alle kolommen die in het kader passen | slide-over | zwevend |
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.
Wie dit nakijkt, en wat er sinds september 2026 echt wordt afgedwongen
Tot 2 september 2026 was de tabletregel een afspraak zonder controle. De apparaatcontrole (corecp-panel/web/scripts/ui-device-audit.mjs) liep 84 van de 146 adressen — de andere routes hadden simpelweg geen apparaatvlag, en "niemand heeft besloten" zag er in de uitkomst precies zo uit als "bewust overgeslagen". En de tabletassertie zelf was een lege huls: hij vergeleek niets en kon dus nooit rood worden.
Beide zijn dicht. Elke route die rendert draagt nu een apparaatbesluit — hij wordt op 390 en 834 pixels nagelopen, of er staat geschreven waarom niet, en de compiler weigert een route die geen van beide zegt. De controle loopt daardoor 146 adressen. En de tabletregel wordt echt gemeten: staat er onder 1280 pixels ook maar één prioriteit‑3-kolom uitgelegd, dan valt de controle om.
Wat u daarnaast moet weten, omdat het eerlijk is en het uitmaakt bij het inrichten van een tabel: kolommen afwerpen is niet altijd genoeg. Op 2 september 2026 zijn alle 128 recordtabellen op tabletbreedte opgemeten; twintig daarvan zijn breder dan hun kader en schuiven er dus in. De zwaarste is de accountlijst: negen kolommen die samen 1065 pixels vragen in een kader van 728, nadat er al 27 prioriteit‑3-cellen zijn afgeworpen. De pagina zelf schuift niet — alleen het kader van de tabel — maar de kolom die zegt wélke rij dit is schuift wel als eerste uit beeld. De volledige lijst staat in docs/research/run5/evidence/tablet-tabelbreedte-834.md, en of daar een vastgezette naamkolom voor komt is een openstaande ontwerpkeuze.
Besloten op 26 september 2026. Een brede tabel doet nu twee dingen:
- Hij laat ook op een groot scherm kolommen vallen als het kader te smal is. Onder 1280 pixels vallen de minst belangrijke kolommen altijd weg; daarboven meet de tabel zelf of alles past. Past het niet, dan laat hij dezelfde kolommen vallen, en maakt u het venster breder, dan komen ze vanzelf terug. Wat wegvalt staat nog steeds één rijklik verderop in het zijpaneel. Een tabel waarvan een rij niets opent, laat daarboven niets vallen — er is dan geen zijpaneel dat het zou bewaren — en schuift in plaats daarvan. Op een laptop van 1280 pixels schuiven daardoor nog 6 van de 136 tabellen in plaats van 17.
- De naam en de rij-acties blijven staan. Schuift een tabel toch opzij, dan blijft de eerste kolom — de naam die zegt wélke rij dit is — links vast staan, en de knop met rij-acties rechts. Een dunne rand met een zachte schaduw laat zien dat er iets onder de vaste kolom door schuift; aan de kant waar niets meer te zien is verdwijnt die rand. U hoeft dus nooit naar het eind te schuiven om een rij te openen of te bewerken.
Correctie van 8 september 2026, want die zin klopte niet helemaal. "De pagina zelf schuift niet" was waar voor de tabel, en onwaar voor vier schermen: op een tablet van 834 pixels kon je de héle pagina van de accountlijst 272 pixels opzij slepen, over een lege strook. De oorzaak zat niet in de kolommen maar in de onzichtbare kopteksten die een tabel voor een schermlezer meedraagt: die zweefden buiten het kader van de tabel en rekten daarmee de pagina. Dat is dicht, en de controle die het had moeten zien is dat óók: haar paginahelft vergeleek twee getallen die onder apparaat-emulatie altijd gelijk zijn, dus die assertie kon nooit rood worden. Alle 156 adressen zijn nu stil op 390, 768 en 834 pixels.
Sinds die dag wordt er ook op drie dingen gelet die eerder geen controle hadden, op élk scherm en op drie breedtes (corecp-panel/web/scripts/ui-polish-audit.mjs):
- de lettermaten. Het systeem heeft er tien; alles wat een scherm verft is er één van. Het vlootdiagram tekende er twee die niet bestonden — een SVG draagt zijn lettergrootte als attribuut, en de broncontrole ziet die niet.
- de naam van elke bediening, zoals de browser hem aan een schermlezer doorgeeft. Zeven filters bleken naamloos: je hoorde "Alle servers, keuzelijst" zonder te horen waarop je filtert. Een keuzelijst mag zijn naam niet uit zijn eigen tekst halen, dus de tekst op het scherm zei niets over wat een schermlezer kreeg.
- één pagina-titel per scherm. De huisstijlpagina had er vijf, omdat de voorbeeldweergave — een plaatje van het aanmeldscherm — zijn titels met echte kopelementen tekende.
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 wegHoe een tabel zijn kolommen verdeelt
Er zijn drie soorten kolom, en precies één ervan mag meerekken. Dat is geen smaak maar een meting: twee meerekkende kolommen in één tabel lieten de browser de tweede tot 24 pixels oplossen met 118 pixels tekst erbuiten, en die tekst schilderde over de buurkolom — de "NAASERVER" en "WAARDTEL" uit de eigenaars-schermafdrukken (owner-fixes #60).
| declaratie | wat de kolom doet | waarvoor |
|---|---|---|
width: 'min' | zo breed als zijn inhoud, nooit gevouwen | een badge, een vinkje, een knop |
width: 'grow' | krijgt de restbreedte, kapt af met …, één per tabel | de kolom die je scant: een naam, een pad, een adres |
| (niets) | één regel met een plafond van 22rem, daarna … | datums, versies, aantallen, alles wat een waarde is |
wrap: true | mag vouwen, met hetzelfde plafond | een kolom die een zin draagt |
De meerekkende kolom heeft ook een vloer (min-w-40): zonder die vloer werd de accountnaam 24 pixels breed terwijl de Websites-kolom een half scherm kreeg. Past het geheel niet, dan schuift de tabel in zijn eigen kader — dat kader is daarvoor.
// web/src/pages/Accounts.tsx — de naam rekt mee, de rest is een waarde
{ 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 }Het is gemeten en niet gehoopt:
node corecp-panel/web/scripts/check-tables.mjs --url=http://127.0.0.1:5199
# 67 table(s) measured, 0 finding(s)Die check loopt élke route uit de routebron langs en faalt als twee celvakken elkaar snijden, als celinhoud buiten zijn eigen vak steekt, als een cel die als éénregelig is gemarkeerd toch over twee regels staat, of als het selectielabel van een rij ("<naam> selecteren") zichtbaar wordt in plaats van alleen voor de schermlezer te bestaan.
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=denseVaste balken en de rand van het scherm
Elk element dat aan een schermrand vastzit — de capsule, 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:5199ui-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.
Markeringen: hoe je een toestand leest
Een markering ("badge") is één woord dat een toestand zegt. Je herkent hem aan de vorm: KAPITALEN, ruim gespatieerd, kleiner en lichter dan de tekst eromheen. Dat is met opzet — een markering hoort te vertellen wat er aan de hand is, niet het zwaarste ding op het scherm te zijn.
De kleur zegt wat de toestand betekent, en niets anders:
| Kleur | Wat het zegt | Voorbeeld |
|---|---|---|
| grijs | normaal, geen actie nodig | GEPLAND |
| iris (paars) | onze eigen accentkleur: een eigenschap, geen alarm | WEB op een server |
| groen | iets waar je op wachtte is gelukt | ACTIEF |
| amber | let op, nog niet stuk | IN ONDERHOUD |
| rood | stuk, of iets gaat verloren | MISLUKT |
| cyaan | ter informatie | POOL |
Groen betekent nooit "gewoon". Als élke rij groen is, zegt geen enkele rij meer iets.
Het bolletje dat klopt
Sommige markeringen hebben een bolletje dat één keer per twee seconden een lichte kring naar buiten laat lopen. Dat betekent altijd hetzelfde: dit is nú waar — de dienst antwoordt op dit moment, de taak loopt op dit moment. Het staat nooit op een instelling; "aangezet" is geen hartslag.
De beweging duurt 200 milliseconden per cyclus van twee seconden. Heb je in je besturingssysteem "beweging beperken" aangezet, dan is de kring er helemaal niet — het bolletje en het woord blijven staan, dus je mist geen informatie. Zo zet je dat aan:
- Windows — Instellingen → Toegankelijkheid → Visuele effecten → Animatie-effecten uit.
- macOS / iOS — Systeeminstellingen → Toegankelijkheid → Beeldscherm → Beweging verminderen aan.
- Android — Instellingen → Toegankelijkheid → Animaties verwijderen.
Alle markeringen worden in beide thema's op leesbaarheid doorgerekend voordat er iets uitgebracht wordt (WCAG AA, 4,5:1). Wil je ze allemaal naast elkaar zien, open dan Instellingen → Stijlgids; ze staan er onder Markeringen, inclusief de pulserende variant.
Techniek-iconen: PHP, MariaDB, nginx en elf anderen
Waar het paneel een product bij naam noemt — de diensten van een server, de gereedschappen die erop staan, de databaseserver, je WordPress-sites — staat er een klein icoontje voor. Veertien stuks: PHP, MariaDB/MySQL, nginx, Apache, LiteSpeed, Valkey, Node.js, Python, Git, Composer, WordPress, Docker, Rspamd en PowerDNS.
Ze zijn herkenbaar maar zelfgetekend, en dat is een bewuste keuze: een merkteken van iemand anders in een beheerpaneel is een licentiekwestie, en een logo dat voor een leesmij-badge getekend is, past nooit bij een set die uit lijnen van 1,5 pixel bestaat. Wat je ziet is dus de vorm die je kent, in onze hand — nooit het echte logo. Eén icoon staat voor MariaDB én MySQL: het is hetzelfde protocol, hetzelfde instellingenbestand en dezelfde rij in het paneel.
Waar je ze tegenkomt:
| Scherm | Waar |
|---|---|
| Servers → een server → Instellingen | naast elke dienst (nginx, MariaDB, Rspamd, Valkey, PowerDNS) |
| Servers → een server → Gereedschap | naast Git, Composer, Node.js, Python, Valkey, Docker, WP-CLI |
| Servers → een server | bij webserver en PHP in de feiten |
| Een account → Databases | bij de motor boven de lijst |
| Een account → WordPress | in de paginakop |
Staat er geen icoontje bij een naam, dan hebben we er bewust geen voor die naam: liever niets dan een plaatje van iets anders. De hele set staat met naam en al onder Instellingen → Stijlgids → Iconen.
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.
| Variant | Wat het is | Waar |
|---|---|---|
compact (standaard) | een rij woorden met een streep onder de actieve | overal |
prominent | een groot duotoon-icoon met de titel eronder | de 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 ontbrekenpublic/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.
Gedimd, verborgen, en het verschil ertussen
Er zijn twee redenen waarom je een onderdeel van het paneel niet kunt gebruiken, en dat zijn niet dezelfde redenen — dus zien ze er ook niet hetzelfde uit (eigenaarsbesluiten r2c-empty-states en r2b-features-v1).
| Toestand | Wat het betekent | Hoe het eruitziet |
|---|---|---|
| Gedimd | nog niet — het onderdeel bestaat, iets wat het nodig heeft nog niet | rustiger label, blijft in de balk, blijft aanklikbaar |
| Verborgen | hoort hier niet — het pakket verkoopt het niet, of je rol krijgt het nooit | staat niet in de balk, en de API weigert dezelfde route |
Het enige "nog niet" van vandaag is een account zonder website: SSL, Mail en DNS gaan over een domein, dus bij een account zonder domein zijn die drie tabbladen gedimd en leggen de pagina's erachter uit waarom, met één knop — Website toevoegen — naar de websites. Ze blijven met opzet klikbaar: verbergen leert niemand dat het paneel mail kán, en een tooltip met de reden bestaat niet op een telefoon. De bestemming legt het zelf uit.
"Hoort hier niet" zijn de onderdeel-atomen van r2b-features-v1. Een pakket zonder FTP heeft helemaal geen FTP-tabblad, want er is niets om aan te komen: dezelfde negen atomen laten de API 403 feature_disabled antwoorden op dezelfde routes. Dat is blijvend niet van toepassing in plaats van een ontbrekende stap, en dat is geen plek in een navigatiebalk waard.
In code zijn het twee losse eigenschappen van een sectie:
// web/src/components/AccountNav.tsx
{ id: 'mail', label: 'nav.mail', icon: IconMail,
perDomain: true, // draagt de domeinkiezer
requiresDomain: true, // gedimd tot het account een website heeft
feature: 'mail' } // verborgen als het pakket geen mail verkooptTabItem kent een dimmed-vlag en markeert de trigger met data-cp-tab-dimmed. Het label houdt --cp-text-faint, die check-tokens op 3:1 tegen het oppervlak houdt — WCAG stelt geen contrasteis aan uitgeschakelde tekst, en dit is niet uitgeschakeld. Er staat nergens pointer-events: none.
cd corecp-panel/web
node scripts/ui-flow-account-empty.mjs --url=http://127.0.0.1:5219
# 71 passed, 0 failedAls een pagina nergens over gaat
Drie toestanden, drie verschillende schermen — nooit een skelet dat blijft draaien (het onderzoek staat in docs/research/r2b/account-empty-states.md):
| Het account | Het scherm |
|---|---|
| heeft websites | de sectie zelf |
| heeft er geen | AccountSectionEmpty: waarom deze sectie een website nodig heeft, plus Website toevoegen |
| bestaat niet | AccountMissing: wat er aan de hand is, en één knop terug naar de lijst |
Beide staan in web/src/components/AccountEmptyState.tsx, zodat de drie per-domein-secties één zinspatroon delen in plaats van drie dialecten ervan. Een pagina leest ze af aan de domeinscope, die naast loading ook empty en missing meldt:
const scope = useDomainScope(account)
if (scope.missing) return <AccountMissing />
if (scope.empty) return <AccountSectionEmpty account={account} section="mail" … />De eigen laadtoestand van een pagina moet scope.loading volgen zodra er geen domein is. Hem alleen sluiten in een effect dat bij !domain vroegtijdig terugkeert is precies wat mail en SSL bij een account zonder website eindeloos liet laden.