- 01.Starting Point
- 02.Three Dots Styling and Dropdown Menu
- 03.Dropdown Positioning Bug
- 04.Remove Installation Backend
- 05.Page Flash on Reload
- 06.Analyzing Step Stayed Forever
- 07.Server-Side Minimum Delay
- 08.Animated Progress Bar
- 09.Outlined vs Filled Icons
- 10.Icon Vertical Alignment
- 11.Where We Landed
- 12.Takeaways
GitHub Onboarding Flow Polish
Continuing from previous session where we'd built the GitHub import onboarding flow (Connect → Select → Setup). Previous commits:
Starting Point
Continuing from previous session where we'd built the GitHub import onboarding flow (Connect → Select → Setup). Previous commits:
cf36da7- Central Icons + UI polishb3c0934- Loading states + complete icon migration
Session focus: polish the connected state, fix UX issues, add proper analyzing flow.
Three Dots Styling and Dropdown Menu
User feedback on Connect step (connected state):
- Make the three dots between logos smaller and gray
- Replace checkmark with three-dot vertical menu on hover
- Move "Add account" button to inline link
First, needed to find the vertical dots icon in Central Icons:
ls node_modules/@central-icons-react/round-filled-radius-1-stroke-1.5 | grep -i "dots\|vertical"Found IconDotGrid1x3Vertical — added to packages/ui/src/components/ui/icons.tsx:
export { IconDotGrid1x3Vertical as IconMoreVertical } from '@central-icons-react/round-filled-radius-1-stroke-1.5/IconDotGrid1x3Vertical';Updated ConnectStep with DropdownMenu containing "Manage in GitHub" and "Remove" options.
Dropdown Positioning Bug
The dropdown was opening in the top-left corner instead of anchored to the button. Root cause: the trigger button had hidden class with group-hover:block — the element had zero dimensions when hidden, breaking Radix's positioning.
Fix: removed the show/hide logic entirely, always show the three-dot menu:
// Before (broken positioning)
<IconCheck className="group-hover:hidden" />
<button className="hidden group-hover:block">
<IconMoreVertical />
</button>
// After (always visible)
<button className="rounded p-1 hover:bg-background">
<IconMoreVertical size={16} className="text-muted-foreground" />
</button>Also removed the now-unused group class from the parent div.
Remove Installation Backend
Added removeInstallation mutation to packages/trpc/src/router/github/index.ts:
removeInstallation: protectedProcedure
.input(z.object({ installationId: z.number() }))
.mutation(async ({ ctx, input }) => {
// Verify ownership
const installation = await db.query.githubInstallations.findFirst({
where: and(
eq(githubInstallations.installationId, input.installationId),
eq(githubInstallations.organizationId, ctx.auth.session.activeOrganizationId),
isNull(githubInstallations.deletedAt),
),
});
if (!installation) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Installation not found' });
}
// Soft delete
await db
.update(githubInstallations)
.set({ deletedAt: new Date() })
.where(eq(githubInstallations.installationId, input.installationId));
return { success: true };
}),Wired up in page.tsx with query invalidation:
const handleRemoveInstallation = (installationId: number) => {
removeMutation.mutate(
{ installationId },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.github.listInstallations.queryKey() });
queryClient.invalidateQueries({ queryKey: trpc.github.hasConnection.queryKey() });
toast.success('GitHub connection removed');
},
},
);
};Page Flash on Reload
Problem: reloading the page showed step 1 briefly before redirecting to step 2 if already connected.
Fix: don't render content until connection check completes:
// Don't render until we know the connection state (prevents flash)
if (isCheckingConnection) {
return (
<div className="flex min-h-screen flex-col">
<OnboardingHeader ... />
<main className="flex flex-1 items-center justify-center p-6" />
</div>
);
}Analyzing Step Stayed Forever
The original implementation had complex client-side timing with refs:
// Complex ref-based approach (buggy)
const validationResultRef = useRef<{ valid: boolean } | null>(null);
const minDelayPassedRef = useRef(false);
const maybeTransitionToReady = useCallback(() => {
if (minDelayPassedRef.current && validationResultRef.current !== null) {
// transition...
}
}, []);This was getting stuck because the callback wasn't being called at the right times.
User suggestion: move the delay to the server side using Promise.all.
Server-Side Minimum Delay
Much cleaner approach in validateRepo:
// Minimum delay for UX (6 seconds)
const minDelay = new Promise((resolve) => setTimeout(resolve, 6000));
// Actual validation work
const doValidation = async () => {
const octokit = await getInstallationOctokit(input.installationId);
// ... check .storybook/, package.json, etc.
return { valid, checks, metadata };
};
// Wait for both: validation AND minimum delay
const [result] = await Promise.all([doValidation(), minDelay]);
return result;Client side simplified dramatically — just call the mutation and handle success/error:
useEffect(() => {
if (step === 'setup' && selectedRepo && selectedInstallation && !setupStartedRef.current) {
setupStartedRef.current = true;
setSetupStatus('analyzing');
validateMutation.mutate(
{ installationId, owner, repo },
{
onSuccess: (result) => {
setSetupStatus(result.valid ? 'ready' : 'failure');
},
onError: (error) => {
toast.error(`Validation failed: ${error.message}`);
setSetupStatus('failure');
},
},
);
}
}, [step, selectedRepo, selectedInstallation, validateMutation]);Animated Progress Bar
Static progress felt dead. Added animation that fills over time with easing:
const [progress, setProgress] = useState(5);
useEffect(() => {
if (isReady) {
setProgress(66);
return;
}
const targetProgress = isImporting ? 90 : 60;
const interval = setInterval(() => {
setProgress((prev) => {
if (prev >= targetProgress) return prev;
// Slow down as we approach target (easing effect)
const remaining = targetProgress - prev;
const increment = Math.max(0.5, remaining * 0.05);
return Math.min(prev + increment, targetProgress);
});
}, 100);
return () => clearInterval(interval);
}, [isAnalyzing, isReady, isImporting]);The remaining * 0.05 creates asymptotic approach — fast at first, slows down near target.
Outlined vs Filled Icons
User feedback: icons looked too heavy inline with text. Switched key icons to outlined variant:
// Before (filled, heavy)
export { IconKey2 } from '@central-icons-react/round-filled-radius-1-stroke-1.5/IconKey2';
// After (outlined, lighter)
export { IconKey2 } from '@central-icons-react/round-outlined-radius-1-stroke-1.5/IconKey2';
export { IconLock } from '@central-icons-react/round-outlined-radius-1-stroke-1.5/IconLock';
export { IconSquareArrowTopRight2 } from '@central-icons-react/round-outlined-radius-1-stroke-1.5/IconSquareArrowTopRight2';Icon Vertical Alignment
Icons were anchored to top of row (items-start with mt-0.5). Changed to center:
// Before
<div className="flex items-start gap-3">
<IconKey2 size={16} className="mt-0.5 text-muted-foreground" />
// After
<div className="flex items-center gap-3">
<IconKey2 size={16} className="text-muted-foreground" />Where We Landed
Commit f6a1a7f:
feat(onboarding): polish setup flow and connection management
- Add 6-second minimum delay on server for analyzing step UX
- Animated progress bar that fills smoothly during analysis
- Add dropdown menu on connected accounts (Manage/Remove)
- Add removeInstallation tRPC mutation (soft delete)
- Switch to outlined icons for lighter inline appearance
- Fix icon vertical alignment (items-center)
- Prevent flash on page load by waiting for connection check
- Simplify client-side state (delay moved to server)
16 files changed, 342 insertions(+), 145 deletions(-)
Takeaways
Server-side delays are cleaner than client-side. The Promise.all pattern for minimum UX delays is elegant — validation runs in parallel with timer, both must complete. No refs, no race conditions, no stale closures.
Hidden elements break Radix positioning. If a Radix trigger has display: none or hidden, it has zero dimensions and the popover/dropdown anchors to (0,0). Either always render the trigger or use visibility: hidden / opacity: 0 instead.
Central Icons package structure:
@central-icons-react/round-filled-radius-1-stroke-1.5— solid icons@central-icons-react/round-outlined-radius-1-stroke-1.5— outlined icons- Individual imports for tree-shaking:
from 'package/IconName' - Icon names don't always match what you'd guess — check with
ls node_modules/...
Asymptotic progress animation: increment = Math.max(0.5, remaining * 0.05) creates natural easing — 5% of remaining distance each tick, with 0.5 minimum so it doesn't stall completely.