Engineering reference

FollowQue component system

Component reference

The implementation contract for the approved FollowQue design system. Every entry documents an existing component or a documented composition from the prototype — no new features, no new visual language. Tokens and rationale live in the design system; this page is what an engineer types.

  • Emphasis budget: only overdue follow-ups, hot leads, AI generation, lead captured, and GHL sync may raise their voice. Everything else stays neutral.
  • Frame: 400px wide, 850–950px tall. Single column, sticky header and bottom nav, one scroll region between them.
  • Reversibility: destructive-feeling actions use the undo toast instead of a confirmation dialog.

Foundations

Iconography

The canonical icon reference for the Chrome side panel and every future FollowQue surface. One library, one stroke, one meaning per glyph. Nothing here changes the visual language — it records it so two engineers reach for the same icon.

src/lib/specs/iconography.ts · status glyphs: src/lib/status-icons.ts

1 · Icon library

Family
lucide-react — the only approved icon source. No second library, ever.
Style
Outline only. No filled, duotone, brand-colour, or 3D variants.
Stroke width
1.9px default · 2.4px for the active tab and other selected chrome
Geometry
24px source grid, 2px rounded caps and joins, ~2px corner radius
  • One glyph carries one meaning across the whole product; never reuse a glyph for a second concept.
  • Silhouette does the work. Every icon must stay readable at 13px in greyscale.
  • Icons support the label, they do not replace it — except in dense chrome where the control is already familiar (back, more, close).
  • No robot, brain, chip, or spark-burst imagery. AI is one small sparkle, nothing else.
  • Weight, not fill, signals emphasis: an active tab thickens its stroke instead of switching to a solid variant.

2 · Size matrix

PreviewSizeStrokeUseExamples
12px2.4Micro-markers stacked on another element — never standalone.Avatar status corner glyph, checkbox tick, chevron inside a chip
16px1.9The default. Inline with 13px text: buttons, list rows, fields, menus, toasts.Btn leading icon, LeadCard meta row, toast icon, input adornment
20px1.9Standalone icon-only controls and navigation, where the glyph is the only label.Bottom nav tabs, header icon buttons, overflow menu trigger
24px1.6Section-level illustration inside a tile. Reserved for empty states and the sync/AI moment.Empty-state tile, sync success checklist header, onboarding step mark

3 · Placement & spacing

Buttons
16px leading icon, 6px gap to the label, optical -1px left nudge. Icon-only buttons are 20px inside a 32×32 (or 40×40 primary) target.
Navigation
20px, stacked above the tab label with a 3px gap. Active state thickens the stroke to 2.4 and switches colour to the primary token; the glyph never changes shape.
Cards
16px in the meta row, 6px gap, vertically centred to the cap height of the 12.5px meta text. Maximum two glyphs per card so a list of eight cards stays quiet.
Badges
13px, stroke 1.9, -2px left nudge inside the pill, 6px gap to the status word. Always aria-hidden; the word is the label.
Inputs
16px adornment inset 10px from the field edge, muted-foreground. Never place icons on both sides of one field.
Empty states
24px centred in a 44px rounded tile at 12% accent tint, 12px above the headline. Exactly one glyph per empty state.
Toasts
16px leading, aligned to the first text line (not centred vertically), 8px gap. Loading uses the spinner; success, warning, and error use their semantic glyph.
Menus
16px leading, 10px gap, one column of glyphs so labels align. Trailing chevrons only where the item opens a submenu.
Tabs
Icons in tabs are mandatory or absent per group — never mixed inside one tab bar.

4 · Approved inventory — Feedback

Fixed by meaning. These five never appear for anything else.

  • CheckCircle2Success — the thing you asked for is done.Success toast, sync checklist, Won badge
  • AlertTriangleWarning — you can continue, but read this first.Warning alert, expiring connection
  • UnplugError — the action failed and needs a decision.Sync failure, disconnected GoHighLevel
  • InfoInformation — neutral context, no action required.Info alert, hint row, discovery coach mark
  • Loader2In progress — spinning only inside a control; lists use skeletons.Button pending state, sync in flight

4 · Approved inventory — Workflow

The nouns and verbs of a Facebook sales day.

  • SparklesAI — a draft the human still owns.AI reply suggestions, rewrite tone
  • CalendarClockFollow-up — a lead with a date attached.Follow-up queue, follow-up picker trigger
  • ClockSnooze / waiting — time you chose to let pass.Snooze menu, Waiting badge
  • BellNeeds attention — overdue, one of the five loud moments.Today header, overdue group
  • ListChecksCompleted set — a checklist that finished.Sync success checklist
  • CheckCompleted item — a single done thing.Mark done, selected option
  • InboxInbox zero / no leads.Empty states
  • RotateCcwUndo — reverse the last action.Undo toast

4 · Approved inventory — Objects

People, places, and systems the panel talks about.

  • UserPlusLead — a new contact being captured.Capture view, new-contact sync match
  • UserRoundExisting contact — already in GoHighLevel.Sync mapping preview
  • MessageCircleConversation — the Facebook thread itself.Conversation view, nav tab
  • ScanLineFacebook source — a thread read from the page you are on. FollowQue never draws the Facebook mark.Source row on a lead
  • PlugZapGoHighLevel — the connected account, drawn as a connection, not a vendor logo.Sync view, settings connection card
  • CloudUploadSync — push this record outward.Sync to GoHighLevel button
  • RefreshCwRetry / refresh — same request, again.Recovery actions
  • FlameHot lead — active buying intent. One of the five loud moments.Hot badge only
  • TrendingUpWarm lead.Warm badge only
  • SnowflakeCold lead.Cold badge only

4 · Approved inventory — Chrome

Navigation and utility. Deliberately unremarkable.

  • HomeToday.Bottom nav
  • SearchSearch.Search field, nav
  • SearchXNo search results.Empty state
  • SettingsSettings.Bottom nav
  • ArrowLeftBack one level.Detail headers
  • ChevronRightDrill in.List rows
  • ChevronDownExpand / open a picker.Selects, disclosures
  • MoreHorizontalOverflow menu.Card and header menus
  • PencilEdit in place.AI draft, editable fields
  • CopyCopy to clipboard.AI reply
  • Link2Linked record.GHL contact link
  • ExternalLinkOpens outside the panel.Help, contact in GHL
  • ShieldCheckPermission or privacy statement.Onboarding, settings
  • LifeBuoySupport.Support footer

5 · Colour rules

Icons inherit currentColor. Never pass a color prop or a hex value — colour comes from the parent's text token so light and dark both resolve for free.

StateTokenBehaviour
DefaultcurrentColorIcons inherit the text colour of their container — usually text-muted-foreground in meta rows, text-foreground in buttons. Never set a colour on the SVG itself.
Active / selectedtext-primary + strokeWidth 2.4Nav tabs and selected chips. Colour and weight change together so the state survives greyscale.
Hovertext-foregroundMuted glyph resolves to full foreground with the parent's 120ms colour transition. No scale, no rotation.
Focusring-2 ring-ring/60 on the controlThe ring is drawn on the button, never on the glyph. Icons are never focusable (focusable="false").
Disabledopacity-45 on the controlThe whole control dims together; do not dim the icon separately or it detaches from its label.
Semantictext-success / text-warning / text-danger / text-infoOnly for the five feedback glyphs, and only inside their own alert, toast, or badge.
Light modeSame tokensmuted-foreground clears 4.5:1 on background, card, and tinted status surfaces.
Dark modeSame tokensDark raises token lightness rather than reusing light values; 1.9px stroke is kept because thinner strokes bloom on dark surfaces.

6 · Accessibility checklist

  • Decorative iconsAny glyph paired with visible text is decorative: aria-hidden="true" and focusable="false". A status badge announces "Hot", never "Flame, Hot".
  • Informative iconsAn icon that is the only carrier of meaning needs a text alternative on the nearest interactive or semantic element — aria-label on a button, or a visually hidden span.
  • Required labelsEvery icon-only control carries an aria-label written as the action ("Snooze follow-up"), not the glyph ("clock").
  • Screen readersNever announce state through a glyph alone. Loading uses aria-busy plus a live region; success uses the toast text, not the check.
  • Touch targetsMinimum 32×32px, 40×40px for primary and nav targets, with ≥8px between adjacent targets — the glyph stays 16–20px inside that box.
  • ContrastIcons are non-text UI: ≥3:1 against their background, and ≥4.5:1 when they act as text (a glyph replacing a word). Verified by the dev Contrast Audit in both themes.

7 · Chrome side-panel guidelines

  • 400px wide: one glyph per row of chrome. If a row needs three, the row is doing too much.
  • Density budget — at most six icons visible in a single viewport of the scroll region, excluding the fixed nav.
  • Recognition beats novelty: reuse the glyph the user already learned elsewhere in the panel rather than a more literal one.
  • Never use an icon purely for decoration inside a card; empty states are the only illustrative surface.
  • Consistent spacing: 6px icon↔label inline, 8px in toasts, 10px in menus. These three numbers cover every case.
  • At narrow widths, labels truncate before icons shrink — a 16px glyph next to a truncated label still reads faster than a 12px one next to full text.
  • Icons never wrap to their own line; a glyph and its label are one flex row with shrink-0 on the glyph.

8 · Usage examples

Buttons

Correct

<Btn><CloudUpload size={16} aria-hidden /> Sync to GoHighLevel</Btn>

Incorrect

<Btn><CloudUpload size={20} color="#2F6F6A" /> <span>Save</span> <ChevronRight /></Btn>

Menus

Correct

<MenuItem icon={<Clock size={16} aria-hidden />}>Snooze 3 days</MenuItem>

Incorrect

<MenuItem>⏰ Snooze 3 days</MenuItem>  // emoji, no token colour, no size control

Cards

Correct

<span className="flex items-center gap-1.5 text-muted-foreground">
  <CalendarClock size={16} aria-hidden /> Due tomorrow
</span>

Incorrect

<CalendarClock size={16} className="text-warning" /> // colour reserved for real warnings

Toasts

Correct

<Toast tone="success" icon={<CheckCircle2 size={16} aria-hidden />}>Lead captured</Toast>

Incorrect

<Toast tone="success" icon={<PartyPopper size={24} />}>Lead captured 🎉</Toast>

Status badges

Correct

<StatusBadge status="Hot" /> // glyph aria-hidden, word is the accessible name

Incorrect

<span className="text-danger"><Flame aria-label="Hot" /></span> // colour-only + doubled name

Empty states

Correct

<Empty spec={emptyStates.inboxZero} /> // one 24px glyph in a tinted tile

Incorrect

<img src="/illustration-robot.png" /> // no mascots, no raster art, no second library

Navigation

Correct

<Tab icon={<Home size={20} strokeWidth={active ? 2.4 : 1.9} aria-hidden />} label="Today" />

Incorrect

<Tab icon={active ? <HomeFilled /> : <Home />} /> // fill switch = second vocabulary

9 · Common mistakes to avoid

  • Introducing a second icon library (Heroicons, Font Awesome, Material) for one missing glyph — compose from Lucide or use a word.
  • Emoji as an icon. They render differently on every OS and defeat token colour.
  • Colouring a glyph outside the five feedback tones, or tinting an icon to make a card look livelier.
  • aria-label on a glyph that already sits next to its word — screen readers then read it twice.
  • Filled variants for active states instead of the 2.4 stroke.
  • Two glyphs for one meaning (CalendarDays and CalendarClock both used for follow-up).
  • Shrinking a glyph below 12px to fit a tight row; remove the glyph instead.
  • Vendor logos for Facebook or GoHighLevel inside the panel — the neutral ScanLine and PlugZap glyphs are the approved stand-ins.
  • Spinners in place of skeletons for content loading; spinners belong inside a control only.

Engineering utility

Props playground

Toggle the documented props of an approved component and watch the visual and interaction state change, in either theme. Only real props are exposed — no arbitrary styling, no new variants, and nothing here ships in the customer-facing side panel.

Props

aria-busy + aria-disabled, label swapped. Stays focusable.

Preview · 400px panel width

The real Btn component. `loading` is not a prop — a busy button is a disabled button whose label changes.

Full spec for Button

Usage

<Btn
  variant="primary"
  size="md"
>
  Sync to GoHighLevel
</Btn>

Interaction contract

Follow-up date picker keyboard map

FollowUpPicker renders presets plus DateGrid, a month grid with a roving tabindex. No time of day, no recurrence — one date, chosen quickly.

Keyboard map

  • Enter / Space (on trigger)Opens the follow-up popover.
  • Arrow Left / RightMove one day back or forward.
  • Arrow Up / DownMove one week back or forward.
  • Home / EndFirst or last day of the focused week.
  • Page Up / Page DownPrevious or next month.
  • Shift + Page Up / DownPrevious or next year.
  • Enter / Space (on day)Select the focused date and close.
  • EscapeClose without changing the date.
  • Tab / Shift + TabMonth arrows, then out of the popover. No trap.

Opening focus

  • Focus lands on the selected date when it is still selectable.
  • With no selection, focus lands on today.
  • If the selected date is unavailable, focus falls to the nearest valid date.
  • The month scrolls into view so the grid is never clipped by the bottom nav.

ARIA

  • role="grid" with rows and gridcell buttons; accessible name "Choose follow-up date, <month year>".
  • Each day is labelled with its full date ("Tuesday, August 11, 2026"); unavailable days append ", unavailable".
  • aria-selected marks the chosen date, aria-current="date" marks today, aria-disabled marks past days.
  • A polite live region announces only the focused date, so the calendar is not re-read on every key press.
  • Selected, today and disabled states each carry a non-colour cue: fill plus ring, a dot, and a strike-through.

Focus restoration

  • Escape returns focus to the control that opened the picker.
  • Selecting a date returns focus to the follow-up date trigger via its data-fq-focus key, after the queue reorders.
  • Clicking outside restores focus only when focus would otherwise fall to the document body.
  • Motion is limited to fq-fade; keyboard behaviour is identical under prefers-reduced-motion.

Verification

  • Keyboard-only pass in light, dark and reduced-motion: day, week, month and year moves all keep focus inside the grid.
  • Enter and Space both select; the lead moves to the correct Upcoming position and the undo toast appears.
  • Escape leaves the date unchanged; past days are announced as unavailable and ignore Enter.
  • Tab exits the popover in order with no keyboard trap and no focus lost to the body.

Primitives

Btn

The only button in the product. One primary action per screen; every other action demotes to ghost or secondary so the panel stays quiet.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "soft" | "danger""secondary"Emphasis level. Primary is budgeted: one per screen.
size"sm" | "md" | "lg" | "icon""md"sm = 32px row actions, md = 40px default, lg = 48px screen commit, icon = 36px square.
fullbooleanfalseStretches to the container width — the default for panel commit actions.
disabledbooleanfalseNative disabled; renders at 50% opacity and drops pointer events.
...restButtonHTMLAttributes<HTMLButtonElement>onClick, type, aria-*, form attributes pass straight through.

TypeScript interface

type BtnVariant = "primary" | "secondary" | "ghost" | "soft" | "danger";
type BtnSize = "sm" | "md" | "lg" | "icon";

export interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: BtnVariant; // default "secondary"
  size?: BtnSize;       // default "md"
  full?: boolean;       // default false
}

Variants

primarysecondaryghostsoftdanger

Default: variant="secondary" size="md" full={false}

Visual states

  • DefaultRadius xl, 600 weight, 12–14px label, icon at 13–15px on the left.
  • Disabledopacity .5, pointer-events none. Never hide an action — disable it.
  • LoadingKeep the label, swap the icon for IndeterminateBar below the button. No spinners.

Interaction states

  • Hoverprimary brightens 8%, secondary fills with secondary, ghost gains a secondary wash.
  • Activescale .985 for 120ms — the only scale transform allowed.
  • Focus-visible2px ring in --ring with a 2px background offset.

Accessibility

  • Icon-only (size="icon") buttons require aria-label.
  • Minimum hit target 32px; use size="md" or larger for primary tap targets.
  • Never convey the action by colour alone — danger buttons still read 'Delete'.

Example usage

<Btn variant="primary" size="lg" full onClick={generate}>
  <Sparkles size={15} /> Generate replies
</Btn>
<Btn variant="ghost" size="sm" onClick={snooze}>
  <Moon size={13} /> Snooze
</Btn>

Chrome side-panel constraints

  • At 400px, two side-by-side buttons max; three only at size="sm" with icon + one word.
  • Commit actions sit at the end of the scroll flow, not fixed — the bottom nav already owns that edge.

Primitives

StatusBadge

Single source of truth for lead temperature. The only place colour carries meaning at full strength.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
status *LeadStatusHot | Warm | Cold | Waiting | Won. Picks both the colour pairing and the fixed glyph from src/lib/status-icons.ts.
mutedbooleanUnselected presentation in a picker — neutral tokens instead of dimming, so contrast holds. The glyph stays.
classNamestringLayout only — never override the status colours or swap the glyph.

TypeScript interface

export type LeadStatus = "Hot" | "Warm" | "Cold" | "Waiting" | "Won";

export interface StatusBadgeProps {
  status: LeadStatus;
  muted?: boolean;
  className?: string;
}

// One glyph per status, used by every surface. Never import an icon
// for a status directly — always read it from this registry.
export const statusIcons: Record<LeadStatus, { icon: LucideIcon; meaning: string }> = {
  Hot:     { icon: Flame,       meaning: "active buying intent" },
  Warm:    { icon: TrendingUp,  meaning: "engaged, moving forward" },
  Cold:    { icon: Snowflake,   meaning: "gone quiet" },
  Waiting: { icon: Clock,       meaning: "ball in their court" },
  Won:     { icon: CheckCircle2,meaning: "closed" },
};

export const STATUS_ICON_SIZE = 13;
export const STATUS_ICON_STROKE = 1.9;

Variants

HotWarmColdWaitingWon

Default: none — status is required

Visual states

  • DefaultPill, 1px border at 25% status colour, soft tint fill, 13px glyph in currentColor at 1.9 stroke, 6px gap, 11px 600 label.
  • MutedNeutral border/secondary fill/muted-foreground ink. The glyph is unchanged, so an unselected option is still identifiable.
  • GreyscaleWith colour removed the five badges stay distinct on silhouette alone: flame, rising line, snowflake, clock, circled check. No two share an outline.
  • Avatar markerAvatar renders the same glyph in a card-surfaced corner chip (16px, 20px at size lg) in the status ink — never a bare colour dot.
  • DenseInside list rows the badge shrinks nothing — it truncates the name instead.

Interaction states

  • StaticNon-interactive. Status changes happen in the conversation view, not on the badge.

Accessibility

  • The status word is always rendered. The glyph is aria-hidden decoration — it adds a second visual channel, never the only one.
  • Decorative-icon rule: every status glyph renders with aria-hidden="true" and focusable="false", carries no aria-label and no SVG <title>, and no tooltip repeats it. The badge takes its accessible name from its visible text alone.
  • Expected accessible names, exactly: "Hot", "Warm", "Cold", "Waiting", "Won". Never "Flame, Hot", "Hot status, flame icon" or "Hot, Hot".
  • Interactive exception: in the Capture status picker the pill is wrapped in a <button> whose accessible name is still just the status word, with selection exposed via aria-pressed and grouping via role="group" + aria-labelledby. The action never restates the icon.
  • Names are identical in light mode and dark mode — theming changes colour, never labels.
  • Automated assertion: src/lib/status-badge-a11y.ts scans [data-status-badge] nodes, computes the accessible name and flags any non-hidden glyph, SVG <title>, icon aria-label or name mismatch. The dev Contrast Audit toolbar reports it live and exposes window.__fqStatusBadgeAudit() for headless runs.
  • Verification (headless Chromium): Today, Queue, Capture, Conversation detail, sync preview and the documentation routes — 19 documentation badges plus 20 in-panel badges across light and dark mode returned 0 findings; all five statuses observed, greyscale silhouettes unchanged.
  • Status is never colour-only: shape + word carry it, so greyscale, protanopia, deuteranopia and tritanopia all read correctly.
  • All five colour pairings pass AA on both canvases — verified in light and dark.
  • Where a badge is the label of an interactive option (Capture status picker), the button owns aria-pressed and the standard 2px focus ring sits on the pill, not on the glyph.

Status mappings

Hot
Flame · bg-hot-soft / text-hot / border-hot/25 — buying signal, deserves emphasis
Warm
TrendingUp · bg-warm-soft / text-warm — engaged, no urgency
Cold
Snowflake · bg-cold-soft / text-cold — dormant, quiet by design
Waiting
Clock · bg-waiting-soft / text-waiting — ball in their court
Won
CheckCircle2 · bg-won-soft / text-won — closed, also the success tint

Example usage

<StatusBadge status={lead.status} />

Chrome side-panel constraints

  • Right-aligned in a flex row with `min-w-0` on the name so long names truncate, badge never wraps.
  • Icon adds ~19px to the pill. Verified at 320px, 400px and 480px panel widths — the name truncates first, the badge never wraps or clips.
  • Surfaces audited for one consistent glyph: Today (hot list, overdue, lead rows), Queue, conversation detail and its status picker, Capture picker, search results, AI summary header and the GoHighLevel sync preview Status row.
  • Do not add a status glyph to semantic feedback (alerts, toasts, sync success/failure) — those keep their own icons so a badge is never confused with a system message.

Primitives

Field / TextInput / TextArea

Labelled text entry for capture and settings, including the 'Auto-detected' affordance that tells the user which values FollowQue read from the Facebook thread.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
label *stringField label, 12px 600. Always visible — no placeholder-only fields.
hintstring11px helper below the control.
detectedbooleanfalseShows the mono 'Auto-detected' chip; used when a value came from the page, not the user.
children *ReactNodeThe control — TextInput, TextArea, Select.
TextInput/TextArea ...restInput/TextareaHTMLAttributesvalue, onChange, placeholder, rows, inputMode.

TypeScript interface

export interface FieldProps {
  label: string;
  hint?: string;
  detected?: boolean;
  children: ReactNode;
}

export type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;

Variants

texttextareadetected

Default: text, detected = false

Visual states

  • DefaultCard fill, 1px --input border, radius xl, 13px text.
  • FilledIdentical to default — no state colour for simply having content.
  • ErrorBorder --danger, message in Field hint styled text-danger. Reserve for blocking problems only.
  • Disabledopacity .6, no border change.

Interaction states

  • Focus2px --ring/60 ring plus border --ring. No motion.
  • TypingNo live validation chatter; validate on blur or submit.

Accessibility

  • Field renders a <label> wrapper, so the control is associated without an id.
  • TextArea uses resize-none — grow via rows, not a drag handle, inside the panel.
  • Announce errors with aria-invalid and aria-describedby when you add validation.

Example usage

<Field label="Phone" detected hint="Read from her Messenger profile">
  <TextInput value={phone} onChange={(e) => setPhone(e.target.value)} inputMode="tel" />
</Field>

<Field label="Context note">
  <TextArea rows={4} value={note} onChange={(e) => setNote(e.target.value)} />
</Field>

Chrome side-panel constraints

  • One column always — never place two fields side by side at 400px.
  • Keyboard overlay is not a concern in a side panel, but keep forms under ~7 fields per screen.

Primitives

Select

Native single-choice control for short, known lists (tone, cadence, pipeline stage).

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
children *ReactNode<option> elements.
...restSelectHTMLAttributes<HTMLSelectElement>value, onChange, disabled.

TypeScript interface

export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;

Variants

default

Default: default

Visual states

  • DefaultInput shell + trailing ChevronDown, 14px, muted.
  • Disabledopacity .6, chevron stays.

Interaction states

  • FocusSame ring as TextInput.
  • OpenNative OS menu — deliberate: it escapes the 400px panel clip.

Accessibility

  • Native element keeps keyboard and screen-reader behaviour for free.
  • Wrap in Field for the label.

Example usage

<Field label="Reply tone">
  <Select value={tone} onChange={(e) => setTone(e.target.value)}>
    <option value="direct">Direct</option>
    <option value="consultative">Consultative</option>
  </Select>
</Field>

Chrome side-panel constraints

  • Prefer Select over a custom popover: a custom listbox inside a 400px panel clips or needs a portal.
  • Above ~8 options, use SearchInput + ListRow instead.

Primitives

Checkbox / RadioGroup / Toggle

Checkbox for independent opt-ins, RadioGroup for one-of-few with description text, Toggle for instantly-applied settings.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
checked *booleanControlled state (Checkbox, Toggle).
onChange *(v: boolean) => voidFires with the next value.
label *stringVisible label.
descriptionstringToggle only — 11px secondary line.
value / optionsT / { value: T; label: string; description?: string }[]RadioGroup selection and choices.

TypeScript interface

export interface CheckboxProps {
  checked: boolean;
  onChange: (v: boolean) => void;
  label: string;
}

export interface ToggleProps extends CheckboxProps {
  description?: string;
}

export interface RadioGroupProps<T extends string> {
  value: T;
  onChange: (v: T) => void;
  options: { value: T; label: string; description?: string }[];
}

Variants

checkboxradiotoggle

Default: checkbox

Visual states

  • Unchecked--input border, card fill.
  • CheckedCheckbox/Toggle fill --primary; radio row tints accent/60 with a primary border.
  • Disabledopacity .6, no pointer events.

Interaction states

  • HoverRadio and toggle rows wash to secondary.
  • PressToggle knob travels 200ms on the standard ease; nothing else moves.
  • Focus-visibleRing on the whole row, not the 18px control.

Accessibility

  • RadioGroup sets role=radiogroup / role=radio / aria-checked.
  • Toggle applies immediately — no Save button — so it must be reversible in one tap.

Example usage

<Toggle
  checked={overdueAlerts}
  onChange={setOverdueAlerts}
  label="Overdue follow-ups"
  description="Premium white label"
/>

<RadioGroup
  value={style}
  onChange={setStyle}
  options={[{ value: "direct", label: "Direct", description: "Answers, then asks." }]}
/>

Chrome side-panel constraints

  • Full-width rows with the control on the right read best at 400px; never inline-wrap a control mid-sentence.

Primitives

SearchInput / Chip / Avatar

Supporting atoms: filtering the queue, showing tags and metadata, and identifying a person without loading a Facebook image.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
SearchInput ...restInputHTMLAttributes<HTMLInputElement>value, onChange, placeholder. Leading Search icon is built in.
Chip.children *ReactNodeTag or metadata label.
Chip.onClick() => voidPresence switches the element from span to button.
Avatar.initials *string1–2 letters derived from the lead name.
Avatar.size"sm" | "md" | "lg""md"32 / 40 / 56px.
Avatar.statusLeadStatusAdds the status dot, ringed in --card.

TypeScript interface

export type SearchInputProps = InputHTMLAttributes<HTMLInputElement>;

export interface ChipProps {
  children: ReactNode;
  onClick?: () => void;
  className?: string;
}

export interface AvatarProps {
  initials: string;
  size?: "sm" | "md" | "lg"; // default "md"
  status?: LeadStatus;
}

Variants

searchchip: static | actionableavatar: sm | md | lg

Default: Chip static, Avatar size="md"

Visual states

  • DefaultChip: secondary fill, muted text. Avatar: secondary circle, ring-border, mono-ish initials.
  • With statusAvatar dot uses the same five status colours as StatusBadge.

Interaction states

  • HoverActionable chips darken text to foreground; static chips do nothing.
  • FocusSearchInput uses the shared input ring.

Accessibility

  • Never render an Avatar as the only identifier — the name is always adjacent.
  • Give SearchInput an aria-label when there is no visible label.

Example usage

<SearchInput placeholder="Search leads" value={q} onChange={(e) => setQ(e.target.value)} />
<Avatar initials={lead.initials} size="sm" status={lead.status} />
<Chip>{tag}</Chip>

Chrome side-panel constraints

  • Avatars stay initials-only — no remote Facebook avatars, which avoids CSP and privacy issues in MV3.

Primitives

Tabs (segmented) & Filter rail

Two documented switching patterns: a 3-up segmented control for reply styles, and a horizontally scrolling filter rail for queue buckets.

src/components/fq/views/RepliesView.tsx, QueueView.tsx

Props

PropTypeDefaultDescription
value *TSelected tab id.
onChange *(v: T) => voidSelection callback.
items *readonly { id: T; label: string }[]3 items max for segmented; unbounded for the rail.
variant"segmented" | "rail""segmented"Segmented for mutually exclusive content, rail for filtering a list.

TypeScript interface

export interface TabsProps<T extends string> {
  value: T;
  onChange: (v: T) => void;
  items: readonly { id: T; label: string }[];
  variant?: "segmented" | "rail"; // default "segmented"
}

Variants

segmentedrail

Default: segmented

Visual states

  • Segmented idleTrack bg-secondary p-1 rounded-xl; inactive labels muted, no fill.
  • Segmented activebg-card, shadow-card, foreground text — a raised slab, no accent colour.
  • Rail activebg-accent + border-primary/30 pill; inactive is card with border.

Interaction states

  • HoverInactive label goes muted → foreground.
  • PressContent swaps with fq-rise (140ms); the tab itself does not animate.
  • OverflowRail scrolls horizontally with fq-scroll and edge-to-edge negative margins.

Accessibility

  • role="tablist" on the container, role="tab" + aria-selected on each control, and aria-controls pointing at the panel.
  • Left/right arrow keys move selection; Tab moves out of the group.

Status mappings

Queue rail
All · Overdue · Today · Upcoming · Waiting for Reply · Completed
Replies segmented
Direct · Consultative · Friendly

Example usage

const styles = [
  { id: "Direct", label: "Direct" },
  { id: "Consultative", label: "Consultative" },
  { id: "Friendly", label: "Friendly" },
] as const;

<Tabs value={style} onChange={setStyle} items={styles} />

Chrome side-panel constraints

  • Never exceed three segments at 400px — a fourth truncates the label.
  • Rails must bleed to the panel edge (-mx-4 px-4) so the last chip reads as scrollable.

Primitives

Status badge — compact & responsive

One badge component serves every surface at every supported side-panel width. Hot, Warm, Cold, Waiting and Won share identical height, padding, radius, type size, icon size and stroke, so a row of mixed statuses reads as one family. There is no icon-only variant, no abbreviated label and no separate compact component: when space runs short we remove surrounding clutter, never badge clarity.

src/components/fq/primitives.tsx · src/lib/status-icons.ts

Props

PropTypeDefaultDescription
status *LeadStatusHot | Warm | Cold | Waiting | Won. Picks both the semantic tone and the fixed glyph.
mutedbooleanNeutral presentation for inactive rows. Uses neutral tokens rather than opacity so contrast holds.
classNamestringLayout hooks only — never geometry or colour overrides.

TypeScript interface

function StatusBadge(props: {
  status: LeadStatus;
  muted?: boolean;
  className?: string;
}): JSX.Element;

const STATUS_ICON_SIZE = 13;   // px, all five statuses
const STATUS_ICON_STROKE = 1.9; // consistent visual weight

Variants

Default (semantic)Muted (neutral)

Default: Default (semantic)

Visual states

  • HotFlame. Danger tone — one of the five surfaces allowed strong emphasis.
  • WarmRising line. Warning tone.
  • ColdSnowflake. Neutral tone; opposite silhouette to Flame.
  • WaitingClock. Info tone. The longest label — spacing is never compressed to fit it.
  • WonCircled check. Success tone; the only enclosed glyph.

Interaction states

  • Inside a pressable rowThe badge is decorative-inert; the focus ring belongs to the surrounding row or card and stays visible at compact widths.
  • Container shrinkThe badge is shrink-0 and whitespace-nowrap. Neighbouring name and situation text truncate first, so the badge never wraps, clips or pushes the card into horizontal scroll.

Accessibility

  • The icon is always paired with the visible status word — no icon-only badges.
  • Icons are aria-hidden and focusable="false", so the accessible name is only the status text ("Hot", "Waiting", …).
  • Meaning survives greyscale, common colour-vision differences and forced-colours mode, because shape and word carry it, not colour.
  • Text and icon contrast verified against every badge surface in light and dark modes.
  • At 200% browser zoom the badge scales with the text and still does not wrap or clip.
  • Compact styling removes no focus affordance: interactive parents keep their visible ring.

Status mappings

Minimum badge size
20px tall × 53.5px wide (Hot / Won), 73.8px wide (Waiting) at 320px
Height
20px fixed (h-5) — identical for all five statuses at 320, 400 and 480px
Icon size
13 × 13px, shrink-0 — never scales between statuses
Stroke width
1.9
Icon-to-label gap
6px (gap-1.5), plus a -2px optical inset on the icon
Padding
8px horizontal; vertical padding replaced by the fixed 20px height for exact baseline parity
Radius
Full pill
Typography
11px / 600 weight / leading-none
Alignment
Icon centre is 0.0px from badge centre in every status at every tested width
Supported widths
320px (minimum), 400px (default), 480px

Example usage

<StatusBadge status="Waiting" />

// Compact row pattern — text yields, badge does not
<div className="flex min-w-0 items-center gap-2">
  <p className="truncate text-[13.5px] font-semibold">{lead.name}</p>
  <StatusBadge status={lead.status} />
</div>

Chrome side-panel constraints

  • Verified at 320 / 400 / 480px in light and dark: badge height 20px, icon 13px, gap 6px, padding 8px and 0.0px icon-centre offset in every instance — zero variance across statuses, widths, themes and views.
  • Covered surfaces: lead-card headers (Today), Follow-Up queue rows, conversation rows, search results, lead-detail metadata, AI summary cards, GoHighLevel sync previews and the /components and /design-system compact examples.
  • No wrapping, clipping or badge-driven overflow found. The only horizontal scroll at 320px is the intentional filter-chip rail.
  • Clutter reduced instead of badges: the queue row action bar (Complete · Snooze · Reschedule) became an even three-column grid with tighter icon gaps, so its labels fit at 320px without touching badge geometry.
  • Greyscale and colour-blind simulation confirm all five statuses stay distinguishable by glyph and word alone.
  • Visual-regression snapshots for the 320px compact layouts live in tests/a11y/status-badge-320.spec.ts.

Primitives

Follow-up date format

One canonical way to render every follow-up date in FollowQue: Weekday, Month Day. Sales people act on 'which day is that' — abbreviations and numeric dates cost a second of translation, so we never use them.

src/lib/follow-up-date.ts (re-exported from src/lib/snooze.ts)

Props

PropTypeDefaultDescription
formatFollowUpDate(iso) *(iso: string) => stringVisible date: 'Tuesday, August 11'. Adds ', 2027' only when the date is outside the current calendar year.
formatFollowUpFull(iso) *(iso: string) => stringAlways includes the year. Use for screen-reader labels and any confirmation that must stand alone.
formatFollowUpLine(iso)(iso: string) => stringVisible list line: helper word plus the exact date — 'Today · Monday, August 3'. The exact date is always present.
followUpRelative(iso)(iso: string) => string | nullSecondary helper only: 'Today', 'Tomorrow', 'Yesterday', 'N days overdue', otherwise null. Never shown alone.
followUpAria(iso, prefix?)(iso: string, prefix?: string) => stringSpoken label: 'Follow up tomorrow, Tuesday, August 11, 2026'.
daysFromToday(iso)(iso: string) => number | nullWhole-day offset used to derive the helper word. Not for display.

TypeScript interface

// src/lib/follow-up-date.ts
export function formatFollowUpDate(iso: string): string;  // "Tuesday, August 11"
export function formatFollowUpFull(iso: string): string;  // "Tuesday, August 11, 2026"
export function formatFollowUpLine(iso: string): string;  // "Today · Monday, August 3"
export function followUpRelative(iso: string): string | null;
export function followUpAria(iso: string, prefix?: string): string;

Variants

Tuesday, August 11Friday, September 4Monday, January 18, 2027 (outside this year)Today · Monday, August 3 (helper + exact)

Default: Weekday, Month Day

Visual states

  • ExactDefault everywhere: lead cards, queue rows, date-picker triggers and presets, sync summaries, capture confirmation.
  • Helper + exactList meta lines where recency matters — 'Today · Monday, August 3', 'Overdue · Saturday, August 1'. The relative word never replaces the date.
  • With yearAutomatic when the date is not in the current calendar year, so January dates seen in December are never ambiguous.
  • Narrow layoutMeta lines use truncate + whitespace-nowrap; half-width cards drop to 12.5px with leading-snug rather than wrapping mid-date.

Interaction states

  • Picker presetPresets are full-width rows — relative label left ('Tomorrow'), resolved exact date right — so the outcome is visible before the tap.
  • Undo toastConfirmations read 'Follow-up moved to Tuesday, August 11' — identical format to the row it changed.

Accessibility

  • Visible text may abbreviate context, never the date; spoken labels always use the full date including the year.
  • Meta lines that truncate visually carry an .sr-only followUpAria(iso) so nothing is lost to ellipsis.
  • Relative words are announced as part of the sentence ('follow up tomorrow, Tuesday, August 11, 2026'), never as the only cue.

Status mappings

Lead card / queue row
formatFollowUpLine(lead.followUpDate)
Date-picker trigger & presets
formatFollowUpDate(iso) + followUpAria(iso)
Toast / undo confirmation
`Follow-up moved to ${formatFollowUpDate(iso)}`
Sync summary & checklist
formatFollowUpDate(iso); spoken details use formatFollowUpFull(iso)
Screen-reader label
followUpAria(iso) — always the full date with year

Example usage

import { formatFollowUpDate, formatFollowUpLine, followUpAria } from "@/lib/snooze";

<p className="truncate">
  <span aria-hidden>{formatFollowUpLine(lead.followUpDate)}</span>
  <span className="sr-only">{followUpAria(lead.followUpDate)}</span>
</p>

updateUndoable(lead.id, { followUpDate: iso, due: formatFollowUpLine(iso) },
  `Follow-up moved to ${formatFollowUpDate(iso)}`);

Chrome side-panel constraints

  • Banned in product copy and mock data: 'Tue', '8/11', 'Aug 11th', bare 'Tomorrow', and any time-of-day. FollowQue schedules days, not appointments.
  • Locale-aware: toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) keeps weekday–month–day clarity wherever the user is.
  • The longest realistic string, 'Wednesday, September 24', fits the 400px panel at every documented size; do not add time or year suffixes by hand.

Cards & patterns

Card (base)

The surface every content block is built from. All lead, conversation, follow-up, AI, and reply cards are Card + a documented composition — there is no second surface style.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
tone"default" | "alert" | "success""default"alert = overdue/hot (hot border + tint), success = captured/synced. Tone is earned, not decorative.
interactivebooleanfalseRenders a <button>, adds hover lift and focus ring.
onClick() => voidRequired when interactive.
classNamestringPadding/layout overrides only (e.g. p-3 for dense rows).
children *ReactNodeComposition slot.

TypeScript interface

export interface CardProps {
  children: ReactNode;
  tone?: "default" | "alert" | "success"; // default "default"
  interactive?: boolean;                  // default false
  onClick?: () => void;
  className?: string;
}

Variants

defaultalertsuccessinteractive

Default: tone="default", interactive={false}

Visual states

  • Defaultrounded-2xl, 1px border, --card fill, shadow-card, p-3.5.
  • Alertborder-hot/35 + bg-hot-soft/50. Only for overdue promises and hot leads.
  • Successborder-success/30 + bg-won-soft/50. Capture and sync confirmations.
  • LoadingSame shell with Skeleton lines inside — never collapse the card.

Interaction states

  • Hover (interactive)-1px translate, border-foreground/15, shadow-lift, 200ms.
  • ActiveReturns to translate-y-0.
  • Focus-visible2px --ring.

Accessibility

  • Interactive cards are real buttons — Enter/Space work and the whole card is one tab stop.
  • Do not nest a button inside an interactive card; split into a button row below a divider instead.

Example usage

<Card interactive onClick={() => openLead(lead.id)}>…</Card>
<Card tone="alert">…</Card>

Chrome side-panel constraints

  • Cards are edge-to-edge inside px-4 gutters; vertical rhythm is space-y-2 in lists, space-y-5 between sections.

Cards & patterns

LeadCard

Answers 'who needs me now' in one glance: person, business, why they matter, and when they were due.

src/components/fq/views/TodayView.tsx

Props

PropTypeDefaultDescription
lead *LeadFull lead record.
notestringOverrides lead.dueNote for context-specific copy.
onOpen *(id: string) => voidOpens the conversation view.

TypeScript interface

export interface LeadCardProps {
  lead: Lead;
  note?: string;
  onOpen: (id: string) => void;
}

Variants

defaultalert (overdue)

Default: default

Visual states

  • DefaultAvatar md + status dot / name 14px 600 / StatusBadge right / business muted 12px / note 2-line clamp / due in mono uppercase 10px.
  • OverdueWrap in tone="alert" — the only list item allowed to carry a tinted background.
  • LoadingThree Skeleton lines at 60% / 90% / 40% width inside the same shell.

Interaction states

  • HoverCard lift; nothing inside the card changes.
  • PressOpens conversation; the panel body cross-fades with fq-rise.

Accessibility

  • One tab stop per card.
  • The name must never be truncated below ~14 characters — use min-w-0 + truncate on the name row, not the badge.

Status mappings

lead.status
→ StatusBadge + Avatar dot
lead.bucket === 'Overdue'
→ Card tone="alert"
lead.due
→ mono meta line, verbatim from the record

Example usage

<Card interactive onClick={() => onOpen(lead.id)} className="mb-2">
  <div className="flex items-start gap-3">
    <Avatar initials={lead.initials} status={lead.status} />
    <div className="min-w-0 flex-1">
      <div className="flex items-center justify-between gap-2">
        <p className="truncate text-[14px] font-semibold">{lead.name}</p>
        <StatusBadge status={lead.status} />
      </div>
      <p className="mt-0.5 truncate text-[12px] text-muted-foreground">{lead.business}</p>
      <p className="mt-2 line-clamp-2 text-[12px] text-foreground/80">{note ?? lead.dueNote}</p>
      <p className="mt-2 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{lead.due}</p>
    </div>
  </div>
</Card>

Chrome side-panel constraints

  • Max four visible lines of text — anything longer belongs in the conversation view.
  • No horizontal swipe actions: the panel shares gestures with the Facebook page beneath it.

Cards & patterns

ConversationCard

Header block of the active conversation: who this is, current temperature, and where the thread stands.

src/components/fq/views/ConversationView.tsx

Props

PropTypeDefaultDescription
lead *LeadActive lead.
onStatusChange *(status: LeadStatus) => voidRoutes through updateUndoable so the change is revertible.

TypeScript interface

export interface ConversationCardProps {
  lead: Lead;
  onStatusChange: (status: LeadStatus) => void;
}

Variants

default

Default: default

Visual states

  • DefaultAvatar lg, name 18px 700, business + source meta, status row of five selectable pills, last-contact timestamp in mono.
  • No contextIf the Facebook tab is not a conversation, show the EmptyState instead of an empty card.

Interaction states

  • Status pressApplies immediately and fires the undo toast — no confirm dialog.
  • BackLeft chevron returns via the store history stack.

Accessibility

  • The status row is a radiogroup: role=radiogroup / aria-checked.
  • Announce the change through the toast text, not colour alone.

Status mappings

lead.status
→ selected pill, Avatar dot, and downstream queue emphasis

Example usage

<Card>
  <div className="flex items-start gap-3">
    <Avatar initials={lead.initials} size="lg" status={lead.status} />
    <div className="min-w-0">
      <h2 className="text-[18px] font-bold">{lead.name}</h2>
      <p className="text-[12px] text-muted-foreground">{lead.business}</p>
    </div>
  </div>
</Card>

Chrome side-panel constraints

  • The five status pills wrap to two rows at 400px — that is the accepted layout, do not shrink the labels.

Cards & patterns

FollowUpCard

A queue row with its actions attached: open, complete, reschedule.

src/components/fq/views/QueueView.tsx

Props

PropTypeDefaultDescription
lead *LeadQueue item.
bucket *QueueBucketOwning bucket; 'Completed' hides the action row.
onOpen *(id: string) => voidOpens the conversation.
onComplete *(id: string) => voidMoves to Completed with an undo toast.
onReschedule *(id: string) => voidOpens the shared follow-up picker; presets and exact dates run through the same path.

TypeScript interface

export type QueueBucket =
  | "Overdue" | "Today" | "Upcoming" | "Waiting for Reply" | "Completed";

export interface FollowUpCardProps {
  lead: Lead;
  bucket: QueueBucket;
  onOpen: (id: string) => void;
  onComplete: (id: string) => void;
  onReschedule: (id: string) => void;
}

Variants

actionablecompleted (read-only)

Default: actionable

Visual states

  • DefaultCard p-3: Avatar sm, name + StatusBadge, situation line, mono due line, chevron; divider then two ghost sm buttons.
  • CompletedAction row removed; due text reads 'Completed today'.
  • Picker openReschedule holds bg-secondary while the follow-up picker is expanded below the divider.

Interaction states

  • CompleteRow leaves its bucket instantly; the 6s undo toast is the safety net.
  • RescheduleToggles the shared follow-up picker; picking a date moves the lead to Today or Upcoming.
  • HoverGhost buttons wash to secondary; the card itself does not lift (the actions own the surface).

Accessibility

  • Two tab stops per card: the open button and each action.
  • Action labels are icon + word — never icon alone at this size.

Status mappings

Overdue
hot emphasis — the loudest bucket in the product
Today
neutral, primary-tinted count
Upcoming
quiet, mono date
Waiting for Reply
waiting colour, no actions encouraged
Completed
won colour, muted, read-only

Example usage

<Card className="p-3">
  <button onClick={() => onOpen(lead.id)} className="flex w-full items-start gap-3 text-left">…</button>
  {bucket !== "Completed" && (
    <div className="mt-2.5 flex gap-1.5 border-t border-border pt-2.5">
      <Btn variant="ghost" size="sm" full onClick={() => onComplete(lead.id)}><Check size={13} /> Complete</Btn>
      <Btn variant="ghost" size="sm" full onClick={() => setSnoozeFor(lead.id)}><Moon size={13} /> Snooze</Btn>
      <Btn variant="ghost" size="sm" full onClick={() => onReschedule(lead.id)}><Clock3 size={13} /> Today</Btn>
    </div>
  )}
</Card>

Chrome side-panel constraints

  • Three sm ghost buttons is the hard limit for a 400px action row.
  • Buckets render as sections with SectionHeader + count, so a single scroll shows the whole day.

Cards & patterns

AISummaryCard

Compresses the thread into summary, key facts, the live objection, and one recommended action — so the rep never rereads Messenger.

src/components/fq/views/ConversationView.tsx

Props

PropTypeDefaultDescription
summary *string2–3 sentence recap.
facts *string[]3–5 bullets, each a single verifiable detail.
objectionstringQuoted in their words.
nextAction *stringOne recommended action, imperative voice.
loadingbooleanfalseRenders skeleton lines in place of the text.

TypeScript interface

export interface AISummaryCardProps {
  summary: string;
  facts: string[];
  objection?: string;
  nextAction: string;
  loading?: boolean; // default false
}

Variants

defaultloadingempty (not enough context)

Default: default

Visual states

  • DefaultEyebrow label, 12.5px relaxed body, facts as a check list, objection in a quoted block, next action in a soft accent block.
  • LoadingFour Skeleton lines (100/95/80/60%) plus 'Reading the last N messages…' in muted 12px.
  • EmptyEmptyState: fewer than three messages means no summary — say so, do not guess.

Interaction states

  • StaticRead-only. The card offers no edit affordance; corrections happen in the reply draft.
  • RegenerateOptional ghost button in the section header; re-enters the loading state in place.

Accessibility

  • aria-busy="true" while loading.
  • Label the block 'AI summary' so assistive tech distinguishes generated text from the rep's own notes.

Example usage

<Card>
  <Eyebrow>AI summary</Eyebrow>
  {loading ? (
    <div className="mt-2 space-y-2">
      <Skeleton /><Skeleton className="w-[90%]" /><Skeleton className="w-[65%]" />
    </div>
  ) : (
    <p className="mt-2 text-[12.5px] leading-relaxed">{summary}</p>
  )}
</Card>

Chrome side-panel constraints

  • Cap facts at five; at 400px a sixth pushes the recommended action below the fold.
  • Generation is one of the five moments allowed strong emphasis — but only through the primary button and the loading line, never a glow.

Cards & patterns

ReplySuggestionCard

Holds one editable AI draft. FollowQue drafts, the human sends — the card never contains a Send action.

src/components/fq/views/RepliesView.tsx

Props

PropTypeDefaultDescription
style *"Direct" | "Consultative" | "Friendly"Active tab; also drives the blurb line.
text *stringDraft body (controlled).
onChange *(v: string) => voidEdits the draft in place.
onCopy *() => voidCopies to clipboard and flips the button to 'Copied'.
editingbooleanfalseSwaps the read view for a TextArea.
loadingbooleanfalseShows three pulsing 96px card skeletons.

TypeScript interface

export type ReplyStyle = "Direct" | "Consultative" | "Friendly";

export interface ReplySuggestionCardProps {
  style: ReplyStyle;
  text: string;
  onChange: (v: string) => void;
  onCopy: () => void;
  editing?: boolean; // default false
  loading?: boolean; // default false
}

Variants

DirectConsultativeFriendly

Default: style="Consultative"

Visual states

  • LoadingThree pulsing card blocks, staggered 120ms, plus a muted reading line.
  • ReadyStyle blurb, draft body 13px relaxed, footer with Edit + Copy, and the 'Review before sending' notice.
  • EditingTextArea replaces the body; Copy stays available.
  • CopiedCopy button shows a check and 'Copied' for ~1.6s, then reverts.

Interaction states

  • Style switchContent swaps with fq-rise; unsaved edits reset — documented and intentional.
  • HoverFooter buttons only.

Accessibility

  • Copy feedback is text, not just an icon change.
  • The review notice is a persistent Alert-style line, not a dismissible toast.

Status mappings

Direct
Short, answers the question, asks for the next step.
Consultative
Leads with their context, then proposes a next step.
Friendly
Warm and casual, low pressure.

Example usage

<Card>
  <p className="text-[11.5px] text-muted-foreground">{styleBlurb[style]}</p>
  {editing
    ? <TextArea rows={6} value={text} onChange={(e) => onChange(e.target.value)} className="mt-2" />
    : <p className="mt-2 whitespace-pre-line text-[13px] leading-relaxed">{text}</p>}
  <div className="mt-3 flex gap-1.5 border-t border-border pt-3">
    <Btn variant="ghost" size="sm" full onClick={() => setEditing(!editing)}><Pencil size={13} /> Edit</Btn>
    <Btn variant="secondary" size="sm" full onClick={onCopy}><Copy size={13} /> Copy</Btn>
  </div>
</Card>

Chrome side-panel constraints

  • Never auto-focus the TextArea: focus stealing inside a side panel interrupts typing in the Facebook composer.
  • Clipboard write must be triggered by the user gesture — MV3 blocks deferred clipboard access.

Cards & patterns

SyncPreview

Pre-sync confidence: is this a new or an existing GoHighLevel contact, and what exactly will happen. Confidence, not configuration.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
match *SyncMatchDiscriminated union: new, or existing with name/company/hint.
actions *string[]Plain-language outcomes, e.g. 'Update phone', 'Create follow-up task'.
classNamestringLayout only.

TypeScript interface

export type SyncMatch =
  | { kind: "new" }
  | { kind: "existing"; name: string; company?: string; hint?: string };

export interface SyncPreviewProps {
  match: SyncMatch;
  actions: string[];
  className?: string;
}

Variants

newexisting

Default: none — match is required

Visual states

  • NewUserPlus in a primary/10 tile, mono 'New contact' eyebrow, and the sentence 'This will create a new GoHighLevel contact.'
  • ExistingUserRound in a secondary tile, 'Existing contact' eyebrow, matched name + · company, optional hint footer.
  • LoadingSame shell with two Skeleton lines in the header while the match resolves.

Interaction states

  • StaticRead-only by design — no field mapping, merge, or conflict UI.

Accessibility

  • Actions are a real <ul>.
  • The match kind is stated in text, so the icon and tint are redundant reinforcement.

Status mappings

kind: 'new'
primary tile + create sentence
kind: 'existing'
neutral tile + matched identity
actions[]
success check per line — what will change, in the user's words

Example usage

<SyncPreview
  match={{ kind: "existing", name: "Maya Okafor", company: "Okafor Creative" }}
  actions={["Update phone", "Update notes", "Add Facebook profile", "Create follow-up task"]}
/>

Chrome side-panel constraints

  • Sits directly above the Sync button so the decision and the commit are on one screen at 400px.
  • Keep actions to four lines; more suggests configuration, which this component deliberately refuses.

Feedback & state

SyncFailure (CRM recovery)

The recovery surface when a GoHighLevel save doesn't complete. Its job is confidence: say what happened, say what didn't happen, say what to do next, and make retrying the obvious move. It never blames the person and never uses technical wording.

src/components/fq/sync-failure.tsx

Props

PropTypeDefaultDescription
spec *SyncFailureSpecCopy record from src/lib/sync-errors.ts. Never pass ad-hoc strings — all failure copy lives in the registry.
attempt *number0 = first failure. Renders the quiet 'Attempt n' mono counter from the second failure onward.
retrying *booleanRetry in flight: primary button swaps to a spinner + 'Trying again…', and every action in the card is disabled.
onRetry *() => voidRuns the same save again. Caller must no-op while retrying is true.
onClose *() => voidDismisses recovery and returns to the ready state. Caller returns focus to the control that started the save.
onGetHelp *() => voidOpens Settings — the connection screen. Primary action when spec.retryable is false; a quiet tertiary 'Check your connection' otherwise.
diagnostics *SyncDiagnostics | nullPlain-language model from buildSyncDiagnostics(). null means there is nothing meaningful to explain, and the 'Review sync details' button is not rendered at all — never an empty diagnostics panel.

TypeScript interface

export interface SyncFailureSpec {
  id: "connection" | "timeout" | "permission" | "signedOut" | "unknown";
  label: string;      // mono chip: "Didn't complete"
  title: string;      // what happened
  body: string;       // what is safe / what did not change
  notDone: string[];  // the steps that did not complete
  next: string;       // what to do now
  details?: string[]; // ONLY when real diagnostics exist
  retryable: boolean; // false => retry can't help yet
}

export interface SyncFailureProps {
  spec: SyncFailureSpec;
  attempt: number;
  retrying: boolean;
  onRetry: () => void;
  onClose: () => void;
  onGetHelp: () => void;
  diagnostics: SyncDiagnostics | null;   // null => no drawer offered
}

Variants

retryablenot retryable (permission / expired sign-in)retryingwith diagnostics drawerwithout diagnostics drawer

Default: retryable, drawer available, attempt 0

Visual states

  • FailureTriangle icon + 2px solid left rule + mono 'Didn't complete' label + danger-soft/50 tint. Shape and words carry the meaning; colour is the quietest signal, so it survives greyscale and colour blindness.
  • Vs. warningWarnings use Alert with a filled Info/AlertTriangle glyph and no left rule; failures always carry the left rule plus the 'What didn't happen' list.
  • Vs. successSuccess uses a circled check, success border and a completed checklist. Failure never borrows the circle.
  • RetryingCard stays exactly where it is; only the primary button changes to spinner + 'Trying again…'. The screen never flashes back to an empty state.
  • Repeat failureSame card, updated spec (connection → timeout): softer wording, attempt counter appears, diagnostics disclosure appears if available.
  • No technical disclosureThe card carries no mono 'View error details' list. Everything explanatory lives in the shared SyncDetails sheet, in plain language, so there is exactly one place to look.
  • Action setPrimary 'Retry sync'. Secondary pair 'Review sync details' + 'Close' — and 'Review sync details' disappears entirely when diagnostics is null, leaving Close full width. Non-retryable failures promote 'Fix this in Settings' to primary and drop Retry entirely — no dead-end button.

Interaction states

  • Retry pressSets retrying, disables all actions. Duplicate submissions are impossible from both the button (disabled) and the handler (early return).
  • Retry succeedsTransitions straight into the approved Sync Success card — same success experience as a first-try save, plus the standard toast.
  • Retry fails againStays in the same recovery flow with the next failure spec. The user is never bounced elsewhere.
  • Review sync detailsOpens the shared SyncDetails sheet: what we were saving, when, how far it got step by step, whether the problem is temporary, and what to do next. Transparency only — never an Undo, because nothing left the prototype. Focus returns to the trigger on close.
  • Retry from the drawer'Retry sync' inside the drawer closes it first, then runs the same retry as the card, so the loading state is visible and no modal hangs over the result.
  • CloseReturns to the ready state with the sync preview intact; nothing about the lead is lost.

Accessibility

  • role="alert" is used once, on a visually hidden line, for the initial announcement of a distinct failure. The same failure is never announced twice; a different failure is.
  • Focus moves to the failure heading (tabIndex={-1}, focus ring applied) when the recovery card replaces the sync actions.
  • On Close, focus returns to the 'Sync to GoHighLevel' button that started the attempt.
  • Reading order matches the visual order: label → what happened → reassurance → what didn't happen → what to do next → details → Retry → secondary actions → simulation notice.
  • Retry is a native button: reachable by Tab, fires on Enter and Space, carries aria-busy while in flight, and is disabled so it cannot double-submit.
  • The 'View error details' disclosure is a button with aria-expanded; the chevron is aria-hidden.
  • 'Review sync details' opens a modal drawer (aria-modal, labelled + described, focus trapped, Escape closes) and returns focus to its own trigger — the failure heading is not re-announced.
  • Icons are decorative (aria-hidden) and always paired with text — no meaning is icon-only or colour-only.
  • Only motion is one fq-rise entrance, which prefers-reduced-motion already disables.

Status mappings

connection
First failure — 'We couldn't finish saving to GoHighLevel'. Retryable. Drawer available (temporary).
timeout
Repeat failure — 'GoHighLevel is taking longer than usual'. Retryable. Drawer available (temporary).
permission
Account can't add contacts. Not retryable → primary becomes 'Fix this in Settings'. Drawer available (needs you).
signedOut
Sign-in expired. Not retryable → reconnect in Settings. Drawer available (needs you).
unknown
Fallback. Retryable, and buildSyncDiagnostics returns null — no drawer, because we would have nothing honest to put in it.

Example usage

const [state, setState] = useState<"ready" | "syncing" | "synced" | "failed">("ready");
const [attempt, setAttempt] = useState(0);
const [retrying, setRetrying] = useState(false);
const saveRef = useRef<HTMLButtonElement>(null);

{state === "failed" && (
  <SyncFailure
    spec={failureForAttempt(attempt)}
    attempt={attempt}
    retrying={retrying}
    onRetry={() => {
      if (retrying) return;              // no duplicate submissions
      setAttempt((a) => a + 1);
      setRetrying(true);                 // loading state, card stays put
    }}
    onClose={() => {
      returnFocus.current = true;        // focus back to the save button
      setState("ready");
    }}
    onGetHelp={() => go("settings")}
    diagnostics={buildSyncDiagnostics({
      spec: failureForAttempt(attempt),
      contactName: name,
      companyName: company,
      attemptedAt: attemptedAt.current,
      attempt,
    })}                                  // null => no "Review sync details"
  />
)}

Chrome side-panel constraints

  • Copy standard: one calm sentence for what happened, one for what is safe, a short list for what didn't complete, one sentence for the next move. No status codes, no 'error', no 'failed request', no blame.
  • Every failure ends with 'Prototype simulation — no live GoHighLevel data was modified.' Success never claims a live change either.
  • 'Review sync details' is conditional. If buildSyncDiagnostics can't name the failed step and what did or didn't complete, the button does not exist — an empty diagnostics panel erodes trust faster than no panel.
  • When the drawer does open, it always describes the simulation in the negative and ends with the prototype notice, so it can never be misread as a record of live CRM changes.
  • Recovery flow: ready → saving → failure (announce + focus heading) → Retry (loading, same card) → success card, or → next failure spec in the same card. Close returns to ready with focus restored.
  • Failure is outside the emphasis budget's celebration rules but inside its attention rules: it may use danger tint once, at one card, at one moment.

Feedback & state

Focus return after dismissing a CRM failure

Dismissing the CRM save failure must put the keyboard exactly back where it started — on the same Save control, for the same lead, with the rest of the screen untouched. A ref alone can't do this: the failure card replaces the Save button while it is open, so the original node is gone by the time focus should return. We remember a stable key instead and re-resolve the live element after the card unmounts.

src/lib/focus-return.ts

Props

PropTypeDefaultDescription
saveFocusKey(leadId)(leadId: string) => stringBuilds the lead-scoped key ('save-crm:tomas') rendered as data-focus-key on the Save control. Identity is the lead, never the button label — several Save buttons can read the same text.
restoreFocus(key, opts)(key, opts) => () => voidRe-resolves and focuses the control after the surface has closed. Returns a cancel function; call it from the effect cleanup.
opts.labelstringHuman name of the control, used only in the fallback announcement.
opts.scopeHTMLElement | nullLimits the fallback search to the current screen.
opts.onFallback(message: string) => voidFires only when focus lands somewhere other than the exact control. Wire it to a role="status" region.
opts.waitFornumberDefault 600ms. How long a present-but-disabled control is waited for before falling back.
data-focus-fallbackstring (attribute)Marks the nearest equivalent actionable control and names it for the announcement. On the sync screen this is the back link.

TypeScript interface

export const saveFocusKey = (leadId: string) => `save-crm:${leadId}`;

export function restoreFocus(
  key: string,
  opts?: {
    onFallback?: (message: string) => void;
    label?: string;
    scope?: HTMLElement | null;
    waitFor?: number;   // default 600ms
  },
): () => void;   // cancel

Variants

exact restorerestore after a disabled windowfallback restore

Default: exact restore

Visual states

  • Exact restoreFocus lands on the same Save control with the standard 2px fq-focus ring plus its offset halo — legible in light and dark.
  • Fallback restoreFocus lands on the marked nearest action (the back link) with the same ring, and a single quiet role="status" line explains the move.

Interaction states

  • Close buttonSets the return flag, closes the card, and restores on the next frame after unmount.
  • EscapeSame shared dismiss() path, so the result is identical. Ignored while the details drawer is open (the drawer owns Escape) and while a retry is in flight.
  • Outside clickThe failure card is an inline, non-modal surface, so there is no outside-dismiss. The details drawer above it is modal and its scrim close runs through the same restoration rule.
  • Double dismissalA dismiss guard plus an activeElement check means Escape-then-click resolves to one focus move, never a second jump.

Accessibility

  • Resolution order: the exact control by data-focus-key, then the same control once it is no longer disabled (600ms poll), then the nearest control marked data-focus-fallback. Focus is never dropped on the body, the panel header, the first control on screen, or another lead's Save button.
  • Focus moves on the second animation frame after state change, so the failure surface has fully left the DOM first. Timing is identical under prefers-reduced-motion — only the visual close differs.
  • A control that is aria-disabled or disabled is never focused; it is waited for, then abandoned in favour of the fallback.
  • The fallback announcement is one concise role="status" line — 'Sync to GoHighLevel is no longer available. Focus moved to the back link for Tomás Alvarez.' It is silent on the normal path.
  • Closing never re-announces the error: the role="alert" lives inside the card and unmounts with it.
  • DOM focus and screen-reader virtual focus stay aligned because the move happens once, after the DOM has settled.
  • Selected lead, scroll offset and expanded sections are untouched — only state === 'failed' → 'ready' changes, so nothing remounts around the control.

Status mappings

Exact control found
focus moves silently — nothing is announced
Control present but disabled
polled for up to 600ms, then focused — still silent
Control gone
nearest action focused + one role="status" sentence
Error text after close
never re-announced; the role="alert" card is unmounted

Example usage

const saveKey = saveFocusKey(lead.id);

useEffect(() => {
  if (state !== "ready" || !returnFocus.current) return;
  returnFocus.current = false;
  return restoreFocus(saveKey, {
    label: "Sync to GoHighLevel",
    scope: screenRef.current,
    onFallback: setFocusFallback,   // rendered in a role="status" region
  });
}, [state, saveKey]);

<Btn data-focus-key={saveKey} …>Sync to GoHighLevel</Btn>
<BackLink focusFallback={`the back link for ${lead.name}`} … />

Chrome side-panel constraints

  • Verified with keyboard only, in light, dark and reduced-motion: trigger the failure from a lead's Save button, dismiss with the Close control and again with Escape — focus returns to data-focus-key="save-crm:tomas" both times, with a visible 2px ring, no leftover alert, no duplicate Save control, and an unchanged scroll offset.
  • Fallback verified by removing the Save control during the close: focus lands on the back link and the status region reads the single fallback sentence.
  • Duplicate-dismissal verified: two Escape presses in a row produce one focus move and no announcement.

Feedback & state

SyncSuccess (CRM confirmation)

The confirmation surface after a GoHighLevel save completes. Its job is closure: one calm headline, a checklist of exactly what happened, an honest simulation notice, and two obvious exits. Sighted and screen-reader users receive the identical outcome.

src/components/fq/sync-success.tsx

Props

PropTypeDefaultDescription
headline *stringHeading text, rendered as a real <h2> with an id (the screen heading above it is the <h1>, so axe heading-order stays valid). Also the first clause of the spoken announcement.
sublinestringQuiet second line — who was saved. Not announced separately; it is already in the reading order.
checklist *string[]Visible outcomes. The announcement sentence is generated from this same array, so spoken and visible copy can never drift.
detailsstring[]Simulated diagnostics, shown behind 'View prototype details' inside the review dialog.
reviewRowsSyncDetailRow[]Summary rows for the 'Review sync details' dialog. Omit and the affordance does not render.
onDone *() => voidPrimary exit. Also the focus target in the replacement case (see focus rules).
onOpenCrm *() => void'View in GoHighLevel'. In the prototype this only raises a toast — it never claims a live CRM change.

TypeScript interface

export interface SyncSuccessProps {
  headline: string;
  subline?: string;
  checklist: string[];
  details?: string[];
  onDone: () => void;
  onOpenCrm: () => void;
}

Variants

inline (default)with details disclosurewithout details

Default: inline, no details

Visual states

  • SuccessCircled check + success border + won-soft/60 header. One fq-pop on the glyph, one fq-ring, then stillness — the whole celebration budget for this moment.
  • ChecklistEach outcome staggers in at 45ms intervals. Check glyphs are decorative; the words carry the meaning.
  • Details openMono 10.5px diagnostics, closed by default, chevron rotates 180°.
  • Simulation noticeAlways present: 'Preview simulation — no live CRM data was changed.' Success never implies a real CRM write.

Interaction states

  • Appears inlineNo focus movement. The person keeps their place; the announcement does the telling.
  • Appears after trigger unmountsFocus lands on the 'Done' button — the primary action, never the container.
  • DoneFades the card out over 160ms, then returns to the lead screen — same scroll position, same lead, focus back on the control that opened sync. Reduced motion returns instantly with identical continuity. See 'View transition & return continuity'.
  • Review sync detailsNative button that opens the shared SyncDetails sheet. It is a transparency affordance, never an Undo: nothing left the prototype, so nothing can be rolled back.

Accessibility

  • Announcement: a visually hidden <p role="status" aria-live="polite">. The region is mounted empty on first paint and written on the next frame, so the change is what gets spoken.
  • Announcement copy is generated from the visible checklist — e.g. 'Synced to GoHighLevel. Their contact was updated, your conversation summary was attached, …, and marked as Hot. Preview simulation — no live CRM data was changed.'
  • Announced exactly once. A ref guards re-runs and the region is cleared after 2s, so unchanged content is never re-read.
  • Focus is NOT moved to the card when it appears inline. Focus stays on the 'Sync to GoHighLevel' button, which remains mounted through the save using aria-disabled + aria-busy (never the disabled attribute, which blurs the element).
  • The card only claims focus when its trigger left the DOM, and then it focuses the 'Done' button. A non-interactive container is never focused just because it appeared.
  • The card renders immediately after the trigger area, so DOM order = reading order = Tab order.
  • role="region" + aria-labelledby points at the <h2> headline, so the card is reachable from a screen reader's region and heading lists.
  • Done, View in GoHighLevel and Review sync details are native buttons: Tab-reachable, Enter/Space activated, fq-focus ring, ≥32px hit targets.
  • All icons are aria-hidden + focusable="false"; nothing is icon-only or colour-only.

Status mappings

role="status" aria-live="polite"
Success. Polite: it waits for the current utterance to finish and never interrupts typing.
role="alert" (SyncFailure)
Failure only. Assertive interruption is reserved for outcomes that need an immediate decision.
aria-busy on the save button
In-flight. The button stays mounted with aria-disabled instead of disabled, so focus is never stolen by the browser.

Example usage

{state === "synced" && (
  <SyncSuccess
    headline="Synced to GoHighLevel"
    subline={`${lead.name} · ${company}`}
    checklist={syncedChecklist}          // same array feeds the announcement
    details={[`contact: matched by email`]}
    onDone={back}
    onOpenCrm={() => notify("This would open GoHighLevel")}
  />
)}

// Trigger stays mounted while saving so focus is never lost:
<Btn
  ref={saveRef}
  aria-disabled={state === "syncing"}
  aria-busy={state === "syncing"}
  onClick={() => { if (state !== "syncing") setState("syncing"); }}
/>

Chrome side-panel constraints

  • Polite for success, assertive for failure. That split is the whole announcement policy — do not escalate a success to role="alert".
  • Never announce a prototype simulation as a real CRM change. The simulation sentence is part of the announcement, not just the visuals.
  • If this state ever becomes a modal or sheet rather than an inline card, the rule flips: move initial focus to the heading or the 'Done' action, trap focus inside, and return focus to the trigger on close.
  • One announcement per outcome. Toasts fire separately and are polite too — success is never spoken twice by two regions saying the same thing.

Feedback & state

AiFailure (reply drafting recovery)

Recovery when a reply draft doesn't arrive — request failed, timed out, service briefly unavailable, too many requests, or an empty/unusable response. Its job is confidence: say what happened, confirm nothing was lost, and make the next move obvious. Primary action is always Generate again; Edit manually and Close sit beside it.

src/components/fq/ai-failure.tsx

Props

PropTypeDefaultDescription
spec *AiFailureSpecCopy record from src/lib/ai-errors.ts. Never pass ad-hoc strings — all drafting failure copy lives in the registry.
attempt *number0 = first failure. From the second onward the quiet mono 'Attempt n' counter appears.
retrying *booleanRetry in flight: primary button becomes a spinner + 'Writing a reply…' and every action is disabled, so a second request is impossible.
replacesViewbooleantrueTrue when the card took over the view: focus moves to the heading and role="alert" is omitted so the failure is announced once, not twice. False for inline use, which uses role="alert" and leaves focus alone.
onRetry *() => voidRuns the same request again. Caller must no-op while a request is open.
onEditManually *() => voidSwitches to the manual composer with the person's existing draft intact.
onClose *() => voidLeaves recovery. Caller returns focus to the control that started drafting.

TypeScript interface

export interface AiFailureSpec {
  id: "failed" | "timeout" | "unavailable" | "rateLimit" | "emptyResponse";
  label: string;   // mono chip: "Took too long"
  shape: "clock" | "alert" | "cloud" | "pause" | "blank";
  title: string;   // what happened
  safe: string;    // what is still here
  next: string;    // what to do now
  details?: string[]; // ONLY when real diagnostics exist
  retryable: boolean;
}

export interface AiFailureProps {
  spec: AiFailureSpec;
  attempt: number;
  retrying: boolean;
  replacesView?: boolean;
  onRetry: () => void;
  onEditManually: () => void;
  onClose: () => void;
}

Variants

timeoutfailedunavailablerate limit (not retryable)empty responseretryingwith detailswithout details

Default: timeout, attempt 0, replacesView

Visual states

  • LoadingAiLoading skeleton in the exact shape of the reply card — no icon, no spinner, no percentage. The caption softens to 'Still working — this one's taking a moment' after ~2.6s. On the reply screen it renders with announce={false}, because that screen owns the single polite live region; elsewhere the caption keeps its own role="status".
  • TimeoutCrossed clock glyph + mono 'Took too long' + 2px left rule. Shape and words carry it; colour is the quietest cue, so it survives greyscale.
  • FailureTriangle glyph + 'Didn't finish'. Same layout as timeout so nothing jumps between them.
  • Unavailable / rate limit / emptyStruck-through cloud, circle-slash and circle-slash respectively, each with its own mono label. Rate limit is retryable: false, so Generate again is disabled and the copy asks for a minute.
  • RetryingCard stays exactly where it is; only the primary button changes to spinner + 'Writing a reply…'. The screen never flashes back to a skeleton.
  • Repeat failureSame card, updated spec and attempt counter. Wording softens rather than escalating, and the person is never bounced elsewhere.
  • Success after retryTransitions straight into the approved reply view with the standard fq-rise. If a manual draft exists it is preserved and a toast says so.
  • Details collapsed'View details' renders only when spec.details exists (timeout and rate limit). Closed by default, mono 10.5px when open.

Interaction states

  • Generate againRetries the identical request. A ref guard plus the disabled button make duplicate requests impossible.
  • Edit manuallyOpens the composer pre-filled with whatever the person already wrote. Their text is never cleared by a failed or successful generation.
  • CloseReturns to the conversation with the draft intact.
  • Reduced motionSpinner and rise honour prefers-reduced-motion via motion-reduce utilities; the state change itself is instant.

Accessibility

  • One live region per screen. Reply suggestions render a single sr-only role="status" aria-live="polite" paragraph, fed by useAiAnnouncement (src/lib/ai-announce.ts) from the same state that draws the UI — loading, slow, ready, retrying, failed, manual — so heard and seen can never drift.
  • The region is mounted empty and written only when the derived sentence changes, so rerenders (style switches, typing, copying) never repeat a message, and the state the screen opens in is never announced.
  • It is polite, never assertive: typing in a draft is never interrupted, and focus is never moved by the announcement.
  • AiLoading is passed announce={false} on that screen so its caption does not become a second announcement layer; aria-busy on the skeleton region is unchanged.
  • role="alert" is reserved for an actual failure, and only for the inline card variant. When the card replaces the view it takes focus instead, and the live region stays silent for that change — spoken once, never twice.
  • When the card replaces the view, focus moves to the <h3> (tabIndex -1, visible focus ring) and role="alert" is dropped — so the failure is spoken once, not twice.
  • A repeat failure updates copy in place and does not move focus again, so the button the person just pressed keeps focus.
  • DOM order = reading order = Tab order: heading, details disclosure, Generate again, Edit manually, Close.
  • Every action is a native button: Tab-reachable, Enter/Space activated, fq-focus ring, ≥32px hit target.
  • All glyphs are aria-hidden; no state is signalled by colour or icon alone — each carries a mono text label.

Status mappings

timeout
First simulated failure — 'Writing a reply is taking longer than expected'. Retryable, with diagnostics.
failed
'We couldn't write a reply just now'. Retryable, no diagnostics.
unavailable
'Reply drafting is unavailable for a moment' — explicitly our side, not theirs.
rateLimit
'You've asked for a lot of drafts in a short time'. retryable: false, so retry is disabled for about a minute.
emptyResponse
'We didn't get a usable reply back'. Retryable, no diagnostics.

Example usage

{phase === "failed" && (
  <AiFailure
    spec={aiFailures[failure]}
    attempt={attempt}
    retrying={retrying}
    onRetry={() => generate({ retry: true })}
    onEditManually={() => setPhase("manual")}
    onClose={back}
  />
)}

// Duplicate requests are impossible:
const generate = (opts = {}) => {
  if (inFlight.current) return;
  inFlight.current = true;
  ...
};

Chrome side-panel constraints

  • Copy standard: what happened, then 'nothing has been lost', then what to do next. Never blame the person, never name a provider, never show an error code.
  • 'View details' appears only where genuine diagnostics exist. No details, no disclosure.
  • A manual draft is never overwritten. A successful regeneration replaces the text only when the person hasn't typed; otherwise their words stay and a toast confirms it.
  • Prototype wording is explicit — 'these drafts are sample copy, so no live service is involved' — so nobody reads a simulated timeout as a real outage.
  • The prototype scenario picker at the bottom of Reply suggestions is a simulation control and must not ship in the extension.

Feedback & state

SyncDetails (Review sync details)

The single details surface for a GoHighLevel save, opened from 'Review sync details' on both the success card and the failure card. Success and failure hand it the same SyncDetailsModel, so both read in the same order with the same sections and neither can drift from the other. It answers what was saved, when, how far it got, and what to do next — in plain language, never as a debugging console.

src/components/fq/sync-details.tsx · model in src/lib/sync-diagnostics.ts

Props

PropTypeDefaultDescription
open *booleanControlled visibility. Nothing renders when false, so no hidden focusables sit in the tab order.
onClose *() => voidFires for Close, the X, Escape and scrim click. The sheet restores focus to triggerRef itself.
model *SyncDetailsModelThe unified model from buildSyncSuccessDetails() or toSyncDetailsModel(diagnostics). Callers must not render the sheet — or its trigger — when there is no model.
retryablebooleanFailure only. False hides 'Retry save', because retrying can't help until the person changes something.
retryingbooleanRetry in flight: the button disables, reads 'Trying again…' and carries aria-busy.
onRetry() => voidRuns the same retry as the card. The sheet closes first so the loading state and the outcome are visible.
triggerRefRefObject<HTMLElement | null>The 'Review sync details' button. Focus returns here on close, by any route.

TypeScript interface

export type StepOutcome = "completed" | "notCompleted" | "unknown";

export interface DetailStep {
  label: string;      // "Contact match"
  outcome: StepOutcome;
  meaning: string;    // one sentence, in the person's language
}

export interface DetailFact {
  label: string;      // "Saved"
  value: string;      // "Today at 4:12 PM"
  meaning: string;
}

export interface SyncDetailsModel {
  tone: "success" | "failure";
  statusLine: string;      // the heading, in words
  stoppedAt?: string;      // failure only
  facts: DetailFact[];
  steps: DetailStep[];
  natureLine?: string;
  nextAction?: string;
}

Variants

successtemporary failureneeds-you failurenot retryable (Retry hidden)

Default: success

Visual states

  • ClosedRenders nothing at all. No scrim, no off-screen focusables.
  • OpenBottom sheet anchored to the panel's lower edge, rounded-t-2xl, max 94% height, over a foreground/25 scrim with a 2px blur. Single column at 400px: values wrap, never truncate.
  • Header'Sync details' eyebrow, the status line as the <h2>, and on a failure 'Stopped at: <step>'. Calm wording, no danger tint — the failure was already signalled on the card.
  • This saveContact, Company, Saved (friendly local time) and Current status, each with a one-line explanation underneath.
  • Step by stepContact match, CRM connection, Conversation summary, Follow-up task. Glyph + outcome word + one sentence: Check/'Completed', Minus/'Didn't complete', Question/'Not known'.
  • What this meansFailure only: 'Temporary' (clock glyph) or 'Needs a quick change from you' (user-settings glyph), then 'Recommended next: …'.
  • Actions'Retry save' primary when a retryable failure, then 'Copy summary' + 'Close'. Success shows 'Copy summary' + 'Close' only.
  • CopiedThe copy button label swaps to 'Summary copied' for ~2.4s. No toast — the sheet is already the focused context.

Interaction states

  • OpenFocus moves into the sheet, onto the heading (tabIndex={-1}) so the status line is the first thing spoken.
  • Tab / Shift+TabTrapped inside the sheet: Close(X) → scroll region → Retry save → Copy summary → Close → wraps.
  • EscapeCloses and stops propagation, so one Escape never dismisses the card behind it too.
  • Scrim clickCloses. The scrim is aria-hidden and not keyboard reachable.
  • CloseFocus returns to the 'Review sync details' button on the card that opened it.
  • Retry from the sheetCloses the sheet, then fires the same onRetry as the card. Success lands on the standard success card; another failure reopens with the next failure's model.
  • Copy summaryWrites exactly the sentences on screen to the clipboard and announces 'Save summary copied to the clipboard.' politely. Clipboard refusal leaves the label unchanged rather than raising an error.
  • Reduced motionThe fq-sheet rise is skipped under prefers-reduced-motion; the sheet simply appears, with identical focus behaviour.

Accessibility

  • Proper dialog semantics: role="dialog" + aria-modal="true", named by its heading.
  • Focus moves into the sheet on open, onto the heading, so the current status is read first and no control is triggered by accident.
  • Focus is trapped while open; Tab wraps forward from the last control and Shift+Tab wraps backwards.
  • Escape closes and stops propagating; the scrim is aria-hidden and has no tab stop.
  • On close — Close, X, Escape or scrim — focus returns to the 'Review sync details' trigger.
  • Reading order matches visual order: status → stopped at → this save → step by step → what this means → recommended next → actions.
  • Sections are real <section> elements with <h3> headings under the dialog's <h2>, so heading order stays valid.
  • Every outcome glyph is aria-hidden and paired with its outcome word; nothing here is icon-only or colour-only.
  • The scrollable region is focusable with a group label, so keyboard users can scroll it without a pointer.
  • 'Summary copied' is announced through a single polite live region; the visible label changes as well.
  • Motion is one fq-sheet rise, skipped under prefers-reduced-motion.

Status mappings

role="dialog" aria-modal="true"
Sheet semantics. The rest of the panel is inert to assistive tech while it is open.
aria-labelledby
The <h2> status line — the accessible name.
"completed"
Check glyph + the word 'Completed'. Never colour alone.
"notCompleted"
Minus glyph + “Didn’t complete”. Muted, not red — this is an explanation, not a second alarm.
"unknown"
Question glyph + 'Not known'. Used when we genuinely cannot say, e.g. contact match after a dropped connection.
nature: temporary
Waiting and retrying is the recommended move.
nature: needsYou
Retrying alone won't fix it; the recommended move is in Settings or with the account owner.

Example usage

const reviewRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);

<Btn ref={reviewRef} variant="secondary" onClick={() => setOpen(true)}>
  Review sync details
</Btn>

<SyncDetails
  open={open}
  onClose={() => setOpen(false)}
  model={model}
  retryable={spec?.retryable}
  retrying={retrying}
  onRetry={() => { setOpen(false); setTimeout(onRetry, 0); }}
  triggerRef={reviewRef}
/>

Chrome side-panel constraints

  • One implementation only. Success and failure must never grow separate details components again — they share this sheet and this model.
  • Never show an empty or generic details panel. When no step can be named honestly, there is no model and the trigger disappears with it.
  • Copy standard: a short label, then one sentence of meaning in the person's language. 'We couldn't determine whether this contact already exists.' — not 'contact lookup failed'.
  • Forbidden in this surface: stack traces, request or response payloads, record IDs, HTTP status codes, internal error strings.
  • No rollback. FollowQue never offers one-click undo for a CRM action; toast-level Undo covers local FollowQue state only.
  • Tone stays calm. The failure card owns the one permitted danger tint; this sheet is neutral surface, muted glyphs and explanation.
  • Layout is tuned for a 400px side panel: one column, wrapped values, sticky action row, sheet capped at 94% height.

Feedback & state

Guidance modes (Help & hints)

One preference decides how much FollowQue teaches: Full, Low or Off. Low Guidance teaches each major workflow exactly once and then goes quiet for it. No mode may ever hide something the user needs to finish an action safely.

src/lib/discovery.ts · src/components/fq/discovery.tsx

Props

PropTypeDefaultDescription
mode *"full" | "low" | "off""full"Read from useDiscovery(). Persisted under fq-guidance-mode.
setMode *(m: GuidanceMode) => voidRadio group in Settings › Help & hints. Announces the new mode via role="status".
completeWorkflow *(w: GuidanceWorkflow) => voidCalled by the view when the user actually finishes the action. Dismissing a hint never calls it.
workflowDone *(w: GuidanceWorkflow) => booleanTrue once that workflow has been completed at least once, across sessions.
reset *() => voidClears dismissed hints and workflow completions only. Leaves onboarding, leads, follow-ups, CRM settings, drafts and preferences untouched.

TypeScript interface

export type GuidanceMode = "full" | "low" | "off";

export type GuidanceWorkflow =
  | "captureLead" | "followUp" | "aiReply"
  | "snooze" | "ghlSync" | "none";

export const GUIDANCE_MODEL_VERSION = 1;

interface DiscoveryCtx {
  mode: GuidanceMode;
  setMode: (m: GuidanceMode) => void;
  completeWorkflow: (w: GuidanceWorkflow) => void;
  workflowDone: (w: GuidanceWorkflow) => boolean;
  reset: () => void;
}

Variants

fulllowoff

Default: full

Visual states

  • Full"Show helpful tips as you learn each feature." Approved coach marks, first-use cards, hints, keyboard hints and calm milestone confirmations, under the existing one-at-a-time / two-per-session budget.
  • Low"Show one helpful introduction, then keep guidance minimal." Only the first eligible moment per workflow. Once that workflow is completed, its proactive guidance never returns — in this or any later session.
  • Off"Hide optional tips and coach marks." Proactive guidance is suppressed entirely. Labels, warnings, confirmations, errors and screen-reader instructions are unaffected.
  • Never suppressedValidation, errors, recovery cards, connection warnings, functional success confirmations, Undo toasts, and any help the user opens themselves (tooltips, Help & support, replayable onboarding).

Interaction states

  • Complete a workflowThe view calls completeWorkflow on the real action — lead saved, follow-up date set, draft copied, contact saved to GoHighLevel. In Low mode that workflow's proactive hints stop.
  • Dismiss a hintHides that one moment (fq-discovery-seen) but does NOT mark the workflow learned, so a genuine first-use card can still teach it.
  • CelebrationIn Low mode only the first calm success confirmation per workflow shows; showing it marks the workflow learned. After that, normal concise success feedback only. No confetti, badges, streaks or gamification.
  • Another workflowCompletion is tracked per workflow, so an unlearned workflow can still show its first moment after others are done.
  • Reset guidance historySecondary ghost action behind a calm inline confirmation: "Show first-time tips again?" — first-use guidance becomes eligible again; nothing else changes.
  • Mode switchSwitching Low → Off → Low keeps completion history; the mode is only a filter over it.

Accessibility

  • Native radio inputs in a <fieldset> with an sr-only <legend>, so arrow keys move between the three options and the group is announced as one control.
  • Each option's label and description are inside the <label>, so screen readers read the full explanation, not just the word Full/Low/Off.
  • The new mode is announced once through a single sr-only role="status" line. Focus is never moved.
  • Reset asks for confirmation inline in the same card; no dialog, no focus trap, and Keep as is is always adjacent.
  • Low and Off never remove instructions required to operate a complex control — picker/menu/dialog descriptions, required-field markers and error text are outside the guidance system.
  • Contextual help stays reachable by keyboard: tooltips open on focus, Help & support and Replay setup stay in Settings.
  • Verified in light, dark and reduced-motion; guidance surfaces use the standard fq-rise / fq-pop tokens, which respect prefers-reduced-motion.

Status mappings

captureLead
firstLeadSaved celebration · completed when a lead is saved
followUp
queueFilters coach mark, waitingHint, firstFollowUpSet · completed when a follow-up date is set
snooze
snoozeHint · completed when a follow-up is moved
aiReply
repliesFirstUse · completed when a draft is copied
ghlSync
syncFirstUse, firstSync · completed on a successful save

Example usage

const { mode, completeWorkflow } = useDiscovery();

// A view marks the workflow learned on the real action only
const setFollowUp = (id: string, iso: string) => {
  updateUndoable(id, { followUpDate: iso }, "Follow-up moved");
  completeWorkflow("followUp");
};

// Guidance surfaces stay declarative — the provider applies the mode
<Hint id="snoozeHint" when={!loading} />
<Celebration id="firstFollowUpSet" when={dateChanged} />

Chrome side-panel constraints

  • Persisted: fq-guidance-mode, fq-guidance-workflows (completed workflows), fq-discovery-seen (dismissed moments), fq-guidance-version.
  • Never persisted: conversation content, message text, CRM credentials or API tokens. Guidance state is a handful of string ids.
  • GUIDANCE_MODEL_VERSION is bumped only for a genuinely new guidance model. Cosmetic copy or styling changes must not reset a user's history.
  • A genuinely new feature may add one new guided moment; it is a new id, so it is eligible even for users with history.
  • Verification: Full shows approved hints; Low shows one per workflow and stops after completion; another unlearned workflow still teaches; warnings and errors persist in every mode; Off hides optional guidance only; returning to Low keeps history; reset makes first-use eligible again; keyboard-only and screen-reader operation, reload persistence, and light/dark/reduced-motion all verified.

Feedback & state

KeyboardHelp (shortcuts panel)

A discoverability surface, not a documentation centre. It lists only the keyboard interactions the approved components already support, grouped by context, and is never shown automatically. V1 has no shortcut system, no chords and no editable preferences.

src/components/fq/keyboard-help.tsx · src/components/fq/kbd.tsx · src/lib/keyboard-help.ts

Props

PropTypeDefaultDescription
KeyboardHelpProvider *{ children }Mounted once inside the panel shell. Owns the dialog, the optional `?` listener and focus restoration.
useKeyboardHelp(){ open, openHelp, closeHelp }openHelp(trigger?) records the element focus returns to. Defaults to document.activeElement.
KeyboardHelpButton()The single product entry point. Lives in Settings › Help & hints. Never in the selling workflow.
Kbd{ children }Semantic <kbd> chip. The only approved key styling anywhere in the product.
KeyHint{ keys, children, hidden? }hidden = trueContextual one-line nudge (e.g. "Esc to close"). aria-hidden by default — the role already conveys the behaviour to screen readers.

TypeScript interface

export interface KeyRow { keys: string[]; action: string }
export interface KeyGroup { id: string; title: string; rows: KeyRow[] }

export const keyboardGroups: KeyGroup[];
export function shouldOpenKeyboardHelp(e: KeyboardEvent): boolean;

Variants

dialogcontextual hint

Default: dialog

Visual states

  • ClosedNothing rendered. No persistent help affordance in Today, Conversation, Replies, Capture, Queue or Sync.
  • OpenModal card centred in the 400px frame, max height 88%, one scroll region. Grouped lists with <kbd> chips right-aligned.
  • Contextual hintOne muted KeyHint line, e.g. the follow-up picker's "Esc to close · arrow keys to choose a date". Subtle, optional, never load-bearing.
  • Dark / lightChips use border-border on bg-secondary with text-foreground in both themes — verified ≥ 4.5:1.

Interaction states

  • Open from SettingsSettings › Help & hints › "Keyboard shortcuts". Trigger element is stored for focus return.
  • Open with ?Optional global key. Ignored when focus is in an input, textarea, select, contenteditable or role=textbox; ignored with Ctrl/Cmd/Alt, on key repeat, when defaultPrevented, and when any aria-modal dialog is already open.
  • Initial focusMoves to the dialog heading (tabIndex -1), so screen readers announce the panel name and its description before the list.
  • Focus trapTab / Shift+Tab cycle inside the dialog only, matching SyncDetailsDialog.
  • EscapeCloses. The keydown listener is capture-phase and stops propagation so it never also closes a surface underneath.
  • CloseBackdrop click, the X control or the Close button. Focus returns to the exact trigger.
  • Reduced motionThe fq-rise entrance is skipped when prefers-reduced-motion is set.

Accessibility

  • role="dialog" aria-modal="true", named by its heading (aria-labelledby) and described by the one-line purpose (aria-describedby).
  • Initial focus lands on the heading; focus is trapped; Escape closes; focus returns to the trigger.
  • Each context is a <section> with its own heading, so screen readers can jump between groups instead of hearing one master list.
  • Keys use semantic <kbd>. Separators ("or", "+") are plain text inside the row, so the row reads naturally.
  • Contextual KeyHint lines are aria-hidden: the dialog/popover role already tells assistive tech that Escape closes.
  • No live region — opening moves focus, which is announcement enough.
  • Collision rules: nothing is bound that Facebook or Chrome owns. `?` is the only global key, it is a printable character, and it is inert wherever a person could be typing.

Status mappings

Anywhere
Tab / Shift+Tab · Enter or Space · ?
Popovers, menus, dialogs
Esc closes · Tab stays inside a dialog · Enter or Space chooses
Follow-up dates and snooze
← → move · Home / End jump · Enter sets the date · Esc closes unchanged
After an action
Tab reaches Undo · Enter undoes before the toast fades
Moving around
Tab reaches the bottom navigation · Enter opens a tab

Example usage

// Panel shell (once)
<KeyboardHelpProvider>{children}</KeyboardHelpProvider>

// Settings › Help & hints
<KeyboardHelpButton />

// Contextual hint
<KeyHint keys="Esc" className="mt-2 justify-center">
  to close · arrow keys to choose a date
</KeyHint>

Chrome side-panel constraints

  • Do not add a help icon to the header or any selling screen. One entry point only.
  • Never open it automatically — not on first run, not after onboarding.
  • Add a row only after the interaction is genuinely supported by a shipped component.
  • No editable shortcut preferences, no shortcut chords, no single-letter global keys in V1.
  • This panel is the customer-facing surface; the full engineering contract stays here in /components.

Feedback & state

Undo — exact state restoration

Rescheduling a follow-up is the easiest mistake to make in the queue, so the immediate Undo window must put everything back exactly as it was — not recalculate a plausible new position. Undo restores a snapshot captured before the change. This is local prototype state only: nothing was sent to GoHighLevel, so nothing is being reversed there either.

src/components/fq/store.tsx (updateUndoable) · src/components/fq/views/QueueView.tsx

Props

PropTypeDefaultDescription
updateUndoable(id, patch, msg, opts?) *(id: string, patch: Partial<Lead>, msg: string, opts?: UndoOptions) => voidApplies the change, shows the toast, and holds the pre-action snapshot for the life of that toast.
opts.undoMsgstringConcise restoration confirmation, e.g. 'Follow-up restored to Tuesday, August 11'.
opts.captureUi() => unknownSnapshot of screen-local UI (expanded card, open picker, confirmation flags) taken at action time.
opts.restoreUi(snapshot: unknown) => voidPuts that screen-local UI back verbatim.
opts.focusKeystringdata-fq-focus key that receives focus once the restore lands.

TypeScript interface

export interface UndoOptions {
  undoMsg?: string;
  captureUi?: () => unknown;
  restoreUi?: (snapshot: unknown) => void;
  focusKey?: string;
}

Variants

Reschedule follow-upSnooze to a preset dateMark complete

Default: Reschedule follow-up

Visual states

  • Snapshot capturedBefore the patch: the full lead record, its index in the list, the selected lead, the scroll offset of the panel body, and screen-local UI.
  • ChangedRow moves to its new bucket and date; toast reads 'Follow-up moved to <Weekday, Month Day>' with Undo for ~5s.
  • RestoredThe exact record returns at its original index. No duplicate card is rendered during the swap, and no stale date, badge, or confirmation copy survives.
  • Deterministic orderUpcoming sorts by follow-up date, then by the lead's original queue position — a total order, so a restored lead lands back in the exact slot it left, including among leads sharing a date. See 'Upcoming queue — stable ordering'.

Interaction states

  • Undo windowOnly the current toast can be undone. There is no action history and no undo after the toast clears.
  • FocusFocus moves to the restored lead's follow-up date control (data-fq-focus="queue-date-<id>"), so keyboard users land on the thing that changed back.
  • Scroll & selectionThe panel body returns to the offset it had at action time, and the previously selected lead is reselected.
  • Reduced motionThe restore is a state swap; view transitions already honour prefers-reduced-motion, so nothing animates when reduction is requested.

Accessibility

  • The restoration is announced through the panel's single polite role="status" region, after the action toast clears, so the two messages never stack.
  • Announcement copy is one short sentence using the canonical date format: 'Follow-up restored to Tuesday, August 11.'
  • Focus is placed on the restored lead's date control rather than left on a button that has just been removed.
  • Fully operable by keyboard: open the date control with Enter, choose a preset, then Tab to Undo and press Enter.

Status mappings

Confirmation copy
`Follow-up moved to ${formatFollowUpDate(iso)}`
Restoration copy
`Follow-up restored to ${formatFollowUpDate(previousIso)}`
No previous date
'Follow-up restored'
Completed → queue
'<First name> moved back to the queue'

Example usage

updateUndoable(
  lead.id,
  { bucket: "Upcoming", due: formatFollowUpLine(iso), followUpDate: iso },
  `Follow-up moved to ${formatFollowUpDate(iso)}`,
  {
    undoMsg: `Follow-up restored to ${formatFollowUpDate(lead.followUpDate)}`,
    focusKey: `queue-date-${lead.id}`,
    captureUi: () => ({ expanded: snoozeFor, dateChanged }),
    restoreUi: (s) => { setSnoozeFor(s.expanded); setDateChanged(s.dateChanged); },
  },
);

Chrome side-panel constraints

  • Verified with Playwright on leads at the beginning, middle and end of the queue: the row moves to the correct new position, and Undo returns the exact pre-action snapshot (record, order, expanded card, scroll offset, focus) with no duplicate cards.
  • Verified in light, dark and reduced-motion modes, and keyboard-only, with the same result and no console errors.
  • Covered by the automated axe suite as scenario 'queue-undo-restored' (npm run test:a11y).
  • Scope is the immediate Undo window only — deliberately no action history and no multi-step undo.

Feedback & state

Upcoming queue — stable ordering

The Upcoming queue is read top to bottom, so its order has to be predictable. Ordering is computed from the leads themselves, never from the order they happen to sit in state, so nothing shifts on its own after a reschedule, an Undo, or a rerender. There is no drag-and-drop and no manual reordering.

src/lib/queue-order.ts · src/components/fq/views/QueueView.tsx

Props

PropTypeDefaultDescription
sortUpcoming(rows) *<T extends Orderable>(rows: readonly T[]) => T[]Pure sort used by the Upcoming section. Returns a new array; never mutates the queue.
compareUpcoming(a, b)(a: Orderable, b: Orderable) => numberThe comparator: follow-up date, then original queue position, then lead id.
queuePosition(id)(id: string) => numberThe lead's original queue position. Seed leads use their authored index; leads captured in-session are assigned the next position once, in arrival order.

TypeScript interface

export type Orderable = { id: string; followUpDate?: string };

export function compareUpcoming(a: Orderable, b: Orderable): number;
export function sortUpcoming<T extends Orderable>(rows: readonly T[]): T[];
export function queuePosition(id: string): number;

Variants

Primary sortTie-breakerUndo restoration

Default: Primary sort

Visual states

  • Primary sort ruleFollow-up date ascending, earliest first. Dates are ISO strings, so a plain string comparison is chronological.
  • Tie-breaker ruleLeads sharing a date keep their original queue position — the index the lead had when the queue was first built. Lead id is the final fallback, so the comparator is always a total order.
  • Undo restoration ruleUndo restores the lead's previous follow-up date and record. Because position is a property of the lead rather than of the array, the lead reappears in its exact former slot within that date group — not appended to the end of it.
  • Same-date exampleNoor Haddad, Reuben Osei and Clara Nyström all fall on Wednesday, August 12 and always render in that order.

Interaction states

  • RescheduleMoving a lead to another date removes it from its date group; the remaining same-date leads keep their relative order.
  • UndoThe lead returns to the exact index it held among its same-date neighbours. No duplicate card is rendered during the swap.
  • Other actionsCompleting a lead, changing a status, switching queue sections or filters, searching, and rerendering all leave the order untouched.
  • Presentation changesLight/dark theme and reduced-motion mode are presentation only and never touch ordering.

Accessibility

  • DOM order is the sorted order, so screen-reader reading order matches the visual order exactly.
  • Keyboard-only rescheduling and Undo produce the identical order to pointer use — the same comparator runs either way.
  • Ordering never changes as a side effect of focus, hover, or announcements.

Status mappings

1. Primary
followUpDate ascending (ISO string compare)
2. Tie-breaker
queuePosition(id) — original queue position
3. Fallback
id.localeCompare(id) — guarantees a total order

Example usage

const rows = bucket === "Upcoming" ? sortUpcoming(filtered) : filtered;

Chrome side-panel constraints

  • Test cases: (1) three leads share Wednesday, August 12; (2) confirm the initial order Noor → Reuben → Clara; (3) move the middle lead (Reuben) to another date; (4) Noor and Clara keep their order; (5) Undo; (6) Reuben returns between Noor and Clara; (7) repeat with the first and last of the group; (8) rerender and switch filters — order unchanged; (9) repeat keyboard-only — same result; (10) reading order matches visual order.
  • Verified in light and dark themes and with reduced motion; no console errors and no duplicate cards.
  • Limitations: ordering is session-scoped like the rest of the prototype — a reload rebuilds it from the seed data. Only the Upcoming section is date-sorted; Overdue, Today, Waiting for Reply and Completed keep their authored order. No drag-and-drop or manual reordering exists, by design.

Shell

Onboarding — Back and per-step Edit

Setup should feel safe to explore. Back returns one logical step with everything the user entered intact, and anything already done can be reopened from the completion recap without repeating the rest. Nobody is ever asked to start over because of a small correction. No branching, no wizard navigation, no extra steps were added.

src/lib/onboarding-state.ts · src/components/fq/onboarding.tsx

Props

PropTypeDefaultDescription
step *numberCurrent step index. 0–5 are the numbered steps; 6 is the completion recap.
completednumber[]Steps the user finished. Preserved across Back and Edit.
skippednumber[]Optional steps the user skipped — GoHighLevel is the only one today.
editReturnnumber | nullWhere an Edit was opened from. Saving or cancelling returns there directly; null during the normal forward pass.
data *OnboardingDataLead name and business, Messenger state, GoHighLevel state, follow-up choice, reply draft and whether the user has typed into it.

TypeScript interface

interface OnboardingData {
  messengerConnected: boolean;
  ghl: "pending" | "connected" | "skipped";
  leadName: string;
  leadBusiness: string;
  leadSaved: boolean;
  followUp: string | null;
  reply: string;
  replyDrafted: boolean;
  replyEdited: boolean;
}

interface OnboardingState {
  step: number;
  completed: number[];
  skipped: number[];
  editReturn: number | null;
  data: OnboardingData;
}

Variants

BackEdit from recapResume after close

Default: Back

Visual states

  • BackOne quiet control, bottom left under the primary button, secondary weight with a small left arrow. Hidden on the first screen. Reads 'Cancel' while editing.
  • EditA small pencil action on the right of each recap row. Subtle by default; it never competes with 'Go to Today'.
  • Editing a stepThe step reopens with its own heading and lede, and the primary button reads 'Save and return' instead of 'Next'.
  • Recap after an editOnly the affected accomplishment row changes. Every other row keeps its previous wording.

Interaction states

  • Back navigationReturns to the immediately previous step. Entered values, completed steps, skipped steps and progress are all preserved — Back never resets the flow.
  • EditOpens only that step. Saving returns straight to the recap; intermediate screens are not replayed.
  • Cancel an editReturns to the recap with the value unchanged.
  • EscapeDoes not navigate. Back is an explicit control only, so no keystroke can lose a screen of work.
  • Closing setupProgress and edited values are written on every change, so reopening resumes at the same step with the same values.

Accessibility

  • Back and Edit are real buttons: keyboard reachable, in natural Tab order, with the standard 2px focus ring and a ≥44px effective hit target.
  • Back moves focus to the step heading, which is tabIndex={-1}, so the screen is announced from the top.
  • Saving an edit moves focus back to the Edit control of the row that changed, so the user lands where they left the recap.
  • A single polite role="status" region announces the outcome only — for example 'Follow-up updated.' The stepper is not re-announced after a small edit.
  • All movement uses the existing fq-rise transition, which respects prefers-reduced-motion.

Status mappings

Lead name changed
Updates the lead preview and the recap row. Follow-up, draft reply and progress untouched.
Follow-up changed
Updates the queue preview and the recap row. Lead and draft reply untouched.
Reply edited
Manual wording is kept verbatim; a new draft is only written after the user confirms 'Replace draft'.
GoHighLevel skipped then connected
Moves the step from skipped to completed and rewrites only that recap row.

Example usage

const goBack = () =>
  setState((s) =>
    s.editReturn !== null
      ? { ...s, step: s.editReturn, editReturn: null }
      : { ...s, step: Math.max(0, s.step - 1) },
  );

Chrome side-panel constraints

  • Persistence: current step, completed steps, skipped steps, edited values and resume position are stored under fq-onboarding-progress-v1 and cleared when setup finishes. No secrets, tokens, passwords or live CRM data are stored — none exist in this flow.
  • Verification: walked Back through every step; edited the lead, the follow-up day and the reply; skipped GoHighLevel and connected it later from the recap; confirmed only the affected recap row changed each time and no reset occurred. Repeated keyboard-only, with the screen reader order matching the visual order, in light mode, dark mode and reduced motion. Closing and reopening setup resumed on the same step with edited values intact.
  • Limitations: the panel has no browser history of its own, so browser Back leaves the extension rather than stepping through setup — in-panel Back is the single navigation control, by design.

Feedback & state

Status badge — accessible name & announcement rules

A status badge must speak its status word exactly once. Not “Flame, Hot”, not “Hot, Hot”, not “Clock, Waiting”. The visible text is the accessible name; the glyph is decorative and never reaches assistive technology. Automated checks read Chrome's own accessibility tree, so the assertion reflects what the browser hands to a screen reader rather than our reading of the markup.

src/components/fq/primitives.tsx · tests/a11y/accessible-name.ts · tests/a11y/status-badge-name.spec.ts

Props

PropTypeDefaultDescription
status *LeadStatusSupplies both the visible label and the whole accessible name.

TypeScript interface

// Approved naming strategy — visible text names the badge.
<span data-status-badge="Hot">
  <Flame aria-hidden focusable="false" />
  Hot
</span>

// Interactive exception — action + current value, spoken once.
<button aria-label="Lead status: Hot. Change status"> … </button>

Variants

Non-interactive badgeInteractive status control

Default: Non-interactive badge

Visual states

  • Hot / Warm / Cold / Waiting / WonSpoken output is exactly “Hot”, “Warm”, “Cold”, “Waiting”, “Won”.
  • Theme changeLight and dark modes produce byte-identical accessible names.

Interaction states

  • Interactive status controlNames the action and the current value once: “Lead status: Hot. Change status”. The glyph stays decorative and contributes no wording.
  • Tooltip open / closeNo tooltip repeats a visible status label, so opening or closing one never re-announces the status.

Accessibility

  • Tooltips: a non-interactive badge gets no tooltip that merely repeats its label; genuinely additional information is attached only to the interactive control the user opens or focuses.
  • Failure output names the status, screen, theme, computed name, duplication source and the offending DOM element, so a failure is fixable without re-running by hand.
  • Limitation: computed accessible names are not spoken phrasing. Screen readers add role and state wording of their own, and vendors differ.
  • Manual checklist, retained alongside the automated suite: with NVDA or VoiceOver on desktop, arrow through a queue row, a lead card and the save preview; confirm each badge speaks its status word once, that no glyph name is heard, and that opening the status control announces the action and current value once.
  • Result: all automated scenarios pass, and the negative suite confirms the audit detects each regression it is meant to catch.

Status mappings

Decorative icon
aria-hidden="true" and focusable="false" on every status glyph
SVG <title>
Never rendered inside a decorative glyph — a <title> becomes part of the computed name
Hidden helper text
No visually hidden text may repeat the visible status word
aria-label
Not allowed on a badge whose visible text is already the correct name
aria-describedby
Never wired to text that duplicates the label
Local test command
bunx playwright test --config=playwright.config.ts status-badge-name
Automated coverage
24 scenarios: light + dark across Today, queue rows, search results, lead detail, GoHighLevel save preview, /design-system matrix and /components playground
Negative coverage
Injects an SVG <title>, a duplicating tooltip, a redundant aria-label, a duplicated aria-describedby, duplicate hidden text and a missing aria-hidden — each must fail the audit

Example usage

// Read what the browser would expose for every badge on screen
const { problems, names } = await auditStatusBadges(page, ctx);
expect(names).toEqual(["Hot", "Waiting", "Won"]);

Chrome side-panel constraints

  • Applies at every supported side-panel width; compact layouts change geometry only, never naming.
  • The same rules govern the /design-system state matrix and the /components props playground.

Feedback & state

Contrast Audit — WCAG threshold selector

A compact selector inside the existing Contrast Audit that points the same sweep at one WCAG 2.1 threshold at a time. It is a filter on the existing audit — same measurement, same compositing, same grading — not a second audit system and not a separate report.

src/components/ds/ContrastAudit.tsx · src/lib/contrast-audit.ts

Props

PropTypeDefaultDescription
kindsAuditKind[]Optional restriction passed to runAudit(). Omitted = every threshold.

TypeScript interface

type AuditKind = "normal-text" | "large-text" | "non-text";

runAudit(document.body, { kinds: ["non-text"] });

Variants

AllNormalLargeNon-text

Default: All

Visual states

  • AllOriginal behaviour: every element graded against the threshold that applies to it.
  • Normal textBody copy and placeholders only, graded at 4.5:1 (WCAG 1.4.3).
  • Large text≥24px, or ≥18.66px bold, graded at 3:1 (WCAG 1.4.3).
  • Non-text UIIcons, control boundaries and fills, graded at 3:1 (WCAG 1.4.11).

Interaction states

  • Switching thresholdRe-runs the same sweep on the current screen. Previous results and highlights stay on screen until the replacement is ready, so the panel never blanks, and focus stays on the radio the user just chose.
  • RecalculatingOne loading line — “Recalculating… showing previous results”. A sweep already in flight blocks any new one, so there is never a second indicator or an overlapping scan.
  • Background refreshThe periodic re-measure is quiet: no loading line, no announcement. Only user-initiated scans announce.
  • Not applicableA focused view reports how many elements belong to a different threshold and were set aside — they are never reclassified into the selected category.

Accessibility

  • Native radio group with a “WCAG threshold under audit” legend: arrow keys move between options, Space selects, and focus stays on the chosen radio while the sweep runs.
  • Completion is announced once per user-initiated scan through a single role="status" region; the continuously refreshed summary is deliberately not a live region, so background re-measures stay silent.
  • The toolbar is marked data-audit-ui so it never audits itself and never alters app styles.

Status mappings

Normal text
4.5:1 — WCAG 1.4.3
Large text
3:1 — WCAG 1.4.3
Non-text UI
3:1 — WCAG 1.4.11: meaningful controls, icons, focus indicators, control boundaries and borderless control fills
Large-text definition
≥24px, or ≥18.66px at weight 700+ — applied only to text that genuinely qualifies
Classification
Unchanged by the selector: every element is graded against its own applicable threshold; the selector filters the view
Persistence
Session state only — never stored, never a customer preference
Design tokens
Untouched — switching threshold changes no styles, only which findings are shown
Availability
import.meta.env.DEV only — the control is dropped from production bundles
Production fallback
Audit sweeps every threshold, exactly as before
Verified
Light and dark modes, reduced motion, 320 / 400 / 480px side-panel widths

Example usage

// Headless sweep of one threshold
window.__fqContrastAudit();              // complete audit
window.__fqContrastAudit("large-text");  // focused QA view
runAudit(document.body, { kinds: ["large-text"] });

Chrome side-panel constraints

  • Developer and QA tooling only — it is never a customer-facing setting.
  • Threshold choice is per session; it does not persist into the product.

Shell

View transition & return continuity

How a screen leaves and how you come back to work. Returning from a secondary screen — most visibly 'Done' on the GoHighLevel success card — must feel like the panel was never left: same scroll position, same lead, same filters, focus back on the control you pressed. Motion is a short cross-fade and nothing else.

src/components/fq/SidePanel.tsx + store.tsx + src/lib/motion.ts

Props

PropTypeDefaultDescription
navKeystring`${view}:${activeId}` — the identity of the screen on stage. Keys scroll memory and the fade.
scrollMemoryMap<string, number>Scroll offset per navKey, written on scroll and on unmount, restored in useLayoutEffect before paint.
pendingFocusKeystring | nulldata-fq-focus key of the control that navigated away. Set only by back(), consumed once, then cleared.
clearPendingFocus() => voidConsumes the pending focus key so focus is never re-applied on a later render.
uiMemoryMap<string, unknown>Screen-scoped UI state (filters, expanded sections) behind useScreenState(key, initial).
VIEW_TRANSITION_MSnumber160Single source of truth for the fade duration, shared by CSS and the Done handler.
useReducedMotion() / prefersReducedMotion()() => booleanReact hook and imperative check for prefers-reduced-motion, live-updating with the OS setting.

TypeScript interface

export const VIEW_TRANSITION_MS = 160;

export function useScreenState<T>(key: string, initial: T): [T, (v: T) => void];
export function useReducedMotion(): boolean;
export function prefersReducedMotion(): boolean;

interface NavContinuity {
  navKey: string;                       // "conversation:maya"
  scrollMemory: Map<string, number>;
  uiMemory: Map<string, unknown>;
  pendingFocusKey: string | null;
  clearPendingFocus: () => void;
}

Variants

forward (go / openLead)return (back / Done)reduced motion

Default: return, full motion

Visual states

  • Timing160ms, cubic-bezier(0.22, 1, 0.36, 1). Well under the ~250ms ceiling, so the return reads as instant but not abrupt.
  • Motion typeOpacity only (`fq-view` in, `fq-leaving` out). No slide, no scale, no page movement — sliding screens create a wizard feel the product deliberately avoids.
  • Done on the success cardThe card fades out over 160ms, then the panel returns. One fade, not two: the outgoing card and incoming screen never animate against each other.
  • Reduced motionprefers-reduced-motion collapses the fade to an instant swap (global CSS) and the Done handler skips its timer entirely — return is immediate. Scroll, selection, filters and focus restore identically.
  • No flickerThe scroll container is persistent; only the inner screen is keyed. Position is restored in useLayoutEffect, before the browser paints.

Interaction states

  • Scroll restorationThe offset is saved continuously while scrolling and once more on unmount, then written back before paint. You land on the exact pixel you left, never at the top.
  • Selection restorationactiveId is panel state, so the same lead is still selected. Nothing refetches and no loading skeleton reappears on return.
  • Expanded sections & filtersuseScreenState(key, initial) keeps screen-owned UI — e.g. the Queue bucket filter — in panel memory across the round trip. Transient popovers are deliberately NOT restored: reopening a menu you never asked for is disorienting.
  • Focus restorationgo()/openLead() record the data-fq-focus key of the focused control. back() replays it once, focusing with preventScroll so restoring focus can't fight scroll restoration. If the departing control had no key, focus is left alone — never guessed.
  • No unexpected focus movementFocus only moves on an actual back-navigation, and only to the control the person themselves used to leave. Forward navigation never moves focus programmatically.

Accessibility

  • Returning is not a state change worth announcing. No live region fires on Done — the sync outcome was already announced once, and it is never re-announced.
  • The success card's role="status" region is written once and cleared, so the fade-out and navigation cannot re-trigger it.
  • Focus lands on a real, visible, interactive control with the standard fq-focus ring — never on a container, and never on an element that is animating out.
  • focus({ preventScroll: true }) keeps restored focus from overriding restored scroll.
  • prefers-reduced-motion removes decoration only. Continuity — position, selection, focus, copy — is identical in both modes.
  • Nothing about the transition implies a live CRM write; the simulation notice lives in the success card and stays until it fades.

Status mappings

data-fq-focus="conversation-sync"
Sync button on the lead screen — focus target after Done.
data-fq-focus="conversation-manage-sync"
'Manage sync →' on the GoHighLevel card — focus target after Done when that was the entry point.
fq-view
Incoming screen: 160ms fade in.
fq-leaving
Outgoing success card: 160ms fade out before back().

Example usage

// Panel body: persistent scroller, keyed screen, restore before paint
<div ref={scroller} className="fq-scroll flex-1 overflow-y-auto">
  <div key={navKey} className="fq-view">{screen}</div>
</div>

useLayoutEffect(() => {
  if (lastKey.current === navKey) return;
  lastKey.current = navKey;
  scroller.current!.scrollTop = scrollMemory.get(navKey) ?? 0;
}, [navKey]);

// Done: fade, then return. Reduced motion returns immediately.
const handleDone = () => {
  if (prefersReducedMotion()) return back();
  setLeaving(true);
  window.setTimeout(back, VIEW_TRANSITION_MS);
};

// Trigger opts into focus restoration by naming itself
<Btn data-fq-focus="conversation-sync" onClick={() => go("sync")}>Sync</Btn>

Chrome side-panel constraints

  • At 400px wide there is no room for spatial metaphor. Cross-fade is the only transition in the product — do not add slide or push for 'hierarchy'.
  • Never remount the scroll container on navigation; keying the container is what caused the old jump-to-top on return.
  • Restore what the person set (scroll, filters, selection). Do not restore what the person closed (menus, popovers, toasts).

Feedback & state

Toast (status / alert)

Confirms a destructive-feeling action and offers a one-tap reversal, which is why the product has no confirm dialogs.

src/components/fq/SidePanel.tsx + store.tsx

Props

PropTypeDefaultDescription
msg *stringPast-tense confirmation, e.g. 'Maya marked complete'.
tone"status" | "error""status"status = confirmation (role="status"); error = actionable failure (role="alert", stays until dismissed).
undo() => voidPresence renders the Undo affordance and extends the lifetime to 5s (3.5s without).
retry() => voidError tone only — repeats the failed action.
onDismiss() => voidAlways rendered by the shell as a labelled close control.
notify(msg, undo?)(msg: string, undo?: () => void) => voidStore API for confirmations.
notifyError(msg, opts?)(msg: string, opts?: { retry?: () => void }) => voidStore API for actionable failures.
updateUndoable(id, patch, msg)(id: string, patch: Partial<Lead>, msg: string) => voidApplies a patch, snapshots the previous values, and wires Undo automatically.

TypeScript interface

export interface ToastEntry {
  id: number;
  msg: string;
  tone: "status" | "error";
  duration: number; // 0 = stays until dismissed
  undo?: (() => void) | undefined;
  retry?: (() => void) | undefined;
  originKey?: string | null; // data-fq-focus key of the triggering control
}

export interface ToastApi {
  toast: ToastEntry | null;
  notify: (msg: string, undo?: () => void) => void;
  notifyError: (msg: string, opts?: { retry?: () => void }) => void;
  dismissToast: () => void;
  updateUndoable: (id: string, patch: Partial<Lead>, msg: string) => void;
}

Variants

confirmationundoableerror

Default: confirmation (no undo)

Visual states

  • ConfirmationInverted pill (bg-foreground / text-background), check icon, 12px label, 3.5s.
  • UndoableSame pill plus an underlined UNDO action, 5s.
  • Errorbg-danger pill, alert icon, optional RETRY; no timeout — it waits for Dismiss.
  • ReplacedA new toast replaces the current one — only one toast exists at a time.

Interaction states

  • Enterfq-rise, 180ms — collapsed to an instant appearance under prefers-reduced-motion.
  • Undo pressRestores the snapshot and dismisses immediately.
  • Hover / focusPauses the auto-dismiss countdown so Undo, Retry and Dismiss stay reachable; it resumes on leave.
  • Manual dismissReturns focus to the triggering control (matched by data-fq-focus) when focus was inside the toast.
  • TimeoutAuto-dismiss for confirmations only; errors never time out.

Accessibility

  • Two shell-owned live regions: role="status" aria-live="polite" for confirmations, role="alert" for errors.
  • The message is written to the live region once per toast id, and the visible text is aria-hidden — re-renders never repeat an announcement.
  • Toasts never move focus when they appear.
  • Undo, Retry and Dismiss are real buttons in the tab order; each carries the message in its accessible name ("Undo: Maya marked complete").

Status mappings

Complete
'{First} marked complete' + undo
Snooze
'Follow-up moved to Tuesday, August 11' + undo — canonical date format
Reschedule
'Follow-up moved to Monday, August 3' + undo
Status change
'{First} set to {Status}' + undo
Capture / Sync
success confirmation, no undo (the sync screen owns its own state)

Example usage

updateUndoable(
  lead.id,
  { bucket: "Completed", due: "Completed today" },
  `${lead.name.split(" ")[0]} marked complete`,
);

Chrome side-panel constraints

  • Positioned bottom-20 so it clears the bottom navigation, width calc(100% - 2rem).
  • Toasts queue, oldest on top, capped at 3 — a pending Undo is never replaced.

Feedback & state

Reschedule (FollowUpPicker)

One scheduling flow. Snooze and Reschedule are the same action: the shared FollowUpPicker opens inline with one preset list (Today / Tomorrow / In 3 days / Next week) plus an exact date.

src/components/fq/views/QueueView.tsx + src/lib/snooze.ts

Props

PropTypeDefaultDescription
open *booleanOne picker open at a time — the queue tracks { id, key } so focus returns to the exact control that opened it.
onPick *(option: SnoozeOption) => voidApplies the preset.
onClose *() => voidCollapses the menu.
optionsSnoozeOption[]followUpPresetsToday / Tomorrow / In 3 days / Next week — the only preset list in the product.

TypeScript interface

export interface SnoozeOption {
  id: string;    // "today" | "tomorrow" | "3d" | "1w"
  label: string; // "In 3 days"
  days: number;  // 0 | 1 | 3 | 7
}

export interface SnoozeMenuProps {
  open: boolean;
  onPick: (option: SnoozeOption) => void;
  onClose: () => void;
  options?: SnoozeOption[]; // default followUpPresets
}

Variants

TodayTomorrowIn 3 daysNext weekExact date

Default: no preselection

Visual states

  • ClosedNothing rendered; the Reschedule button is a plain ghost.
  • OpenInline panel inside the card (not a floating popover): full-width preset rows, label left, resolved date right as 'Tuesday, August 11', plus an exact-date grid.
  • AppliedPicker closes, lead moves to Today or Upcoming depending on the date, due/followUpDate update, undo toast appears.

Interaction states

  • TogglePressing Reschedule again closes the picker; opening another card's picker closes this one.
  • Enterfq-rise, 140ms; height is not animated to avoid list jitter.

Accessibility

  • Grouped inline panel; Escape closes and returns focus to the exact control that opened it (date link or Reschedule).
  • Each row reads label + date, so the outcome is spoken, not inferred.

Status mappings

Today
addDays(0) → bucket 'Today'
Tomorrow
addDays(1) → bucket 'Upcoming'
In 3 days
addDays(3) → bucket 'Upcoming'
Next week
addDays(7) → bucket 'Upcoming'

Example usage

{followUpPresets.map((o) => {
  const iso = addDays(o.days);
  return (
    <button key={o.id} onClick={() => updateUndoable(
      lead.id,
      { bucket: "Upcoming", followUpDate: iso, due: formatFollowUpLine(iso) },
      `Follow-up moved to ${formatFollowUpDate(iso)}`,
    )}>
      <span>{o.label}</span>
      <span className="text-[11.5px]">{formatFollowUpDate(iso)}</span>
    </button>
  );
})}

Chrome side-panel constraints

  • Inline, not floating: a popover at 400px would clip against the panel edge or need a portal.
  • Custom dates are intentionally absent — the queue is a rhythm, not a calendar.

Feedback & state

Alert (success / warning / danger / info)

Persistent in-flow messaging tied to a screen — sync failures, disconnected GoHighLevel, review-before-sending notices.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
tone"info" | "success" | "warning" | "danger""info"Meaning, not decoration.
title *stringOne clause, sentence case.
childrenReactNodeOptional 11.5px explanation.
actionReactNodeAt most one recovery button, usually secondary sm.

TypeScript interface

export interface AlertProps {
  tone?: "info" | "success" | "warning" | "danger"; // default "info"
  title: string;
  children?: ReactNode;
  action?: ReactNode;
}

Variants

infosuccesswarningdanger

Default: info

Visual states

  • Infoinfo-soft fill, Info icon — neutral context such as the AI review notice.
  • Successsuccess-soft, Check — lead captured, contact synced.
  • Warningwarning-soft, TriangleAlert — recoverable, e.g. missing phone before sync.
  • Dangerdanger-soft, AlertTriangle — sync failed, GoHighLevel disconnected. Always pairs with a Retry action.

Interaction states

  • StaticAlerts do not auto-dismiss; that is what Toast is for.
  • ActionSingle recovery button; never two competing actions.

Accessibility

  • Danger and warning alerts should be role="alert"; info/success can stay silent.
  • Title text carries the meaning — colour is secondary.

Status mappings

Sync failed
danger + 'Retry sync'
Lead captured
success (Card tone="success" is the alternative for the full-screen moment)
AI drafts
info — 'Review before sending'
Missing data
warning — sync still allowed

Example usage

<Alert tone="danger" title="Sync failed" action={<Btn variant="secondary" size="sm" onClick={retry}>Retry</Btn>}>
  GoHighLevel didn't respond. Nothing was changed on the contact.
</Alert>

Chrome side-panel constraints

  • Place the alert directly above the control it concerns; at 400px a top-of-screen banner loses its referent after scrolling.

Feedback & state

Skeleton / Progress / IndeterminateBar

Waiting states without spinners. Skeletons for content that has a known shape, an indeterminate bar for work of unknown length, Progress only when there is a real count.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
Skeleton.classNamestringWidth/height override; base is h-3 w-full with the fq-skeleton shimmer.
Progress.value *number0–100, clamped.
Progress.labelstringLeft label; the percentage renders in mono on the right.

TypeScript interface

export interface SkeletonProps { className?: string }

export interface ProgressProps {
  value: number;   // 0–100
  label?: string;
}

// IndeterminateBar takes no props.

Variants

skeletonindeterminatedeterminate

Default: skeleton

Visual states

  • SkeletonShimmer between --skeleton-base and --skeleton-highlight; the highlight brightens in dark mode.
  • Indeterminate1px track, one-third-width primary bar travelling on loop.
  • Determinate1.5px track, primary fill, width transitions 300ms ease-out.

Interaction states

  • NoneLoading states are never interactive; keep the surrounding layout stable so nothing shifts on resolve.

Accessibility

  • Mark the region aria-busy="true" and keep the final layout identical to the skeleton.
  • Progress needs role="progressbar" with aria-valuenow/min/max.

Status mappings

AI generating
card skeletons + 'Reading the last N messages…'
GHL syncing
IndeterminateBar under the Sync button
Bulk work
Progress with a real count

Example usage

<div aria-busy className="space-y-2">
  <Skeleton /><Skeleton className="w-[90%]" /><Skeleton className="w-[60%]" />
</div>

<IndeterminateBar />
<Progress value={62} label="Syncing contacts" />

Chrome side-panel constraints

  • No spinners anywhere in the product — a rotating element in a narrow panel reads as instability.
  • Skeleton counts should match the real content count so the panel height does not jump.

Feedback & state

Loading → empty transition

The rule for every surface that may finish loading with nothing to show: the result must read as an intentional statement, never as data that failed to arrive.

src/components/fq/loading.tsx

Props

PropTypeDefaultDescription
useLoading(duration, deps, delay = 120, min = 320)hookReturns { loading, showSkeleton }. Nothing is revealed for the first 120ms, and once revealed the skeleton is held at least 320ms.
LoadingSlot.show *booleanOpacity gate. The skeleton renders (invisibly) during the pre-reveal window so the region's height never collapses.
LoadedStatus.loading *booleanSilent while true.
LoadedStatus.message *stringThe one sentence announced ~60ms after loading resolves — the outcome, not the process.

TypeScript interface

export function useLoading(
  duration?: number, deps?: unknown[], delay?: number, min?: number,
): { loading: boolean; showSkeleton: boolean };

export interface LoadingSlotProps { show: boolean; children: ReactNode }
export interface LoadedStatusProps { loading: boolean; message: string }

Variants

instant (<120ms)brief (120–320ms)normalresolves to empty

Default: normal

Visual states

  • InstantUnder 120ms nothing is ever revealed: the surface goes straight from reserved space to content or empty state. No flash.
  • BriefRevealed skeletons are held to 320ms minimum, so a skeleton is never seen blinking.
  • Resolving to emptyThe empty state replaces the skeleton in the same slot and fades in with fq-fade — opacity only, no travel.
  • RefreshExisting content stays on screen; skeletons are only for first load of a surface.

Interaction states

  • NoneLoading is never interactive and never moves focus. Keyboard position is untouched across the whole transition.

Accessibility

  • Skeleton regions are aria-hidden="true": assistive tech hears nothing while waiting.
  • Loading captions are visual only; LoadingCaption no longer announces by default.
  • Exactly one role="status" per surface, and it speaks only after loading resolves — so "loading" and "empty" are never read back to back.
  • Focus is never moved by a transition; only user-initiated navigation moves it.

Status mappings

Loading
skeleton only — no empty state, no error state, no announcement
Loaded with content
content + one polite outcome sentence
Loaded with nothing
empty state + one polite outcome sentence

Example usage

const { loading, showSkeleton } = useLoading(800);

<LoadedStatus loading={loading} message={rows.length ? `${rows.length} follow-ups in your queue.` : "Your follow-up queue is empty."} />

{loading ? (
  <LoadingSlot show={showSkeleton}><FollowUpsLoading rows={3} /></LoadingSlot>
) : rows.length ? (
  rows.map(...)
) : (
  <Empty spec={emptyStates.noUpcoming} />
)}

Chrome side-panel constraints

  • Never render the empty state while loading is still true — the ternary always branches on `loading` first.
  • Never render an error state before loading completes; failures replace the skeleton, they don't precede it.
  • Skeleton row counts approximate the real content so the panel height barely moves on resolve.
  • Audited surfaces: Today (context, overdue, hot, quiet threads), Follow-up queue, Search, Reply suggestions, CRM match lookup, Sync to GoHighLevel.

Feedback & state

Search completion states

Search over the follow-up queue has exactly four outcomes — loading, results, no results, connection error — and each one presents a single obvious next step.

src/components/fq/search-states.tsx

Props

PropTypeDefaultDescription
useLeadSearch(leads, query)hookDebounced simulated search. Returns { phase, showSkeleton, results, retry }. phase is idle | loading | results | empty | error and can only reach an outcome after loading finishes.
SearchNoResults.query *stringEchoed back in the copy so the user can see exactly what was searched.
SearchNoResults.onClear *() => voidPrimary CTA — 'Clear search'.
SearchConnectionError.onRetry *() => voidPrimary CTA — 'Try again'. The simulated outage is transient, so a retry always resolves.
SearchConnectionError.onClose *() => voidSecondary CTA — 'Close' clears the search and returns the full queue.

TypeScript interface

export type SearchPhase = "idle" | "loading" | "results" | "empty" | "error";

export function useLeadSearch(leads: Lead[], query: string, delay?: number, min?: number): {
  phase: SearchPhase; showSkeleton: boolean; results: Lead[]; retry: () => void;
};

export interface SearchNoResultsProps { query: string; onClear: () => void }
export interface SearchConnectionErrorProps {
  onRetry: () => void; onClose: () => void; retrying?: boolean;
}

Variants

loadingresultsno resultsconnection error

Default: results

Visual states

  • Loading<SearchLoading /> compact row skeletons inside a <LoadingSlot>, 120ms pre-reveal and 320ms minimum hold. No empty or error state can appear underneath it.
  • ResultsThe normal queue sections, filtered. Buckets with no match are omitted rather than showing their own empty card.
  • No resultsDashed neutral card, crossed-magnifier (SearchX) tile, the searched term quoted back, three contextual tips, and one 'Clear search' button.
  • Connection errorSolid warning-toned card, disconnected-plug (PlugZap) tile, 'Try again' primary above a 'Close' ghost, and a mono 'Simulated for this prototype' footnote.

Interaction states

  • TypingEach keystroke restarts the debounce; the previous outcome stays on screen until the new one is ready, so nothing flickers between characters.
  • RetryReturns to the loading skeleton in the same slot and resolves to results or no results.
  • ClearEmpties the field and restores the full queue immediately — no skeleton, because nothing is being fetched.

Accessibility

  • The field is labelled 'Search leads by name, business or note'.
  • Loading is silent: skeleton regions are aria-hidden and the single role="status" only speaks the outcome — '3 leads match maya' or 'No leads match zebra'.
  • No results is a plain region; it is never announced as an error.
  • The connection error is role="alert" once, and it takes focus on its heading because it replaces the result area the user was watching. Nothing else moves focus.
  • Both CTAs are real buttons in reading order: primary first, secondary second, both reachable by Tab and operable with Enter and Space.
  • The error card does not rely on colour: a distinct icon, a distinct border weight and explicit copy all carry the meaning.

Status mappings

Loading
skeletons + role="status" (silent until resolved)
No results
neutral empty card — never role="alert", nothing failed
Connection error
role="alert", announced once, focus moves to the heading

Example usage

const search = useLeadSearch(leads, query);

{search.phase === "loading" ? (
  <LoadingSlot show={search.showSkeleton}><SearchLoading rows={3} /></LoadingSlot>
) : search.phase === "error" ? (
  <SearchConnectionError onRetry={search.retry} onClose={() => setQuery("")} />
) : search.phase === "empty" ? (
  <SearchNoResults query={query} onClear={() => setQuery("")} />
) : (
  <Results leads={search.results} />
)}

Chrome side-panel constraints

  • Copy standard: no results states the fact then offers the way back ('No matching leads were found. Try a different search term, or clear your search to view all leads.'). The error states what happened, reassures that existing data is safe, and asks for one retry — no status codes, no jargon.
  • Prototype simulation: search runs over mock data with no network call, so a real failure is impossible. Typing 'offline' triggers the simulated error, and the card says 'Simulated for this prototype' so it is never mistaken for a live incident. A retry always succeeds.
  • Transitions are opacity-only (fq-fade) inside a height-reserving slot, and the global prefers-reduced-motion rule collapses them to an instant swap.

Feedback & state

EmptyState

Turns an empty screen into a calm statement of fact plus, at most, one way forward.

src/components/fq/primitives.tsx

Props

PropTypeDefaultDescription
icon *ReactNodeLucide icon at 18px.
title *stringThe fact — 'Nothing overdue'.
body *stringOne or two sentences of context or reassurance.
actionReactNodeOptional single button, capped at 240px.

TypeScript interface

export interface EmptyStateProps {
  icon: ReactNode;
  title: string;
  body: string;
  action?: ReactNode;
}

Variants

informationalactionable

Default: informational (no action)

Visual states

  • DefaultDashed border, card/60 fill, 44px icon tile, 14px title, 12px body clamped at 280px.
  • First runIncludes a primary action, e.g. 'Capture your first lead'.

Interaction states

  • Enterfq-rise on mount; nothing else animates.

Accessibility

  • Icon is decorative (aria-hidden); the title carries the message.
  • Never use an empty state to hide an error — errors use Alert.

Status mappings

Overdue
'Nothing overdue' — praise, no action
Today
'No follow-ups today' + capture hint
Upcoming / Waiting / Completed
quiet factual copy, no action
No leads at all
primary 'Capture your first lead'
No AI context
'Not enough context yet' + back to conversation

Example usage

<EmptyState
  icon={<ListChecks size={18} />}
  title="No captured leads yet"
  body="Open a Facebook conversation and capture the prospect."
  action={<Btn variant="primary" size="sm" full onClick={() => go("capture")}>Capture your first lead</Btn>}
/>

Chrome side-panel constraints

  • Vertically centred inside the scroll area, not the panel — the header and nav stay put.

Shell

BottomNav (TabBar)

Four fixed destinations. Detail screens (conversation, replies, sync) are pushed on top and return via back — they are never tabs.

src/components/fq/SidePanel.tsx

Props

PropTypeDefaultDescription
view *ViewCurrent route in the panel store.
onNavigate *(v: View) => voidPushes onto the history stack.
tabs{ id: View; label: string; icon: LucideIcon }[]Today, Queue, Capture, SettingsFixed set — adding a fifth requires a design review.

TypeScript interface

export type View =
  | "today" | "conversation" | "replies"
  | "capture" | "queue" | "sync" | "settings";

export interface BottomNavProps {
  view: View;
  onNavigate: (v: View) => void;
  tabs?: { id: View; label: string; icon: LucideIcon }[];
}

Variants

4-up grid (fixed)

Default: Today · Queue · Capture · Settings

Visual states

  • InactiveMuted icon at strokeWidth 1.9 with a 10.5px label.
  • ActivePrimary colour, strokeWidth 2.4 — weight and colour only, no pill or underline.
  • Detail screen openThe originating tab stays visually active; the back control lives in the content, not the nav.

Interaction states

  • Hovermuted → foreground.
  • PressBody swaps with fq-rise keyed on view; the bar itself never animates.

Accessibility

  • Use <nav> with aria-current="page" on the active tab.
  • Each tab is a 44px-tall target with a visible label — no icon-only navigation.

Example usage

<nav className="sticky bottom-0 grid grid-cols-4 border-t border-border bg-background/90 px-2 py-1.5 backdrop-blur">
  {tabs.map((t) => (
    <button key={t.id} onClick={() => onNavigate(t.id)} aria-current={view === t.id ? "page" : undefined}>
      <t.icon size={17} strokeWidth={view === t.id ? 2.4 : 1.9} />
      {t.label}
    </button>
  ))}
</nav>

Chrome side-panel constraints

  • Four is the maximum at 400px: five labels at 10.5px start truncating.
  • Sticky, not fixed, so it stays inside the panel document in MV3.

Contrast Audit

Dev & QA only — not a customer setting