8px Grid, Icon Set & Brand System
This page documents three foundational layers of the Whity design system that sit beneath the
components: the 8px spacing grid, the curated icon set (@tabler/icons-react), and the
brand assets. It documents what already exists — it does not redefine spacing tokens
(those live in src/design/tokens/base.json, owned by the token system).
- Tokens: spacing/radius/type values are referenced from
base.jsonby name. Do not redefine them here. - Brand assets: per-tenant, uploaded through settings and served from
GET /api/v1/branding— see Theme-Customization. - Components: see Component-Library; patterns: UI-Patterns.
The 8px spacing grid
Section titled “The 8px spacing grid”Whity spaces and sizes UI on an 8px base grid with a 4px half-step for fine
adjustments. This is defined in base.json and is the rhythm every layout, component, and icon
should snap to.
Spacing scale (from base.json)
Section titled “Spacing scale (from base.json)”Source of truth:
src/design/tokens/base.json→spacing. Reproduced here for reference — do not edit these values in this doc; change them inbase.json.
| Step | Value | On the grid | Typical use |
|---|---|---|---|
0 |
0px |
0 | reset / flush |
1 |
4px |
½ unit | hairline gaps, icon-to-text nudges, tight insets |
2 |
8px |
1 unit | base unit — small gaps, compact padding |
3 |
12px |
1½ units | field/control inner padding |
4 |
16px |
2 units | card padding, default content gap |
6 |
24px |
3 units | section gaps |
8 |
32px |
4 units | major section / header separation |
- Base unit:
spacing.unit = 8px. Multiples of 8 (8, 16, 24, 32, …) are the default. - Half-step:
4pxis allowed for fine tuning (icon nudges, dense controls) — avoid odd values that fall off both the 8px and 4px rhythm.
How it maps to Tailwind
Section titled “How it maps to Tailwind”Tailwind’s spacing scale is already 4px-based (1 = 4px, 2 = 8px, 4 = 16px, 8 = 32px),
so the grid is expressed directly through utilities — no custom config needed:
| Grid intent | Tailwind | px |
|---|---|---|
| 1 unit gap | gap-2, p-2, space-y-2 |
8 |
| 2 unit padding | p-4, gap-4, space-y-4 |
16 |
| 3 unit gap | gap-6, space-y-6 |
24 |
| 4 unit section | space-y-8, mb-8 |
32 |
The shipped UI already follows this: admin pages use space-y-8 between sections, forms use
space-y-4 between fields, the header uses mb-8/pb-6, and modals use gap-4. (See
web/app/(protected)/admin/users/page.tsx, users/create-modal.tsx.)
Applying the grid
Section titled “Applying the grid”- Component padding: snap internal padding to 1–2 units (
p-2/p-4). Card content padding is 2 units (px-4). - Layout rhythm: stack sections on multiples of 8 (
space-y-4within a group,space-y-8between groups). Keep vertical rhythm consistent down a page. - Control heights: the components sit on the grid (Button/Input
h-7= 28px; sizes step in 4px increments). Don’t introduce off-grid heights. - Icon sizing: size icons in grid steps (see Icon sizing).
- Clearspace: brand clearspace is expressed in grid units (see Brand).
[!NOTE] The token agent is adding an explicit type scale to
base.jsonalongside the existing spacing grid. Line-heights in the type scale should also resolve to grid-friendly values (multiples of 4px) so text baselines align to the 8px rhythm. Reference the type scale by name once it lands; this doc does not define it.
Icon set
Section titled “Icon set”Whity standardizes on @tabler/icons-react (configured as "iconLibrary": "tabler" in
web/components.json, dependency @tabler/icons-react ^3.44.0). All icons — in shipped
components and app code — come from this one library. Do not mix in other icon packs.
Curated set (icons actually in use)
Section titled “Curated set (icons actually in use)”These are the icons currently used across web/; treat this as the starting curated set and
extend it from Tabler when a new need appears.
| Icon | Used for | Where |
|---|---|---|
IconPlus |
Create / add actions | admin list page headers |
IconMenu2 |
Row-action / overflow trigger | admin list rows |
IconX |
Close / dismiss | Dialog close, Toast dismiss |
IconCheck |
Selected / confirmed | Select, Dropdown, Toast (success) |
IconSelector |
Select trigger caret | Select |
IconChevronUp / IconChevronDown |
Sort / scroll / expand | DataTable, Select, permission panel |
IconChevronLeft / IconChevronRight |
Sidebar collapse, submenu | Sidebar, Dropdown sub |
IconAlertCircle / IconAlertTriangle |
Error / destructive warning | Toast (error), delete modals |
IconInfoCircle |
Info | Toast (info) |
IconDashboard, IconUsers, IconLock, IconBuilding, IconBuildingCommunity, IconSettings |
Navigation | Sidebar |
IconUserShield, IconDatabase, IconServer, IconCpu, IconLogout |
Stats / nav / session | Stats page, Sidebar |
Usage conventions
Section titled “Usage conventions”1. Sizing — snap to the grid. Icons sit on the 8px/4px grid. Two ways to size, depending on context:
| Context | How | Result |
|---|---|---|
Inside Button/Badge/menu/select |
Let the component size it (the [&_svg]:size-* rules) |
auto, per component size (e.g. size-3.5 = 14px in a default button) |
| Standalone | className="size-4" (Tailwind) — preferred |
16px (2 units) |
| Standalone (legacy) | size={16} prop |
16px |
Recommended standalone sizes: size-4 (16px) default, size-3.5 (14px) inline-with-text,
size-5 (20px) emphasis, size-8–size-12 (32–48px) for empty-state/illustrative use. Avoid
arbitrary px that fall off the 4px grid.
[!NOTE] The codebase mixes the
size={n}prop (e.g.IconPlus size={18}in list headers) with the Tailwindsize-*class. Prefer thesize-*class for grid consistency; notesize={18}is slightly off the 4px step. Minor cleanup follow-up.
2. Stroke. Tabler icons are stroke-based with a default stroke-width of 2. Keep the
default for UI consistency; only adjust (stroke={1.5}) for large illustrative icons.
3. Color via currentColor / tokens. Tabler icons inherit currentColor, so they take the
text color of their context automatically. Drive color with token-backed text utilities — never
hard-code:
<IconAlertCircle className="text-destructive" /> {/* error */}<IconUsers className="size-8 text-muted-foreground" /> {/* muted, empty state */}<IconCheck className="text-accent-foreground" /> {/* on accent surface */}Inside components this happens for free (e.g. destructive dropdown items tint their icon via
the destructive token). The brand accent dot in the brand mark uses var(--accent) so it
follows white-label theming.
4. Accessibility / labels.
- Decorative icons (next to a text label) need no extra markup — Tabler renders inert SVG.
- Icon-only controls must have an accessible name. Add
aria-labelon the control, or ansr-onlytext span (the Dialog close button uses<span className="sr-only">Close</span>). Never ship an icon-only button with no name (the Toast dismiss button is a current gap — see UI-Patterns › Follow-ups).
// Good — icon-only trigger has a name<Button variant="ghost" size="icon-sm" aria-label="Open menu"><IconMenu2 className="size-4" /></Button>Brand assets
Section titled “Brand assets”There is no brand asset folder in the repo. This section previously described
web/public/brand/ and two placeholder SVGs served at /brand/*; WC-237 deleted both when
branding became per-tenant white-label, and the description outlived them.
Branding is now data, not files. Each tenant sets a site name, a wide logo, a square logo
and a favicon through the settings layer, over an operator-level global set that acts as the
fallback (which is what a single-tenant deployment actually uses) (SettingsRegistry / SettingsService, with
BrandingService as the domain boundary). The bytes are held by the content-addressed
LocalStorageDriver; SVG is accepted only after a hardened sanitizer and rendered <img>-only.
The public, leak-proof GET /api/v1/branding resolves the right set by request host, so
pre-auth surfaces — login, <title>, favicon — are branded before anybody is authenticated.
| Slot | Role |
|---|---|
| Wide logo | Primary identity where there is width — header, login |
| Square logo | Mark/glyph — collapsed sidebar, compact lockups |
| Favicon | Browser tab |
What follows still holds: it is about how a mark of any origin sits on the grid and takes its colour from the token system.
Color usage (monochrome + accent)
Section titled “Color usage (monochrome + accent)”Whity is white-label, so the identity is monochrome-first with one themeable accent:
| Element | Source | Behavior |
|---|---|---|
| Logo / wordmark / mark ring | currentColor |
Inherits text color → correct in light and dark mode automatically |
| Brand accent | var(--accent) token |
The single brand-color slot — white-label-overridable per tenant. Never hard-code a hex. |
Because the accent is the tenant-overridable brand color, brand assets and components must
reference the --accent token (or accent/accent-foreground utilities), letting per-tenant
CSS-variable injection cascade (see Theme-Customization).
Spacing / clearspace on the grid
Section titled “Spacing / clearspace on the grid”Brand spacing uses the same 8px grid:
- Clearspace: keep clear margin around the logo equal to 2 grid units (16px) at the default mark size — nothing should intrude within that zone.
- Minimum sizes: wordmark ≥ 96px wide; mark ≥ 16px (2 units). Below 16px, drop the accent dot.
- Lockups: when pairing mark + wordmark, separate them by 1–2 grid units (
gap-2/gap-4).
Don’ts
Section titled “Don’ts”Don’t recolor outside the rules above, stretch/skew/rotate, add shadows/outlines, place on low-contrast surfaces (keep ≥ 4.5:1), or rebuild the wordmark in another typeface.
Typography (current state)
Section titled “Typography (current state)”Fonts are configured in web/app/layout.tsx and exposed as CSS variables for Tailwind:
| Role | Token | Current value |
|---|---|---|
| Body / UI | font-sans |
Noto Sans (loaded as --font-sans in layout.tsx) |
| Mono / code | font-mono |
Geist Mono (--font-geist-mono) |
| Headings | font-heading |
maps to sans (e.g. CardTitle/DialogTitle use font-heading) |
[!WARNING] Font inconsistency:
base.jsondeclaresfont-sans/font-headingas Inter, and the design docs reference Inter, butlayout.tsxactually loads Noto Sans for--font-sans(Geist/Geist Mono are also imported). Reconcilebase.json,layout.tsx, and the docs on a single sans family. Flagged as a follow-up (font choice + token value is owned jointly by the token agent and the web owner; not changed here).
The forthcoming type scale (token agent, in base.json) should resolve line-heights to
4px multiples so text aligns to the 8px grid.
Follow-ups
Section titled “Follow-ups”- Icon sizing is mixed —
size={n}prop vs Tailwindsize-*class, andsize={18}is off the 4px step. Standardize onsize-*classes on grid steps. - Toast dismiss button has no accessible name (icon-only
IconX). Addaria-label. - Font mismatch —
base.json/docs say Inter;layout.tsxloads Noto Sans. Pick one and align tokens + layout + docs. - Brand assets are placeholders — replace
web/public/brand/*.svgwith official assets (keep filenames), then generate favicon/app-icon raster sizes from the final mark. --accentnot yet a distinct brand color — currentlyaccent≈ a neutral inbase.json. The token agent is introducing a real brand accent; once it lands, the brand mark’s accent dot andaccentutilities will reflect it (and per-tenant overrides).
Related documentation
Section titled “Related documentation”- Theme-Customization — per-tenant branding and accent overrides (replaces the deleted brand assets README)
- Design-System-Overview — architecture & principles
- Theme-Customization — tokens & white-label theming
- Component-Library — component specs & states
- UI-Patterns — loading / error / empty / validation patterns