Design system
The visual contract for the Complied app: tokens, type, shape, components, copy, and the shared UI primitives that carry them. For engineers and designers building or changing screens. The app follows the SEG design system, shared by Secure Environmental Group, ABATED and Complied (source). Tokens are defined in src/index.css; this page says how to use them.
The idea
Black, white and one green, #00bf63. Green is the accent, never the ground. It marks the one primary action on a view, the thing you are on right now (the current nav item, the selected row, the active tab), and "compliant". Everywhere else, colour means status, and status is always a dot plus a word.
Text on a green fill is black, never white: white on #00bf63 is 2.4:1. Green as text uses brand-text (#007f3d in light), never the fill colour on white.
Scope
The rules apply inside the app shell (data-app-shell on the layout root, which switches font, selection colour and focus ring for everything below it): the staff app, the client portal and the auth pages. Generated PDF reports have their own stylesheet and are out of scope.
Tokens
Use the canonical names: canvas, surface, surface-2, surface-3, ink, ink2, ink3, rule, rule-strong, border-strong, highlight, brand, brand-text, brand-soft, on-brand, kpi-number, and the status-* set. Never hard-code a hex value in a component.
| Role | Light | Dark | Use |
|---|---|---|---|
| canvas / surface | #F6F6F3 / #FFFFFF | #17191B / #202326 | Page background (warm off-white) / panels, cards, drawers, dialogs, inputs |
| surface-2 / surface-3 | #F3F3F0 / #EBEBE6 | #272A2E / #30343A | Raised areas, table headers / hover and pressed |
| ink | #1C1D1F | #F2F2F0 | Text and icons; softened off pure black and white |
| ink2 / ink3 | #62666B / #7C8085 | #B8BCC1 / #959A9F | Secondary text / placeholders, captions and disabled text (not body copy) |
| brand | #00BF63 | #00BF63 | Primary fill, selected controls, the current-item edge, progress |
| brand-text | #007F3D | #5FD699 | Green text and links |
brand-soft (highlight) | #E5F8ED | #16301F | The "current" wash: selected row, active tab, current in-page item |
| on-brand | #1A1A1A | #1A1A1A | Text and icons on a green fill; never white |
| ring | ink | ink | Focus ring: 2px, offset 2px. Ink, not green: green on white is under 3:1 |
| rule | #E6E5E0 | #353A40 | Dividers and card outlines |
border-strong (rule-strong, input) | #B3B4AE | #7A8087 | Control borders, hover and dashed outlines |
| kpi-number | #3F4347 | #D6D8DB | Big KPI numbers: a dark grey, so a row of them doesn't read as severe |
Status. A muted set that sits with the green: overdue is coral #D4675D, due soon honey #E3B04B, scheduled or pending dusty blue #6B93C4, in progress is ink, done is the compliant green. The fill colour is for dots, fills and borders only; words use the darker -text shade on the matching wash (overdue #A9473C, due soon #7A5716, pending #3F6694; dark #EAA49C / #E8CC8A / #A2C0E4). shadcn's destructive, warning and info are those readable text shades, so text-destructive passes as words, bg-destructive/10 is a wash of the same hue, and a solid bg-destructive takes text-destructive-foreground (white in light). destructive-text is an alias of destructive. Service is grey, lab is tan, and neutral is its own grey. Each status has dot, background and text tokens (--status-overdue-dot, -bg, -text, and so on); text on its wash meets AA contrast. The kit's success tone is the done green.
The current thing is the green tint. The selected row, the active tab, the current in-page section and the active stat filter use bg-highlight (SEG's accent-soft) or the highlight-stroke utility. A chosen option in a group (a ticked filter, a pressed segment, the selected option card) is a solid bg-brand text-on-brand fill. One current thing per region.
Sidebar. The sidebar is a light rail on the canvas in every appearance (canvas ground, ink wordmark), set apart from the page by a hairline. The current item is a lifted row (.nav-current: side-active-bg, white in light and a lifted grey in dark, with a hairline and a soft shadow) whose icon turns green. The rail carries data-rail, so keyboard focus takes the rail's active ink. The staff app and the client portal share the tokens. SEG shares one palette across brands and varies only the logo, so a tenant cannot recolour its portal; what it sets is the sidebar for its logo (Settings → Branding → Client portal sidebar, tenant_branding.portal_sidebar): black puts the portal on the brand-black panel in both themes (.side-black), for a logo that exists only in white, as ABATED's does.
The os-*, tint-* and rail-* names remain declared only as deprecated aliases; do not use them in new code.
Appearances. Three, all first-class, each the same green: Light (default), Dark (.dark on <html>) and Eco (.eco: oat and linen paper, forest and moss ink, earthy status tones). src/lib/appearanceSetting.ts owns the setting (the class, the saved choice, and every switcher on screen kept in step); AppearanceSwitcher is its control, as icons in the portal's top bar or spelled out as Light · Dark · Eco in the staff account menu (AppearanceMenuSection). index.html restores the saved one before first paint; Tailwind has dark: and eco: variants for the rare screen that needs one. Check all three before calling a screen done.
Phones. The shell layout variables on [data-app-shell] (--topbar-h, --bottombar-h, --shell-top, --page-gutter) and the pt-safe / pb-safe / pb-bottombar utilities size pages around the top and bottom bars and the notch (viewport-fit=cover in index.html). Below sm, menu and select rows are 44px; below lg, a table container with data-sticky-first keeps its first column in view while it scrolls sideways.
Type
Two families. Headings are Open Sauce Sans (font-title), self-hosted from public/fonts at weights 500–700; the app shell applies it to every h1–h3, card title and dialog title. Everything else is DM Sans (font-ui, also font-sans). Codes and IDs use DM Mono (font-mono). font-heading and font-display are aliases of the heading face.
SEG's type styles are Tailwind utilities (src/index.css), and cn() knows them as font sizes. Use them, or the plain text-xs / text-sm / text-base that equal caption, body-sm and body; no other pixel sizes.
| Style | Utility | Size / line, weight | Use |
|---|---|---|---|
| display | text-display | 56/60, 700 | Marketing only |
| h1 | font-title text-h1 | 40/44, 700 | Page titles (PageHeader, DetailLayout; h2 size below sm) |
| h2 | font-title text-h2 | 28/34, 600 | Section titles, auth card titles |
| h3 | text-h3 | 20/28, 600 | Card titles, dialog titles, empty-state titles |
| KPI number | font-title text-h2 font-bold (sm:text-h1 on KpiCard), text-kpi-number | 28/34 (40 from sm), 700 | Stat, KpiCard |
| body-lg | text-body-lg | 18/28 | Intro paragraphs |
| body | text-body / text-base | 16/24 | Default text, page subtitles, field values |
| body-sm | text-body-sm / text-sm | 14/20 | Table cells, dense UI, helper text |
| label | text-label | 14/20, 500 | Form labels, buttons, tabs, nav items |
| caption | text-caption / text-xs | 12/16 | Timestamps, footnotes, counts |
| overline | text-overline uppercase | 12/16, 600, 0.08em | Eyebrows and table column headers; the only uppercase |
Shape and density
Corners are mostly squared-off. --radius is 10px: rounded-lg (buttons, inputs) is 10px, rounded-xl (cards) 12px, rounded-sm (badges, checkboxes) 5px, rounded-2xl (dialogs, large panels) 16px. rounded-full is for status pills, dots and avatars only. SEG prefers borders to shadows; shadows are a soft blur tinted from the ink, never pure black: shadow-subtle for cards on the canvas, shadow-soft for a lifted row, shadow-medium for menus and popovers, shadow-modal for dialogs, shadow-drawer for side sheets.
Controls are SEG's sizes: buttons 32 / 40 / 48px (sm / default / lg), inputs and selects 40px with 16px text. Toolbar search boxes and toolbar toggles are 36px with 14px text, as in SEG's CRM example. List rows are 56px, and layout follows a 4px grid. src/index.css defines the density variables --row-h, --control-h and --section-gap for [data-density] values comfortable (56 / 40), compact (44 / 32) and field (56 / 44).
Field and tablet surfaces are the exception. Inspectors work in apartments on tablets, so on the Projects hub's Field work and Floor plans tabs and the inspection wizard (/field/:inspectionId and its field-job tabs) every control is 44px minimum. The 52px tab cards, empty-state actions and descendant rules implement this; do not shrink them back to 36px.
Components
| Component | Rule |
|---|---|
| Buttons | 32 / 40 / 48px, 14px weight-500 labels (16px on lg), 8px corners. Primary is the green fill with a black label, at most once per view. Outline (SEG's secondary) is white with a rule-strong border. Secondary is surface-2. Ghost is transparent with a surface-3 hover. Destructive (SEG's danger) is red text with a red border on white, never a red fill. Labels are sentence case, verb first. |
| Sidebar | A light rail on the canvas with the ink wordmark in every appearance (brand-black with the reverse wordmark only for a .side-black portal tenant). Items are 14/20 weight 500 with 16px icons, 36px tall on the desktop rail and 44px in the phone drawer; the current item is .nav-current. Section headers are overlines that fold. The avatar is a green disc with a black initial. |
| Top bar | Canvas background, hairline, quiet: the page's title slot and the notification bell. Sticky on phones and tablets, in flow on desktop. |
| Bottom bar | Phones and tablets only: up to four destinations, then More. The current tab is ink with a green icon. |
| Lists and tables | Column header: 12px weight-600 uppercase, 0.08em tracking, ink2, on surface-2 (TABLE_HEAD_CELL for a hand-built table). Cells 14/20 with 12px × 16px padding; hairline rows; hover surface-2; selected bg-highlight. The keyboard cursor is an ink ring, shown only while the row has keyboard focus. A list page's rows sit on one white rounded card (RecordsExplorer surface="card", the default outside embedded). A group header (GroupHeader) is a quiet label, not a black bar: grey chevron, the name at 80% ink, its count, and while folded a tally of soft ToneChips ("2 overdue"). Below md rows are cards and the list flows with the page. |
| KPIs | A page's headline numbers are KpiCards in a KpiGrid (two across on a phone, four from lg): a 14px label with a status dot, a 28px (40px from sm) bold Open Sauce number in kpi-number grey, one short note. The tone colours only the dot, and only while the number is above zero, so a row never reads as red. With onClick it is a filter toggle, pressed = the green tint. StatStrip / Stat remain for in-section rows; SummaryTile and MiniStat are the smaller sizes. |
| Tabs | Section tab strips use IconTabs: an underline bar on a full-width hairline, labels in ink2, the current tab in ink with a 2px green underline, counts as small numbers (amber when something needs a person), a tick when settled, icons optional. On a phone the bar scrolls sideways with faded edges (useScrollEdges + [data-scroll-fade]) and 44px tabs. A list's views and short switches (7 / 30 / 90 days) use Segmented: a tinted track with the current segment lifted; Segmented variant="tabs" is the IconTabs bar. TabCard is a pipeline stage (ProjectStageNav), not a tab strip. Plain text tabs (ui/tabs) are SEG's: 14px weight 500, the active one on the green tint in brand-text. |
| Cards | 1px border, 8px corners, 24px padding (16px when size="sm"), 12px gaps; title is h3; footer separated by a border. |
| Badges | 24px, 4px corners, 12px semibold. Default is neutral surface-2. |
| Drawers | Right sheet, white, soft shadow, header, scrolling body, footer. A plain-words banner at the top ("Overdue by 4 days..."). |
| Dialogs | Centred, 12px corners; interrupt only when needed. |
| Fields | 40px with 16px text (44px on field surfaces), rule-strong border, ink focus ring. Labels 14/20 weight 500. |
| Links | brand-text, underlined on hover. |
| Status | A dot plus a word, always; never colour alone. Pill (redesign/kit.tsx) is SEG's StatusPill: 28px, fully round, an 8px dot then the word at 14px weight 500, on the status wash. StatusDot is the dot alone. ToneChip (redesign/GroupHeader.tsx) is the dotless soft chip for a count whose words carry the meaning ("2 overdue"). All take a tone. |
Interactive hover is elevation or a fill change only, with no scaling. On phones every tapped thing is 44px, or keeps its look and adds TAP_PAD (redesign/kit.tsx) for the hit area. Every interactive element keeps a visible focus ring and an accessible name. Anything made sticky must not trap keyboard focus.
Shell
The staff shell (AppLayout, data-app-shell="staff") is the rail, the top bar, the page and, below lg, the bottom bar.
- Destinations. One table,
src/lib/staffNav.ts, feeds the rail, the bottom bar and the command palette, each filtered bycanOpenPath(the route map the route guard reads), so nothing is offered to someone who would be refused it. The rail's sections are Work (My day, Projects, Clients, Billing), Portfolio (Buildings, Violations, Deadlines, Documents, Map) and Admin (Team, Performance, Rate cards, Settings); a section left with nothing this person can open drops out. My day (/home,src/pages/DashboardPage.tsx, drawn bysrc/components/dashboard/) is where the office lands (defaultStaffPath); Billing (/billing,view_financials,src/pages/BillingPage.tsxwith its sections insrc/pages/billing/) is the firm-wide money workspace; Performance (/performance,manage_tenant,src/pages/performance/) is the admin's company and per-person figures. The Compliance board is the Projects hub's Compliance tab (/projects?tab=compliance,review_xrf_reports;/compliance-boardredirects), and the Compliance role's Projects item opens on it. Each office role gets its own rail and phone bar (src/lib/roleWorkspace.ts, the role read from the keys: Dispatch, Compliance, Billing, Inspector, Crew); the admin gets the full rail plus a footnote counting the modules not built yet. The role orders navigation and never hides a page: whatever else the keys open follows the role's own rail under More, and search offers everything the keys open. A field worker's role comes from holding field-work keys without an office job's (booking, review, billing), soview_buildingsdoesn't move an inspector out of the field workspace. Where a login lands and where the brand mark goes are one rule (defaultStaffPath): Today for the field, My day only for keys that put something on it, else the first page the keys open. My queue reads the same keys (queueRoleForKeys), never a permission bundle's editable name. The route guard still checks keys alone. Search opens a building the firm doesn't track on its public page (/buildings/public/:id). The top bar and phone bar keep an empty centre slot for the compliance assistant. Field jobs, Work orders and Floor plans are tabs of the Projects hub (/projects?tab=,src/lib/projectsHub.ts), so Projects is the current item on/field*,/work-ordersand/floor-plans*too. Search lists them under More, each opening its tab and offered by the keys of the route it replaced (an item'sgate), along with New project and Team roles, which have no rail item. Deadlines, Documents and Rate cards are on the rail (the client's design had moved them to search only; they are daily pages). - Rail. Brand (to the person's landing page), a Search button with the
⌘K/Ctrl Khint that opens the command palette, the sections, and the account block at the bottom (Appearance, Settings, Sign out). A section folds from its header and remembers it per person in this browser (useFoldedSections); the section holding the current page never folds. A folded section shows the sum of its counts. The only counts are the two "waiting for a decision" ones the rail already reads cheaply: work orders to triage (manage-work-orders), on Projects, and portal building requests to review (manage-buildings), on Buildings. - Branding. The shell wears the tenant's own brand from Settings → Branding (
ShellBrandProvider,src/components/app/ShellBrand.tsx): its logo at the top of the rail, its square mark (or the logo) in the phone top bar, and the tab title<Tenant> · Complied. It is the portal's own component,TenantBrand: the full logo, else the square mark beside the name, else two initials beside the name, with the sameportal_sidebarrule (a black-sidebar tenant gets the.side-blackrail and a black chip on a light ground). A quiet "Powered by Complied" line sits under the account block. An operator viewing as a tenant sees that tenant's brand; the operator console (OperatorLayout) keeps Complied's mark and title. The sign-in page on a tenant's subdomain shows the same mark before sign-in (tenant-login-branding); the bare domain and localhost keep Complied's. The fallback rules are pure and tested insrc/lib/tenantBrand.ts. - Top bar. The page's own title (
useTopBarLeft; the tenant's mark on a phone page that sets none), the View as control (ViewAsMenu, tenant admins and operators only, previews) and the notification bell; belowlga search button too. Sticky on phones and tablets, in flow fromlg. - Preview banner. While a tab previews a role or a client portal,
PreviewBannersits above the shell (as the operator's "Viewing as" banner does): "Previewing as· read-only · Exit" or "Previewing 's portal · read-only · Exit". Submit buttons and buttons marked write(<Button write>) are disabled meanwhile. - Bottom bar. Below
lg: the first four of Projects, Buildings, Violations, Map, Clients and Settings this person can open, then More, which opens the rail as a left drawer (every destination, search and the account menu). - Page.
PageContainerpads the page with--page-gutter(1rem on phones, 1.5rem fromsm); a page that bleeds to the edges uses-mx-[var(--page-gutter)], never-mx-6.mainis not a scroll container, so the document scrolls andstickyworks; sticky things usetop-[var(--shell-top)]to sit under the phone top bar.maincarriespb-bottombar, so no page ends under the bottom bar. A full-bleed page (usePageFullBleed: the map, a floor plan) gets a window-high column instead and is aflex min-h-0 flex-1child that fills it.
The client portal (PortalLayout, data-app-shell="portal") is the same frame over its own destinations, one table in src/lib/portalNav.ts: Overview (Home, Setting up, Map, Buildings), Compliance (Violations, Calendar), Services (Requests & orders, Documents, Invoices) and a heading-less footer (Your company). Its sections fold like the staff rail's; Requests & orders is the current item on /portal/projects/* and /portal/work-orders, the Calendar on /portal/deadlines (both old list addresses redirect, keeping their query: PortalRedirect). Its top bar is sticky at every width and carries the appearance icons and New work order; below lg, PortalBottomBar holds Home, Buildings, Violations and Requests, then More (the rail as a drawer). The tenant's sidebar setting still applies to the rail and the drawer (.side-black), and both carry data-rail.
Client portal
Same contract as the staff app (same tokens, fonts, green primary, green tint for "current", status pills) so a firm's clients see the same product its staff use. Only the layout changes for an audience that visits now and then: cards instead of dense tables, plain words instead of phase codes, and "New work order" one click away on every page. On a light-sidebar tenant the firm's logo sits on a white backing in dark, so a dark logo stays visible; on a black-sidebar tenant it sits bare on black, and on a black tile in the phone top bar. The portal offers the same Light, Dark and Eco appearances as the staff app. It is one design for every tenant; the firm's own name, logo and contact details are read from its branding settings, never hard-coded. Build portal screens from src/components/portal/ui.tsx and the plain-language helpers in src/pages/portal/portalDisplay.ts. A portal page is PortalPage: the title, an optional description, and an optional status — the one-line "where do I stand" sentence with a status dot ("2 overdue · 5 due in 30 days") — then its headline numbers as KpiTiles in a KpiRow (the kit's KpiCard / KpiGrid, so a number reads the same on both sides), then cards (PortalCard, RowList / Row). Page actions sit right of the title and fill a row under it on a phone. Lists use the kit's SearchBox, ListToolbar and Segmented; section tabs are the portal Segmented (IconTabs). Home's Coming up (src/pages/portal/home/ComingUpPanel.tsx) and the Calendar share one fold of the client's dated things (src/pages/portal/calendar/calendarModel.ts). Nothing on a portal screen is sample data: a panel whose data we don't have is left out.
Confirmation steps use useConfirm() (src/components/ui/confirm-dialog.tsx), never the browser's window.confirm, alert or prompt, which ESLint rejects.
Shared primitives
Build screens from the shared primitives rather than re-implementing layout:
| Primitive | Where | Used for |
|---|---|---|
RecordsExplorer | src/components/redesign/RecordsExplorer.tsx | The list surfaces (/buildings, /projects and its tabs, /clients, /deadlines, the documents table and several portal lists): columns, view tabs (a Segmented switch), groups (foldable with collapsedGroups / onToggleGroup, a tally per group), the page-kit toolbar (onSearch, filterPanel, appliedFilters, toolbarRight), moreActions, selection, paging, and a phone action row (mobileActions) |
DetailLayout, FactsRail | redesign/DetailLayout.tsx | Detail pages: breadcrumbs, a title block, content, and a facts rail of fixed width |
DetailDrawer | redesign/DetailDrawer.tsx | Side panels over a list |
| Kit | redesign/kit.tsx | StatusDot, Pill, Stat, StatStrip, KpiCard / KpiGrid, SummaryTile, MiniStat, PageHeader, IconTabs / TabCard, SearchBox, FilterChips, EmptyState (the one empty state: align="center", icon, size="sm"; TableEmptyState wraps it), SectionNav, ObligationBanner, and TAP_PAD / CARD_SURFACE class strings |
| Page kit | redesign/simple.tsx | ListToolbar (one search + one Filter button), FilterSheet + FilterGroup (the filters, in a side sheet), AppliedFilters (= FilterChips), MoreMenu (every action but the primary, behind ⋯), SimpleCard, SimpleRow, Segmented, BarList, Sparkline; their arithmetic is redesign/chartMath.ts (barShare, sparklinePoints, heatBackground) |
| Groups | redesign/GroupHeader.tsx, NestedGroups.tsx, GroupBuilder.tsx, groupTree.ts, useGroupLevels.ts, useCollapsedGroups.ts | GroupHeader / StaticGroupHeader / FoldableGroup, GroupTally / ToneChip; stackable grouping (buildGroupTree + GroupBuilder chips + levels in ?group=, drawn by NestedGroups or flattened with leafGroups into RecordsExplorer); folded groups remembered per browser (useFoldedGroups, a UI preference) |
| Filter row | src/components/filters/FilterRow.tsx, filterModel.ts | A desktop row of compact filter buttons with "More filters", one sheet on a phone: pills for a short fixed set, a searchable lookup with ticks and "Top" for a long list, one summary chip per filter. ChoiceMenu for sort / group. |
| Motion helpers | redesign/useScrollEdges.ts, HoverScrollText.tsx | Faded edges on a sideways strip; a cut-off line that slides on row hover (hover-marquee in src/index.css, off under reduced motion) |
| Filters | redesign/filters.tsx, src/lib/urlFilters.ts | Filter menus and URL-backed filter state. The ""-deletes convention in applyParamPatch is load-bearing: a cleared filter must leave no trace in the URL. |
| Shell | src/components/app/, src/lib/staffNav.ts | AppLayout, AppSidebar, TopBar, BottomBar, CommandPalette (global jump-to-record search), MobileNav, PageContainer (default content shell; usePageFullBleed lets a page such as the map run edge to edge), AppearanceSwitcher, PortalLayout. See Shell |
Filter state lives in the URL so a view is a link and a saved view is a stored query string. Lists, detail pages and the portal share these primitives, so a change to a primitive changes every surface that uses it; check them all.
Copy
Plain, direct, factual. A screen answers "what needs doing, by when, and what happens if not" before anything else ("XRF inspection due Oct 14 — Apt 4B"). Controls name the action ("Send proposal", not "Submit"). Sentence case for headings, buttons and labels. Name the rule when it matters (Local Law 1, Local Law 31, EPA RRP). No emoji, no exclamation marks, no fear-selling. Use the glossary's terms (REQUIREMENTS.md); do not invent testimonials or marketing language inside the app.
Change rules
- Layout, structure, hierarchy and flow can change freely within these rules. Changing a token in
src/index.css, a font or a radius is a design-system decision, not a screen-level tweak; keep it in step with the SEG source. - Do not change behaviour, routes or data while restyling a screen.
- Never put white text on the green fill, green text (other than
brand-text) on white, or a logo on a green ground. npm run lintandnpm run buildmust pass, andnpm run testif you touched anything with a test. Check both themes and keyboard focus.