SkyLab Frontend — Style Guide
This document describes the frontend styling architecture and the rules for writing styles. It applies to every page and component in the frontend: new pages and components must follow it so that the visual result and the code style stay consistent. If you want to change
_variables.scssor_themes.scssyourself, discuss it with the frontend team first.
Directory structure
src/assets/styles/
├── global.scss # Global style entry point (@use themes, backgrounds, reset; full-page background layer, toast styles)
├── _themes.scss # CSS custom properties (light / dark theme)
├── _backgrounds.scss # Background presets selected by body[data-bg]
├── _variables.scss # SCSS structural variables (spacing, fonts, breakpoints, radii)
├── _mixins.scss # Reusable SCSS mixins
└── _reset.scss # CSS resetComponent and page styles are written as CSS Modules and live next to the component:
src/pages/personal/resources/
├── ResourcesPage.jsx
└── ResourcesPage.module.scss ← same name as the component, same directoryUsing the shared variables and mixins in an SCSS Module
vite.config.js already injects the following into every SCSS file through css.preprocessorOptions.scss.additionalData:
@use "@/assets/styles/variables" as *;
@use "@/assets/styles/mixins" as *;So inside a .module.scss you can use $spacing-*, $font-size-*, @include flex-center and so on directly. You do not need to (and should not) add @use variables / mixins manually at the top of the file — a manual import is redundant. Leftover manual imports in older files are harmless; remove them when you happen to refactor the file.
Note: the global injection covers only the
variablesandmixinsfiles. The colours in_themes.scssare CSS custom properties (var(--color-*)) and never needed an import in the first place.
⚠️ Rules for variables
Do not declare new SCSS variables or CSS custom properties inside component SCSS.
Always look up and reuse what is already defined in _variables.scss (spacing, fonts, radii, etc.) and _themes.scss (colours). If there really is no matching variable, discuss first whether a global definition should be added instead of declaring one inside the component.
Colour system
Every colour must use a CSS custom property defined in _themes.scss; never hard-code a HEX value in component SCSS. Status colours also go through --color-success and friends (hard-coded colours are allowed only in the exceptions listed below).
Main variables
Background
| Variable | Purpose |
|---|---|
--color-bg-base | Page background |
--color-bg-gradient-blue/yellow/green | The three-colour glow gradient background |
Surfaces
| Variable | Purpose |
|---|---|
--color-surface | Card and panel background |
--color-surface-glass | Frosted-glass background |
--color-surface-glass-border | Frosted-glass border |
--color-sidebar | Sidebar background |
Brand colours
| Variable | Purpose |
|---|---|
--color-primary | Primary colour (blue-violet) |
--color-primary-dark | Dark primary (backgrounds only, e.g. the hover state of a primary button; it stays dark in dark mode, so text in this colour becomes unreadable) |
--color-primary-light | Light primary |
--color-primary-on-surface | Brand colour for text / borders; meets AA in both light and dark mode. Do not confuse it with --color-text-primary (the words are in the opposite order and both are blue) |
Text
| Variable | Purpose |
|---|---|
--color-text | Body text |
--color-text-primary | Headings, emphasised text |
--color-text-secondary | Secondary text |
--color-text-muted | Helper text, placeholders |
--color-text-on-primary | Text on a primary-colour background (white) |
Borders and interaction
| Variable | Purpose |
|---|---|
--color-border | Regular border |
--color-divider | Divider line |
--color-hover | Hover background |
--color-row-hover | Table row hover background (darker than --color-hover so it does not match the header colour) |
--color-overlay | Modal backdrop |
Shadows
| Variable | Purpose |
|---|---|
--shadow-sm | Subtle shadow |
--shadow-md | Medium shadow (cards) |
--shadow-lg | Large shadow (dialogs) |
--shadow-glass | Frosted-glass shadow |
Special-purpose palettes are reserved for one component each: --color-flow-* (topology canvas, see Card), --color-code-* (code samples, see the exceptions below), --color-terminal-* (the terminal output on the home-page machine card), --color-folder-* (quick-practice folders) and --color-kind-shared (the machine source badge MachineKindBadge).
Status colours
The frontend uses the following five semantic colours. Amber is reserved for the "pending review / pending" meaning and is not a warning colour — warnings and errors are always red and always use --color-danger (there is deliberately no --color-warning):
| Variable | Light value | Dark value | Meaning | When to use |
|---|---|---|---|---|
--color-success | #28a745 | same | 🟢 OK | running, connected, succeeded |
--color-info | #2b4d98 | #89a5e0 | 🔵 Neutral info | in progress, explanations, generic labels |
--color-pending | #d97706 | #f59e0b | 🟠 Pending | pending review, draft, scheduled, waiting |
--color-danger | #dc3545 | same | 🔴 Danger | errors, failures, destructive actions |
--color-status-neutral | #6b7280 | #9ca3af | — | ⚫ Inactive |
For the darker hover state of destructive actions use --color-danger-dark (#b91c1c).
Exception: terminal-style content areas stay dark and do not follow the theme — the VNC / xterm screen background (ConsoleDialog and Classroom's
#1e1e1e), the task log output area (JobsdialogOutput), and the white backgrounds of the error-page illustrations (NotFoundPage's cloud,CrashState's application window andConnectionLostState's cloud and window are all#fff, and their inner colour blocks are also mixed against#fff— the illustration is the same image in both themes; in light mode the outline comes from a drop-shadow).Exception: sample code blocks (the code samples in the AI API quick start and the code blocks inside API chat replies) keep a fixed dark background and do not follow the theme, mirroring a dark VS Code editor; use the
--color-code-*palette from_themes.scss(bg / header / border / hover / text / muted) and do not define another dark palette inside a page.Exception: the VS Code-like config file editor on the Gateway page (
ConfigCodeEditor.module.scss) hard-codes the whole vs-dark palette (#1e1e1e,#252526,#007acc, etc.) and the 13px/12px font sizes, and deliberately does not follow the theme — the frame has to match Monaco'stheme="vs-dark", emulating a VS Code window, which is a container with its own colour scheme.
The standard way to write a status badge
The pill shape and text come from @include badge-base; (inline-flex, pill radius, 12px / 500, no wrapping); add the padding and one of these colour pairs:
.badge_success { background: color-mix(in srgb, var(--color-success) 12%, transparent); color: var(--color-success); }
.badge_info { background: color-mix(in srgb, var(--color-info) 12%, transparent); color: var(--color-info); }
.badge_pending { background: color-mix(in srgb, var(--color-pending) 12%, transparent); color: var(--color-pending); }
.badge_danger { background: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); }
.badge_muted { background: color-mix(in srgb, var(--color-status-neutral) 12%, transparent); color: var(--color-status-neutral); }Always use
var(--color-*); do not hard-code status colours as HEX — otherwise the lighter dark-mode values for info / pending never take effect.
Badges never have a border: use only "status colour at 12% as a tint + status colour as text", and do not add a
border(neither solid nor dashed). Pill labels for status, type, scope and the like all count as badges; non-status neutral labels use thebadge_mutedabove. When a badge sits on something that lets the underlying image show through (such as a canvas), mix the tint withvar(--color-surface)instead (color-mix(in srgb, <colour> 12%, var(--color-surface))) so it becomes opaque — still without a border. The selected outline of tabs, SegmentedControl and Stepper is not covered by this rule.
SCSS variables (_variables.scss)
Spacing
$spacing-4: 4px $spacing-8: 8px $spacing-16: 16px
$spacing-24: 24px $spacing-32: 32px $spacing-48: 48pxFont sizes
$font-size-10: 10px $font-size-12: 12px $font-size-14: 14px $font-size-16: 16px
$font-size-18: 18px $font-size-24: 24px $font-size-28: 28px $font-size-32: 32px
$font-size-10is restricted to data-dense areas (dense grids, card meta rows) and only for secondary labels; regular body and helper text is$font-size-12at the smallest.
Font weights
$font-weight-400: 400 $font-weight-500: 500 $font-weight-700: 700Border radii
$radius-8: 8px $radius-12: 12px $radius-16: 16px $radius-pill: 999pxTransitions
$transition-base: 0.2s ease $transition-slow: 0.3s easeBreakpoints
$breakpoint-sm: 576px $breakpoint-md: 768px
$breakpoint-lg: 992px $breakpoint-xl: 1200px
$breakpoint-sidebar: 1024px // the sidebar switches from a drawer to a fixed columnOthers
$font-family-base $line-height-base: 1.6 $form-control-padding-x: 12px
$header-height: 60px $sidebar-width: 260px $sidebar-width-collapsed: 72px
$content-padding: 40px $container-max-width: 1200px
$z-ai-assistant: 2000 $z-screen-dialog: 2100 // see z-index layersMixins (_mixins.scss)
Flex layout
@include flex-center; // display:flex; align-items:center; justify-content:center
@include flex-between; // display:flex; align-items:center; justify-content:space-between
@include flex-column; // display:flex; flex-direction:columnText truncation
@include text-truncate; // single-line truncation with an ellipsis
@include text-clamp(3); // multi-line clamp (defaults to 2 lines)Container
@include container; // max-width: 1200px; margin-inline: auto; padding-inline: 16pxFrosted-glass surface
@include glass-surface; // default glass shadow var(--shadow-glass)
@include glass-surface($shadow: var(--shadow-sm)); // different shadow
@include glass-surface($shadow: none); // no box-shadow emitted
@include glass-surface(8px, 1.2); // fixed filter parameters (ignores style switching; special cases only)The backdrop-filter of a glass surface always goes through var(--glass-backdrop-filter) (the sidebar uses var(--sidebar-backdrop-filter)), so that style variants such as "liquid glass" can swap the filter globally — do not hard-code blur(12px) saturate(1.4) inside a component.
Responsive breakpoints
@include respond-to(md) {
// applied at min-width: 768px
}
@include respond-below(md) {
// applied below 768px (max-width: 767.98px)
}New layouts are mobile-first with respond-to. respond-below exists for existing desktop-first layouts that would cost too much to invert; use it instead of a raw max-width media query so the breakpoints stay on the shared scale.
Icon rules
Every icon goes through the MIcon component, which defaults to the outlined style; when you need a filled icon pass the filled prop instead of swapping the class yourself.
import MIcon from "../components/MIcon";
<MIcon name="search" size={16} /> {/* material-icons-outlined (default) */}
<MIcon name="star" size={16} filled /> {/* material-icons (filled, only for special emphasis) */}- The font is Material Icons (
@material-design-icons/font), not Material Symbols. Look up names in the Material Icons set (outlined and filled share the same name); a name that exists only in Material Symbols renders as plain text - Never use
<span className="material-icons">,material-icons-outlinedor any other icon library directly - Never mix in inline SVG, emoji or any other icon system
Naming conventions
CSS Modules class names
Use camelCase:
.cardHeader { }
.statusDot { }
.headerBtn { }BEM-style sub-variants
Separate variants with an underscore _ rather than BEM's --:
.badge_success { }
.badge_danger { }
.dot_connected { }
.dot_error { }Animation / state suffixes
| Suffix | Purpose |
|---|---|
Out | Exit animation of an element (e.g. .powerMenuOut) |
Active | Actively selected state (e.g. .menuBtnActive) |
Disabled | Disabled styling (prefer the CSS :disabled pseudo-class) |
Page layout
The page root container (.page) is always full width: no max-width, no margin: 0 auto centring; let the content fill the DashboardLayout content area. This keeps the page margins consistent across the whole site, so wide screens never show a mix of "some pages centred with side gaps, some pages full width".
The standard form:
.page {
@include flex-column;
gap: $spacing-24;
padding: $spacing-8 $spacing-16;
flex: 1;
@media (min-width: $breakpoint-sidebar) { padding: 0; }
}Exception: narrow form-type pages may constrain their width — input fields stretched across a wide screen are actually harder to use. This exception is limited to "single-column form" pages; regular content pages stay full width.
Component style conventions
Card
.card {
@include glass-surface;
border-radius: $radius-16;
@include flex-column;
overflow: hidden;
}- A clickable card's hover only gets a light tint (
background: var(--color-hover)) or a shadow / lift, with atransitionfor fade-in and fade-out; do not change the border colour — a blue border is reserved for the "selected / current" state (such as an active tab or the current step of a flow) - Do not build the tint with a
linear-gradientlayer: gradients cannot transition, so the hover colour would jump instantly - Content blocks inside a glass card (info fields, note boxes,
preblocks for code / logs / keys, list rows, stat cells) always usebackground: var(--color-surface)(white) plus1px solid var(--color-border); do not fill them with primary-tinted light blues such as--color-hoveror--color-bg-base. Those follow the primary colour, so when the user changes the background colour they blur into the glass that lets the background through. Light blue is reserved for hover / selected states, buttons, badges and callout boxes - The topology canvas (React Flow) is not covered by the white-background rule: its background, border and shadow always use the canvas-specific tokens
background: var(--color-flow-bg),1px solid var(--color-flow-border),box-shadow: 0 4px 24px var(--color-flow-shadow); the firewall page, the course environment editor, resource details and the read-only topology of the class session environment all share this one set
Dialog / Modal
New dialogs always use the shared <Modal> (components/Modal/Modal); do not hand-write the backdrop and card again. It takes care of portalling to body, the backdrop and the enter / exit animations, role / aria-modal / aria-labelledby, closing on Esc and backdrop click (not while busy; with two stacked dialogs only the top one closes), moving focus into the dialog on open and restoring it after close, trapping Tab inside the dialog, and locking scroll of the page underneath:
const presence = useDialogPresence(show);
{presence.open && (
<Modal
as="form" onSubmit={submit} // only for form-type dialogs
closing={presence.closing} onClose={() => setShow(false)} busy={saving}
title="重設密碼" description="新密碼只會顯示一次"
size="sm" // sm 400 / md 640 / lg 1100 / xl 1280; use log (560) for wide content such as error logs
closeButton // forms with many fields: × in the title bar, the body scrolls on its own, the button row is pinned to the bottom
actions={<><button …>取消</button><button type="submit" …>送出</button></>}
>
…fields…
</Modal>
)}- Compact card (default): confirmation boxes, naming boxes, small forms; the
closeButtonvariant: forms with many fields (such as the connection dialog) — a reminder whose body is only a sentence or two (such as the session warning) can adddividers={false}to drop the rules under the title bar and above the button row;bare: only the outer frame, you lay out the title bar and body yourself - Screens the AI cannot read, such as terminals and VNC, use
layer="screen"(usually together withbare): they cover the AI assistant instead of making room for it; the keyboard is handed entirely to the screen, Esc does not close and Tab is not trapped (so Tab completion and vim's Esc work). Grab the dialog body with arefwhen you need fullscreen - Guide attributes such as
data-guideare passed straight toModal(attached to the dialog body); for the × and the button row useclosePropsandactionsPropsrespectively - Every dialog in the frontend is built on
Modal, includinguseConfirm,ConnectionDialogand the terminal / VNC / classroom watch screens; do not bring back a hand-written backdrop - Width tiers (
size): confirmation / naming boxessm400px; small single-column formsmd640px; regularlg1100px; wide (such as VNC)xl1280px; wide content such as error logslog560px - Height: a
Modalcard is at most90vhtall; the screen dialogs (VNC / terminal console, classroom watch) fix their own height at88vh - Fullscreen: the dialog that can go fullscreen styles the
:fullscreenpseudo-class withmax-width: 100%; height: 100%; border-radius: 0(seeConsoleDialog.module.scss) - Backdrop:
var(--color-overlay)withbackdrop-filter: blur(4px)atz-index: 300, portalled todocument.body: when an ancestor hasbackdrop-filter/transform,position: fixedgets trapped inside that layer and cannot cover the whole screen. While the AI assistant panel is open the backdrop leaves the panel's width free (dialog-overlay-avoid-ai) and centres the dialog in the remaining space - Card shadow: the compact card uses the default
glass-surfaceshadow; thecloseButtonlayout usesvar(--shadow-lg)so it stands out more against the backdrop - Destructive confirmations pass
role="alertdialog"(useConfirmdoes this)
Confirmation dialog (unified site-wide)
Destructive actions and either/or confirmations always use the shared useConfirm() (components/ConfirmDialog/ConfirmProvider); do not build a local ConfirmModal inside a page:
const confirm = useConfirm();
if (!(await confirm({ title, message, confirmText, danger: true }))) return;
// …the dialog closes as soon as confirm is pressed; show progress with a disabled button + toast, not a spinner inside the dialogThe look comes from Modal's compact card: a 400px frosted-glass card with $spacing-24 padding, and the spacing between title, body and button row handled by a $spacing-16 gap rather than margins.
- Title:
$font-size-16/$font-weight-700; danger dialogs carry a redwarningicon, regular ones an amberhelpicon - Body:
$font-size-14in the secondary colour withoverflow-wrap: anywhere(long unbroken strings such as hostnames are often inserted) - Enter / exit: backdrop fadeIn 0.15s, card slideUp 0.18s, Esc closes
Buttons
Buttons always use the button mixin family (six variants) from _mixins.scss; do not copy the whole set of styles into a page:
.btnPrimary { @include btn-primary; } // primary action: at most one per screen
.btnSecondary { @include btn-secondary; } // regular actions placed side by side, back
.btnDanger { @include btn-danger; } // only in confirmation dialogs / the final confirmation step
.btnDangerOutline { @include btn-danger-outline; } // the "entry point" of a destructive action on a page; clicking it opens useConfirm
.btnGhost { @include btn-ghost; } // low-emphasis helper action: tinted text button (has the --color-hover tint at rest, one step darker on hover)
.btnGhostDanger { @include btn-ghost($danger: true); } // light-red tint with red text (delete in tight spaces)
.iconBtn { @include btn-icon; } // 32×32 icon button; the JSX must carry aria-label
.iconBtnDanger { @include btn-icon($danger: true); } // text is red even before hover
.menuBtn { @include btn-icon-secondary; } // action menu button in table rows (white with outline); the icon is always more_vert (⋮)
.dialogClose { @include btn-dialog-close; } // dialog-only top-right close button: same as btn-icon($danger: true) but with no background at rest- Shared base (built into the mixins): 36px tall,
$radius-8corners, 14px / 500 text, hover always scoped to:not(:disabled), disabled alwaysopacity: 0.5; cursor: not-allowed - Page-specific differences (width, margin, grid placement) go after the
@include; if you want something shorter or smaller, first ask whether it is really needed - Existing "modifier class" families on some pages (such as
.actionBtn.actionBtnDangerin table rows) keep the base + modifier form, but a danger modifier must show red text before hover, and its hover must also include:not(:disabled) - Danger items inside dropdown menus (PowerMenu, the ⋯ menu in page headers) are menu styles and are not among these six variants
Forms
Form fields always use the form mixin family from _mixins.scss; do not invent another set of font sizes and paddings inside a page:
.field { @include form-field; } // vertical container for label + control
.field input, .field select, .field textarea { @include form-control; }
.fieldInvalid.fieldInvalid { @include form-control-invalid; } // fields left empty at submit<label className={styles.field}>
<span>班級名稱</span>
<input className={invalid ? styles.fieldInvalid : undefined} aria-invalid={invalid} … />
</label>Rule 1: controls are fixed at 36px tall with 14px text, deliberately matching
.btnPrimary/.btnSecondaryso that fields and buttons on the same row line up. If you want a shorter or smaller form, first ask whether it is really needed; do not overridemin-heightorfont-sizeinside a page. The height is computed the same way as for buttons: site-wide line-height 1.6 (14px text → 22.4) + vertical padding 6×2 + border 1×2 ≈ 36px. Writing 8px padding makes it 40px; a search box whose border sits on the container usesmin-height: 36px, not 38px. The shared SegmentedControl is also fixed at 36px, so it sits on the same row as fields and buttons without extra height locking.
Rule 2: how fields are arranged (how many columns, which one spans) lives in the page's own grid (
.formGrid,.createFormGrid,.fieldFull); the mixins are only responsible for what a single field looks like.
Rule 3: a "from–to" pair of values is one field, not two. Use a paired control such as
.timePair, label it "Class time", and do not split it into two.fields labelled "Start time" and "End time".
Rule 4: textareas always have a fixed height —
_reset.scssalready setsresize: noneglobally, and the height comes fromrowsin the JSX orheight/min-heightin the page CSS; do not writeresizeagain inside a component. Only in special cases (a long-text editor that genuinely needs to be user-resizable) should a page explicitly setresize: verticalback, so that the exception is visible.
Switch and checkbox
Enable/disable toggles that take effect immediately always use the shared Switch (components/Switch/Switch); do not use a toggle-icon button (toggle_on / toggle_off) or a checkbox in their place:
<Switch checked={rule.enable !== 0} onChange={(next) => toggle(rule, next)} disabled={busy} ariaLabel={t("…enableSwitch")} title={t("…enableSwitch")} />- Division of labour: sends as soon as it flips →
Switch; a form field that only takes effect after pressing "Save" → checkbox (.checkRow). A switch tells users the change is immediate, so do not mix the two - Look: a 36×20 pill track with a 16px white knob, on =
--color-primary, off = grey; it is a singlebutton role="switch" - In tables and list rows show the switch alone, without an "Enabled" caption (the column header or the row already says what it controls), and pass
ariaLabel+titleinstead; only pass a visiblelabelwhen it is genuinely needed, saying what the switch controls and not changing with the state - The current state is shown by the knob position and colour; in a list, a disabled row swaps its status badge for a red "Disabled" (e.g. a firewall rule's "Allow" becomes "Disabled") instead of adding a separate "Disabled" badge next to it
- A disabled row dims only its content; the actions cell holding the switch is not dimmed, so it is clear it can be turned back on
- Currently used by: the firewall rules in resource details › advanced settings, the rules panel on the firewall page, and the node list on the PVE Management page
Tables
List-page tables always use the table mixin family from _mixins.scss; do not copy the whole set of styles into a page:
.tableWrap { @include table-wrap; } // glass container + rounded corners + horizontal scrolling
.table { @include table-base; min-width: 720px; } // set min-width to fit the content so it scrolls
.th { @include table-th; }
.tr { @include table-tr; } // the base has no hover, see the rule below
.td { @include table-td; }- Page-specific differences such as column widths, alignment and special cells (
.thRight,.tdNowrap, …) go after the@include - The responsive behaviour is uniformly horizontal scrolling of the container (
table-wraphasoverflow-x: autobuilt in); tables are not turned into cards - When a table is embedded inside an existing card you may use only
table-base/table-th/table-tr/table-tdand omit the outertable-wrap - The
<table>itself is rendered bycomponents/DataTable(TanStack Table, see the development guide), and the classes still come from the page's SCSS Module:classNamefor the table,rowClassName(row)for rows, andmeta.thClassName/meta.tdClassNameon each column
Rule 1: a whole-row hover colour means the row is clickable
A whole-row hover colour is an interaction signal, not decoration. When the row itself is not clickable (all interaction is on buttons inside the cells), do not colour the whole row, otherwise it is a false hint that the row can be clicked.
.tr { @include table-tr; } // not clickable: divider only, no hover
.trClickable { @include table-tr-clickable; } // clickable: cursor + hover together- There is exactly one criterion: whether the
<tr>itself has anonClick(orrole/tabIndex) - The same table may mix both (monitoring page: node rows are clickable, VM rows are not), so the two mixins are stacked rather than taking a boolean parameter
- Do not write
cursor: pointeror&:hover { background: … }yourself — the cursor and the hover would drift apart - When a different hover colour is needed (for example a group row that already has its own background), override
backgroundafter the@include
Rule 2: tables whose row count changes need fixed column widths
The default table-layout: auto computes column widths from the content of every row. As soon as expanding / collapsing adds or removes rows with the full set of columns, the content of every column changes and the whole table jumps.
.table { @include table-base; table-layout: fixed; min-width: 1080px; }
.colStatus { width: 100px; } // declare column widths in one place, paired with <colgroup> in the JSX- This is only unnecessary when the expanded row is a full-width details panel in a
<td colSpan={N}>— a colspan cell does not take part in individual column width calculation and does not cause jumping - After switching to
fixed, remove anymin-widthon cells that was set to "fight auto layout for width"; it would only make the content overflow the column - The one column without a width absorbs the remaining space (usually the name column)
Rule 3: cell icons must carry information the text does not
MIcon is always aria-hidden, so screen readers cannot read it. If the information the icon encodes is already written in the adjacent text (type, category), it is just layout habit — remove it and let the text speak.
List toolbar and batch actions (DataToolbar)
The "filters + search" bar above a list or table and the "batch actions after ticking rows" always use the components/DataToolbar set. Don't build a separate floating action bar or your own checkboxes in a page:
const selection = useRowSelection(shown.filter(canSelect).map((r) => r.id)); // in on-screen order
const selected = shown.filter((r) => selection.isSelected(r.id));
<DataToolbar
selecting={selected.length > 0}
batch={(
<BatchBar countLabel={t("…selectedCount", { count: selected.length })} onClear={selection.clear} progress={progress}>
<BatchAction icon="block" label={t("…disable")} onClick={…} />
<BatchDivider />
<BatchAction icon="delete" label={t("…delete")} danger onClick={…} />
</BatchBar>
)}
>
<SegmentedControl … />
<ToolbarSearch value={query} onChange={setQuery} placeholder={t("…searchPlaceholder")} />
</DataToolbar>
<SelectCheckbox checked={selection.isSelected(r.id)} onToggle={(shift) => selection.toggle(r.id, shift)} ariaLabel={…} />- The toolbar is always there: normally it has no background and holds the status switch and search, aligned with the list's left and right edges; once something is ticked it switches in place to the batch bar (a raised card with a light primary tint that sticks to the top). It is at least 56px tall, and when the filters wrap onto two lines on a narrow screen the batch bar keeps that height — the list never jumps when it switches
- Select all: in a table it goes in the first header cell (
SelectCheckbox cell); lists without a header row (users, requests, class cards) passselectAlltoBatchBar, which puts it before "N selected" - Checkboxes: the whole cell (or the area around the box) is clickable; when the row itself is clickable, its
onClickmust ignore clicks on thelabel. Shift-click selects a range, Esc clears the selection, and the select-all box shows "partial" when only some rows are ticked - While selecting, the whole row is clickable: once at least one row is ticked, clicking anywhere on a row ticks it too (
onClick={(e) => selection.rowClick(id, e)}; when it returnstrue, don't open the detail view). Other buttons and links in the row keep working. Rows that normally open a detail view (table rows, review rows, class tickets) tick instead while selecting, and go back to opening the detail once nothing is ticked - Only visible rows can be ticked:
useRowSelectiononly knows the ids you pass in; rows that disappear after a filter change, a page change or a refresh are unticked automatically, so nobody switches back later and finds a hidden row still ticked and acted on - Actions: send them only to the rows they apply to (disable only goes to enabled accounts…); if none apply, disable the button and use
titleto say how many will be skipped. While sending,progressturns the bar into "…ing 3 / 10"; afterwards refresh silently, untick the rows that succeeded and keep the failed ones ticked (selection.replace) so they can be retried straight away - Irreversible actions (delete, remove, archive, reject) use
danger, sit at the far right after aBatchDivider, and confirm first with the names listed (up to 5, then "and N more") - When there is no batch API on the backend, use
utils/runBatchto call the single-item API one by one (4 at a time by default; one failure doesn't stop the rest) - Ticked rows get
@include row-selected;for their background (list rows that are cards themselves passvar(--color-surface)) - Guide attributes such as
data-guidecan be passed straight toDataToolbar(attached to the toolbar's outer frame), so a tour step can spotlight the whole filters + search bar - Re-check widths after adding a checkbox column: when list rows use grid, decide between one line and two lines by the container width (
container-type: inline-size+@container), not the viewport — once the sidebar appears (≥1024) the content area is narrower than at 992, so a viewport switch overflows sideways between 1024 and 1180 - Currently used on: resource management, user management (batch enable / disable, add to or remove from a group, delete), request review (batch reject), class management (batch archive and reclaim), and the class workspace student roster (batch remove)
Dropdown menus
- Menus that float over lists are portalled to
document.bodywithposition: fixedand positioned byhooks/useAnchoredMenu: it follows the anchor button on scroll and resize, opens downward or upward depending on the space left, closes when the anchor scrolls out of view, and closes on an outside click or Esc. Used byPowerMenu, the port suggestions inConnectionDialog, and menus on the AI API and templates pages - Give the menu an opaque
var(--color-surface)background, notglass-surface: list rows underneath would show through - The close animation uses
useDialogPresence(open, 130)+ a CSStransition, notonAnimationEnd
Stepper
Flow tabs and setup steps always use the shared Stepper (components/Stepper/Stepper); do not hand-craft chevron segments or draw your own dots. Currently used in the class workspace, the course environment editor, one-click class creation (ClassSetupPage), the first-install setup wizard (SetupPage) and the first-login onboarding (OnboardingPage).
<Stepper
ariaLabel={t("...")}
steps={[{ key, label, done, disabled }]} // dots are numbered 1, 2, 3… in order and become ✓ when done; disabled = a step the wizard cannot jump to yet
extras={[{ key, label, icon }]} // optional: tabs that are not steps, placed after the divider
activeKey={tab}
onSelect={(key) => ...}
/>- Visually it is a row of "dot + label" placed directly on the page (no background fill, no card), with connecting lines between steps; the first dot aligns with the left edge of the page content
- State is expressed by the dot alone: not done = white with a light-blue border and a number, done = white with a primary border and ✓, current = solid primary with a glow; labels only distinguish current (bold, dark) from the rest
- A connecting line turns primary only when both of its ends have been "reached" (done or current); otherwise it uses the light primary
- The stepper sits directly on the gradient background, do not use the light grey
--color-borderor a pale solid fill: it blurs into the background and becomes invisible; always use white with blue-family borders - Wizard flows: completed steps and the first pending step are clickable (you can go back, fix something and jump straight back), later steps are
disabled: only blocks clicks, no dimming, and never a button that silently does nothing. Whether a step is reachable or ticked depends on the actual progress saved to the backend, not on "whether you walked past it"; when the URL points at a step that cannot be reached yet, fall back to the last reachable step - Switching wizard steps does not auto-save: when the current step has unsaved changes, call
confirmLeave()first and discard after confirmation before switching (one-click class creationClassSetupPage) - Wizard layout (
ClassSetupPage): below the stepper is "the current step's card | summary column on the right". Every step is the same card (title, body, bottom button row); the button row usesposition: sticky; bottom: 0, so the card usesoverflow: clip(hiddenwould turn it into a scroll container and the button row would no longer stick); the card height follows the content, with nomin-height. Whether to use two columns depends on the actual page width (container: wizard / inline-size, two columns only at ≥760px); when there is not enough room the summary collapses into an expandable row above the card; the overview step is itself the summary and does not get the right column - Tabs that are not steps (class progress after the class is activated, AI) go into
extras: the dot becomes an icon and has no connecting line; do not force them in as steps 5 and 6 - On phones only the current step keeps its label, the other steps are reduced to dots (labels remain for screen readers); when it still does not fit, scroll horizontally
Animation rules
Enter animations
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
// usage: animation: slideUp 0.18s cubic-bezier(0.25, 0.8, 0.25, 1);@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
// usage: animation: fadeIn 0.15s ease;Dialog / popup enter and exit (the standard approach)
Dialogs always enter with "backdrop fadeIn + content slideUp" (built into Modal). The exit is handled by the shared hook hooks/useDialogPresence.js: on close it keeps the DOM for 150ms with closing set so the fade-out can play, then unmounts. Pass presence.closing to Modal as in the example above.
const dialog = useDialogPresence(editTarget); // a boolean or a data object both work
// while closing, dialog.item keeps the last data so the content does not flicker
{dialog.open && <EditDialog target={dialog.item} closing={dialog.closing} … />}Shared dialog components (such as ConnectionDialog) accept a closing prop controlled by the parent's useDialogPresence. Self-contained dialogs (such as VncDialog, TerminalDialog, ClassroomWatchDialog) call setClosing(true) internally and then setTimeout(onClose, 150).
Exit animations (closing)
Prefer setTimeout + a CSS transition; do not use onAnimationEnd (it has known edge-case problems). useDialogPresence(open, 130) packages this for menus; written out by hand it looks like this:
// JSX
function closeMenu() {
setClosing(true);
setTimeout(() => { setOpen(false); setClosing(false); }, 130);
}// SCSS
.menu {
opacity: 1;
transform: translateY(0);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.menuOut {
animation: none; // override the enter animation so the transition takes over
opacity: 0;
transform: translateY(6px);
pointer-events: none;
}Dark mode
Theme switching is implemented through the body.dark class; every colour already has a light and a dark value defined in _themes.scss.
Component SCSS always uses the CSS custom properties, so you do not need to write your own body.dark & overrides.
If a component has special dark-mode needs, scope the override under body.dark:
:global(body.dark) & {
color: #xxx;
}z-index layers
| Layer | Value | Purpose |
|---|---|---|
| Base card | 1 | Regular cards |
| Card hover / menus | 50 | Dropdown menus inside a container |
| Sidebar / sticky header | 100 | Sidebar and page-top bars |
| Portalled floating menus | 150 | Dropdowns portalled to body (such as components/PowerMenu) |
| Dialog / Modal | 300 | Full-page overlay dialogs |
| AI assistant | 2000 ($z-ai-assistant) | Stays above regular dialogs so it remains usable while one is open |
| Screen dialogs | 2100 ($z-screen-dialog) | Modal layer="screen" (VNC, terminal, classroom watch): content the AI cannot read covers the assistant |
| UserGuide tour | 3000–3199 | The tour spotlight layer must sit above everything, dialogs included (overlay 3100, demo window 3099). This range is reserved for UserGuide; regular components must not use it |
| Toasts | sonner default | sonner's own toaster z-index sits above every other layer |
⚠️ Note: elements with
backdrop-filterortransformcreate a new stacking context, so thez-indexof their children cannot escape to the outside. If a dropdown is hidden behind another card, check whether the parent has one of these properties.A glass surface (
glass-surface) combined with anoverflow: hiddencontainer will also clip an absolutely positioned menu that overflows. If a floating layer may extend beyond its container,createPortalit todocument.bodyand position it withposition: fixed(example:components/PowerMenu), and give it an opaquevar(--color-surface)background, otherwise the list content underneath shows through.