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 panel | Admin panel | |
|---|---|---|
| Files | hitechcloudpanel/static/css/design-system/ | hitechcloudadmin/static/css/design-system/ |
| Linked from | internal/web/templates/base.html | internal/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.
| File | Contents |
|---|---|
index.css | The entry point: imports the two files below. It is the only file a layout links. |
tokens.css | Every --ds-* custom property, light values on :root, dark values on .dark. |
components.css | Every 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
componentscascade layer that Tailwind'soutput.cssdeclares (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), sodir="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-visiblering; 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.
| Token | Value | Use |
|---|---|---|
--ds-font-sans | system UI stack | All text |
--ds-font-mono | system monospace stack | Paths, keys, code, logs |
--ds-text-2xs | 11px | Overlines, dense captions |
--ds-text-xs | 12px | Help text, badges, table headers |
--ds-text-sm | 13px | Table cells, secondary text |
--ds-text-base | 14px | Body text and controls |
--ds-text-md | 16px | Card titles, lead text |
--ds-text-lg | 18px | Section titles |
--ds-text-xl | 20px | Modal titles |
--ds-text-2xl | 24px | Page titles |
--ds-text-3xl | 30px | Dashboard figures |
--ds-leading-tight / -snug / -normal | 1.25 / 1.375 / 1.5 | Headings / labels / body |
--ds-weight-regular … -bold | 400, 500, 600, 700 | |
--ds-tracking-tight / -normal / -wide | -0.01em / 0 / 0.04em | Large 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.
| Token | Light | Dark | Use |
|---|---|---|---|
--ds-color-primary | theme primary, else #2563eb | theme primary, else #2563eb | Primary buttons, selected state, active marks |
--ds-color-primary-hover / -active | #1d4ed8 / #1e40af | same | |
--ds-color-primary-subtle (+ -border, -text) | primary at 8% on the surface | primary at 22% | Selected rows and options, primary badges; a flat tint, so it follows the theme |
--ds-color-on-primary | #ffffff | #ffffff | Text on a primary fill (5.2:1) |
--ds-color-link | theme link, else #1d4ed8 | theme link, else #93c5fd | Links, active tab text |
--ds-surface-page | theme page, else #f8fafc | #0b1120 | Page background |
--ds-surface | theme card, else #ffffff | #111827 | Cards, inputs, tables |
--ds-surface-raised | #ffffff | #1f2937 | Menus, modals, toasts |
--ds-surface-sunken | #f1f5f9 | #0f172a | Table headers, footers, wells |
--ds-surface-hover / -selected / -disabled | Interaction backgrounds | ||
--ds-surface-inverse | #1e293b | #f1f5f9 | Tooltips |
--ds-surface-overlay | slate at 50% | near-black at 70% | Modal and drawer backdrop |
--ds-surface-header / -sidebar | theme top bar / sidebar | Chrome | |
--ds-text | #0f172a (17.9:1) | #f1f5f9 (16.2:1) | Primary text |
--ds-text-secondary | #475569 | #cbd5e1 | Secondary text |
--ds-text-muted | #586579 (5.4:1 on sunken) | #94a3b8 | Help text, captions |
--ds-text-disabled | #94a3b8 | #64748b | Disabled controls only |
--ds-border | theme border, else #e2e8f0 | #1f2937 | Card and table lines |
--ds-border-strong / -hover | #cbd5e1 / #94a3b8 | #374151 / #4b5563 | Control outlines |
--ds-focus-color, --ds-focus-ring | primary | lighter link colour | The 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.
| Status | Solid | Text | Subtle | Border |
|---|---|---|---|---|
| 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
| Token | Value | Use |
|---|---|---|
--ds-radius-none | 0 | |
--ds-radius-sm | 4px | Checkboxes, small badges |
--ds-radius-md | 6px | Buttons, inputs, menu items |
--ds-radius-lg | 8px | Cards, menus, toasts, tables |
--ds-radius-xl | 12px | Modals |
--ds-radius-full | pill | Badges, 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
| Token | Value | Layer |
|---|---|---|
--ds-z-base | 0 | |
--ds-z-raised | 10 | A focused item over its neighbours |
--ds-z-sticky | 20 | Sticky table headers, sticky form actions |
--ds-z-header | 40 | Page header |
--ds-z-sidebar | 45 | Sidebar |
--ds-z-dropdown | 1000 | Menus, listboxes |
--ds-z-drawer | 1100 | Drawers |
--ds-z-modal-backdrop / --ds-z-modal | 1200 / 1210 | Modals |
--ds-z-popover | 1300 | Popovers over a modal |
--ds-z-tooltip | 1400 | Tooltips |
--ds-z-toast | 1500 | Toasts, 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
| Token | Value | Use |
|---|---|---|
--ds-duration-fast | 120ms | Hover, focus, colour |
--ds-duration-base | 200ms | Menus, tooltips, switches |
--ds-duration-slow | 300ms | Modals, drawers |
--ds-ease-standard / -enter / -exit | cubic-bezier curves | Moving / 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.
| State | Selector | Notes |
|---|---|---|
| Default | the bare class | |
| Hover | :hover | Interactive components only |
| Focus | :focus-visible | The --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-disabled | Uses --ds-text-disabled / --ds-surface-disabled; cursor not-allowed |
| Loading | aria-busy="true", .is-loading | A spinner or a pulse; interaction blocked |
| Error | aria-invalid="true", .is-error | Danger border/colour; pair with a message |
| Success | .is-success | Success 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.
Dropdown menu
<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).
Modal
<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">‹</a>
<a class="ds-page" aria-current="page">2</a>
<span class="ds-page ds-page--gap">…</span>
</nav>
Breadcrumb
<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.
Sidebar and header
<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
- Use
ds-*classes in the template; keep Tailwind utilities for layout (grid, flex, margins). - 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.cssin both panels. - A page whose output changes on purpose has its golden recaptured
(
hitechcloudpanel/engine/tests/goldens/legacy,TZ=UTC, seeengine/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.cssfree 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.
- Principles
- Dark mode and panel themes
- Tokens
- Typography
- Colour
- Spacing
- Radius
- Shadow
- Z-index
- Motion
- States
- Components
- Button
- Input and textarea
- Select
- Combobox
- Checkbox and radio
- Switch
- Tabs
- Dropdown menu
- Modal
- Drawer
- Tooltip
- Toast
- Alert
- Badge
- Card
- Table
- Pagination
- Breadcrumb
- Sidebar and header
- Form
- File upload
- Chart
- Loading
- Skeleton
- Empty state
- Adopting the system on a page
- Changing the system