COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
ONBOOK

GitHub Onboarding Flow Polish

Continuing from previous session where we'd built the GitHub import onboarding flow (Connect → Select → Setup). Previous commits:

2026-01-07 // RAW LEARNING CAPTURE
PROJECTONBOOK

Starting Point

Continuing from previous session where we'd built the GitHub import onboarding flow (Connect → Select → Setup). Previous commits:

  • cf36da7 - Central Icons + UI polish
  • b3c0934 - 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):

  1. Make the three dots between logos smaller and gray
  2. Replace checkmark with three-dot vertical menu on hover
  3. 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.

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.

LOG.ENTRY_END
ref:onbook
RAW