Clearline · Internal
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.
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.
| Role | Hex | Used for |
|---|---|---|
| Navy (brand/headers) | #0C2D4A | Sidebar background, page titles, masthead |
| Accent blue | #0E6BA8 | Primary buttons, links, focus states |
| Accent blue hover | #145E82 | Primary button hover |
| Ink (body text) | #14233a | Primary text |
| Muted text | #5a6b7e / #6b7a8d | Secondary text, labels |
| Faint text | #8593a4 | Hints, metadata, placeholders |
| Border | #dde3ea / #e1e6ec | Default hairlines |
| Border, subtle | #f1f4f6 | Row dividers inside cards |
| Page background | #f4f6f8 | Body background |
| Card background | #f8f9fb | Table headers, secondary surfaces |
| Danger | #8f2d1f (text/border), #fdf5f4 (bg) | Reject, remove, destructive actions |
| Success | #1f9d6b / #1a6b4a | Approved, active, confirmations |
.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.
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 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):
#27500A (green text)#854F0B (amber text), detail panel background #fdf6ec#5a6b7e (neutral slate text) — routes to human review, no suggested action'Outfit', system-ui, sans-serif (set once on body in globals.css)h1): 18px, weight 600letter-spacing: .05em, color #8593a424px 28px15px 28px (or 14px 28px), with paddingRight: 220 (or 180 on narrower headers) to clear the fixed UserMenu in the top-right corner — every page header needs this right-padding, or its own content collides with the account menu.| Color | Meaning | Examples |
|---|---|---|
| Blue filled | Default/neutral progressing action | Save, Send, Add, Next, Invite |
| Green filled | Explicit positive confirmation | Approve |
| Dark red filled/outlined | Destructive or negative confirmation | Reject, Archive, Offboard, Confirm rejection |
| Gray outline | Cancel / secondary / dismiss | Cancel (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.
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.
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.
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).
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:
#fdf5f4, border #f0c0b8, text #8f2d1f, prefixed with ⚠..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:
#eef1f4 bg / #14233a text. Anything that isn't clearly success or error.#d4f0e4 bg / #1a6b4a text (same as .pill-green). Matches /added|restored|updated|archived|approved|sent|reactivated|invited|applied|removed|moved|cancelled|uploaded/i.#fce8e4 bg / #8f2d1f text (same as .pill-red). Matches /error|failed/i, checked before the success pattern.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.
Two different things currently both get called "hover," worth keeping distinct:
title="..." attribute — a browser-rendered tooltip. Cheap, zero-JS, but renders in the OS's own font, outside the app's control. Fine for a short, low-stakes hint. Don't use it for anything meant to read as part of the product's voice.:hover styling — regular CSS, renders in Outfit, right tool for interactive feedback.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.
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.
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.
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.
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.
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.
letter-spacing to read intentionally, not by accident).