OpenInspection ships with a small, opinionated design system ("Design System 0523") rather than raw Tailwind utilities. It has three layers:
- Token layer — CSS custom properties + a Tailwind v4
@themeblock (app/styles/tailwind.css) that map semantic names (bg-ih-bg-card,text-ih-fg-2,bg-ih-ok) to real colors, and flip automatically for dark mode. - Component primitives —
packages/shared-ui/src/— a small set of token-based React components (Button, Card, Modal, Input, ...) shared by both the standalone engine and (via the package) any SaaS overlay. - Conformance tooling —
npm run lint:ds(scripts/check-ds-tokens.mjs) fails the build when UI code bypasses the token layer with raw Tailwind palette classes.
If you're adding or changing UI, this doc is the reference for what exists today — don't invent new tokens or components ad hoc; extend what's here.
All tokens are CSS custom properties, declared once in app/styles/tailwind.css
and re-exposed as Tailwind utilities via a @theme block (so bg-ih-primary,
text-ih-fg-1, etc. work as ordinary Tailwind classes). Components must
consume tokens, not literal colors — dark mode is then "free": swapping the
:root custom properties is enough, no per-component dark-mode code needed.
| Token | Purpose |
|---|---|
--color-ih-primary / -600 / -700 |
Brand color + hover/active shades. The FILL role: button and accent-bar backgrounds. A tenant's brand color lands here verbatim |
--color-ih-primary-text |
The TEXT role: links, tab labels, brand-colored glyphs. Equal to --color-ih-primary by default, but when a tenant sets a brand color this one is derived — the same hue and saturation, moved along the lightness axis until it clears 4.5:1 on the card (darker on light themes, lighter on dark). 63.6% of sRGB fails AA as text on white, so the two roles cannot share one value. Use text-ih-primary only where the brand is the fill of the thing being colored, e.g. a checkbox accent |
--color-ih-primary-tint |
Low-opacity primary wash (selected tab pill, badges) |
--color-ih-primary-glow |
Focus-ring glow color (see shadow-ih-focus) |
--color-ih-primary-fg |
Foreground for content on bg-ih-primary; defaults to white, but flips to dark text per-surface when a bright custom brand color is set. Chosen by measuring both candidates' real WCAG ratios and taking the higher — 6.7% of sRGB admits no passing choice at all, so this is a best-effort token, not a guarantee |
--color-ih-fg-1 … -5 |
Text scale from near-black/white (-1, headings/body) down to faint (-5, disabled/hairline) |
--color-ih-fg-inverse |
Text on an inverted surface (bg-ih-bg-inverse, bg-ih-primary) |
--color-ih-bg-app |
Page background |
--color-ih-bg-card |
Card/panel/input surface |
--color-ih-bg-muted |
Subtle fill (badges, disabled zones, hover rows) |
--color-ih-bg-inverse |
Inverted surface (tooltips, photo-studio chrome) |
--color-ih-border / -strong |
Hairline border / emphasized border |
--color-ih-agent-accent / -fg |
Sub-brand accent reserved for the Agent portal surface |
Four semantic tones, each with a -bg (tint) and -fg (text/icon) pair:
ih-ok, ih-watch, ih-bad, ih-info. Use these for anything that
communicates state — never reach for a raw emerald-500 or red-600.
| Tone | Meaning | Example usage |
|---|---|---|
ih-ok |
Satisfactory / success | "Satisfactory" rating pill, success toast accent |
ih-watch |
Needs monitoring / warning | "Monitor" rating pill, warning banners |
ih-bad |
Defect / error | "Defect" rating pill, error toast accent, form field errors |
ih-info |
Informational | Info banners, neutral callouts |
Exactly two elevations exist — do not use Tailwind's shadow-sm/md/lg/xl/2xl:
| Token | Use |
|---|---|
shadow-ih-card |
Resting elevation for cards/panels |
shadow-ih-popover |
Elevated overlays — Modal, dropdowns, toasts |
shadow-ih-focus |
Focus ring (0 0 0 3px var(--ih-primary-glow)) — applied via focus:shadow-ih-focus |
Five semantic corner radii — do not use raw rounded-md/lg/xl or arbitrary
rounded-[10px] in components (lint:ds flags the latter):
| Token | Value | Use |
|---|---|---|
rounded-ih-pill |
4px | Pills, segment buttons, small chips |
rounded-ih-button |
6px | Buttons, icon buttons, segmented-control track |
rounded-ih-input |
8px | Text inputs (the .ih-input base radius) |
rounded-ih-card |
8px | Cards, banners, popovers, panels |
rounded-ih-modal |
12px | Modal dialog corners |
Two named spacing tokens capture recurring rhythms; use the standard Tailwind
scale for everything else. Arbitrary px spacing (p-[18px], gap-[2px]) is
flagged by lint:ds:
| Token | Value | Use |
|---|---|---|
space-y-ih-list / gap-ih-list / p-ih-list |
18px | Vertical rhythm between list rows / page sections |
p-ih-card / gap-ih-card |
14px | Compact card padding |
(These are @theme spacing extensions, so they work with any spacing utility —
p-, px-, gap-, space-y-, m-, etc.)
| Token | Use |
|---|---|
bg-ih-backdrop |
The single canonical overlay scrim — a fixed dark wash (rgba(15,23,42,0.55) light / rgba(2,6,23,0.65) dark) behind full-screen dialogs |
Modal and Drawer both render bg-ih-backdrop as their full-screen scrim;
Popover deliberately has no scrim (it is an anchored, non-blocking
overlay). Never hand-roll a scrim with bg-[rgba(...)] or backdrop-blur —
both are flagged by lint:ds; use this token.
font-ih-display (Bricolage Grotesque, headings), font-ih-body (Inter,
default body), font-ih-mono (JetBrains Mono, .ih-kbd and code).
The data-color-scheme attribute on <html> selects the palette:
"light" (default), "dark", or "field". useTheme()
(app/hooks/useTheme.ts) is the single place that resolves the user's
preference (including "auto" → OS media query) and writes the attribute —
components never branch on scheme themselves. Do not use Tailwind's
dark: variant for OpenInspection colors; token consumption makes it
redundant (the .dark class is still added alongside data-color-scheme so
plain dark: utilities keep working for third-party components that need
them).
"field" is a first-class third scheme (not just "auto → dark"): a
high-contrast, large-type (18px base) variant of dark for outdoor/sunlight
field use. It inherits the dark palette and overrides foregrounds, background,
and borders for higher contrast.
- Add the CSS custom property to both the
:rootblock (light) and thehtml[data-color-scheme="dark"]block (dark) inapp/styles/tailwind.css— every token needs a value in both, or dark mode silently falls back to the light value. - Expose it as a Tailwind utility by adding a matching line to the
@themeblock (--color-ih-foo: var(--ih-foo);). Step 2 is not optional. Without itbg-ih-foois not an error — Tailwind emits no CSS for it and says nothing, so the element paints no background in every theme. Ten alias names shipped in that state, one of them at 17 call sites. - Consume it as
bg-ih-foo/text-ih-foo/ etc. Never reference the raw--ih-fooCSS variable directly from a component unless there is no Tailwind utility surface for it (e.g. inlinestyle={{ boxShadow: ... }}). Mind the namespace:bg-/text-/border-read--color-*,rounded-reads--radius-*,p-/gap-read--spacing-*,shadow-reads--shadow-*.ih-cardexists in three of those, soshadow-ih-cardresolves andbg-ih-carddoes not. - Run
npm run lint:ds. It fails on anih-*alias with no@themeentry (step 2 above) and on raw palette classes. It has nothing to say about what the token is WORTH —npm run lint:contrastis the gate that does the arithmetic.
A handful of non-component CSS utility classes live in tailwind.css for
patterns that recur across many components:
| Class | Purpose |
|---|---|
.ih-eyebrow |
9px, bold, uppercase, letter-spaced label style (used by the deprecated Eyebrow component and a few standalone labels) |
.ih-input |
The canonical 36px-tall block input style (border, radius, focus ring). Input and most raw <input>/<select> field markup in app/ build on this class directly |
.ih-kbd |
Keyboard-shortcut chip (monospace, bordered) |
.ih-pill (+ --sat / --monitor / --defect / --ni) |
Base pill shape; the shared-ui Pill component composes this with a tone prop — prefer the component over the raw class in new code |
.ih-row / .ih-row__hover |
Hover-reveal pattern: child marked .ih-row__hover is invisible until the .ih-row ancestor is hovered or has .is-active |
.ih-sidebar |
Sidebar width transition, driven by html[data-sidebar-collapsed] |
Use these for the exact pattern they name. For anything else, compose
Tailwind utilities from the token layer directly (text-[13px] text-ih-fg-2 font-bold, etc.) — there is no separate general-purpose typography scale
beyond the ad hoc pixel sizes already used throughout app/components/.
packages/shared-ui/src/ (exported from index.ts) — 25 components. Check
here before hand-rolling UI. A new repeated pattern (a card variant used in
three places, a new pill tone) belongs in shared-ui, not copy-pasted across
route files.
| Component | Purpose | Key props / variants |
|---|---|---|
Button |
Primary interactive control | variant: primary | secondary | ghost | danger | link | danger-link; size: sm | md | lg; icon; selected (sets aria-pressed + a pressed ring — for toggle affordances). link/danger-link are borderless text actions (no fill/border) |
IconButton |
Square icon-only button — same variants as Button, no text padding; aria-label is required (TS-enforced) |
aria-label (required), variant: primary | secondary | ghost (default) | danger; size: sm (28px) | md (36px) | lg (44px); selected (aria-pressed toggle). Use for toolbar/close/settings glyph buttons instead of a hand-rolled w-9 h-9 flex <button> |
MenuItem |
Dropdown-row primitive to pair with Popover |
role="menuitem", full-width left-aligned; icon, tone: default | danger, disabled, onClick. Use for popover/dropdown menu rows instead of hand-rolled w-full text-left px-3 py-1.5 hover:bg-ih-bg-muted <button>s |
Pill |
Small status/tag chip | tone: sat | monitor | defect | ni | np | info | gen | primary | neutral | warning; dot (leading dot) |
Icon |
Inline SVG icon from a fixed named set | name (see ICON_PATHS in Icon.tsx for the full list — dashboard, calendar, check, edit, camera, ...), size, strokeWidth |
Card |
Bordered/rounded/elevated surface container | no variants — compose with className |
Input |
Labeled text input built on .ih-input |
label, error (red border + message), hint (shown only when no error) |
Textarea |
Labeled multiline input — same .ih-input chrome as Input but grows with rows and resizes vertically |
label, error, hint, rows (default 3), bare (raw control only, for inline composition) |
Select |
Labeled single/multi select mirroring Input's chrome; tokenized chevron in single-select mode |
label, error, hint, options (SelectOption[]) or native <option> children, multiple, bare. Use for a fixed option list; use Input for free text |
Checkbox |
Single checkbox with native <label> association; DS accent-ih-primary fill |
label, error, bare (raw input only, e.g. inside FormField) |
Radio / RadioGroup |
RadioGroup renders a <fieldset>/role=radiogroup set of options; Radio is the single control for custom layouts |
RadioGroup: name, value, onChange(value), options (RadioOption[]), legend, error, hint. Prefer RadioGroup; reach for bare Radio only for bespoke layouts |
Modal |
Centered dialog overlay (role="dialog" + aria-modal), full-screen bg-ih-backdrop scrim, Escape-to-close, click-outside-to-close, focus trap |
open, onClose, title, size: sm | md | lg | xl, footer. Use for confirm/decision moments and short forms (see §4) |
Drawer |
Right-side slide-in panel sharing Modal's dialog behavior + scrim |
open, onClose, title, footer, wide (480px vs 360px; mobile always full-width), initialFocusRef. Use for "adjust while seeing the page" flows — filters, long side forms — NOT confirm moments (see §4) |
Popover |
Anchored, non-blocking floating panel — no scrim, no scroll-lock, no hard focus-trap; Esc / click-outside close, focus restores to the anchor | open, onClose, anchorRef (trigger the panel positions against), align: left | right (default right). Use for lightweight in-context choices (column toggles, dropdowns) where the page must stay visible (see §4) |
EmptyState |
Centered icon + title + description + action for empty lists | icon, title, description, action |
Eyebrow |
Deprecated small label chip (bg tint + text) | color: slate | indigo | emerald | amber | rose — kept for back-compat; new pages use a breadcrumb + a Pill in PageHeader's meta instead |
PageHeader |
Page title row with optional meta line and trailing actions | title, meta, actions; eyebrow/eyebrowColor are deprecated (same reason as Eyebrow) |
Pagination |
Page-number nav + page-size selector | page, pageSize, total, totalPages, onPageChange, onPageSizeChange, pageSizeOptions |
Skeleton |
Loading placeholder block | variant: text | block, width |
TabStrip |
Underline-style tab bar with optional counts | tabs ({id, label, count?}[]), activeId, onChange, orientation: horizontal (default, underline) | vertical (left border-accent) |
FileDropzone |
Drag-and-drop / click-to-pick file input with a full state machine (idle → drag-over → busy → selected → error) | accept, onFile, fileName/fileSize (controlled selection display), busy, error, hint, onClear. Also exports firstFileFromDrop, formatFileSize, truncateMiddle helpers |
Banner |
Full-width inline status/notice strip with ARIA live-region role (alert for warn/danger, status otherwise) |
tone: info | warn | danger | success | brand; actions, dismissible/onDismiss, icon, sticky. Use for page-level or section-level messages; use Pill for a compact inline badge, a toast for a transient async outcome |
Table |
Generic column-config data table (tokenized header, hover rows, empty-state slot) | columns (TableColumn<T>[] — label, align, cell, key), rows, empty, getRowKey, onRowClick. Use for tabular listings instead of hand-rolled <table> markup |
SegmentedControl |
Single-select segmented toggle (WAI-ARIA radiogroup — roving tabindex, Arrow/Home/End keys) | options (SegmentedControlOption[] — value, label, icon, title), value, onChange(value), size: sm | md, ariaLabel. Use for a small set of mutually exclusive views/modes; use TabStrip for page navigation |
Avatar |
User initials/status chip; consolidates the app's initials logic (avatarInitials helper is exported) |
name (initials derived), size: 28 | 32 | 36, variant: flat | self (gradient), statusDot, ring, fallbackIcon |
StatCard |
Compact metric card (label + large value + optional hint), optional left-accent tone bar reusing the Pill tone palette |
label, value, tone (PillTone), hint |
Eyebrow and PageHeader's eyebrow/eyebrowColor props are deprecated but
still shipped for back-compat — don't use them on new pages.
Hints, descriptions and other 11px small print use text-ih-fg-3.
ih-fg-4 is a decoration tier — chevrons, dividers, offline dots,
placeholders — not a text tier: at 11px it measures 2.56:1 on a light card and
3.07:1 on a dark one, against WCAG AA's 4.5:1 for normal-size text. ih-fg-3
clears it in all three themes (4.76:1 light, 5.71:1 dark, 12.02:1 field).
lint:ds cannot see this — it validates token names, and ih-fg-4 is a
legitimate name. npm run lint:contrast (scripts/check-contrast.mjs) does the
arithmetic instead, and runs in pre-commit and CI.
Two container shapes cover essentially every editable field in the app: inline editing (no submit button) and forms (explicit Save/submit). Getting this choice right matters more than pixel details — it's the difference between an editor that feels fluid and one that feels bureaucratic in the wrong places, or reckless in the wrong others.
Use inline editing only when ALL of these hold:
- Single, self-contained field — no cross-field validation or dependencies.
- Auto-save semantics — the value persists on change/blur (or flows into the editor draft handled by the sync layer); a save failure surfaces as an error toast without interrupting typing.
- Continuous workflow — the edit happens inline with surrounding context that must stay visible (renaming a section, rating items, writing report narratives).
- Low risk, no side effects — saving never sends, publishes, creates an entity, or bills.
Canonical examples in this repo:
- Template editor section title —
app/components/template/SectionAuthorHeader.tsx: a transparent-background<input>with a focus underline (border-b-2 border-transparent focus:border-ih-primary), committing on every keystroke viarenameSection. - Inspection editor field entry —
app/components/form/FormField.tsx: each item type (text,number,select,boolean, ...) renders a bare controlled input wired straight toonChange; there is no per-field save button, the value flows into the inspection's sync layer. - PCA narrative textareas — same pattern, a
textareawithonChange-driven persistence for free-text report narrative.
Only two inline input visual styles are sanctioned — do not invent a third:
- Transparent title style:
bg-transparent border-b-2 border-transparent focus:border-ih-primary outline-none— for large, title-like inline text (section titles, item labels). .ih-inputblock style: the bordered,bg-ih-bg-cardblock input — for ordinary field values (FormFieldrenderers, mostInputusage).
A form is required when ANY of these hold:
- Multiple fields submitted together, or cross-field validation/ dependencies.
- Submission has side effects — sends email/SMS, publishes, creates an entity, charges money.
- Explicit confirmation semantics are needed — a Save/Cancel pair, or an unsaved-changes guard.
Rule of thumb: if an "unsaved state" can exist, it's a form; a single field that saves on blur can be edited inline.
Container choice:
- Page-level forms for settings-style surfaces — see
app/routes/settings-workspace.tsx: a React Routeraction+ Zod schema (workspaceSchema) via@conform-to/react/@conform-to/zod, withuseForm({ shouldValidate: "onBlur", shouldRevalidate: "onInput" })— this is the eager-after-error pattern: don't validate until the field is first touched/blurred, then revalidate on every subsequent change so the error clears as soon as the user fixes it. Modalfor short or critical confirmations (destructive actions, small single-purpose dialogs) — build on the sharedModalcomponent'sfooterslot for the Save/Cancel button pair.
Three overlay primitives cover three distinct intents. Picking the wrong one is the usual cause of "this feels heavy/interrupting" complaints:
| Primitive | Shape | Scrim / blocking | Use it for |
|---|---|---|---|
Modal |
Centered dialog | Full-screen bg-ih-backdrop scrim + focus trap — interrupts the page |
Confirm/decision moments (destructive actions), short single-purpose forms |
Drawer |
Right-side slide-in panel | Same full-screen scrim + focus trap, but content stays anchored to the edge | Longer/complex side forms and "adjust while the page context is still framed" flows (filters, settings sub-panels) |
Popover |
Small panel anchored to a trigger | No scrim, no scroll-lock, no hard focus-trap — the page stays visible and interactive | Lightweight in-context choices (column toggles, dropdown menus, quick pickers) |
Rules of thumb:
- If the user must stop and answer before continuing →
Modal. - If they're filling a longer form but the page behind it still gives useful
context →
Drawer. - If it's a quick, low-stakes pick anchored to a button and the rest of the
page should stay live →
Popover.
Drawer deliberately reuses Modal's dialog chrome (useDialogBehavior:
scroll-lock, focus-trap, Escape/click-outside close). Popover intentionally
does not — it reimplements Esc/click-outside close and focus capture/restore
without the trap or scroll-lock, because those would be wrong for a non-blocking
anchored panel.
app/hooks/useToast.ts (pushToast) + app/components/Toast.tsx
(ToastPortal, mounted once near the root). Three variants:
| Variant | Visual | When |
|---|---|---|
neutral (default) |
Plain card, no accent | Informational, low-stakes confirmations (e.g. "Entered next section: Roof") |
success |
Left accent bar in ih-ok |
A background action completed (e.g. photo upload succeeded) |
error |
Left accent bar in ih-bad + inline ! marker |
A background/auto-save action failed (e.g. "Save failed — your last change did NOT reach the server") |
Toast vs. inline error: toast a background/async outcome the user isn't
actively looking at (auto-save, background upload); show an inline error
(the Input/FormField error prop, or a form's field-level Zod error) when
the user is actively looking at the field that failed validation.
Toasts also support an optional actionLabel/onAction (e.g. "Undo" after a
batch rating change) and a caller-specified durationMs.
Never use window.confirm / window.alert / window.prompt for
confirmations — always use the shared Modal component with explicit
Save/Cancel (or Confirm/Cancel) actions in its footer.
scripts/check-ds-tokens.mjs scans app/ and packages/shared-ui/src/ for
eight violation classes:
- Dead
-bg0pseudo-token —ih-(ok|watch|bad|primary)-bg0generates no utility and silently ships invisible elements. - Raw palette utilities — any Tailwind color-prefixed class
(
bg-/text-/border-/ring-/shadow-/... ) against a raw hue (slate,red,indigo,emerald, ...) with a numeric shade, e.g.bg-slate-200,text-indigo-600. These bypass dark mode and the brand hue entirely. - Literal
bg-white/bg-blackon in-app surfaces. - Non-token shadows —
shadow-sm|md|lg|xl|2xl. Onlyshadow-ih-cardandshadow-ih-popoverare sanctioned. - Arbitrary radius —
rounded-[10px]and friends. Use the semantic radii (rounded-ih-pill|button|input|card|modal). - Arbitrary px spacing — arbitrary-value padding/margin/gap/space like
p-[18px],gap-[2px]. Use the standard Tailwind scale or theih-list/ih-cardspacing tokens. (Width/height/inset andmin-/max-dimensions are legitimately bespoke and are not flagged.) backdrop-blur— glass blur is not part of the DS surface language.bg-[rgba(...)]scrims — hand-rolled overlay tints. Use the singlebg-ih-backdropoverlay token instead.
ds-allowcomment — on the offending line, or anywhere in the 10 lines above it — excuses the violation. Always state the reason (fixed-dark surfaces, print output, email bodies rendered in external clients).print:-variant utilities are ignored — print output is intentionally fixed-color.- File allowlist — a short, justified list in
FILE_ALLOWLISTinside the script (currently: the printable agreement route, the email-template preview, and the Media/Photo Studio chrome components, which are intentionally fixed-dark regardless of theme). Keep this list short; prefer ads-allowcomment for anything narrower than a whole file.
npm run lint(part of the aggregate lint script alongsidelint:svg,lint:erasure,lint:migrefs,lint:filesize,lint:dup,lint:tenant-scope,lint:tests, andlint:deadcode— knip, which flags unused exports so retired primitives don't linger inshared-ui).- Pre-commit (
.githooks/pre-commit) — runs on every commit that touches non-docs/tests files. - CI (
.github/workflows/ci.yml,verifyjob) vianpm run lint.
Before opening a PR that touches UI:
- Tokens only — no raw Tailwind palette classes,
bg-white/bg-black, orshadow-sm/md/lg/xl/2xl. Runnpm run lint:dslocally. - Dark mode checked — toggle
data-color-scheme="dark"on<html>(or use the in-app theme switcher) and confirm the surface still reads correctly. Token-only components get this for free; anything with a hardcoded color won't. - Reuse primitives — check
packages/shared-ui/src/index.tsbefore writing a new button/card/modal/pill. If you're duplicating a pattern a third time, promote it intoshared-uiinstead of copy-pasting again. - Interaction pattern matches the rules above — a single auto-saving
field is inline; anything with cross-field validation, side effects, or
an unsaved state is a form (page-level or
Modal). - No native dialogs —
window.confirm/alert/promptare banned; useModal. -
npm run lint:dsandnpm run lintpass.