Skip to main content
Version: 4.1

Design system

The user panel (port 2083) and the admin panel (port 2087) share one design system: a set of tokens (CSS custom properties) and a set of components (CSS classes) built only from those tokens. It exists so that a button, a table or a modal looks and behaves the same on every page of both panels, in light and dark mode and in every panel theme.

User panelAdmin panel
Fileshitechcloudpanel/static/css/design-system/hitechcloudadmin/static/css/design-system/
Linked frominternal/web/templates/base.htmlinternal/webtemplates/_layout.html
URL/static/css/design-system/index.css/static/css/design-system/index.css

The three files are byte-identical in both repositories. Change them in both, in the same change.

FileContents
index.cssThe entry point: imports the two files below. It is the only file a layout links.
tokens.cssEvery --ds-* custom property, light values on :root, dark values on .dark.
components.cssEvery ds-* class, inside Tailwind's components layer.

Principles​

  • Enterprise and plain. Flat colour, hairline borders, soft neutral shadows. No colour blends, no neon or glow, no translucent blur ("glassmorphism"), no decorative motion.
  • Tokens only. A component never names a literal colour, size or duration; it reads a token. Changing a token changes every component.
  • Opt-in, no regressions. Every class starts with ds-, and nothing in the files matches an element or class a page already uses. Linking the stylesheet changes no existing page; a page adopts the system by using the classes.
  • One component, one class. Variants are modifiers (ds-btn--danger), parts are children (ds-modal__footer). Do not create a second button, card or badge: extend the existing one with a modifier.
  • Utilities still win. The components live in the components cascade layer that Tailwind's output.css declares (it loads first), so a utility on the same element overrides them: class="ds-btn w-full" is a full-width button.
  • Right-to-left ready. Components use logical properties (inline-start/inline-end), so dir="rtl" pages mirror with no extra rule.
  • Accessible by default. Text tokens keep at least 4.5:1 contrast on the surfaces they are used on; every interactive component has a visible :focus-visible ring; state is read from ARIA attributes where one exists.

Dark mode and panel themes​

Dark mode is the .dark class on <html>, the switch both panels already use. Every colour token has a dark value.

The panel themes (data-theme-brand on <html>: default, cPanel style, DirectAdmin style, Plesk style, HiTechCloud 2) publish their colours as --tb-* properties in brand-themes.css (user panel) and themes.css (admin panel). The brand-dependent design tokens read those first and fall back to the default look:

--ds-color-primary: var(--tb-primary, #2563eb);
--ds-surface: var(--tb-card, #ffffff);
--ds-surface-sidebar: var(--tb-sidebar, #ffffff);

So a ds-btn--primary is orange in the cPanel style and blue in the DirectAdmin style with no per-theme rule. New themes only need to define their --tb-* tokens.

Tokens​

Typography​

Font stacks are the system UI and monospace stacks: no web font is downloaded.

TokenValueUse
--ds-font-sanssystem UI stackAll text
--ds-font-monosystem monospace stackPaths, keys, code, logs
--ds-text-2xs11pxOverlines, dense captions
--ds-text-xs12pxHelp text, badges, table headers
--ds-text-sm13pxTable cells, secondary text
--ds-text-base14pxBody text and controls
--ds-text-md16pxCard titles, lead text
--ds-text-lg18pxSection titles
--ds-text-xl20pxModal titles
--ds-text-2xl24pxPage titles
--ds-text-3xl30pxDashboard figures
--ds-leading-tight / -snug / -normal1.25 / 1.375 / 1.5Headings / labels / body
--ds-weight-regular … -bold400, 500, 600, 700
--ds-tracking-tight / -normal / -wide-0.01em / 0 / 0.04emLarge headings / body / uppercase labels

Text classes: ds-h1 … ds-h4, ds-body, ds-small, ds-caption, ds-overline, ds-mono, ds-link.

Colour​

Semantic tokens; use these, not the raw grey ramp (--ds-gray-0 … --ds-gray-950), in components.

TokenLightDarkUse
--ds-color-primarytheme primary, else #2563ebtheme primary, else #2563ebPrimary buttons, selected state, active marks
--ds-color-primary-hover / -active#1d4ed8 / #1e40afsame
--ds-color-primary-subtle (+ -border, -text)primary at 8% on the surfaceprimary at 22%Selected rows and options, primary badges; a flat tint, so it follows the theme
--ds-color-on-primary#ffffff#ffffffText on a primary fill (5.2:1)
--ds-color-linktheme link, else #1d4ed8theme link, else #93c5fdLinks, active tab text
--ds-surface-pagetheme page, else #f8fafc#0b1120Page background
--ds-surfacetheme card, else #ffffff#111827Cards, inputs, tables
--ds-surface-raised#ffffff#1f2937Menus, modals, toasts
--ds-surface-sunken#f1f5f9#0f172aTable headers, footers, wells
--ds-surface-hover / -selected / -disabledInteraction backgrounds
--ds-surface-inverse#1e293b#f1f5f9Tooltips
--ds-surface-overlayslate at 50%near-black at 70%Modal and drawer backdrop
--ds-surface-header / -sidebartheme top bar / sidebarChrome
--ds-text#0f172a (17.9:1)#f1f5f9 (16.2:1)Primary text
--ds-text-secondary#475569#cbd5e1Secondary text
--ds-text-muted#586579 (5.4:1 on sunken)#94a3b8Help text, captions
--ds-text-disabled#94a3b8#64748bDisabled controls only
--ds-bordertheme border, else #e2e8f0#1f2937Card and table lines
--ds-border-strong / -hover#cbd5e1 / #94a3b8#374151 / #4b5563Control outlines
--ds-focus-color, --ds-focus-ringprimarylighter link colourThe focus ring: 2px gap + 2px ring

Status colours come in four parts each: the solid colour (icons, borders, fills), a text colour that keeps 4.5:1 on its subtle surface and on white, a subtle surface and a subtle border.

StatusSolidTextSubtleBorder
success--ds-success--ds-success-text--ds-success-subtle--ds-success-border
warning--ds-warning--ds-warning-text--ds-warning-subtle--ds-warning-border
danger--ds-danger (+ -hover, -active)--ds-danger-text--ds-danger-subtle--ds-danger-border
info--ds-info--ds-info-text--ds-info-subtle--ds-info-border
neutral--ds-neutral--ds-neutral-text--ds-neutral-subtle--ds-neutral-border

Never use colour as the only signal: a status badge keeps its word, an error keeps its message.

Charts: --ds-chart-1 … --ds-chart-8 are the categorical series, in order (blue, teal, amber, violet, pink, green, cyan, slate), with lighter values in dark mode; --ds-chart-grid and --ds-chart-axis colour the chart chrome. A single-series chart uses --ds-chart-1.

Spacing​

A 4px grid: --ds-space-0, -px, -0-5 (2px), -1 (4px), -1-5 (6px), -2 (8px), -3 (12px), -4 (16px), -5 (20px), -6 (24px), -8 (32px), -10 (40px), -12 (48px), -16 (64px).

Control heights line every input-like component up in one row: --ds-control-sm 32px, --ds-control-md 38px (default), --ds-control-lg 44px (also the minimum touch target).

Radius​

TokenValueUse
--ds-radius-none0
--ds-radius-sm4pxCheckboxes, small badges
--ds-radius-md6pxButtons, inputs, menu items
--ds-radius-lg8pxCards, menus, toasts, tables
--ds-radius-xl12pxModals
--ds-radius-fullpillBadges, switches, spinners

Shadow​

Soft, neutral and only for elevation: --ds-shadow-xs (controls, cards), -sm (hovered cards), -md (tooltips), -lg (menus, toasts), -xl (modals, drawers), -none. Dark mode uses darker, denser shadows.

Z-index​

TokenValueLayer
--ds-z-base0
--ds-z-raised10A focused item over its neighbours
--ds-z-sticky20Sticky table headers, sticky form actions
--ds-z-header40Page header
--ds-z-sidebar45Sidebar
--ds-z-dropdown1000Menus, listboxes
--ds-z-drawer1100Drawers
--ds-z-modal-backdrop / --ds-z-modal1200 / 1210Modals
--ds-z-popover1300Popovers over a modal
--ds-z-tooltip1400Tooltips
--ds-z-toast1500Toasts, always on top

The overlay values sit above the existing chrome (Tailwind z-50), so a design-system modal covers the current sidebar and header.

Motion​

TokenValueUse
--ds-duration-fast120msHover, focus, colour
--ds-duration-base200msMenus, tooltips, switches
--ds-duration-slow300msModals, drawers
--ds-ease-standard / -enter / -exitcubic-bezier curvesMoving / appearing / leaving

With prefers-reduced-motion: reduce every duration becomes 0, skeletons stop pulsing, and spinners keep turning (they carry meaning) at half speed.

States​

Every component expresses state the same way. Prefer the ARIA attribute: it styles the component and tells assistive technology at once.

StateSelectorNotes
Defaultthe bare class
Hover:hoverInteractive components only
Focus:focus-visibleThe --ds-focus-ring; never remove it
Active:active; persistent: aria-selected, aria-current, aria-pressed, aria-expanded, .is-active
Disabled:disabled, aria-disabled="true", .is-disabledUses --ds-text-disabled / --ds-surface-disabled; cursor not-allowed
Loadingaria-busy="true", .is-loadingA spinner or a pulse; interaction blocked
Erroraria-invalid="true", .is-errorDanger border/colour; pair with a message
Success.is-successSuccess border/colour

Components​

Each entry lists the classes and the markup to use. The comment above each section of components.css repeats the markup.

Button​

<button class="ds-btn ds-btn--primary">Save</button>
<button class="ds-btn">Cancel</button>
<button class="ds-btn ds-btn--danger">Delete</button>
<button class="ds-btn ds-btn--ghost ds-btn--icon" aria-label="Refresh"><i class="bi bi-arrow-clockwise"></i></button>
<button class="ds-btn ds-btn--primary" aria-busy="true" disabled>Saving</button>

Variants --primary (one per view), --secondary (the default look), --ghost, --danger, --link; sizes --sm, --lg; --icon (square, needs an aria-label), --block (full width). Group with ds-btn-group. Loading keeps the label's width and draws a spinner over it; combine it with disabled so the action cannot run twice.

Input and textarea​

<input class="ds-input" type="text">
<textarea class="ds-input ds-textarea"></textarea>
<div class="ds-input-group"><span class="ds-input-group__addon">https://</span><input class="ds-input"></div>
<div class="ds-input-wrap" aria-busy="true"><input class="ds-input"></div>

Sizes --sm, --lg; --mono for paths and keys. Read-only inputs take the sunken surface. Error: aria-invalid="true". Loading: wrap in ds-input-wrap with aria-busy.

Select​

<div class="ds-select"><select>...</select></div>

The wrapper draws the chevron (no image is involved), so it follows the text colour in every theme. States go on the select (disabled, aria-invalid) or the wrapper (.is-error, .is-loading).

Combobox​

<div class="ds-combobox">
<input class="ds-input" role="combobox" aria-expanded="true" aria-controls="lb" aria-activedescendant="o2">
<ul class="ds-listbox" role="listbox" id="lb">
<li class="ds-option" role="option" id="o1">example.com</li>
<li class="ds-option is-active" role="option" id="o2" aria-selected="true">example.net</li>
</ul>
</div>

The listbox shows while the input has aria-expanded="true". .is-active is the keyboard-highlighted option; aria-selected is the chosen one. ds-listbox__empty / __status for "no match" and "searching". The listbox is reusable on its own (for an autocomplete under any field).

Checkbox and radio​

<label class="ds-check"><input type="checkbox" class="ds-checkbox"> Enable backups</label>
<label class="ds-check"><input type="radio" class="ds-radio" name="php"> PHP 8.3</label>

Indeterminate checkboxes are drawn (input.indeterminate = true). A ds-check__hint under the label adds a description. Group them in a ds-fieldset with a legend.

Switch​

<label class="ds-check"><input type="checkbox" role="switch" class="ds-switch"> Force HTTPS</label>

A switch applies immediately; use a checkbox when the choice is saved with a form. While the change is being applied set aria-busy="true".

Tabs​

<div class="ds-tabs" role="tablist">
<button class="ds-tab" role="tab" aria-selected="true">General</button>
<button class="ds-tab" role="tab" aria-selected="false">Logs <span class="ds-tab__count">3</span></button>
</div>

Links as tabs use aria-current="page". ds-tabs--pills gives the segmented look for filters.

<div class="ds-dropdown ds-dropdown--end">
<button class="ds-btn" aria-haspopup="menu" aria-expanded="false">Actions</button>
<div class="ds-menu" role="menu">
<p class="ds-menu__label">Site</p>
<button class="ds-menu__item" role="menuitem">Edit</button>
<div class="ds-menu__separator" role="separator"></div>
<button class="ds-menu__item ds-menu__item--danger" role="menuitem">Delete</button>
</div>
</div>

The menu shows while the trigger has aria-expanded="true" (or the dropdown has .is-open).

<div class="ds-modal-backdrop">
<div class="ds-modal" role="dialog" aria-modal="true" aria-labelledby="t">
<div class="ds-modal__header"><h2 class="ds-modal__title" id="t">Delete site</h2></div>
<div class="ds-modal__body">...</div>
<div class="ds-modal__footer">
<button class="ds-btn">Cancel</button>
<button class="ds-btn ds-btn--danger">Delete</button>
</div>
</div>
</div>

Hide with the hidden attribute on the backdrop. A native <dialog class="ds-modal"> opened with showModal() also works. Sizes --sm, --lg, --xl; --danger for destructive confirmations. Footer buttons: the primary action last.

Drawer​

<div class="ds-modal-backdrop ds-drawer-backdrop">
<aside class="ds-drawer" role="dialog" aria-modal="true">header / body / footer as the modal</aside>
</div>

Opens from the inline end; ds-drawer--start opens from the inline start, --lg is wider. It reuses the modal's header, body and footer parts.

Tooltip​

<span class="ds-tooltip-wrap">
<button class="ds-btn ds-btn--ghost ds-btn--icon" aria-label="Help" aria-describedby="tt"><i class="bi bi-question-circle"></i></button>
<span class="ds-tooltip" role="tooltip" id="tt">Shown on hover and on keyboard focus</span>
</span>

--bottom places it below. Never put the only copy of important information in a tooltip.

Toast​

<div class="ds-toast-region" aria-live="polite">
<div class="ds-toast ds-toast--success" role="status">
<div class="ds-toast__body"><p class="ds-toast__title">Saved</p><p class="ds-toast__text">The site configuration was updated.</p></div>
</div>
</div>

Variants --info, --success, --warning, --danger (use role="alert"), --loading (spinner). A toast is for a passing message; a message that must stay on the page is an alert.

Alert​

<div class="ds-alert ds-alert--warning" role="alert">
<div class="ds-alert__body"><p class="ds-alert__title">Certificate expires in 5 days</p><p>Renew it from SSL.</p></div>
</div>

Variants --info, --success, --warning, --danger; ds-alert__actions for buttons inside.

Badge​

<span class="ds-badge ds-badge--success ds-badge--dot">Running</span>

Variants --primary, --info, --success, --warning, --danger; --square, --dot. A badge that is a link or a button gets hover and focus.

Card​

<section class="ds-card">
<header class="ds-card__header"><h3 class="ds-card__title">Disk</h3></header>
<div class="ds-card__body">...</div>
<footer class="ds-card__footer">...</footer>
</section>

a.ds-card or ds-card--interactive is a whole-card link with hover, focus and active. ds-card__body--flush for a table that runs to the card's edges. ds-stat (__label, __value, __meta) inside a card is a dashboard figure.

Table​

<div class="ds-table-wrap">
<table class="ds-table">
<thead><tr><th aria-sort="ascending"><button class="ds-table__sort">Domain</button></th><th class="ds-num">Size</th></tr></thead>
<tbody><tr aria-selected="true"><td>example.com</td><td class="ds-num">1.2 GB</td></tr></tbody>
</table>
</div>

The wrapper scrolls sideways instead of widening the page; headers stick. --compact, --striped; ds-num right-aligns figures, ds-actions the row actions. Empty: one row with a ds-table__empty cell holding a ds-empty ds-empty--compact. Loading: aria-busy on the table, or skeleton rows.

Pagination​

<nav class="ds-pagination" aria-label="Pagination">
<span class="ds-pagination__info">1-20 of 312</span>
<a class="ds-page" href="?p=1" aria-label="Previous">&lsaquo;</a>
<a class="ds-page" aria-current="page">2</a>
<span class="ds-page ds-page--gap">&hellip;</span>
</nav>
<nav class="ds-breadcrumb" aria-label="Breadcrumb">
<ol><li><a href="/">Home</a></li><li><a href="/sites">Sites</a></li><li><a aria-current="page">example.com</a></li></ol>
</nav>

The separators are drawn and mirror in right-to-left pages.

<nav class="ds-sidebar" aria-label="Main">
<p class="ds-sidebar__section">Websites</p>
<a class="ds-nav-item" href="/sites" aria-current="page"><i class="bi bi-globe"></i><span>Sites</span></a>
</nav>
<header class="ds-header">
<div class="ds-header__start">...</div>
<div class="ds-header__end">...</div>
</header>

Both read the theme's sidebar and top-bar tokens (--tb-sidebar*, --tb-topbar*), so a theme's dark navy sidebar needs no extra rule. A page's own title row is ds-page-header (__title, __text, __actions).

Form​

<form class="ds-form">
<div class="ds-field is-error">
<label class="ds-label" for="d">Domain <span class="ds-required" aria-hidden="true">*</span></label>
<input class="ds-input" id="d" aria-invalid="true" aria-describedby="d-help d-err">
<p class="ds-help" id="d-help">Without www.</p>
<p class="ds-field__error" id="d-err">This domain is already in use.</p>
</div>
<div class="ds-form__actions"><button class="ds-btn ds-btn--primary">Add</button></div>
</form>

ds-form__row lays fields side by side and wraps on narrow screens; ds-form__section groups them under a ds-form__section-title; ds-form__actions--sticky keeps the actions visible on long forms. The field owns its state: .is-error shows ds-field__error and colours the control, .is-success shows ds-field__success. A busy form (aria-busy) blocks its fields.

File upload​

<label class="ds-dropzone">
<input type="file" class="ds-visually-hidden" multiple>
<span class="ds-dropzone__title">Drop files or <span class="ds-link">browse</span></span>
<span class="ds-dropzone__hint">Up to 50 MB each</span>
</label>
<ul class="ds-file-list">
<li class="ds-file"><span class="ds-file__name">backup.tar.gz</span><span class="ds-file__meta">42%</span>
<div class="ds-progress"><div class="ds-progress__bar" style="width:42%"></div></div></li>
</ul>

Script adds .is-dragover while a file is held over the zone. Each file row takes .is-error / .is-success.

Chart​

<figure class="ds-chart">
<figcaption class="ds-chart__header">
<span class="ds-chart__title">CPU</span>
<ul class="ds-legend"><li><span class="ds-legend__swatch" style="--_c: var(--ds-chart-1)"></span>Used</li></ul>
</figcaption>
<div class="ds-chart__canvas"><canvas></canvas></div>
</figure>

The chart library takes its colours from the tokens at draw time, for example getComputedStyle(document.documentElement).getPropertyValue('--ds-chart-1'), and redraws when .dark changes. States: aria-busy (pulsing placeholder), .is-empty, .is-error. Legend entries that toggle a series are button.ds-legend__item with aria-pressed.

Loading​

ds-spinner (--sm, --lg, with role="status" and an aria-label); ds-progress with a ds-progress__bar (role="progressbar" and aria-valuenow), .is-indeterminate when the amount is unknown, .is-success / .is-error / --warning; ds-loading-overlay covers a positioned container.

Skeleton​

ds-skeleton with --text, --title, --circle, --block: grey shapes that pulse in opacity only, standing in for content that is loading. Mark the container aria-busy="true" meanwhile.

Empty state​

<div class="ds-empty">
<div class="ds-empty__icon"><i class="bi bi-globe"></i></div>
<h3 class="ds-empty__title">No sites yet</h3>
<p class="ds-empty__text">Add a site to start serving it.</p>
<div class="ds-empty__actions"><a class="ds-btn ds-btn--primary" href="/sites/new">Add site</a></div>
</div>

--compact inside cards and tables; .is-error for "could not load".

Adopting the system on a page​

  1. Use ds-* classes in the template; keep Tailwind utilities for layout (grid, flex, margins).
  2. Replace a page's hand-rolled control with the component, not a copy of it. If the component is missing something, add a modifier to components.css in both panels.
  3. A page whose output changes on purpose has its golden recaptured (hitechcloudpanel/engine/tests/goldens/legacy, TZ=UTC, see engine/tests/goldens.rs); say so in the pull request.

Changing the system​

  • Add or change a token in tokens.css, with its dark value, and check the contrast of any text token against the surfaces it is used on.
  • Keep components.css free of literal colours, colour blends and blur.
  • Copy the changed files to the other panel in the same change; the two copies must stay identical.
  • Update this page.
Was this helpful?