Foundations

Color palette

The palette is fixed. Never introduce raw Tailwind color utilities outside this page — use the semantic classes (pills, alerts, status dots, text hierarchy) instead. Every token is a light-dark() pair (hexes below are light / dark); the swatches render the live tokens, so use the theme switcher above to preview both sides. Never use dark: variants in views — darkness flows entirely through the tokens. Dark neutrals are warm (a trace of the brand orange) — pure gray reads cold next to orange-main.

Orange

orange-dark

#9F180F / #FF9A62 — orange-as-text

orange-main

#FF671D / #FF671D — fills stay brand

orange-light

#FE7633 / #FF7B3A

orange-lighter

#FAF7EF / #241812

orange-lightest

#FFF0E8 / #2A1710

Neutrals

white

#FFFFFF / #1A1817 — raised surface

black

#212121 / #F5F2F0 — primary text

canvas

#FFFFFF / #121110 — page bg

gray-100

borders, zebra

gray-400

muted text

Green

green-main

#10B981 / #34D399 — active dots

Semantic tokens

--color-success-*

bg / border / text

--color-error-*

bg / border / text

--color-warning-*

bg / border / text

--color-info-*

bg / border / text

Typography

Headings are styled globally (base layer) — a bare <h2> already gets its scale. Don't restate the size/weight utilities on headings unless you are intentionally deviating (e.g. the page-header h1).

Heading 1 — 4xl bold

Heading 2 — 3xl semibold

Heading 3 — 2xl semibold

Heading 4 — xl medium

Heading 5 — lg medium
Heading 6 — base medium

Body text — base size, regular weight

Small text — sm with .text-secondary

Extra small — xs with .text-tertiary

Text hierarchy

Prefer these helpers over raw text-gray-* utilities so a future contrast pass is a one-line change.

Aa .text-primary

Primary content (black)

Aa .text-secondary

Secondary content (gray-600)

Aa .text-tertiary

Labels, metadata (gray-500)

Aa .text-muted

Placeholders, separators (gray-400)

Icons

Always render icons through Icon.render("solid/<name>", "w-4 h-4"). No inline <svg> and no literal glyph characters (⋯, •) for interactive elements.

Buttons & links

Choosing a button: form submits get btn btn-primary; dangerous/irreversible actions get btn btn-destructive (never btn-primary for a delete); page-level secondary actions get btn btn-secondary; inline actions in cards, tables and headers get btn-action.

Primary — main/submit actions

Secondary — page-level actions

Destructive — irreversible or dangerous

Action — inline actions in sections, tables and headers

Hover for orange border and text.

Links

Inline primary link — orange, underline on hover.

Disabled primary link

Sidebar/table micro-actions use text-xs link-primary. Don't hand-roll link colors (text-blue-*, text-orange-main hover:*) — it's always .link .link-primary.

Pills, chips & status

The one rule: colored pills are for lifecycle state (active, completed, pending, cancelled). Category labels — roles, kinds, scopes, service tags — use pill-neutral. A colored category badge reads as a status it isn't. One documented extension: a blocking condition may use a colored pill — a task's Unassigned is pill-error because unassigned work cannot be completed (the state machine refuses), so it behaves as workflow state, not a category. Assignment to a specific person stays plain text.

Sizes

Three geometries, one tone system. Never write the geometry utilities by hand.

Terms accepted
.pill — page headers and prominent status
Pending verification Reader
.pill-sm — table cells and inline tags
RR 60m GL
.chip — dense abbreviation chips (see service chips below)

Tones

pill-success pill-warning pill-error pill-info pill-neutral pill-b2b pill-b2c
  • pill-success — completed, active, accepted
  • pill-warning — pending, requires action
  • pill-error — rejected, failed
  • pill-info — informational lifecycle (scheduled by client, member)
  • pill-neutral — categories, inactive, everything that isn't a lifecycle state
  • pill-b2b / pill-b2c — customer type

Status dots

Lightweight active/inactive signal for record headers and inside pills. .status-dot + .status-dot-active / .status-dot-inactive.

Active
Inactive
Active Inactive

Shared pill partials (rendered live)

queued active completed application/state_pill — lifecycle pill, label: + optional tone: (defaults to pill-neutral). Feed the tone from a per-domain *_state_pill_class helper.
Manager staffs/users/role_pill — role as category, role:
Completed Scheduled · candoo staffs/service_deliveries/state_pill — colored lifecycle pill, state:
90m 60m QS RR GL Lib
application/service_chipsservices:. Renders every service; chips wrap to fill the cell. Shown here at a realistic table-cell width so the row-height cost of wrapping is visible. No truncation, because the vocabulary is closed and the labels are abbreviated — there is nothing a reader would expand. Open-ended history (a member's sessions) is the opposite case and does get capped. Order is the catalog order the presenters sort by, so a given service sits in the same position on every row.

Forms

Core controls

Help text under a field uses .text-tertiary.

  • • Labels: .label. Required marker: .required-indicator.
  • f.select takes class: "select" — not "input".
  • • Field errors: render("field_errors", form_object: f.object, key: :name).
  • • Form-level errors: render("form_errors", form_object: f.object) at the top of the form.

Checkboxes & radios

Checkboxes always get class: "checkbox". Boolean settings use the inline pattern (checkbox leading, label following). Groups use .checkbox-group / .radio-group.

Optional helper text, indented under the label.

What is your comfort level?

Form sections (registration flows)

Public/senior-facing forms group fields in .form-section cards with .form-question subheadings, and use a full-width submit (btn btn-primary w-full). Conditional fields toggle via Stimulus (e.g. email-conditional).

Do you have an email address? (required)

Alerts & flash

One component: .alert + tone (.alert-success/-error/-warning/-info). Flash messages render through application/_flash in every layout — never hand-roll a flash block or a colored box (bg-yellow-100, bg-red-100, …).

Operation completed successfully
Something went wrong
Please review before proceeding
Here's some helpful information
  • • Flash: rendered by the layouts via render("application/flash").
  • • Validation errors: application/_form_errors and _base_errors use .alert .alert-error; inline field errors use .text-error-text.
  • • Static callouts (import instructions, info banners) use .alert .alert-warning / .alert-info.

Surfaces

Panel vs card: new staff-console work uses .panel — flat, no shadow, no hover. .card (shadow + orange hover glow) remains on older staff pages and the public registration flow; don't introduce it on redesigned pages.

.panel

Section title · 4

Sectioned content. Header via .panel-header + .panel-title; count suffix is text-muted font-normal.
Plain content panel: panel p-4. Tables use panel overflow-hidden (see Tables).

.panel-attention

A work panel demanding a decision — e.g. a typed task's inline action (approve/reject an eligibility verification). Same shape as .panel, framed in the brand orange with .panel-attention-header and .panel-attention-footer. One per page, at the top of the main column; its confirm button is the page's primary action.

Eligibility verification · pending review

Open full record
Decision context (who, what, the key fields).

.card (legacy surface)

Active Sessions

24

Hover me — orange glow

Notes

Prefers morning sessions

Jan 10, 2026

When it's still right

Public registration flow, older staff show pages, and clickable summary cards where the hover glow signals interactivity.

Details panel (definition list)

Details

ID
1042
Created
Mar 3, 2026
Self-scheduling
Enabled

.note-content

Partner summary

Call the warm hand-off line.

  • One Care
  • SCO

Portal: https://member.example.org/

Markdown note body rendered by the markdown helper — typography plugin themed with the design tokens. Used by application/_note_content.

Tables

.table-list — the staff-console data table

Wrap in panel overflow-hidden. When rows link to a detail page add .row-hover to each row and a trailing .row-chevron cell. Numeric cells add text-right tabular-nums. Cell padding overrides (e.g. px-2 on the chevron cell) still work.

Name State Users
Trinity Woods Active 42
Kendal at Oberlin Active 18
RiseBoro Inactive 0
<div class="panel overflow-hidden">
  <table class="table-list">
    <thead><tr><th>Name</th><th class="w-8"></th></tr></thead>
    <tbody>
      <tr class="row-hover">
        <td>…</td>
        <td class="px-2"><span class="row-chevron block" aria-hidden="true"><%%== Icon.render("solid/chevron-right", "w-3.5 h-3.5") %></span></td>
      </tr>
    </tbody>
  </table>
</div>

Legacy: .striped

Older pages zebra-stripe rows with .striped and p-3 cells, relying on the global table/thead styles. Don't add new striped tables — migrate to .table-list when touching one (the drift spec allowlists the current files and nothing else).

Charts & meters

The size ladders are deliberate — do not "simplify" them to inline styles. The CSP sets style-src to :self with no unsafe-inline, so an inline style="width: 63%" is dropped before it ever applies — and the two primitives then fail in opposite directions, neither raising an error. A .meter-fill declares no width of its own, so a dropped width leaves width: auto and the block expands to fill its track: a 63% meter renders as a confident 100%, overstating the very number it exists to report. A chart bar has no content, so a dropped height leaves height: auto and it collapses to nothing. Sizes are therefore snapped to the fixed steps below. The steps must also exist as real classes in application.css, because Tailwind's scanner cannot see a class name that only exists inside Ruby — the presenter builds them by interpolation ("meter-fill-#{snap(percent)}"), which the scanner never reads.

.meter-track / .meter-fill

A horizontal ratio bar: one value against a known limit. .meter-track is the rounded gray groove; .meter-fill is the brand-orange bar inside it, sized by a .meter-fill-{step} width class. The fill has one color — there are no tone variants today, so a meter never signals success/warning/error by itself; pair it with a .pill-sm when the status matters.

.meter-fill-0 0%
.meter-fill-5 5%
.meter-fill-20 20%
.meter-fill-50 50%
.meter-fill-80 80%
.meter-fill-100 100%

Steps: 0 2 5 10 20 30 40 50 60 70 80 90 100. Snap a real percentage to the nearest one rather than adding a step.

Meter with a threshold

There is no tick-mark class. A limit is expressed as a caption row under the track — floor on the left, the named limit on the right — and the limit label turns .text-critical once the value crosses it. The number is what communicates the breach; the meter just shows how close it is.

Bounce rate
2.10% of 8,420 reached
Healthy
0% AWS limit 5%
Bounce rate
5.40% of 8,420 reached
Over limit
0% AWS limit 5%

.chart-track / .bar-fill-{step}

A small trend chart: .chart-track is a fixed-height (h-40) bottom-aligned flex row; each bar is a .bar-fill-{step} height class on the same ladder, measured against the tallest bar rather than a fixed scale. Labels live in a sibling row below the track, not inside it, so the bars stay bottom-aligned. Wrap the pair in overflow-x-auto with a min-w-* when a narrow column would crush them.

Weekly volume

412
806
545
1204
938
24
Mar 3
Mar 10
Mar 17
Mar 24
Mar 31
Apr 7

The last bar is .bar-fill-2 (4px): a nonzero value floors at that step so a small-but-real number never renders as an empty slot indistinguishable from zero. Only a true zero gets .bar-fill-0. Stack two fills in one column (a rounded-t segment above a flat one) to split a bar into two series.

Sole consumer today: app/views/staffs/email_deliveries/show.html.erb, with the snapping in Staffs::EmailDelivery (STEPS, snap, height_class). A second consumer should reuse that ladder rather than adding intermediate steps.

Page patterns

.page-header — titled pages (forms, titled indexes)

The h1 here intentionally overrides the base h1 scale: text-2xl font-semibold tracking-tight text-black leading-none mb-0.

Index page anatomy (staff console)

  1. content_for(:query_actions) — a form with a .quiet-search input and/or primary action
  2. Optional application/_filter_form — collapsible multi-field filters
  3. application/_query_summary — record counts + the :query_actions slot
  4. panel overflow-hidden + .table-list (see Tables)
  5. Kaminari pagination
Total records: 128 Per page: 25
table goes here

Record header (show pages)

Show pages use a richer header: avatar/logo slot, title, a metadata line with · separators (text-muted), a status dot, and an action cluster on the right.

Trinity Woods

trinity-woods · Active · 3 cohorts

.stat-strip

Horizontal row of stat cells on show pages. Compose cells with the application/_stat partial (label:, value:, optional sub:) or inline with the same typography.

Active cohorts
2 of 3
Active users
70 of 80
Services in use
6
Liaisons
1

Sidebar section header

Liaisons · 3

list items…

Split-view queue (tasks)

A viewport-locked list + detail split for working a queue without page scroll. The page sets content_for(:viewport_locked); the staff layout turns that into an lg:h-dvh lg:overflow-hidden flex column (small screens keep page scroll). Every wrapper between the layout and the scrolling panes carries min-h-0, and each pane scrolls itself with overflow-y-auto. Selection is bg-orange-lightest plus aria-current="true" on the active row — JavaScript must keep both in sync, and keeps the row in view by setting the list's scrollTop, never scrollIntoView (which would scroll the locked page). Group headers are sticky top-0. Runs of ≥5 identical rows collapse to a summary row. Live implementation: staffs/tasks/_queue.html.erb + task_queue_controller.js.

Overdue · 2
Verify Eligibility 12d overdue Unassigned Follow up: onboarding call Due Aug 4
18 more Verify Eligibility · due Aug 02–Aug 03 Show all
↑↓ to move · C complete 1 of 20
detail pane — scrolls itself

client_avatar helper

Deterministic initials tile, CSP-safe. client_avatar(name:, size:):sm (28px) in rows, :lg (48px) in headers. Uploaded logos render via image_tag(logo.variant(...)) at the call site.

Legacy & migration

Hand-rolled utility strings are banned

spec/design_system/drift_spec.rb fails CI when a view hand-rolls a pattern that has a component class. Each legacy pattern has a frozen allowlist of existing files — the list can only shrink. When you touch an allowlisted file, migrate it and remove it from the list.

Instead of writing… Use
inline-flex items-center px-3 py-1.5 rounded-full … .pill / .pill-sm / .chip + tone
border border-gray-200 rounded-lg bg-white .panel
absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg … .dropdown-menu
block px-4 py-2 text-sm text-gray-700 hover:bg-orange-lightest … .dropdown-item / .dropdown-item-sm
bg-red-100 / bg-green-100 / bg-yellow-100 boxes .alert + tone
content_for(:subnav) breadcrumb markup render("application/breadcrumb", crumbs: [...])

btn-nav — removed

The .btn-nav class is gone (it had zero remaining uses). Grouped page-level actions → a dropdown with a btn-action trigger; standalone actions → btn-action directly.

.striped tables — legacy

Migrate to .table-list (see Tables) when touching a page. No new striped tables.

Full-width search cards — legacy

The old full-width search row (input + submit button above the table) is replaced by .quiet-search in the query-actions slot, plus application/_filter_form for multi-field filtering.

.card on redesigned staff pages

New staff-console sections use .panel. .card stays on the public flow and un-migrated pages.