Candoo Style Guide
app/assets/tailwind/application.css) and, where
possible, the real shared partials — so this page cannot drift from the implementation.
Enforced by spec/design_system/drift_spec.rb.
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.
.text-primary
Primary content (black)
.text-secondary
Secondary content (gray-600)
.text-tertiary
Labels, metadata (gray-500)
.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.
Pills, chips & status
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.
.pill — page headers and prominent status
.pill-sm — table cells and inline tags
.chip — dense abbreviation chips (see service chips below)
Tones
pill-success— completed, active, acceptedpill-warning— pending, requires actionpill-error— rejected, failedpill-info— informational lifecycle (scheduled by client, member)pill-neutral— categories, inactive, everything that isn't a lifecycle statepill-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.
Shared pill partials (rendered live)
application/state_pill — lifecycle pill,
label: + optional tone:
(defaults to pill-neutral). Feed the tone from a
per-domain *_state_pill_class helper.
staffs/users/role_pill — role as category,
role:
staffs/service_deliveries/state_pill — colored lifecycle pill,
state:
application/service_chips —
services:. 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
- • Labels:
.label. Required marker:.required-indicator. - •
f.selecttakesclass: "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.
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, …).
- • Flash: rendered by the layouts via
render("application/flash"). - • Validation errors:
application/_form_errorsand_base_errorsuse.alert .alert-error; inline field errors use.text-error-text. - • Static callouts (import instructions, info banners) use
.alert .alert-warning/.alert-info.
Surfaces
.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
.panel-header +
.panel-title; count suffix is
text-muted font-normal.
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.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
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
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.
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.
.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
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.
Dropdowns
Structure is always: div.relative[data-controller="dropdown"] →
trigger with data-action="click->dropdown#toggle" →
.dropdown-menu with data-dropdown-target="menu" →
.dropdown-item links (.dropdown-item-sm +
mt-1 w-32 menu for compact in-row menus). Never write the menu chrome
or item hover classes by hand.
- • Don't wrap a single action in a dropdown — a lone "Add" is a bare
btn-action. - • Menu width: default w-48; override with
w-32/w-40/w-56for shorter/longer labels. - • Divider between groups / before destructive items:
div.border-t.border-gray-200. - • Destructive entries (log out, delete) use
.dropdown-item-danger, always below a divider.
Page patterns
.page-header — titled pages (forms, titled indexes)
Page title
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)
content_for(:query_actions)— a form with a.quiet-searchinput and/or primary action- Optional
application/_filter_form— collapsible multi-field filters application/_query_summary— record counts + the :query_actions slotpanel overflow-hidden+.table-list(see Tables)- Kaminari pagination
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
.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.
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.
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.