@@PRODUCT@@

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. 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.
  4. Dicht, en leesbaar. 13px voor de interface, 14px voor tekst die je leest, cijfers altijd tabellarisch zodat een kolom uitlijnt.
  5. 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/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 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 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 768pxcapsule (actief label) + meer-sheetkaarten, 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.

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.

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 weg

Hoe 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).

declaratiewat de kolom doetwaarvoor
width: 'min'zo breed als zijn inhoud, nooit gevouweneen badge, een vinkje, een knop
width: 'grow'krijgt de restbreedte, kapt af met …, één per tabelde 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: truemag vouwen, met hetzelfde plafondeen 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=dense

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

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:

KleurWat het zegtVoorbeeld
grijsnormaal, geen actie nodigGEPLAND
iris (paars)onze eigen accentkleur: een eigenschap, geen alarmWEB op een server
groeniets waar je op wachtte is geluktACTIEF
amberlet op, nog niet stukIN ONDERHOUD
roodstuk, of iets gaat verlorenMISLUKT
cyaanter informatiePOOL

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:

SchermWaar
Servers → een server → Instellingennaast elke dienst (nginx, MariaDB, Rspamd, Valkey, PowerDNS)
Servers → een server → Gereedschapnaast Git, Composer, Node.js, Python, Valkey, Docker, WP-CLI
Servers → een serverbij webserver en PHP in de feiten
Een account → Databasesbij de motor boven de lijst
Een account → WordPressin 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.

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.

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

ToestandWat het betekentHoe het eruitziet
Gedimdnog niet — het onderdeel bestaat, iets wat het nodig heeft nog nietrustiger label, blijft in de balk, blijft aanklikbaar
Verborgenhoort hier niet — het pakket verkoopt het niet, of je rol krijgt het nooitstaat 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 verkoopt

TabItem 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 failed

Als 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 accountHet scherm
heeft websitesde sectie zelf
heeft er geenAccountSectionEmpty: waarom deze sectie een website nodig heeft, plus Website toevoegen
bestaat nietAccountMissing: 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.