Files
multica/packages/views/onboarding/source-backfill-modal.tsx
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00

367 lines
14 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Briefcase,
CalendarDays,
Globe,
HelpCircle,
MoreHorizontal,
Newspaper,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { useAuthStore } from "@multica/core/auth";
import {
agentCompletedIssueCountOptions,
needsSourceBackfill,
saveQuestionnaire,
SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES,
type QuestionnaireAnswers,
type Source,
} from "@multica/core/onboarding";
import { useCurrentWorkspace } from "@multica/core/paths";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
} from "@multica/ui/components/ui/dialog";
import {
GitHubIcon,
GoogleIcon,
LinkedInIcon,
OpenAIIcon,
XIcon,
YouTubeIcon,
} from "./components/brand-icons";
import {
IconOptionCard,
IconOtherOptionCard,
type QuestionOption,
} from "./components/icon-option-card";
import { mergedQuestionnairePatch } from "./source-backfill-merge";
import { useSourceBackfillDismissCount } from "./source-backfill-dismiss";
import { useT } from "../i18n";
const EMPTY_BACKFILL: Pick<
QuestionnaireAnswers,
"source" | "source_other" | "source_skipped"
> = {
source: [],
source_other: null,
source_skipped: false,
};
/**
* Source-attribution prompt for onboarded users whose questionnaire
* never recorded a source. The question is not asked during
* onboarding at all — this prompt is its only collection point.
* Rendered as a Dialog overlay on top of the workspace shell — the
* user keeps their workspace context visible behind a dimmed backdrop.
*
* Self-mounted: the caller drops `<SourceBackfillModal />` once inside
* the dashboard layout. Two gates decide whether the dialog opens:
*
* 1. User-level: `needsSourceBackfill(user, dismissCount)` — no
* source recorded, never declined, dismiss cap not reached.
* 2. Workspace-level: agents (or squads) have completed at least
* SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES issues here. Attribution
* is a zero-payoff ask for the user, so it waits until Multica
* has visibly delivered value. The count query only runs while
* gate 1 passes, so settled users never pay for it.
*
* Once the dialog opens we capture the open decision in a ref so
* subsequent re-renders that flip the predicate to false (e.g. after
* submit, before refreshMe round-trips) don't tear the dialog away
* mid-animation.
*
* Three exit shapes:
* - Submit → PATCH merged questionnaire, terminal.
* - Skip → PATCH `source_skipped=true`, terminal (never
* ask again).
* - Close X / ESC → bump per-user dismiss counter; predicate
* returns false on next mount once the cap is
* reached.
*
* State persistence is intentional:
* - source / source_other: server (JSONB merged with prior answers).
* - dismissCount: per-user localStorage, view-layer only.
*/
export function SourceBackfillModal() {
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? null;
const [dismissCount, bumpDismissCount] =
useSourceBackfillDismissCount(userId);
// Gate 1 first: it's pure and free. Gate 2 costs a (tiny) issues
// query, so it's `enabled` only while gate 1 says this user still
// owes an answer and we know which workspace to count in.
const userEligible = needsSourceBackfill(user, dismissCount);
const wsId = useCurrentWorkspace()?.id ?? null;
const { data: agentDoneCount } = useQuery({
...agentCompletedIssueCountOptions(wsId ?? ""),
enabled: userEligible && wsId !== null,
});
const shouldPrompt =
userEligible &&
(agentDoneCount ?? 0) >= SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES;
// Decide once per (user, gate delta) whether the prompt should
// open. After it opens we stop reconsulting the predicate so a
// midflight refreshMe (which sets source) doesn't unmount the
// dialog while the submit animation is still running. `openedRef`
// is reset when the user identity changes.
//
// Strict-mode caveat: this effect runs twice in dev. The ref MUST
// only be stamped when the dialog actually opens — not when the
// effect schedules the timer — or the second strict-mode pass sees
// `openedForUserRef.current === user.id` and bails without setting
// up a new timer to replace the one strict-mode's cleanup just
// cleared, leaving the dialog forever closed.
const [open, setOpen] = useState(false);
const openedForUserRef = useRef<string | null>(null);
useEffect(() => {
if (!user) {
openedForUserRef.current = null;
setOpen(false);
return;
}
if (openedForUserRef.current === user.id) return;
if (!shouldPrompt) return;
// Soft entrance: let the user see the workspace for a beat before
// the modal floats in, so it doesn't feel like a hard block. Common
// delight pattern — ~700ms is short enough that nobody starts an
// interaction first but long enough that the workspace renders.
// Honour `prefers-reduced-motion: reduce`: those users have opted
// out of incidental animations, so open immediately.
const reducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
if (reducedMotion) {
openedForUserRef.current = user.id;
setOpen(true);
return;
}
const uid = user.id;
const timer = window.setTimeout(() => {
openedForUserRef.current = uid;
setOpen(true);
}, 700);
return () => window.clearTimeout(timer);
}, [user, shouldPrompt]);
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next || !open) return;
// Base UI fires onOpenChange(false) for X, ESC, and outside
// click — all three count as "dismiss without committing".
bumpDismissCount();
setOpen(false);
}}
>
<SourceBackfillDialogBody
onComplete={() => setOpen(false)}
/>
</Dialog>
);
}
SourceBackfillModal.displayName = "SourceBackfillModal";
/**
* Inner panel split out so its expensive setup (option list, effects)
* only runs while the dialog is actually open. Closing the dialog
* unmounts this subtree and clears the picker state — the next open
* starts fresh.
*/
function SourceBackfillDialogBody({
onComplete,
}: {
onComplete: () => void;
}) {
const { t } = useT("onboarding");
const [answers, setAnswers] = useState(EMPTY_BACKFILL);
const [busy, setBusy] = useState(false);
const options = useMemo<QuestionOption[]>(
() => [
{ slug: "friends_colleagues", icon: <Users className="h-4 w-4" />, label: t(($) => $.questions.source.friends_colleagues) },
{ slug: "search", icon: <GoogleIcon className="h-[18px] w-[18px]" />, label: t(($) => $.questions.source.search) },
{ slug: "social_x", icon: <XIcon className="h-[15px] w-[15px]" />, label: t(($) => $.questions.source.social_x) },
{ slug: "social_linkedin", icon: <LinkedInIcon className="h-[18px] w-[18px]" />, label: t(($) => $.questions.source.social_linkedin) },
{ slug: "social_youtube", icon: <YouTubeIcon className="h-[18px] w-[18px]" />, label: t(($) => $.questions.source.social_youtube) },
{ slug: "social_github", icon: <GitHubIcon className="h-[18px] w-[18px]" />, label: t(($) => $.questions.source.social_github) },
{ slug: "social_other", icon: <Globe className="h-4 w-4" />, label: t(($) => $.questions.source.social_misc) },
{ slug: "blog_newsletter", icon: <Newspaper className="h-4 w-4" />, label: t(($) => $.questions.source.blog_newsletter) },
{ slug: "ai_assistant", icon: <OpenAIIcon className="h-[16px] w-[16px]" />, label: t(($) => $.questions.source.ai_assistant) },
{ slug: "from_work", icon: <Briefcase className="h-4 w-4" />, label: t(($) => $.questions.source.from_work) },
{ slug: "event_conference", icon: <CalendarDays className="h-4 w-4" />, label: t(($) => $.questions.source.event_conference) },
{ slug: "dont_remember", icon: <HelpCircle className="h-4 w-4" />, label: t(($) => $.questions.source.dont_remember) },
{ slug: "other", icon: <MoreHorizontal className="h-4 w-4" />, label: t(($) => $.questions.source.other), isOther: true },
],
[t],
);
// Single-select: at most one slug in `source` at any time. The server
// schema keeps the array (back-compat with the v2 multi-select shape
// and with existing rows), but the modal UI commits exactly one pick
// — primary-source attribution is the documented industry default for
// HDYHAU prompts (Fairing, Recast, HockeyStack) and gives the team
// clean channel weights without splitting users across N buckets.
const pickedSlug: string | null = answers.source[0] ?? null;
const otherOption = options.find((o) => o.isOther) ?? null;
const otherSelected = pickedSlug === otherOption?.slug;
const otherFilled = (answers.source_other ?? "").trim().length > 0;
const canSubmit =
!busy &&
pickedSlug !== null &&
// If the user picked Other, gate Submit on having typed something —
// an empty Other selection isn't useful attribution data.
(!otherSelected || otherFilled);
const handleSelect = useCallback(
(option: QuestionOption) => {
if (option.isOther) {
const slug = option.slug as Source;
setAnswers((a) => ({
...a,
source: [slug],
// Picking Other doesn't carry text from a prior Other pick
// forward: the text input auto-focuses fresh so the user can
// type immediately. A previous text value would be misleading.
source_other: a.source[0] === "other" ? a.source_other : null,
source_skipped: false,
}));
return;
}
const slug = option.slug as Source;
setAnswers((a) => ({
...a,
source: [slug],
source_other: null,
source_skipped: false,
}));
},
[],
);
const handleOtherChange = useCallback((value: string) => {
setAnswers((a) => ({ ...a, source_other: value }));
}, []);
const submit = useCallback(async () => {
if (!canSubmit) return;
setBusy(true);
try {
// PATCH /api/me/onboarding replaces the JSONB wholesale, so we
// re-read the stored answers from the auth store and overlay
// only the source slots — preserving role / use_case / version
// for the historical users this prompt targets.
const stored =
useAuthStore.getState().user?.onboarding_questionnaire ?? null;
await saveQuestionnaire(
mergedQuestionnairePatch(stored, {
source: answers.source,
source_other: answers.source_other,
source_skipped: false,
}),
);
onComplete();
} catch (err) {
setBusy(false);
toast.error(err instanceof Error ? err.message : "Failed to save");
}
}, [canSubmit, answers.source, answers.source_other, onComplete]);
const skip = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const stored =
useAuthStore.getState().user?.onboarding_questionnaire ?? null;
await saveQuestionnaire(
mergedQuestionnairePatch(stored, {
source: [],
source_other: null,
source_skipped: true,
}),
);
onComplete();
} catch (err) {
setBusy(false);
toast.error(err instanceof Error ? err.message : "Failed to save");
}
}, [busy, onComplete]);
return (
<DialogContent className="sm:max-w-2xl p-0 gap-0 overflow-hidden">
<div className="px-6 pt-6 pb-2">
<div className="text-micro font-medium uppercase tracking-[0.08em] text-muted-foreground">
{t(($) => $.source_backfill.eyebrow)}
</div>
<h2 className="mt-1 text-balance font-serif text-display-sm font-medium leading-tight tracking-tight text-foreground">
{t(($) => $.questions.source.question)}
</h2>
<p className="mt-2 text-body text-muted-foreground">
{t(($) => $.source_backfill.lede)}
</p>
</div>
<fieldset
role="radiogroup"
aria-label={t(($) => $.questions.source.question)}
className="m-0 grid grid-cols-1 gap-2 p-0 px-6 pt-4 sm:grid-cols-2"
>
{options.map((option) =>
option.isOther ? (
<IconOtherOptionCard
key={option.slug}
icon={option.icon}
label={option.label}
selected={otherSelected}
onSelect={() => handleSelect(option)}
otherValue={answers.source_other ?? ""}
onOtherChange={handleOtherChange}
onConfirm={submit}
placeholder={t(($) => $.questions.source.other_placeholder)}
mode="radio"
/>
) : (
<IconOptionCard
key={option.slug}
icon={option.icon}
label={option.label}
selected={pickedSlug === option.slug}
onSelect={() => handleSelect(option)}
mode="radio"
/>
),
)}
</fieldset>
<div className="mt-4 flex flex-wrap items-center justify-end gap-x-4 gap-y-2 border-t bg-muted/40 px-6 py-3">
<span
aria-live="polite"
className="mr-auto text-caption text-muted-foreground"
>
{canSubmit
? t(($) => $.source_backfill.hint_ready)
: t(($) => $.step_question.hint_pick)}
</span>
<div className="flex items-center gap-2">
<Button variant="secondary" disabled={busy} onClick={skip}>
{t(($) => $.common.skip)}
</Button>
<Button disabled={!canSubmit} onClick={submit}>
{t(($) => $.source_backfill.submit)}
</Button>
</div>
</div>
</DialogContent>
);
}