On this page
Every screen in MSO Cloud is built from one shared component kit and one set of design tokens. Nothing in the product hardcodes a colour, a radius or a shadow: a screen asks for a role (a card surface, a control radius, a warning tone) and the active theme decides what that role looks like. That is what makes the same product readable in light and dark, and re-skinnable per platform deployment, per organization and per brand, without a screen being rewritten.
This page is the reference for that system: what the kit gives you, what the tokens are, what accessibility is guaranteed, and how a skin is changed or added.
The kit#
@yng/ui is the component kit. Every visible string is a prop, so a component carries no built-in copy that could bypass translation; the Vietnamese constants in the package are only a fallback.
| Group | Primitives |
|---|---|
| Action | Button (with asChild to render as a link) |
| Form | Field, Input, Textarea, Select, Checkbox, Switch, RadioGroup, Radio, FieldGroup, FileInput |
| Overlay | Dialog (including the right-hand drawer posture), useConfirm / ConfirmDialog, Tooltip |
| Navigation | Tabs, TabList, Tab, TabPanel |
| Feedback | Toast, Alert, Skeleton, ErrorState |
| Data | DataTable, plus Table / Th / Td / Pagination for tables DataTable cannot absorb |
DataTable is the default answer for rows and columns: it brings sorting, search, paging and export. The lower-level table primitives exist for bespoke cells, matrices and nested headers, so that even a hand-built table shares one set of paddings, borders, zebra striping and sticky-header rules.
Typography roles#
Each step carries its own line height, so a size never needs a separate leading class.
| Class | Size | Line height | Use for |
|---|---|---|---|
text-caption |
11px | 16px | kickers, hints, field errors |
text-meta |
12px | 16px | table column heads, badges |
text-body-sm |
13px | 18px | dense table cells, secondary UI |
text-body |
14px | 20px | default body and control text |
text-body-lg |
15px | 22px | long-form paragraphs |
text-title-sm |
16px | 22px | card and dialog headings |
text-title |
18px | 26px | section headings |
text-title-lg |
20px | 28px | page headings |
text-display-sm |
24px | 32px | secondary figures |
text-display |
30px | 38px | KPI headline figures |
text-hero / text-hero-lg |
36px / 48px | 38px / 51px | public marketing, sign-in and legal headings only |
A dashboard heading tops out at text-title-lg; a KPI figure at text-display. The kit font (Plus Jakarta Sans) draws a narrow space, so the html rule that names it also sets word-spacing: 0.07em; never add word-spacing or extra spaces per control (guard: font-word-spacing.test.ts). nums-tabular sets tabular figures and belongs on every number a reader compares down a column, so digits keep one advance width.
Spacing, radius, elevation, stacking, motion#
Spacing is named for the relationship it expresses, not for a pixel count: tight (4px, label to control), snug (8px, items inside a control row), field (12px, between form fields and cell padding), card (16px, card and dialog body), gutter (20px, between cards), section (24px, between page sections), page (32px, page top and bottom).
Radius roles: rounded-chip (6px, badges and tags), rounded-field (8px, inner rows and small tiles), rounded-control (10px, inputs, buttons, switches), rounded-card (14px, cards, panels, table frames), rounded-modal (16px, dialogs and popovers), rounded-pill (fully rounded, switches, avatars, pills).
Elevation: shadow-soft, shadow-card, shadow-card-hover, shadow-overlay for dialogs, shadow-popover for tooltips and switch thumbs. All five read theme-adaptive custom properties, so a skin override reaches them.
Stacking is a fixed ladder, so two overlays can never fight: z-base 0, z-raised 10 (sticky table headers), z-sticky 40 (page-level sticky bars), z-dropdown 45 (menus and popovers), z-overlay 50 (full-screen scrims), z-modal 60 (dialogs), z-toast 70, z-tooltip 80.
Motion: duration-fast (150ms) for colour and border changes, duration-normal (220ms) for size and position, duration-slow (320ms) for entrances. Anything that animates while idle honours the operating system's reduced-motion preference, and the kit neutralises its spinners and pulses under that setting.
A skin is mostly colour. It may optionally adjust three radii (control, card, panel) and the two card shadows through one optional group; it can never change motion. A brand-level override may change none of them - see below.
Tables show 20 rows per page#
Every table in the product paginates at 20 rows by default, from one shared constant. That is the number an operator can scan without losing the top of the list, and it is the same everywhere, so a page count means the same thing on the audit log, on an orders table and inside a dashboard widget. A table may offer a page-size control, but 20 is what it opens with.
Accessibility guarantees#
These behaviours are built into the primitives, so a screen gets them by using the kit rather than by remembering to add them.
- Dialog. Focus is trapped inside the dialog while it is open, Escape closes it, page scroll is locked, and focus is restored to whatever opened it on close. A destructive confirmation goes further: it is announced as an alert dialog, focus starts on Cancel rather than the destructive button, a backdrop click does not dismiss it, there is no close button, and both buttons are disabled while the change is in flight.
- Tabs. A roving tab index, so one Tab keypress enters the strip and does not walk every tab. Arrow keys move between tabs, Home and End jump to the ends, and panels are real tab panels wired to their tab. The selection can be mirrored into the query string, so a tab is linkable.
- Toast. Toasts render into a polite live region, so a screen reader announces them without interrupting; error toasts announce assertively. At most three are shown at once.
- Tooltip. Opens on hover and on keyboard focus, closes on Escape, and is linked to its trigger as a description. A tooltip is never the only copy of information a reader needs.
- Skeleton. One loading status region per loading block, not one per shimmering line, so a loading page announces once instead of a dozen times.
- Colour is never the only signal. A status, a threshold breach or a direction always carries a second cue - an icon, an arrow, a sign or the value itself.
- Contrast is enforced at the point a theme is saved, not audited afterwards. See below.
- Every clickable element shows the hand cursor. One base rule covers links, buttons, tabs, radios/checkboxes, menu items, options and labelled controls; disabled state shows
not-allowed, a drag handle showsgrab/grabbing. A component that is a click target but none of those elements opts in withdata-clickable.
The two theming axes#
Theming has two independent axes, and every skin must supply both halves.
data-skinon the document is the skin identity. The server sets it from the deployment's active theme.data-themeis light or dark. The reader controls it with the toggle in the top bar, it is remembered in that browser, and it defaults to light. The operating system's colour-scheme preference is deliberately not read, so a shared screen does not change appearance on its own.
The default skin is the product's own palette, expressed directly in the stylesheet, with no override rendered. Any other skin is two generated CSS blocks - one for light, one for dark - injected into a single style element in the root layout. There is exactly one injection path; a second style tag or a hand-built token string is rejected by a static audit.
Which skin a request gets is resolved server-side, in a fixed order: the brand in view, then the organization, then the deployment's active skin, then the default. The platform admin area always renders the system skin, so an org's palette can never disguise which environment an administrator is working in.
One schema validates every theme payload, and the same schema is used by the editor, the API and the renderer, so there is no second definition of what a theme is.
Skins at three levels#
flowchart TB P["Platform skin<br/>owned by a super admin<br/>offered to every organization"] O["Organization skin<br/>owned by the org<br/>visible only inside it"] B["Brand ink override<br/>a partial tint on top of the chosen skin"] P --> O --> B B --> R["One merged payload, one style element,<br/>rendered server-side"]
Platform skins are authored by a super admin and offered to every tenant. One of them is the site-wide default.
Organization skins belong to a single organization and are visible only there. An owner or org admin authors, edits, exports and imports them without needing a super admin. When an org skin and a platform skin share a name, the org's own always wins - a fixed order, never a coin flip.
Brand overrides are the third layer, and the narrowest. A brand may re-tint ink: accent, text, status colours, chart series, heat ramps, panel tones and accent gradients. A brand may not change structure: the brand ramp, surfaces, the sidebar, borders, radius and elevation are refused outright rather than silently ignored, because shape, elevation, the ground a card sits on and the shell around it are what a reader recognises as one product. Without that rule, five brands in one organization end up looking like five different applications.
A brand override is validated against the merged result, not the fragment, so a brand cannot save itself into an unreadable combination.
An override that is empty is refused, since a brand claiming to be customised while rendering identically is a state nobody can debug; clearing an override is an explicit action. Chart series and panel tones replace as a whole - half a series would mix two palettes, and a panel's fill, edge and ink must agree. Everything else merges field by field.
The merge happens server-side, immediately before the CSS is rendered, so a brand's tint travels the same single injection path as any other theme. Brand overrides apply to tenant surfaces, including a public share link; the platform admin area always renders the system skin.
Changing the active skin (no code)#
- Sign in as a super admin and open Appearance in the platform Identity group.
- Press Activate on the skin you want and confirm. It applies to everyone after their next page load.
- To adjust colours, duplicate a built-in skin, edit each colour group with the live preview and contrast warnings, save, then activate.
- To return to the default, activate the default skin again.
Every theme mutation is gated, rate-limited and written to the audit log.
Adding a built-in skin (code)#
- Copy the nearest existing entry in the built-in theme module.
- Give it a new slug (lowercase letters, digits and hyphens, 2 to 48 characters), plus a Vietnamese and English name and a short description.
- Fill in both modes. Keep the status colours at their defaults unless there is a reason to move them, give the chart palette 4 to 8 series colours (fewer than 8 cycles), and make sure the tooltip background does not equal any series colour - the server rejects that, because a tooltip that matches a line is unreadable over it.
- The server enforces contrast thresholds on save: primary text against the app background and against a card is at least 4.5:1; inverse text on the accent colour is at least 3:1; sidebar text against both ends of the sidebar gradient is at least 3:1.
- Run the full verification gate, then open Appearance, check the preview card, activate it, and revert to the default.
- Never edit the base stylesheet to make one skin look right - that stylesheet is the default skin.
Adding a themeable token#
This is rare and needs a design owner's agreement.
- Add the token to the base stylesheet in both the light and the dark block first.
- Add the field to the theme schema and to the one mapping function that turns a payload into tokens - exactly one place.
- Update every built-in payload and the snapshot tests.
- The theme single-source audit must stay green; it asserts that every token the renderer can emit exists in the stylesheet.
- Radius, elevation and motion are not themeable. Do not add them.
Moving skins between deployments#
Skins travel as a file, so a deployment can carry its own set without a code change.
- Export. In Appearance, tick the skins you want and export them. The result is a versioned bundle file.
- Import. Choose the file, review the preview table of names and slugs, and confirm. Each skin comes back as
imported,renamedorrejected. - An imported skin is always a custom skin. A slug that collides gets a numeric suffix, reported in the result column. A skin that fails the contrast or safety checks is rejected and never written.
- Limits: 256 KB per bundle, 20 skins per import. Export and import are both audited.
Traps that have cost time#
| Trap | What to do instead |
|---|---|
| Editing the base stylesheet to change a custom skin | Wrong layer - a custom skin's payload lives in the database or the built-in module |
| Emitting tokens by hand, or adding a second style element | Not allowed; there is one injection path, and the audit fails the build |
| Forgetting the dark half of a skin | Both modes are required; the schema refuses a half skin |
| A chart mixing two skins' colours | The palette cycles through 8 slots; do not remove the cycling |
| A tooltip background equal to a series colour | The server refuses it; pick another background |
| Wide letter-spacing on uppercase Vietnamese | Capped, because Vietnamese diacritics need the tighter setting to stay legible |
| Expecting a PDF export to follow the skin | It does not; PDF output keeps a fixed brand |
Verifying a theme change#
- Run the full verification gate, which includes the theme single-source audit and a design-token drift baseline that may only shrink.
- Check in a real browser, not with a command-line fetch: light and dark, on the active skin, with Vietnamese diacritics, empty states and chart tooltips.
- If charts or dates are involved, view a report under a month filter as well.
- Revert any experimental activation back to the default skin.
Sources#
packages/ui/README.md- typography, spacing, radius, elevation, stacking and motion scalespackages/ui/src/kit.stories.md- the primitives and their accessibility behaviourpackages/ui/src/components/data-table.tsx- the 20-row default page sizepackages/ui/src/components/confirm-dialog.tsx- destructive confirm behaviourpackages/ui/src/theme-schema.ts- the single theme payload and bundle schemaapps/web/src/server/themes/- skin resolution, org scope and brand override mergedocs/adr/0033-org-owned-themes-brand-override.md- org-owned skins and the brand ink layer