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
| Preview | Size | Stroke | Use | Examples |
|---|---|---|---|---|
| 12px | 2.4 | Micro-markers stacked on another element — never standalone. | Avatar status corner glyph, checkbox tick, chevron inside a chip | |
| 16px | 1.9 | The default. Inline with 13px text: buttons, list rows, fields, menus, toasts. | Btn leading icon, LeadCard meta row, toast icon, input adornment | |
| 20px | 1.9 | Standalone icon-only controls and navigation, where the glyph is the only label. | Bottom nav tabs, header icon buttons, overflow menu trigger | |
| 24px | 1.6 | Section-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.
| State | Token | Behaviour |
|---|---|---|
| Default | currentColor | Icons 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 / selected | text-primary + strokeWidth 2.4 | Nav tabs and selected chips. Colour and weight change together so the state survives greyscale. |
| Hover | text-foreground | Muted glyph resolves to full foreground with the parent's 120ms colour transition. No scale, no rotation. |
| Focus | ring-2 ring-ring/60 on the control | The ring is drawn on the button, never on the glyph. Icons are never focusable (focusable="false"). |
| Disabled | opacity-45 on the control | The whole control dims together; do not dim the icon separately or it detaches from its label. |
| Semantic | text-success / text-warning / text-danger / text-info | Only for the five feedback glyphs, and only inside their own alert, toast, or badge. |
| Light mode | Same tokens | muted-foreground clears 4.5:1 on background, card, and tinted status surfaces. |
| Dark mode | Same tokens | Dark raises token lightness rather than reusing light values; 1.9px stroke is kept because thinner strokes bloom on dark surfaces. |
6 · Accessibility checklist
- Decorative icons — Any glyph paired with visible text is decorative: aria-hidden="true" and focusable="false". A status badge announces "Hot", never "Flame, Hot".
- Informative icons — An 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 labels — Every icon-only control carries an aria-label written as the action ("Snooze follow-up"), not the glyph ("clock").
- Screen readers — Never announce state through a glyph alone. Loading uses aria-busy plus a live region; success uses the toast text, not the check.
- Touch targets — Minimum 32×32px, 40×40px for primary and nav targets, with ≥8px between adjacent targets — the glyph stays 16–20px inside that box.
- Contrast — Icons 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 controlCards
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 warningsToasts
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 nameIncorrect
<span className="text-danger"><Flame aria-label="Hot" /></span> // colour-only + doubled nameEmpty states
Correct
<Empty spec={emptyStates.inboxZero} /> // one 24px glyph in a tinted tileIncorrect
<img src="/illustration-robot.png" /> // no mascots, no raster art, no second libraryNavigation
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 vocabulary9 · 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 ButtonUsage
<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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| full | boolean | false | Stretches to the container width — the default for panel commit actions. |
| disabled | boolean | false | Native disabled; renders at 50% opacity and drops pointer events. |
| ...rest | ButtonHTMLAttributes<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
Default: variant="secondary" size="md" full={false}
Visual states
- Default — Radius xl, 600 weight, 12–14px label, icon at 13–15px on the left.
- Disabled — opacity .5, pointer-events none. Never hide an action — disable it.
- Loading — Keep the label, swap the icon for IndeterminateBar below the button. No spinners.
Interaction states
- Hover — primary brightens 8%, secondary fills with secondary, ghost gains a secondary wash.
- Active — scale .985 for 120ms — the only scale transform allowed.
- Focus-visible — 2px 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
| Prop | Type | Default | Description |
|---|---|---|---|
| status * | LeadStatus | — | Hot | Warm | Cold | Waiting | Won. Picks both the colour pairing and the fixed glyph from src/lib/status-icons.ts. |
| muted | boolean | — | Unselected presentation in a picker — neutral tokens instead of dimming, so contrast holds. The glyph stays. |
| className | string | — | Layout 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
Default: none — status is required
Visual states
- Default — Pill, 1px border at 25% status colour, soft tint fill, 13px glyph in currentColor at 1.9 stroke, 6px gap, 11px 600 label.
- Muted — Neutral border/secondary fill/muted-foreground ink. The glyph is unchanged, so an unselected option is still identifiable.
- Greyscale — With colour removed the five badges stay distinct on silhouette alone: flame, rising line, snowflake, clock, circled check. No two share an outline.
- Avatar marker — Avatar renders the same glyph in a card-surfaced corner chip (16px, 20px at size lg) in the status ink — never a bare colour dot.
- Dense — Inside list rows the badge shrinks nothing — it truncates the name instead.
Interaction states
- Static — Non-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
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | string | — | Field label, 12px 600. Always visible — no placeholder-only fields. |
| hint | string | — | 11px helper below the control. |
| detected | boolean | false | Shows the mono 'Auto-detected' chip; used when a value came from the page, not the user. |
| children * | ReactNode | — | The control — TextInput, TextArea, Select. |
| TextInput/TextArea ...rest | Input/TextareaHTMLAttributes | — | value, 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
Default: text, detected = false
Visual states
- Default — Card fill, 1px --input border, radius xl, 13px text.
- Filled — Identical to default — no state colour for simply having content.
- Error — Border --danger, message in Field hint styled text-danger. Reserve for blocking problems only.
- Disabled — opacity .6, no border change.
Interaction states
- Focus — 2px --ring/60 ring plus border --ring. No motion.
- Typing — No 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
| Prop | Type | Default | Description |
|---|---|---|---|
| children * | ReactNode | — | <option> elements. |
| ...rest | SelectHTMLAttributes<HTMLSelectElement> | — | value, onChange, disabled. |
TypeScript interface
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;Variants
Default: default
Visual states
- Default — Input shell + trailing ChevronDown, 14px, muted.
- Disabled — opacity .6, chevron stays.
Interaction states
- Focus — Same ring as TextInput.
- Open — Native 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
| Prop | Type | Default | Description |
|---|---|---|---|
| checked * | boolean | — | Controlled state (Checkbox, Toggle). |
| onChange * | (v: boolean) => void | — | Fires with the next value. |
| label * | string | — | Visible label. |
| description | string | — | Toggle only — 11px secondary line. |
| value / options | T / { 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
Default: checkbox
Visual states
- Unchecked — --input border, card fill.
- Checked — Checkbox/Toggle fill --primary; radio row tints accent/60 with a primary border.
- Disabled — opacity .6, no pointer events.
Interaction states
- Hover — Radio and toggle rows wash to secondary.
- Press — Toggle knob travels 200ms on the standard ease; nothing else moves.
- Focus-visible — Ring 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
| Prop | Type | Default | Description |
|---|---|---|---|
| SearchInput ...rest | InputHTMLAttributes<HTMLInputElement> | — | value, onChange, placeholder. Leading Search icon is built in. |
| Chip.children * | ReactNode | — | Tag or metadata label. |
| Chip.onClick | () => void | — | Presence switches the element from span to button. |
| Avatar.initials * | string | — | 1–2 letters derived from the lead name. |
| Avatar.size | "sm" | "md" | "lg" | "md" | 32 / 40 / 56px. |
| Avatar.status | LeadStatus | — | Adds 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
Default: Chip static, Avatar size="md"
Visual states
- Default — Chip: secondary fill, muted text. Avatar: secondary circle, ring-border, mono-ish initials.
- With status — Avatar dot uses the same five status colours as StatusBadge.
Interaction states
- Hover — Actionable chips darken text to foreground; static chips do nothing.
- Focus — SearchInput 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
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | T | — | Selected tab id. |
| onChange * | (v: T) => void | — | Selection 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
Default: segmented
Visual states
- Segmented idle — Track bg-secondary p-1 rounded-xl; inactive labels muted, no fill.
- Segmented active — bg-card, shadow-card, foreground text — a raised slab, no accent colour.
- Rail active — bg-accent + border-primary/30 pill; inactive is card with border.
Interaction states
- Hover — Inactive label goes muted → foreground.
- Press — Content swaps with fq-rise (140ms); the tab itself does not animate.
- Overflow — Rail 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
| Prop | Type | Default | Description |
|---|---|---|---|
| status * | LeadStatus | — | Hot | Warm | Cold | Waiting | Won. Picks both the semantic tone and the fixed glyph. |
| muted | boolean | — | Neutral presentation for inactive rows. Uses neutral tokens rather than opacity so contrast holds. |
| className | string | — | Layout 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 weightVariants
Default: Default (semantic)
Visual states
- Hot — Flame. Danger tone — one of the five surfaces allowed strong emphasis.
- Warm — Rising line. Warning tone.
- Cold — Snowflake. Neutral tone; opposite silhouette to Flame.
- Waiting — Clock. Info tone. The longest label — spacing is never compressed to fit it.
- Won — Circled check. Success tone; the only enclosed glyph.
Interaction states
- Inside a pressable row — The badge is decorative-inert; the focus ring belongs to the surrounding row or card and stays visible at compact widths.
- Container shrink — The 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
| Prop | Type | Default | Description |
|---|---|---|---|
| formatFollowUpDate(iso) * | (iso: string) => string | — | Visible date: 'Tuesday, August 11'. Adds ', 2027' only when the date is outside the current calendar year. |
| formatFollowUpFull(iso) * | (iso: string) => string | — | Always includes the year. Use for screen-reader labels and any confirmation that must stand alone. |
| formatFollowUpLine(iso) | (iso: string) => string | — | Visible list line: helper word plus the exact date — 'Today · Monday, August 3'. The exact date is always present. |
| followUpRelative(iso) | (iso: string) => string | null | — | Secondary helper only: 'Today', 'Tomorrow', 'Yesterday', 'N days overdue', otherwise null. Never shown alone. |
| followUpAria(iso, prefix?) | (iso: string, prefix?: string) => string | — | Spoken label: 'Follow up tomorrow, Tuesday, August 11, 2026'. |
| daysFromToday(iso) | (iso: string) => number | null | — | Whole-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
Default: Weekday, Month Day
Visual states
- Exact — Default everywhere: lead cards, queue rows, date-picker triggers and presets, sync summaries, capture confirmation.
- Helper + exact — List meta lines where recency matters — 'Today · Monday, August 3', 'Overdue · Saturday, August 1'. The relative word never replaces the date.
- With year — Automatic when the date is not in the current calendar year, so January dates seen in December are never ambiguous.
- Narrow layout — Meta lines use truncate + whitespace-nowrap; half-width cards drop to 12.5px with leading-snug rather than wrapping mid-date.
Interaction states
- Picker preset — Presets are full-width rows — relative label left ('Tomorrow'), resolved exact date right — so the outcome is visible before the tap.
- Undo toast — Confirmations 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
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "default" | "alert" | "success" | "default" | alert = overdue/hot (hot border + tint), success = captured/synced. Tone is earned, not decorative. |
| interactive | boolean | false | Renders a <button>, adds hover lift and focus ring. |
| onClick | () => void | — | Required when interactive. |
| className | string | — | Padding/layout overrides only (e.g. p-3 for dense rows). |
| children * | ReactNode | — | Composition slot. |
TypeScript interface
export interface CardProps {
children: ReactNode;
tone?: "default" | "alert" | "success"; // default "default"
interactive?: boolean; // default false
onClick?: () => void;
className?: string;
}Variants
Default: tone="default", interactive={false}
Visual states
- Default — rounded-2xl, 1px border, --card fill, shadow-card, p-3.5.
- Alert — border-hot/35 + bg-hot-soft/50. Only for overdue promises and hot leads.
- Success — border-success/30 + bg-won-soft/50. Capture and sync confirmations.
- Loading — Same shell with Skeleton lines inside — never collapse the card.
Interaction states
- Hover (interactive) — -1px translate, border-foreground/15, shadow-lift, 200ms.
- Active — Returns to translate-y-0.
- Focus-visible — 2px --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
| Prop | Type | Default | Description |
|---|---|---|---|
| lead * | Lead | — | Full lead record. |
| note | string | — | Overrides lead.dueNote for context-specific copy. |
| onOpen * | (id: string) => void | — | Opens the conversation view. |
TypeScript interface
export interface LeadCardProps {
lead: Lead;
note?: string;
onOpen: (id: string) => void;
}Variants
Default: default
Visual states
- Default — Avatar md + status dot / name 14px 600 / StatusBadge right / business muted 12px / note 2-line clamp / due in mono uppercase 10px.
- Overdue — Wrap in tone="alert" — the only list item allowed to carry a tinted background.
- Loading — Three Skeleton lines at 60% / 90% / 40% width inside the same shell.
Interaction states
- Hover — Card lift; nothing inside the card changes.
- Press — Opens 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
| Prop | Type | Default | Description |
|---|---|---|---|
| lead * | Lead | — | Active lead. |
| onStatusChange * | (status: LeadStatus) => void | — | Routes through updateUndoable so the change is revertible. |
TypeScript interface
export interface ConversationCardProps {
lead: Lead;
onStatusChange: (status: LeadStatus) => void;
}Variants
Default: default
Visual states
- Default — Avatar lg, name 18px 700, business + source meta, status row of five selectable pills, last-contact timestamp in mono.
- No context — If the Facebook tab is not a conversation, show the EmptyState instead of an empty card.
Interaction states
- Status press — Applies immediately and fires the undo toast — no confirm dialog.
- Back — Left 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
| Prop | Type | Default | Description |
|---|---|---|---|
| lead * | Lead | — | Queue item. |
| bucket * | QueueBucket | — | Owning bucket; 'Completed' hides the action row. |
| onOpen * | (id: string) => void | — | Opens the conversation. |
| onComplete * | (id: string) => void | — | Moves to Completed with an undo toast. |
| onReschedule * | (id: string) => void | — | Opens 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
Default: actionable
Visual states
- Default — Card p-3: Avatar sm, name + StatusBadge, situation line, mono due line, chevron; divider then two ghost sm buttons.
- Completed — Action row removed; due text reads 'Completed today'.
- Picker open — Reschedule holds bg-secondary while the follow-up picker is expanded below the divider.
Interaction states
- Complete — Row leaves its bucket instantly; the 6s undo toast is the safety net.
- Reschedule — Toggles the shared follow-up picker; picking a date moves the lead to Today or Upcoming.
- Hover — Ghost 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
| Prop | Type | Default | Description |
|---|---|---|---|
| summary * | string | — | 2–3 sentence recap. |
| facts * | string[] | — | 3–5 bullets, each a single verifiable detail. |
| objection | string | — | Quoted in their words. |
| nextAction * | string | — | One recommended action, imperative voice. |
| loading | boolean | false | Renders 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
Default: default
Visual states
- Default — Eyebrow label, 12.5px relaxed body, facts as a check list, objection in a quoted block, next action in a soft accent block.
- Loading — Four Skeleton lines (100/95/80/60%) plus 'Reading the last N messages…' in muted 12px.
- Empty — EmptyState: fewer than three messages means no summary — say so, do not guess.
Interaction states
- Static — Read-only. The card offers no edit affordance; corrections happen in the reply draft.
- Regenerate — Optional 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
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
| Prop | Type | Default | Description |
|---|---|---|---|
| match * | SyncMatch | — | Discriminated union: new, or existing with name/company/hint. |
| actions * | string[] | — | Plain-language outcomes, e.g. 'Update phone', 'Create follow-up task'. |
| className | string | — | Layout 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
Default: none — match is required
Visual states
- New — UserPlus in a primary/10 tile, mono 'New contact' eyebrow, and the sentence 'This will create a new GoHighLevel contact.'
- Existing — UserRound in a secondary tile, 'Existing contact' eyebrow, matched name + · company, optional hint footer.
- Loading — Same shell with two Skeleton lines in the header while the match resolves.
Interaction states
- Static — Read-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
| Prop | Type | Default | Description |
|---|---|---|---|
| spec * | SyncFailureSpec | — | Copy record from src/lib/sync-errors.ts. Never pass ad-hoc strings — all failure copy lives in the registry. |
| attempt * | number | — | 0 = first failure. Renders the quiet 'Attempt n' mono counter from the second failure onward. |
| retrying * | boolean | — | Retry in flight: primary button swaps to a spinner + 'Trying again…', and every action in the card is disabled. |
| onRetry * | () => void | — | Runs the same save again. Caller must no-op while retrying is true. |
| onClose * | () => void | — | Dismisses recovery and returns to the ready state. Caller returns focus to the control that started the save. |
| onGetHelp * | () => void | — | Opens Settings — the connection screen. Primary action when spec.retryable is false; a quiet tertiary 'Check your connection' otherwise. |
| diagnostics * | SyncDiagnostics | null | — | Plain-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
Default: retryable, drawer available, attempt 0
Visual states
- Failure — Triangle 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. warning — Warnings 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. success — Success uses a circled check, success border and a completed checklist. Failure never borrows the circle.
- Retrying — Card stays exactly where it is; only the primary button changes to spinner + 'Trying again…'. The screen never flashes back to an empty state.
- Repeat failure — Same card, updated spec (connection → timeout): softer wording, attempt counter appears, diagnostics disclosure appears if available.
- No technical disclosure — The 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 set — Primary '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 press — Sets retrying, disables all actions. Duplicate submissions are impossible from both the button (disabled) and the handler (early return).
- Retry succeeds — Transitions straight into the approved Sync Success card — same success experience as a first-try save, plus the standard toast.
- Retry fails again — Stays in the same recovery flow with the next failure spec. The user is never bounced elsewhere.
- Review sync details — Opens 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.
- Close — Returns 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
| Prop | Type | Default | Description |
|---|---|---|---|
| saveFocusKey(leadId) | (leadId: string) => string | — | Builds 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) => () => void | — | Re-resolves and focuses the control after the surface has closed. Returns a cancel function; call it from the effect cleanup. |
| opts.label | string | — | Human name of the control, used only in the fallback announcement. |
| opts.scope | HTMLElement | null | — | Limits the fallback search to the current screen. |
| opts.onFallback | (message: string) => void | — | Fires only when focus lands somewhere other than the exact control. Wire it to a role="status" region. |
| opts.waitFor | number | — | Default 600ms. How long a present-but-disabled control is waited for before falling back. |
| data-focus-fallback | string (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; // cancelVariants
Default: exact restore
Visual states
- Exact restore — Focus lands on the same Save control with the standard 2px fq-focus ring plus its offset halo — legible in light and dark.
- Fallback restore — Focus 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 button — Sets the return flag, closes the card, and restores on the next frame after unmount.
- Escape — Same 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 click — The 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 dismissal — A 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
| Prop | Type | Default | Description |
|---|---|---|---|
| headline * | string | — | Heading 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. |
| subline | string | — | Quiet 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. |
| details | string[] | — | Simulated diagnostics, shown behind 'View prototype details' inside the review dialog. |
| reviewRows | SyncDetailRow[] | — | Summary rows for the 'Review sync details' dialog. Omit and the affordance does not render. |
| onDone * | () => void | — | Primary 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
Default: inline, no details
Visual states
- Success — Circled 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.
- Checklist — Each outcome staggers in at 45ms intervals. Check glyphs are decorative; the words carry the meaning.
- Details open — Mono 10.5px diagnostics, closed by default, chevron rotates 180°.
- Simulation notice — Always present: 'Preview simulation — no live CRM data was changed.' Success never implies a real CRM write.
Interaction states
- Appears inline — No focus movement. The person keeps their place; the announcement does the telling.
- Appears after trigger unmounts — Focus lands on the 'Done' button — the primary action, never the container.
- Done — Fades 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 details — Native 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
| Prop | Type | Default | Description |
|---|---|---|---|
| spec * | AiFailureSpec | — | Copy record from src/lib/ai-errors.ts. Never pass ad-hoc strings — all drafting failure copy lives in the registry. |
| attempt * | number | — | 0 = first failure. From the second onward the quiet mono 'Attempt n' counter appears. |
| retrying * | boolean | — | Retry in flight: primary button becomes a spinner + 'Writing a reply…' and every action is disabled, so a second request is impossible. |
| replacesView | boolean | true | True 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 * | () => void | — | Runs the same request again. Caller must no-op while a request is open. |
| onEditManually * | () => void | — | Switches to the manual composer with the person's existing draft intact. |
| onClose * | () => void | — | Leaves 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
Default: timeout, attempt 0, replacesView
Visual states
- Loading — AiLoading 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".
- Timeout — Crossed clock glyph + mono 'Took too long' + 2px left rule. Shape and words carry it; colour is the quietest cue, so it survives greyscale.
- Failure — Triangle glyph + 'Didn't finish'. Same layout as timeout so nothing jumps between them.
- Unavailable / rate limit / empty — Struck-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.
- Retrying — Card stays exactly where it is; only the primary button changes to spinner + 'Writing a reply…'. The screen never flashes back to a skeleton.
- Repeat failure — Same card, updated spec and attempt counter. Wording softens rather than escalating, and the person is never bounced elsewhere.
- Success after retry — Transitions 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 again — Retries the identical request. A ref guard plus the disabled button make duplicate requests impossible.
- Edit manually — Opens the composer pre-filled with whatever the person already wrote. Their text is never cleared by a failed or successful generation.
- Close — Returns to the conversation with the draft intact.
- Reduced motion — Spinner 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
| Prop | Type | Default | Description |
|---|---|---|---|
| open * | boolean | — | Controlled visibility. Nothing renders when false, so no hidden focusables sit in the tab order. |
| onClose * | () => void | — | Fires for Close, the X, Escape and scrim click. The sheet restores focus to triggerRef itself. |
| model * | SyncDetailsModel | — | The unified model from buildSyncSuccessDetails() or toSyncDetailsModel(diagnostics). Callers must not render the sheet — or its trigger — when there is no model. |
| retryable | boolean | — | Failure only. False hides 'Retry save', because retrying can't help until the person changes something. |
| retrying | boolean | — | Retry in flight: the button disables, reads 'Trying again…' and carries aria-busy. |
| onRetry | () => void | — | Runs the same retry as the card. The sheet closes first so the loading state and the outcome are visible. |
| triggerRef | RefObject<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
Default: success
Visual states
- Closed — Renders nothing at all. No scrim, no off-screen focusables.
- Open — Bottom 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 save — Contact, Company, Saved (friendly local time) and Current status, each with a one-line explanation underneath.
- Step by step — Contact match, CRM connection, Conversation summary, Follow-up task. Glyph + outcome word + one sentence: Check/'Completed', Minus/'Didn't complete', Question/'Not known'.
- What this means — Failure 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.
- Copied — The copy button label swaps to 'Summary copied' for ~2.4s. No toast — the sheet is already the focused context.
Interaction states
- Open — Focus moves into the sheet, onto the heading (tabIndex={-1}) so the status line is the first thing spoken.
- Tab / Shift+Tab — Trapped inside the sheet: Close(X) → scroll region → Retry save → Copy summary → Close → wraps.
- Escape — Closes and stops propagation, so one Escape never dismisses the card behind it too.
- Scrim click — Closes. The scrim is aria-hidden and not keyboard reachable.
- Close — Focus returns to the 'Review sync details' button on the card that opened it.
- Retry from the sheet — Closes 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 summary — Writes 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 motion — The 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
| Prop | Type | Default | Description |
|---|---|---|---|
| mode * | "full" | "low" | "off" | "full" | Read from useDiscovery(). Persisted under fq-guidance-mode. |
| setMode * | (m: GuidanceMode) => void | — | Radio group in Settings › Help & hints. Announces the new mode via role="status". |
| completeWorkflow * | (w: GuidanceWorkflow) => void | — | Called by the view when the user actually finishes the action. Dismissing a hint never calls it. |
| workflowDone * | (w: GuidanceWorkflow) => boolean | — | True once that workflow has been completed at least once, across sessions. |
| reset * | () => void | — | Clears 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
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 suppressed — Validation, 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 workflow — The 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 hint — Hides that one moment (fq-discovery-seen) but does NOT mark the workflow learned, so a genuine first-use card can still teach it.
- Celebration — In 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 workflow — Completion is tracked per workflow, so an unlearned workflow can still show its first moment after others are done.
- Reset guidance history — Secondary ghost action behind a calm inline confirmation: "Show first-time tips again?" — first-use guidance becomes eligible again; nothing else changes.
- Mode switch — Switching 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 = true | Contextual 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
Default: dialog
Visual states
- Closed — Nothing rendered. No persistent help affordance in Today, Conversation, Replies, Capture, Queue or Sync.
- Open — Modal card centred in the 400px frame, max height 88%, one scroll region. Grouped lists with <kbd> chips right-aligned.
- Contextual hint — One 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 / light — Chips use border-border on bg-secondary with text-foreground in both themes — verified ≥ 4.5:1.
Interaction states
- Open from Settings — Settings › 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 focus — Moves to the dialog heading (tabIndex -1), so screen readers announce the panel name and its description before the list.
- Focus trap — Tab / Shift+Tab cycle inside the dialog only, matching SyncDetailsDialog.
- Escape — Closes. The keydown listener is capture-phase and stops propagation so it never also closes a surface underneath.
- Close — Backdrop click, the X control or the Close button. Focus returns to the exact trigger.
- Reduced motion — The 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
| Prop | Type | Default | Description |
|---|---|---|---|
| updateUndoable(id, patch, msg, opts?) * | (id: string, patch: Partial<Lead>, msg: string, opts?: UndoOptions) => void | — | Applies the change, shows the toast, and holds the pre-action snapshot for the life of that toast. |
| opts.undoMsg | string | — | Concise restoration confirmation, e.g. 'Follow-up restored to Tuesday, August 11'. |
| opts.captureUi | () => unknown | — | Snapshot of screen-local UI (expanded card, open picker, confirmation flags) taken at action time. |
| opts.restoreUi | (snapshot: unknown) => void | — | Puts that screen-local UI back verbatim. |
| opts.focusKey | string | — | data-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
Default: Reschedule follow-up
Visual states
- Snapshot captured — Before 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.
- Changed — Row moves to its new bucket and date; toast reads 'Follow-up moved to <Weekday, Month Day>' with Undo for ~5s.
- Restored — The 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 order — Upcoming 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 window — Only the current toast can be undone. There is no action history and no undo after the toast clears.
- Focus — Focus 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 & selection — The panel body returns to the offset it had at action time, and the previously selected lead is reselected.
- Reduced motion — The 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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) => number | — | The comparator: follow-up date, then original queue position, then lead id. |
| queuePosition(id) | (id: string) => number | — | The 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
Default: Primary sort
Visual states
- Primary sort rule — Follow-up date ascending, earliest first. Dates are ISO strings, so a plain string comparison is chronological.
- Tie-breaker rule — Leads 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 rule — Undo 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 example — Noor Haddad, Reuben Osei and Clara Nyström all fall on Wednesday, August 12 and always render in that order.
Interaction states
- Reschedule — Moving a lead to another date removes it from its date group; the remaining same-date leads keep their relative order.
- Undo — The lead returns to the exact index it held among its same-date neighbours. No duplicate card is rendered during the swap.
- Other actions — Completing a lead, changing a status, switching queue sections or filters, searching, and rerendering all leave the order untouched.
- Presentation changes — Light/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
| Prop | Type | Default | Description |
|---|---|---|---|
| step * | number | — | Current step index. 0–5 are the numbered steps; 6 is the completion recap. |
| completed | number[] | — | Steps the user finished. Preserved across Back and Edit. |
| skipped | number[] | — | Optional steps the user skipped — GoHighLevel is the only one today. |
| editReturn | number | null | — | Where an Edit was opened from. Saving or cancelling returns there directly; null during the normal forward pass. |
| data * | OnboardingData | — | Lead 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
Default: Back
Visual states
- Back — One quiet control, bottom left under the primary button, secondary weight with a small left arrow. Hidden on the first screen. Reads 'Cancel' while editing.
- Edit — A small pencil action on the right of each recap row. Subtle by default; it never competes with 'Go to Today'.
- Editing a step — The step reopens with its own heading and lede, and the primary button reads 'Save and return' instead of 'Next'.
- Recap after an edit — Only the affected accomplishment row changes. Every other row keeps its previous wording.
Interaction states
- Back navigation — Returns to the immediately previous step. Entered values, completed steps, skipped steps and progress are all preserved — Back never resets the flow.
- Edit — Opens only that step. Saving returns straight to the recap; intermediate screens are not replayed.
- Cancel an edit — Returns to the recap with the value unchanged.
- Escape — Does not navigate. Back is an explicit control only, so no keystroke can lose a screen of work.
- Closing setup — Progress 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
| Prop | Type | Default | Description |
|---|---|---|---|
| status * | LeadStatus | — | Supplies 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
Default: Non-interactive badge
Visual states
- Hot / Warm / Cold / Waiting / Won — Spoken output is exactly “Hot”, “Warm”, “Cold”, “Waiting”, “Won”.
- Theme change — Light and dark modes produce byte-identical accessible names.
Interaction states
- Interactive status control — Names the action and the current value once: “Lead status: Hot. Change status”. The glyph stays decorative and contributes no wording.
- Tooltip open / close — No 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
| Prop | Type | Default | Description |
|---|---|---|---|
| kinds | AuditKind[] | — | 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
Default: All
Visual states
- All — Original behaviour: every element graded against the threshold that applies to it.
- Normal text — Body 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 UI — Icons, control boundaries and fills, graded at 3:1 (WCAG 1.4.11).
Interaction states
- Switching threshold — Re-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.
- Recalculating — One 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 refresh — The periodic re-measure is quiet: no loading line, no announcement. Only user-initiated scans announce.
- Not applicable — A 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
| Prop | Type | Default | Description |
|---|---|---|---|
| navKey | string | — | `${view}:${activeId}` — the identity of the screen on stage. Keys scroll memory and the fade. |
| scrollMemory | Map<string, number> | — | Scroll offset per navKey, written on scroll and on unmount, restored in useLayoutEffect before paint. |
| pendingFocusKey | string | null | — | data-fq-focus key of the control that navigated away. Set only by back(), consumed once, then cleared. |
| clearPendingFocus | () => void | — | Consumes the pending focus key so focus is never re-applied on a later render. |
| uiMemory | Map<string, unknown> | — | Screen-scoped UI state (filters, expanded sections) behind useScreenState(key, initial). |
| VIEW_TRANSITION_MS | number | 160 | Single source of truth for the fade duration, shared by CSS and the Done handler. |
| useReducedMotion() / prefersReducedMotion() | () => boolean | — | React 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
Default: return, full motion
Visual states
- Timing — 160ms, cubic-bezier(0.22, 1, 0.36, 1). Well under the ~250ms ceiling, so the return reads as instant but not abrupt.
- Motion type — Opacity 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 card — The 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 motion — prefers-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 flicker — The scroll container is persistent; only the inner screen is keyed. Position is restored in useLayoutEffect, before the browser paints.
Interaction states
- Scroll restoration — The 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 restoration — activeId is panel state, so the same lead is still selected. Nothing refetches and no loading skeleton reappears on return.
- Expanded sections & filters — useScreenState(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 restoration — go()/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 movement — Focus 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
| Prop | Type | Default | Description |
|---|---|---|---|
| msg * | string | — | Past-tense confirmation, e.g. 'Maya marked complete'. |
| tone | "status" | "error" | "status" | status = confirmation (role="status"); error = actionable failure (role="alert", stays until dismissed). |
| undo | () => void | — | Presence renders the Undo affordance and extends the lifetime to 5s (3.5s without). |
| retry | () => void | — | Error tone only — repeats the failed action. |
| onDismiss | () => void | — | Always rendered by the shell as a labelled close control. |
| notify(msg, undo?) | (msg: string, undo?: () => void) => void | — | Store API for confirmations. |
| notifyError(msg, opts?) | (msg: string, opts?: { retry?: () => void }) => void | — | Store API for actionable failures. |
| updateUndoable(id, patch, msg) | (id: string, patch: Partial<Lead>, msg: string) => void | — | Applies 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
Default: confirmation (no undo)
Visual states
- Confirmation — Inverted pill (bg-foreground / text-background), check icon, 12px label, 3.5s.
- Undoable — Same pill plus an underlined UNDO action, 5s.
- Error — bg-danger pill, alert icon, optional RETRY; no timeout — it waits for Dismiss.
- Replaced — A new toast replaces the current one — only one toast exists at a time.
Interaction states
- Enter — fq-rise, 180ms — collapsed to an instant appearance under prefers-reduced-motion.
- Undo press — Restores the snapshot and dismisses immediately.
- Hover / focus — Pauses the auto-dismiss countdown so Undo, Retry and Dismiss stay reachable; it resumes on leave.
- Manual dismiss — Returns focus to the triggering control (matched by data-fq-focus) when focus was inside the toast.
- Timeout — Auto-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
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
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "info" | "success" | "warning" | "danger" | "info" | Meaning, not decoration. |
| title * | string | — | One clause, sentence case. |
| children | ReactNode | — | Optional 11.5px explanation. |
| action | ReactNode | — | At 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
Default: info
Visual states
- Info — info-soft fill, Info icon — neutral context such as the AI review notice.
- Success — success-soft, Check — lead captured, contact synced.
- Warning — warning-soft, TriangleAlert — recoverable, e.g. missing phone before sync.
- Danger — danger-soft, AlertTriangle — sync failed, GoHighLevel disconnected. Always pairs with a Retry action.
Interaction states
- Static — Alerts do not auto-dismiss; that is what Toast is for.
- Action — Single 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
| Prop | Type | Default | Description |
|---|---|---|---|
| Skeleton.className | string | — | Width/height override; base is h-3 w-full with the fq-skeleton shimmer. |
| Progress.value * | number | — | 0–100, clamped. |
| Progress.label | string | — | Left 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
Default: skeleton
Visual states
- Skeleton — Shimmer between --skeleton-base and --skeleton-highlight; the highlight brightens in dark mode.
- Indeterminate — 1px track, one-third-width primary bar travelling on loop.
- Determinate — 1.5px track, primary fill, width transitions 300ms ease-out.
Interaction states
- None — Loading 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
| Prop | Type | Default | Description |
|---|---|---|---|
| useLoading(duration, deps, delay = 120, min = 320) | hook | — | Returns { loading, showSkeleton }. Nothing is revealed for the first 120ms, and once revealed the skeleton is held at least 320ms. |
| LoadingSlot.show * | boolean | — | Opacity gate. The skeleton renders (invisibly) during the pre-reveal window so the region's height never collapses. |
| LoadedStatus.loading * | boolean | — | Silent while true. |
| LoadedStatus.message * | string | — | The 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
Default: normal
Visual states
- Instant — Under 120ms nothing is ever revealed: the surface goes straight from reserved space to content or empty state. No flash.
- Brief — Revealed skeletons are held to 320ms minimum, so a skeleton is never seen blinking.
- Resolving to empty — The empty state replaces the skeleton in the same slot and fades in with fq-fade — opacity only, no travel.
- Refresh — Existing content stays on screen; skeletons are only for first load of a surface.
Interaction states
- None — Loading 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
| Prop | Type | Default | Description |
|---|---|---|---|
| useLeadSearch(leads, query) | hook | — | Debounced 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 * | string | — | Echoed back in the copy so the user can see exactly what was searched. |
| SearchNoResults.onClear * | () => void | — | Primary CTA — 'Clear search'. |
| SearchConnectionError.onRetry * | () => void | — | Primary CTA — 'Try again'. The simulated outage is transient, so a retry always resolves. |
| SearchConnectionError.onClose * | () => void | — | Secondary 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
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.
- Results — The normal queue sections, filtered. Buckets with no match are omitted rather than showing their own empty card.
- No results — Dashed neutral card, crossed-magnifier (SearchX) tile, the searched term quoted back, three contextual tips, and one 'Clear search' button.
- Connection error — Solid warning-toned card, disconnected-plug (PlugZap) tile, 'Try again' primary above a 'Close' ghost, and a mono 'Simulated for this prototype' footnote.
Interaction states
- Typing — Each keystroke restarts the debounce; the previous outcome stays on screen until the new one is ready, so nothing flickers between characters.
- Retry — Returns to the loading skeleton in the same slot and resolves to results or no results.
- Clear — Empties 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
| Prop | Type | Default | Description |
|---|---|---|---|
| icon * | ReactNode | — | Lucide icon at 18px. |
| title * | string | — | The fact — 'Nothing overdue'. |
| body * | string | — | One or two sentences of context or reassurance. |
| action | ReactNode | — | Optional single button, capped at 240px. |
TypeScript interface
export interface EmptyStateProps {
icon: ReactNode;
title: string;
body: string;
action?: ReactNode;
}Variants
Default: informational (no action)
Visual states
- Default — Dashed border, card/60 fill, 44px icon tile, 14px title, 12px body clamped at 280px.
- First run — Includes a primary action, e.g. 'Capture your first lead'.
Interaction states
- Enter — fq-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
Modal / Sheet
Rare interruption for a decision that cannot live inline. In a side panel it renders as a bottom sheet clipped to the panel, never a page-level overlay.
src/components/fq/primitives.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open * | boolean | — | Controlled visibility; returns null when closed. |
| onClose * | () => void | — | Scrim, X button, and Escape all call this. |
| title * | string | — | 15px 700. |
| description | string | — | 12px muted supporting line. |
| children | ReactNode | — | Body content. |
| footer | ReactNode | — | Right-aligned actions: ghost cancel + one primary. |
TypeScript interface
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children?: ReactNode;
footer?: ReactNode;
}Variants
Default: sheet — place-items-end at panel width
Visual states
- Closed — Not mounted.
- Open — foreground/25 scrim with 2px blur, rounded-2xl card, shadow-panel, 12px inset from the panel edges.
Interaction states
- Enter — fq-rise, 180ms; the scrim fades with it.
- Dismiss — Scrim tap, X, or Escape — all non-destructive.
Accessibility
- role="dialog" aria-modal="true"; the close button has an aria-label.
- Trap focus while open and restore it to the trigger on close.
- Do not use a modal for anything undo can cover — prefer the toast pattern.
Example usage
<Modal
open={open}
onClose={() => setOpen(false)}
title="Disconnect GoHighLevel?"
description="Captured leads stay in FollowQue. Nothing is deleted."
footer={<>
<Btn variant="ghost" onClick={() => setOpen(false)}>Cancel</Btn>
<Btn variant="danger" onClick={disconnect}>Disconnect</Btn>
</>}
/>Chrome side-panel constraints
- Positioned absolute inside the panel shell, so it never escapes the 400px frame or covers the Facebook page.
- One modal at a time; no nested dialogs.
Shell
Header
Persistent identity and connection status. It never changes per screen, which is what makes the panel feel stable.
src/components/fq/SidePanel.tsx
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| ghlConnected * | boolean | — | Drives the GHL pill. |
| dark / onToggleDark * | boolean / () => void | — | Theme control, persisted via useTheme. |
TypeScript interface
export interface HeaderProps {
ghlConnected: boolean;
dark: boolean;
onToggleDark: () => void;
}Variants
Default: default (FollowQue mark)
Visual states
- Default — Sticky, 85% background with backdrop blur, bottom border; wordmark left, GHL pill + theme button right.
- Connected — GHL pill in won-soft/success.
- Disconnected — GHL pill neutral secondary — quiet, not alarming; the Settings screen carries the fix.
Interaction states
- Theme toggle — Sun/Moon swap, colours cross-fade with the token transition.
- Scroll — Header stays put; content scrolls beneath the blur.
Accessibility
- Theme button needs aria-label="Toggle theme" and should expose aria-pressed.
- The GHL pill states its word — the dot alone is not the signal.
Example usage
<Header ghlConnected={ghlConnected} dark={dark} onToggleDark={toggle} />Chrome side-panel constraints
- Header + nav must stay under ~110px total so the content area keeps ~740px at the 850px minimum panel height.
- The wordmark never wraps the header to two lines.
Contrast Audit
Dev & QA only — not a customer setting