Clearline · Internal

UI Style Guide

Conventions already established across the app, written down so they don't get re-decided per page. When building something new, match what's here before inventing something else. If you deviate, there should be a reason — update docs/style-guide.md when you do.

Colors

Not in a design-token file yet — these are the literal hex values used consistently throughout src/app/admin/** and src/app/portal/**. Treat them as the tokens until someone formalizes real CSS variables.

RoleHexUsed for
Navy (brand/headers)#0C2D4ASidebar background, page titles, masthead
Accent blue#0E6BA8Primary buttons, links, focus states
Accent blue hover#145E82Primary button hover
Ink (body text)#14233aPrimary text
Muted text#5a6b7e / #6b7a8dSecondary text, labels
Faint text#8593a4Hints, metadata, placeholders
Border#dde3ea / #e1e6ecDefault hairlines
Border, subtle#f1f4f6Row dividers inside cards
Page background#f4f6f8Body background
Card background#f8f9fbTable headers, secondary surfaces
Danger#8f2d1f (text/border), #fdf5f4 (bg)Reject, remove, destructive actions
Success#1f9d6b / #1a6b4aApproved, active, confirmations

Pills (.pill + modifier, in globals.css)

When to use one: a pill represents a state a record is currently in — one of a fixed, known set of values (a document's status, a vendor's status, a role badge). It is not a label, not a category tag on its own, and not a place to put free text. If what you're showing is informational rather than state ("AI reviewed", a count, a role name with no state behind it), don't reach for the pill shape — see the AI signal section below for why.

approved · active · cleared action required · flagged rejected · expired · non-compliant awaiting review · invited inactive · not applicable expiring soon · awaiting submission incomplete

Amber, yellow, and orange are visually close and each currently means something different (action-required vs. early-warning vs. incomplete-and-vendor's-turn). This is a real, known tension — see "Known inconsistencies" below — mitigated today only by every pill also carrying an icon and text label, never relying on color alone.

AI signal color (distinct from status pills)

AI review indicators are never shaped like a status pill — that reads as a second status rather than a separate kind of signal. They're plain colored text + a small vector icon (SparkleIcon, inline SVG — not the ✨ emoji character, which ignores CSS color on most platforms and renders as a fixed-color glyph regardless of what you set):

Typography

Spacing & shape

Components

Buttons — color maps to what the action does, not to visual hierarchy alone

ColorMeaningExamples
Blue filledDefault/neutral progressing actionSave, Send, Add, Next, Invite
Green filledExplicit positive confirmationApprove
Dark red filled/outlinedDestructive or negative confirmationReject, Archive, Offboard, Confirm rejection
Gray outlineCancel / secondary / dismissCancel (always present, always leftmost)

One filled blue primary per view. Green is reserved specifically for the moment of approving something — don't reach for it as a generic "positive" button color elsewhere, or it stops signaling "this approves something" at a glance. Disabled state: opacity: 0.5, cursor: not-allowed (or "default"), never hidden.

Row-level actions — always a dropdown, never inline buttons

Every list/table with per-row actions uses the same pattern: a small icon-only "⋯" trigger that opens a position: fixed panel positioned off the trigger's getBoundingClientRect(), flipping above the trigger if it would overflow the viewport bottom. Panel: white bg, 1px solid #e1e6ec border, 8px radius, shadow, items divided by 1px solid #f1f4f6. Closes on outside click via a mousedown listener scoped to a data-*-menu wrapper attribute. Examples: DocActionsMenu, the vendor portal's ReqActionMenu, the Vendor Requirements OverflowMenu.

Always "⋯", never a text label like "Actions ▾" — the two existed side by side until both were unified on "⋯", so if you spot a text-label trigger anywhere, that's drift, not an intentional variant. Don't put row actions as separate inline buttons either — always route through this one pattern.

Viewing a submitted document or template — one path, not several

A filename (submitted document, uploaded template) is always plain, non-interactive text — never a link, never paired with its own inline "View →" button. The "⋯" menu's "View" / "View template" item is the single way to open it. Standardized on the Vendor Requirements version — one path is easier to notice and to keep consistent than three.

Exception: input types where the displayed text is the submitted value, not a pointer to one — license-number and text (free-text response) — make that text itself the clickable affordance instead, and the menu drops its own "View" entry for these two types so there's still only one path, just a different one.

Modals — always before an action, never after

A modal gates something that hasn't happened yet — either it needs a yes/no decision (Archive, Offboard, Reject) or it needs to collect required input a toast can't (a reason, a date, a file). Once the action has actually happened, feedback is a toast, never a second modal to click through. If you catch yourself writing a "success" modal, it should almost always be a toast instead.

position: fixed; inset: 0; background: rgba(17,36,58,0.5–0.6), centered flex, white card (11px radius), typical width 420–500px. Title 15px/600, subtitle 12.5px muted, Cancel + primary action right-aligned at the bottom, Cancel always first (left of the primary button).

Confirmation dialogs (destructive or hard-to-reverse actions)

Same modal shell, plus an explicit block before the action buttons stating plainly what happens and whether it's reversible. Two tiers, and it matters which one you reach for:

Toasts — always after an action, lightweight and non-blocking

.toast — fixed top-center at top: 78px (near eye level, not bottom-of-page), auto-dismiss after ~3s. One toast at a time, not a queue. Wraps instead of forcing one long line (max-width: 440px).

Colored by outcome, matching the pill palette exactly, inferred from the message text via toastClass() in src/lib/toast.ts:

If you add a new showToast() call, phrase the message so it naturally matches one of these patterns rather than fighting the heuristic — don't add a 4th "type" parameter unless the regex approach actually breaks down.

Hover & tooltips

Two different things currently both get called "hover," worth keeping distinct:

Discoverability affordance: plain hover with no visual cue isn't obvious a tooltip exists. Anywhere a title="..." carries real explanation, append a small ⓘ (InfoDot component). Put the title on a wrapping element around both the pill/text and the dot — not on the pill alone — so the dot sits outside the pill's colored background.

Audit logging

Every meaningful write (create/edit/remove, status change, override) writes a row to audit_logs with a human-readable label in metadata, not just an action string. For edits specifically, log a diff (field, from, to) rather than just "X was edited" — see logPolicyChange in admin/policies/page.tsx. Rapid repeated changes to the same field should debounce the log write and only record the net change from baseline to final value.

Role gating

Two roles exist app-wide: coordinator and administrator (src/lib/adminRole.ts). Coordinator gets read access to everything Administrator can configure, with write controls hidden (not just disabled) and a small "👁 View only" badge next to the page title. Never fully block a page for Coordinator if the content itself is safe to see — hide the specific write actions instead.

Known inconsistencies (flagged, not yet fixed)

Real findings from auditing the app against the rules above — worth fixing, but scoped separately rather than bundled into whatever else is being built when they're noticed.

Document-level and vendor-level status don't share one consistent palette

They're supposed to be the same kind of thing (a status pill), but the color vocabulary diverges, and amber is the worst case: at the document level, amber (not-submitted) means "the vendor hasn't done their part yet." At the vendor level, amber (action_required) means the opposite — "the district needs to review something." Same color, opposite party responsible. Recommendation: reserve amber exclusively for "the district/admin needs to act," and use a distinct color (slate reads as closest to what's already used for "nothing here yet" elsewhere) for "waiting on the vendor, nothing submitted yet." Not fixed yet — would touch every status pill in the app, scope it as its own pass.

A handful of pages redundantly re-declare the Outfit font

body already sets font-family: 'Outfit', system-ui, sans-serif globally in globals.css, so it's inherited everywhere by default. /, /demo, /backstage, and /onboarding each redeclare fontFamily: "'Outfit', sans-serif" on their own root div anyway. Harmless, but dead weight — worth deleting next time one of those files is touched for something else.

Content / copy conventions