The FollowQue Design System
One visual language for every screen of the Facebook sales workspace. Every token below is already implemented in src/styles.css and every component ships from @/components/fq/primitives, so engineering implements by composing — never by restyling.
01
Design philosophy
FollowQue sits three inches from a live customer conversation, eight hours a day. The interface earns its place by staying quiet and being instantly legible in peripheral vision.
Five principles
- 1. Quiet by default. Neutral surfaces, hairline borders, one shadow layer. Color is information, not decoration.
- 2. Emphasis is rationed. Only five moments may raise their voice: overdue follow-up, hot lead, AI generating, lead captured, GHL synced.
- 3. Native rhythm, not mimicry. Messenger's density and calm — our own palette and typography.
- 4. One glance, one answer. Every card resolves a single question: who needs me, what do I say, when do I return.
- 5. Fast beats fancy. Motion confirms, never performs. Nothing exceeds 260ms.
Emphasis budget
Why: A fixed emphasis budget prevents the slow drift where every team adds "just one more highlight" until nothing stands out. Anything not on this list renders in neutral tones.
Do
- Let whitespace and type weight create hierarchy
- Use one accent per screen region
- Keep the panel scannable at arm's length
Don't
- Gradients, glows, or neon accents
- Colored backgrounds for ordinary rows
- Robot/AI iconography or sci-fi language
02
Color palette
A warm-neutral base carries 95% of the pixels. Six semantic accents carry meaning, and each one has exactly one job.
Neutral foundation
--backgroundPanel canvas--cardRaised surfaces--secondaryChips, hover, tracks--borderHairline separation--foregroundPrimary text--muted-foregroundMeta text--primaryDeep teal — one CTA per view--accentQuiet AI/system tintWhy: The canvas is warm off-white rather than pure white so the panel reads as a distinct surface next to Messenger's cool white without competing with it. Teal is the only brand accent — it is far enough from Facebook blue to never be mistaken for a Facebook control.
Semantic accents — one meaning each
--successCaptured, synced, won--dangerOverdue, failed, destructive--infoSystem explanation, AI notes--warningCaution, expiring, review--hotLead temperature: hot--warmLead temperature: warm--coldLead temperature: cold--waitingAwaiting their replyWhy: Each accent ships with a -soft pair used for fills, so a status never relies on a saturated background behind text. Meaning is also duplicated in a label and a dot shape, so the system survives color blindness and grayscale screenshots.
03
Typography scale
Plus Jakarta Sans for everything human-readable; JetBrains Mono for counts, timestamps, and micro-labels. Two families, six sizes, three weights.
The quick follow-up wins
Display / 22px / 700Panel title, empty-state headlineThe quick follow-up wins
Title / 16px / 700Screen heading, modal titleThe quick follow-up wins
Body strong / 13px / 600Lead name, row titleThe quick follow-up wins
Body / 13px / 400Message text, descriptionsThe quick follow-up wins
Meta / 11.5px / 400Timestamps, helper textThe quick follow-up wins
Micro-label / 10px mono / 500Section eyebrows, countsWhy: A 400px panel cannot afford a ten-step scale — steps that close would read as accidents. Six sizes with a −0.011em global tracking keep dense rows legible while letting weight, not size, carry most hierarchy. Mono micro-labels make section headers recede instantly to peripheral vision.
04
Spacing & radius
A 4px base grid. Inside the panel we mostly use 6 / 10 / 14 — Messenger-like density that never feels cramped.
Spacing scale
2pxIcon-to-dot nudges6pxWithin a row (icon ↔ label)10pxBetween stacked elements in a card14pxCard padding, screen gutters20pxBetween card groups32pxSection break in long viewsRadius scale
6pxCheckbox8pxIcon button, chip12pxInput, button, list row16pxCard, modal999pxBadge, avatar, dotWhy: Radius encodes size, not style: the bigger the surface, the rounder the corner. That single rule keeps nested elements visually concentric and stops components from looking like they came from different kits.
05
Elevation
Three levels. Ever. Shadows are cool-tinted and low-opacity so they read as depth, not as a drop shadow effect.
Level 1 — card
--shadow-cardResting cards, list containers
Level 2 — lift
--shadow-liftHover, primary button, toast
Level 3 — panel
--shadow-panelModal, the panel itself
Why: Borders do the separating; shadows only signal interactivity and layering. Limiting elevation to three steps means a raised element always means something actionable is happening — which is why a hover lift of a single pixel is enough feedback.
07
Inputs, search, dropdowns & choices
Field labels are always visible, never placeholders-as-labels. Focus is a 2px teal ring with a matching border.
Text, search, select
Choices
Why: Checkbox = multiple, radio = one of a set, toggle = an instant setting with no save step. Keeping those three meanings strict removes the moment of hesitation where a user wonders whether a change already took effect.
08
Status system, badges & tags
Lead temperature is the product's core vocabulary. It always renders as dot + word, in the same five colors, everywhere.
Why: Badges carry state the user cannot change by looking; chips carry facts and filters. Because temperature colors never appear on anything else, a single glance down the queue communicates the day's shape without reading a word.
09
Alerts, toasts, modals & empty states
Feedback escalates in exactly three steps: inline alert (contextual), toast (transient confirmation), modal (blocks until decided).
Alerts
AI used the last 12 messages
Synced to GoHighLevel
Follow-up due in 30 minutes
Sync failed
Toast & modal
Toasts sit above the tab bar, live 2.4s, and never carry an action the user must take.
Empty state
No follow-ups due today
Today is clear. Capture the prospect in your open Messenger thread and it will be waiting for you tomorrow.
Why: Empty states always name the next action, because in a sales tool "nothing here" is either a win worth confirming or a gap worth filling — never a dead end.
09a
Empty states
Every empty surface in FollowQue is authored, not defaulted. Copy lives in one registry (src/lib/empty-states.ts) and renders through <Empty spec={…} />, so the same situation reads identically everywhere.
Follow-up queue · first run
No leads captured yet
Open a Facebook conversation and capture the prospect. FollowQue keeps the context so you never re-read a thread to remember where you left off.
Queue · Today bucket
No follow-ups due today
Today is clear. Capture the prospect in your open Messenger thread and it will be waiting for you tomorrow.
Today · nothing needs attention
You're all caught up
Nothing overdue, nothing hot waiting on you. This is what a worked pipeline looks like — check back after your next conversation.
Today / Conversation · no Facebook thread
No conversation selected
Open a Messenger thread in this tab and FollowQue reads the prospect's context automatically. Or pick someone you've already saved.
AI replies · thread too short
No AI suggestions available yet
This thread has fewer than three messages, so a draft would be guesswork. Add a note about what they want and suggestions unlock.
Sync · GoHighLevel not connected
GoHighLevel isn't connected
Connect your location once and every lead you capture lands in your pipeline with the conversation summary attached — no retyping.
Any search field
No matches for that search
Try a first name, a business, or a shorter phrase. Search looks at names, businesses and the notes you've saved.
Today / Queue · Overdue bucket
No overdue follow-ups
Every promise you made has been kept. Keep the streak by setting a date the moment you capture someone.
Today · Hot leads
No hot leads right now
Mark a conversation Hot when a prospect asks about pricing or a demo — they'll surface at the top of Today.
Queue · Upcoming bucket
Nothing scheduled ahead
Set a follow-up date when you capture a lead and it will appear here, sorted by the day you promised.
Queue · Waiting for reply
No threads waiting
Every conversation has the ball in your court. Nothing here means nobody is left hanging.
Queue · Completed
Nothing completed yet
Finished follow-ups land here so you can see the week's work in one place.
Why: Four rules, no exceptions. (1) Name the situation in plain language — never "Error" or "Nothing found". (2) Teach the next move in one sentence that also says why the feature is worth using. (3) Exactly one CTA, and only when there is a real next step. (4) A cleared queue is a win, not a void: those states use the muted success chip instead of the neutral grey one, so "no overdue follow-ups" feels earned rather than blank.
09b
GoHighLevel sync preview
One confidence block shown directly above the sync button. It answers two questions — is this someone I already have, and what is about to change — and nothing else.
Existing contact matched
Existing contact
- Update phone
- Update notes
- Add Facebook profile
- Create follow-up task
Matched on email. Existing details stay — nothing is overwritten without a new value.
No match found
New contact
This will create a new GoHighLevel contact.
- Add contact details
- Add notes
- Add Facebook profile
- Create follow-up task
Do
- State New contact or Existing contact in one line, before anything else
- Name the matched person and company so recognition is instant
- List outcomes as plain verbs — Update phone, Add Facebook profile
- Keep the block quiet: no status color, no accent except the check marks
Don't
- Show field-by-field mapping, merge tools, or conflict resolution
- Expose duplicate management or sync configuration here
- Make the user decide anything before pressing Sync
- Use alarm colors — a match is normal, not a warning
Why: Sync is the moment users fear most: they are writing into their CRM from a browser panel. Showing recognition ("this is Maya at Okafor Creative") plus a four-line outcome list converts that fear into a one-second read. Because the preview only describes consequences and never asks for configuration, confidence goes up while the interface stays smaller, not larger.
09c
Progressive feature discovery
FollowQue teaches itself in the moment a feature becomes useful, then never mentions it again. Every teaching moment lives in one registry (src/lib/discovery.ts) and renders through five components, so nobody can add a sixth kind of interruption.
Hint
One calm line under the thing it explains. No card, no icon shouting. Use for a control the user can already see but might not notice.
“Tap any date to move a follow-up.”
Coach mark
Small anchored card with an arrow, sitting beside a control. Never dims the screen, never chains into a tour. One per screen, ever.
“Narrow the list to just what's due”
Tooltip
On demand only — hover, focus, or long-press. It never appears by itself, so it can never interrupt.
“GoHighLevel is connected”
First-use card
Shown the first time a screen does real work, to set expectations before the user reacts to the output.
“These are starting points, not sends.”
Celebration
Quiet acknowledgement of a first milestone, at the size of an alert. No confetti, no full screen, no sound.
“Your first contact is in GoHighLevel.”
Governance — enforced by DiscoveryProvider
- One at a time. Never two teaching surfaces on screen together. The lower priority waits.
- Two per session. After two, the rest wait for another day. Nobody is taught six things at once.
- Once is once. Seen, dismissed, or acted on — it never returns. No 'tips' setting to police.
- Doing beats reading. Use the feature and its lesson retires silently, unshown.
- Dismissable in one tap. Every hint and coach mark has a close affordance ≥ 24px, and Escape closes it.
- Never blocks. No overlays, no dimming, no 'next' chains. The screen stays usable underneath.
- Quiet celebration. Only the five emphasis-budget moments celebrate, once, at the size of an alert.
- Guidance is a preference. Full, Low or Off. Low teaches each major workflow once, then goes quiet for it.
- Completion, not dismissal. Closing a hint hides it; only finishing the action marks a workflow learned.
The registry
| Kind | Surface | Fires when | Copy |
|---|---|---|---|
| coach | Follow-up queue · filter row | Second visit to the queue, once there are more than four leads | Narrow the list to just what's due |
| hint | Follow-up queue · under the first row | User has opened the queue but never changed a follow-up date | Tap any date to move a follow-up. |
| hint | Today · waiting for reply | A thread has been quiet for seven days | FollowQue nudges you when a thread goes quiet for 7 days. |
| firstUse | Reply drafts · above the first draft | First time drafts are generated | These are starting points, not sends. |
| firstUse | Sync to GoHighLevel · above the match preview | First time the sync preview is shown | We check for an existing contact first. |
| celebration | Capture · after saving | First lead ever saved | First lead saved. |
| celebration | Queue · after the first date change | First follow-up date the user sets themselves | That one won't slip. |
| celebration | Sync to GoHighLevel · after the first successful save | First contact saved to GoHighLevel | Your first contact is in GoHighLevel. |
| tooltip | Header · GoHighLevel status dot | Hover, focus, or long-press — never automatic | GoHighLevel is connected |
Do
- Teach one thing, on the screen where it already matters
- Retire a lesson silently the moment the user does the thing
- Keep every hint closable in one tap, and with Escape
- Celebrate only the five emphasis-budget moments, once each
Don't
- Chain coach marks into a product tour
- Dim the screen or block the control being explained
- Show a hint while a screen is still loading in
- Re-teach on a later session because engagement dipped
Why: Discovery is charged against the same emphasis budget as everything else: at most one teaching surface on screen, at most two per session, and never a second time. A hint that returns is an interruption; a hint that appears once, in the right moment, reads as the product being helpful. Because relevance is declared by the screen and the budget is enforced centrally, no single feature team can spend everyone else\'s attention.
10
Loading & progress
Five named loading states, one per surface, all in src/components/fq/loading.tsx. Skeletons mirror the shape of the content that is coming; bars carry determinate work; a spinner survives in exactly one place.
Loading Messenger — <MessengerLoading />
Loading AI — <AiLoading />
Loading follow-ups — <FollowUpsLoading rows={2} />
Loading CRM match — <CrmMatchLoading />
Loading search — <SearchLoading rows={3} />
Progress
Indeterminate — AI drafting
Why: Rules. (1) Skeleton whenever we know the shape; a spinner only where the user pressed a button and is waiting on someone else's system — saving to GoHighLevel. (2) The skeleton copies the real layout: same padding, radii and line count, so nothing jumps when data lands. That absence of reflow is what actually reads as "fast". (3) One quiet mono caption per surface in plain language ("Checking if you already have them"), announced with role="status" so screen readers hear it once. (4) Timing via useLoading(): nothing renders for the first 120ms, so quick loads stay invisible, and once shown a skeleton holds for at least 320ms so it never flickers. (5) Loading is never an emphasis moment — no accent colour, no motion beyond the 1.4s shimmer, which pauses under prefers-reduced-motion.
11
Cards, lists & tables
Four card archetypes cover the whole product. Each one is the same shell — 16px radius, hairline border, 14px padding — differing only by content and, when warranted, a single tone.
Lead card
Queue card — overdue (emphasis moment)
Devon Ricci · 2 days overdue
Promised a pricing breakdown on Tuesday.
AI card
Consultative draft
"Happy to walk through the 40-lead setup — is the bottleneck volume, or keeping up with replies once they land?"
Success card
In your pipeline
Maya Okafor · Qualified
List rows & tables
Why: Lists are the table for a 400px panel: the same data, stacked, with the status column pinned right. Full tables appear only in the web dashboard, where they inherit these exact row heights (44px), hairline dividers, and right-aligned status.
13
Motion rules
Motion exists to explain a change of state. If a user notices the animation itself, it is too much.
Duration & easing tokens
--dur-fast120ms · hover, press, focus--dur-base180ms · view change, reveal--dur-slow260ms · success confirmation--ease-standardcubic-bezier(0.22, 1, 0.36, 1)fq-rise6px up + fade — entering contentfq-popscale 0.82→1 — success onlyfq-shimmer1.4s loop — skeletonsRules
- · Hover lifts 1px and brightens; never scales up.
- · Press scales to 0.985 — a physical confirmation with no travel.
- · Views enter with fq-rise; they never slide horizontally.
- · fq-pop is licensed for exactly two events: lead captured, GHL synced.
- · Nothing loops except skeletons and the indeterminate AI bar.
- · All motion respects prefers-reduced-motion by falling back to opacity.
Why: A single decelerating curve across the whole product makes unrelated components feel mechanically related. Capping everything at 260ms keeps the panel feeling instant during rapid-fire conversation switching, where any transition longer than a blink becomes an obstacle.
14
Icons, illustration & avatars
Lucide, 1.9px stroke, 15–17px in chrome and 13–14px inline. Outline only, never filled, never colored except to mark a status. Every lead status carries one fixed glyph so temperature survives greyscale and color blindness.
Icon set
Why: Active tabs thicken the stroke to 2.4px rather than switching to a filled variant — weight change reads as emphasis without introducing a second icon vocabulary. There is no robot, brain, or spark-burst imagery: AI is signalled by one small sparkle in a muted teal tile, and nothing else.
Status glyphs
Why: One fixed glyph per status, defined once in src/lib/status-icons.ts and used on every surface: Flame for Hot, a rising line for Warm, Snowflake for Cold, Clock for Waiting, a circled check for Won. The second row is the same badges in greyscale — five distinct silhouettes, so nothing depends on colour vision. The glyph is decorative; the word next to it is what a screen reader announces.
Avatars
Why: Initials on a neutral tile with a hairline ring, plus the status glyph in the corner — the same trick a messaging app uses for presence, repurposed for temperature. Photos are optional and cropped to the same circle, so a row's rhythm never changes based on whether an avatar loaded.
15
Accessibility rules
Non-negotiable. These are acceptance criteria, not aspirations: focus-ring behaviour, keyboard navigation per surface, and the contrast pairing every component must clear — measured live from the tokens currently on screen.
Requirements
- · Body text ≥ 4.5:1 contrast; meta text ≥ 4.5:1 (muted-foreground is tuned to pass, not to look faint).
- · Minimum interactive target 32×32px; primary targets 40×40px.
- · Every control is reachable by Tab in visual order; the panel traps focus inside open modals.
- · Focus ring: 2px teal at 60% + matching border, never removed, visible on both themes.
- · Status is never color-only — always a dot plus a word.
- · Icon-only buttons carry an aria-label; toggles expose checked state.
- · Full parity in dark mode, which raises accent lightness rather than reusing light values.
Implementation tokens
focus ringring-2 ring-ring/60 + border-ringmin targeth-8 (32px) / h-10 (40px)hit padding≥ 8px between adjacent targetstext base13px / 1.5 line-heighttracking-0.011em body, -0.02em headingsWhy: Contrast and target size are the two failures that make an all-day tool tiring rather than unusable, and tiredness is what makes a rep stop opening the panel. Treating them as tokens means a compliant component is the easiest one to build.
15a
Focus ring behaviour
One ring, one recipe, every interactive element. Focus is the only affordance a keyboard user has, so it is never removed, never restyled per component, and never softened to look tidier.
Live sample
Tab through the samples below — the ring appears on keyboard focus only (:focus-visible), never on pointer press.
Why: Using :focus-visible rather than :focus means mouse users never see a ring after a click, so the panel stays quiet — while keyboard users always do.
The recipe
ringring-2 ring-ring/60offsetring-offset-2 ring-offset-backgroundinputsring-2 ring-ring/60 + border-ringtrigger:focus-visible onlytransitionbox-shadow 120ms var(--ease-standard)Rules
- · Never
outline: nonewithout a replacement ring in the same rule. - · The ring sits outside the element (offset 2px) so it stays legible on cards, tinted rows and dark mode alike.
- · On inputs the border also switches to --ring so the whole control reads as focused, not just its halo.
- · Rings never animate in size or colour — only opacity/box-shadow, 120ms.
- · Focus is visible inside scrollable rails: scroll the focused chip into view rather than clipping it.
- · Composite widgets (nav bar, tabs, menus) expose one tab stop; arrows move within.
Focus movement on state change
Destructive-free actions
- · Complete / Snooze: focus moves to the next row in the list, not to the top.
- · If the list empties, focus lands on the empty state heading.
Layers
- · Opening a modal or menu moves focus in; closing returns it to the trigger.
- · Esc always exits the topmost layer only.
Async
- · Generating replies keeps focus on the button; results are announced via aria-live=polite.
- · Sync success/failure never steals focus — the Retry button is the next tab stop.
15b
Keyboard navigation rules
A rep working the queue should never need the mouse. Every surface below has a defined key map; anything not listed falls back to plain Tab and Enter.
Key map by surface
Panel shell
- Tab / Shift+Tab
- Moves through header → view content → bottom nav, in visual order.
- Esc
- Closes the topmost transient layer: menu, then modal. Never closes the panel itself.
Bottom navigation
- Tab
- Enters the bar once; the active item receives focus first.
- ← / →
- Moves between destinations without activating (roving tabindex).
- Enter / Space
- Switches view. Focus lands on the new view's heading.
Tabs (segmented) & filter rail
- ← / →
- Selects the previous/next tab immediately — content follows selection.
- Home / End
- Jumps to first/last tab.
- Tab
- Leaves the group and enters the tab panel.
Lead / follow-up cards
- Enter
- Opens the conversation — the card's single default action.
- Tab
- Steps into the row's own actions (Complete, Snooze) after the card itself.
Snooze menu
- Enter / Space / ↓
- Opens the menu and focuses the first preset.
- ↑ / ↓
- Moves between 1 day / 3 days / 1 week.
- Enter
- Applies the preset, closes the menu, returns focus to the trigger.
- Esc
- Closes without changing the follow-up date.
Modal / sheet
- Open
- Focus moves to the modal heading; background is inert.
- Tab
- Cycles inside the modal only — focus is trapped.
- Esc
- Dismisses and restores focus to the element that opened it.
Toast with Undo
- Tab
- Reaches Undo without dismissing the toast; hovering or focusing pauses the timer.
- Enter
- Reverts the action; focus returns to the list position it came from.
Fields, selects, toggles
- Space
- Toggles a checkbox or switch.
- ← / →
- Moves the RadioGroup selection.
- Enter
- Submits the surrounding form's single primary action.
Global principles
Always true
- · DOM order equals visual order — no positive tabIndex anywhere in the panel.
- · One tab stop per composite widget; arrow keys move inside it (roving tabindex).
- · Enter activates the default action, Space activates buttons and toggles.
- · Esc is reserved for dismissal and never triggers a data change.
- · No keyboard shortcut without a modifier — a single letter would collide with typing a reply.
- · Reversible actions apply immediately and offer Undo; we never gate them behind a confirm dialog that traps focus.
Screen-reader contract
Announcements
- · Toasts render in an aria-live=polite region; the Undo button is a real button inside it.
- · Sync failure and overdue counts use aria-live=assertive — the only two assertive regions in the product.
- · Skeletons expose aria-busy=true on the container they replace, not per shimmer block.
- · Icon-only controls carry aria-label; decorative icons are aria-hidden.
- · Tabs use role=tablist/tab/tabpanel with aria-controls; the bottom nav uses nav + aria-current=page.
Do
- Return focus to a sensible neighbour after a row disappears.
- Let Tab reach the Undo affordance before the toast expires.
- Keep arrow-key selection and content in sync in the segmented tabs.
Don't
- Don't add hover-only actions — anything a mouse can do, a key must reach.
- Don't autofocus anything except the first field of an opened modal.
- Don't trap focus in the panel shell; only modals trap.
15c
Required contrast checks per component
Every approved token pairing, measured against the theme you are currently viewing. Text needs 4.5:1, large text and non-text UI (borders, dots, focus rings) need 3:1. Flip the theme toggle to re-run the whole matrix in dark mode.
| Component | Pairing | Required | Measured | Result |
|---|---|---|---|---|
| Measuring tokens… | ||||
Why: Contrast is checked as a token pairing, not as a screenshot: a component built from approved pairings is compliant by construction, which is why a new screen needs no separate audit. If a pairing you need is not in this table, it is not approved yet — extend the system rather than hardcoding a colour.
Contrast Audit
Dev & QA only — not a customer setting