fix(dashboard): UI bug fixes (#1377)

## Summary

Rolling PR for dashboard UI bug fixes. Each fix is appended to the **Fix
log** below with before/after screenshots. This PR stays open until we
batch-merge or split.

---

## Fix log

### 1. Hide Alpha/Beta stage badges in onboarding "Select apps" tooltip

**Bug:** On the new-project onboarding, hovering an app card showed an
"Alpha" or "Beta" stage badge next to the app name in the tooltip. These
shouldn't be surfaced on the onboarding step.

**Fix:** Removed the stage badge from the onboarding app-card tooltip
only. The "Required" badge is preserved, and stage badges on other
surfaces (app management, app store, command palette) are unchanged.

#### Before / After — Beta (Payments)

| Before | After |
| --- | --- |
|
![before-payments](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-hover-beta-payments.png)
|
![after-payments](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-hover-beta-payments.png)
|

#### Before / After — Alpha (Onboarding)

| Before | After |
| --- | --- |
|
![before-onboarding](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-hover-alpha-onboarding.png)
|
![after-onboarding](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-hover-alpha-onboarding.png)
|

---

### 2. Eliminate full-page flash when advancing onboarding steps

**Bug:** Moving between onboarding steps (e.g. Configure authentication
→ Select email theme) briefly blanked out the entire page — only the
navbar remained visible for roughly two seconds — before the next step
rendered. It felt like a complete browser reload.

**Fix:** Contained the suspension inside the wizard. A local Suspense
boundary around the onboarding page means that when any data cache
refresh fires during the step advance, the suspension no longer bubbles
up to the site-wide loading indicator. The step-advance state update is
also marked as a React transition, so the current step stays rendered
until the next step is ready to commit. Net effect: the previous step is
visible throughout the save, then the next step swaps in without a blank
frame.

#### Before — full blank flash mid-transition

| Auth step (start) | Mid-transition (blank) | Email theme step (end) |
| --- | --- | --- |
|
![before-auth](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-01-auth-step.png)
|
![before-flash](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-02-suspense-flash.png)
|
![before-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-03-email-theme-step.png)
|

#### After — previous step stays visible, no blank frame

| Auth step (start) | Mid-transition (auth stays visible) | Email theme
step (end) |
| --- | --- | --- |
|
![after-auth](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-suspense-01-auth-step.png)
|
![after-mid](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-suspense-02-mid-transition.png)
|
![after-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-suspense-03-email-step.png)
|

---

### 3. Add a subtle back arrow to the onboarding timeline

**Bug:** The only way to return to a previous step in the new-project
onboarding was to click one of the tiny completed-step dots at the
bottom of the page — not discoverable, and easy to miss.

**Fix:** Added a small muted left-arrow next to the timeline dots.
Clicking it advances back one step. It's absolute-positioned so the dots
stay perfectly centered, and it hides itself on the first step (where
there's nothing to go back to).

#### Before / After — Select apps step

| Before — dots only | After — back arrow next to the dots |
| --- | --- |
|
![before-back-arrow](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-back-arrow-apps.png)
|
![after-back-arrow](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-back-arrow-apps.png)
|

### 4. Unify onboarding step styling — cards everywhere, no
glassmorphism

**Bug:** Step-to-step styling in the onboarding was inconsistent. The
Config and Email-theme steps used a glassmorphic surround
(`backdrop-blur`, translucent whites) while the other steps used solid
cards. Advancing from auth to email made it look like the visual
language had changed mid-flow.

**Fix:** Dropped the glassmorphic variants from the onboarding wizard.
The config-choice option cards, the email-theme container, and the
`ModeNotImplementedCard` surround all now use the same solid card
treatment (`bg-white/90` light, `bg-white/[0.06]` dark, with subtle
ring). One consistent surface across every step.

#### Before / After — Config choice step

| Before — glassmorphic | After — solid card |
| --- | --- |
|
![before-glass-config](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-glass-config-choice.png)
|
![after-glass-config](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-glass-config-choice.png?v=2)
|

#### Before / After — Email theme step

| Before — glassmorphic | After — solid card |
| --- | --- |
|
![before-glass-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-glass-email-theme.png)
|
![after-glass-email](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-glass-email-theme.png)
|

### 5. Add "Copy prompt" button on the project setup page

**Bug:** The post-project-creation setup page surfaces a terminal
command for every framework (Next.js, React, JS, Python), but there was
no one-click handoff for users who drive their setup through an AI
agent. Users had to manually copy the command, figure out whether the
Stack Auth MCP server got registered, and add it themselves if not.

**Fix:** Added a compact **✦ Copy prompt** button at the top-right above
the steps list. Clicking it copies a framework-aware prompt to the
clipboard — the prompt tells the user's AI agent to run the install
command for the currently-selected framework, then verify the Stack Auth
MCP server (`stack-auth`, transport `http`,
`https://mcp.stack-auth.com/`) is registered in its client config and
add it manually if the install didn't.

#### Before / After — Project setup page

| Before — no AI handoff | After — "Copy prompt" at the top-right |
| --- | --- |
|
![before-copy-prompt](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/before-copy-prompt-setup.png)
|
![after-copy-prompt](https://gist.githubusercontent.com/aadesh18/948fc31499e8bca4943199173cbe0e00/raw/after-copy-prompt-setup.png)
|

### 6. Disable email theme cards while the onboarding step is saving

**Bug:** On the "Select an email theme" step, the theme cards stayed
clickable after clicking Continue. Because we keep the previous step
visible during the step-advance transition (fix #2), users could click
through to a different theme mid-save — the server would then commit
whatever selection was active at click time, not the one on screen when
Continue was pressed.

**Fix:** Added `disabled={saving}` to the email theme buttons, matching
the same pattern the config-choice, apps-selection, and auth-setup steps
already follow. Added `disabled:cursor-not-allowed disabled:opacity-60`
so users get a clear visual signal that the cards are locked while the
save is in flight.

---

<!-- Append new fixes above this line. Template:
### N. <title>
**Bug:** …
**Fix:** …
#### Before / After
| Before | After |
| --- | --- |
| ![before](…) | ![after](…) |
-->

## Test plan

- [ ] Load the new-project onboarding "Select apps" step and hover every
app card — no Alpha/Beta badge appears.
- [ ] Hover a required app — "Required" badge still appears.
- [ ] Confirm app management tooltips, app store detail page, and
command palette still show stage badges (out of scope for this PR).
- [ ] Drive the onboarding from Configure authentication to Select email
theme — the auth panel stays rendered throughout the save phase and the
email panel swaps in without the site-wide loading indicator or a blank
content area.
- [ ] Repeat for other step transitions (Config → Apps, Apps → Auth,
Email → Domain, Domain → Payments) — same seamless behavior.
- [ ] From any step after Config, the back arrow appears to the left of
the dots. Clicking it goes back one step. On the first step, the arrow
is not rendered.
- [ ] Walk through every onboarding step. Container surface is visually
consistent across steps — no glassmorphic/card mismatch between Config,
Apps, Auth, Email Theme, Payments.
- [ ] On the project setup page, the "Copy prompt" button appears above
the steps (top-right). Clicking it copies the prompt for the
currently-selected framework (Next.js / React / JS / Python) and shows a
success toast.
- [ ] On the "Select an email theme" step, click Continue — the three
theme cards become visibly dimmed (`opacity-60`, `cursor-not-allowed`)
for the duration of the save and don't respond to clicks. Once the next
step renders they stop being visible anyway.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added back navigation to onboarding wizard steps.
* Added "Copy prompt" button for framework-aware terminal commands with
MCP verification.
  * Added loading indicator during asynchronous operations.

* **UI/UX Improvements**
  * Updated card styling for unselected options.
  * Disabled email theme selection during save operations.
  * Removed stage badges (Alpha/Beta) from app cards.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
aadesh18 2026-04-28 18:49:28 -07:00 committed by GitHub
parent e2dc5f5ee0
commit ed8961069c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 113 additions and 36 deletions

View File

@ -23,7 +23,7 @@ import {
Typography,
cn,
} from "@/components/ui";
import { CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react";
import { ArrowLeftIcon, CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react";
import { AdminOwnedProject } from "@stackframe/stack";
import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails";
@ -40,6 +40,7 @@ export type OnboardingPageProps = {
disabled?: boolean,
primaryAction: ReactNode,
secondaryAction?: ReactNode,
onBack?: () => void,
wide?: boolean,
actionsLayout?: "stacked" | "inline",
children: ReactNode,
@ -92,7 +93,18 @@ export function OnboardingPage(props: OnboardingPageProps) {
</div>
<div className="onboarding-cascade fixed bottom-6 left-0 right-0 z-50 flex justify-center" style={{ "--cascade-i": 3 } as CSSProperties}>
<div className="flex items-center gap-[5px]">
<div className="relative flex items-center gap-[5px]">
{props.onBack != null && (
<button
type="button"
onClick={props.onBack}
disabled={props.disabled}
aria-label="Go back to previous step"
className="absolute right-full mr-3 inline-flex h-5 w-5 items-center justify-center rounded-full text-foreground/40 transition-colors hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-40"
>
<ArrowLeftIcon className="h-3.5 w-3.5" weight="bold" />
</button>
)}
{props.steps.map((step, index) => {
const isComplete = index < currentIndex;
const isCurrent = index === currentIndex;
@ -124,16 +136,6 @@ export function OnboardingPage(props: OnboardingPageProps) {
);
}
function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) {
if (stage === "alpha") {
return "orange";
}
if (stage === "beta") {
return "blue";
}
return null;
}
export type OnboardingAppCardProps = {
appId: AppId,
selected: boolean,
@ -145,7 +147,6 @@ export type OnboardingAppCardProps = {
export function OnboardingAppCard(props: OnboardingAppCardProps) {
const app = ALL_APPS[props.appId];
const stageBadgeColor = appStageBadgeColor(app.stage);
return (
<Tooltip delayDuration={0}>
@ -190,13 +191,6 @@ export function OnboardingAppCard(props: OnboardingAppCardProps) {
{props.required && (
<DesignBadge label="Required" color="orange" size="sm" />
)}
{!props.required && stageBadgeColor != null && (
<DesignBadge
label={app.stage === "alpha" ? "Alpha" : "Beta"}
color={stageBadgeColor}
size="sm"
/>
)}
</div>
<Typography className="text-xs leading-relaxed text-muted-foreground">
{app.subtitle}
@ -544,7 +538,6 @@ export function ModeNotImplementedCard(props: { onBack: () => void }) {
variant="warning"
title="Not available yet"
description="Linking an existing config into onboarding is not available yet."
glassmorphic
/>
<div className="flex justify-center">
<DesignButton variant="outline" className="rounded-full px-8" onClick={props.onBack}>

View File

@ -26,7 +26,7 @@ import { PlusCircleIcon } from "@phosphor-icons/react";
import { AdminOwnedProject, useStackApp, useUser } from "@stackframe/stack";
import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import type { ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields";
import { ProjectOnboardingWizard } from "./project-onboarding-wizard";
@ -38,6 +38,20 @@ import {
} from "./shared";
export default function PageClient() {
return (
<Suspense
fallback={
<div className="flex w-full flex-grow items-center justify-center">
<Spinner size={24} />
</div>
}
>
<PageClientInner />
</Suspense>
);
}
function PageClientInner() {
const app = useStackApp();
const appInternals = useMemo(() => getStackAppInternals(app), [app]);
const user = useUser({ or: "redirect", projectIdMustMatch: "internal" });
@ -55,6 +69,7 @@ export default function PageClient() {
const [projectStatuses, setProjectStatuses] = useState<Map<string, ProjectOnboardingStatus>>(new Map());
const [loadingStatuses, setLoadingStatuses] = useState(true);
const [, startStatusTransition] = useTransition();
const [projectName, setProjectName] = useState(displayNameFromSearch ?? "");
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [creatingTeam, setCreatingTeam] = useState(false);
@ -181,10 +196,12 @@ export default function PageClient() {
throw new Error(`Failed to update onboarding status: ${response.status} ${await response.text()}`);
}
setProjectStatuses((previous) => {
const next = new Map(previous);
next.set(project.id, status);
return next;
startStatusTransition(() => {
setProjectStatuses((previous) => {
const next = new Map(previous);
next.set(project.id, status);
return next;
});
});
await appInternals.refreshOwnedProjects();

View File

@ -166,6 +166,14 @@ export function ProjectOnboardingWizard(props: {
});
}, [currentTimelineIndex, props.mode, setMode, setStatus, timelineSteps]);
const handleBack = useMemo(() => {
if (currentTimelineIndex <= 0) {
return undefined;
}
const previousStep = timelineSteps[currentTimelineIndex - 1].id;
return () => handleTimelineStepClick(previousStep);
}, [currentTimelineIndex, handleTimelineStepClick, timelineSteps]);
const advanceFromDomainSetup = useCallback(() => {
return runAsynchronouslyWithAlert(async () => {
setDomainSetupAutoAdvanceError(null);
@ -304,6 +312,7 @@ export function ProjectOnboardingWizard(props: {
steps={timelineSteps}
currentStep="config_choice"
onStepClick={handleTimelineStepClick}
onBack={handleBack}
disabled={saving}
primaryAction={
<DesignButton
@ -330,7 +339,7 @@ export function ProjectOnboardingWizard(props: {
"relative flex flex-col items-center gap-6 rounded-2xl p-10 text-center transition-[box-shadow,background-color] duration-150 hover:transition-none",
createNewSelected
? "bg-white ring-2 ring-blue-500/50 shadow-md dark:bg-blue-500/[0.08] dark:ring-blue-500/50 dark:shadow-none"
: "bg-white/50 ring-1 ring-black/[0.06] hover:ring-black/[0.10] dark:bg-background/60 dark:backdrop-blur-xl dark:ring-white/[0.06] dark:hover:ring-white/[0.10]",
: "bg-white/90 ring-1 ring-black/[0.06] hover:ring-black/[0.10] dark:bg-white/[0.06] dark:ring-white/[0.10] dark:hover:ring-white/[0.14]",
)}
>
{createNewSelected && (
@ -358,7 +367,7 @@ export function ProjectOnboardingWizard(props: {
"relative flex flex-col items-center gap-6 rounded-2xl p-10 text-center transition-[box-shadow,background-color] duration-150 hover:transition-none",
linkExistingSelected
? "bg-white ring-2 ring-blue-500/50 shadow-md dark:bg-blue-500/[0.08] dark:ring-blue-500/50 dark:shadow-none"
: "bg-white/50 ring-1 ring-black/[0.06] hover:ring-black/[0.10] dark:bg-background/60 dark:backdrop-blur-xl dark:ring-white/[0.06] dark:hover:ring-white/[0.10]",
: "bg-white/90 ring-1 ring-black/[0.06] hover:ring-black/[0.10] dark:bg-white/[0.06] dark:ring-white/[0.10] dark:hover:ring-white/[0.14]",
)}
>
{linkExistingSelected && (
@ -398,6 +407,7 @@ export function ProjectOnboardingWizard(props: {
steps={timelineSteps}
currentStep="apps_selection"
onStepClick={handleTimelineStepClick}
onBack={handleBack}
disabled={saving}
wide
primaryAction={
@ -512,6 +522,7 @@ export function ProjectOnboardingWizard(props: {
steps={timelineSteps}
currentStep="auth_setup"
onStepClick={handleTimelineStepClick}
onBack={handleBack}
disabled={saving}
wide
primaryAction={
@ -668,6 +679,7 @@ export function ProjectOnboardingWizard(props: {
steps={timelineSteps}
currentStep="email_theme_setup"
onStepClick={handleTimelineStepClick}
onBack={handleBack}
disabled={saving}
wide
primaryAction={
@ -705,7 +717,6 @@ export function ProjectOnboardingWizard(props: {
variant="warning"
title="No themes found"
description="Theme selection is temporarily unavailable. You can still continue."
glassmorphic
/>
)}
<div className="grid gap-4 sm:grid-cols-3">
@ -716,8 +727,10 @@ export function ProjectOnboardingWizard(props: {
key={theme.id}
type="button"
onClick={() => setSelectedEmailThemeId(theme.id)}
disabled={saving}
className={cn(
"relative flex flex-col overflow-hidden rounded-2xl text-left transition-[box-shadow,background-color] duration-150 hover:transition-none",
"disabled:cursor-not-allowed disabled:opacity-60",
isSelected
? cn(
"bg-blue-500/[0.06] dark:bg-blue-500/[0.04] ring-1 ring-blue-500/40",
@ -725,8 +738,8 @@ export function ProjectOnboardingWizard(props: {
"dark:shadow-[0_14px_48px_-10px_rgba(96,165,250,0.38),0_0_1px_rgba(96,165,250,0.25)]",
)
: cn(
"bg-white/60 dark:bg-background/40 dark:backdrop-blur-xl",
"ring-1 ring-black/[0.05] hover:ring-black/[0.09] dark:ring-white/[0.05] dark:hover:ring-white/[0.09]",
"bg-white/90 dark:bg-white/[0.06]",
"ring-1 ring-black/[0.06] hover:ring-black/[0.10] dark:ring-white/[0.10] dark:hover:ring-white/[0.14]",
),
)}
>
@ -773,6 +786,7 @@ export function ProjectOnboardingWizard(props: {
steps={timelineSteps}
currentStep="payments_setup"
onStepClick={handleTimelineStepClick}
onBack={handleBack}
disabled={saving}
actionsLayout="inline"
primaryAction={

View File

@ -4,10 +4,10 @@ import { CodeBlock } from '@/components/code-block';
import { APIEnvKeys, NextJsEnvKeys } from '@/components/env-keys';
import { InlineCode } from '@/components/inline-code';
import { StyledLink } from '@/components/link';
import { Tabs, TabsContent, TabsList, TabsTrigger, Typography, cn } from "@/components/ui";
import { CopyPromptButton, Tabs, TabsContent, TabsList, TabsTrigger, Typography, cn } from "@/components/ui";
import { DesignButton } from "@/components/design-components";
import { useThemeWatcher } from '@/lib/theme';
import { BookIcon, XIcon } from "@phosphor-icons/react";
import { BookIcon, SparkleIcon, XIcon } from "@phosphor-icons/react";
import { use } from "@stackframe/stack-shared/dist/utils/react";
import { deindent } from '@stackframe/stack-shared/dist/utils/strings';
import dynamic from "next/dynamic";
@ -25,6 +25,21 @@ const Globe = dynamic(() => import('react-globe.gl').then((mod) => mod.default),
const commandClasses = "text-red-600 dark:text-red-400";
const nameClasses = "text-green-600 dark:text-green-500";
const INSTALL_COMMAND_BY_FRAMEWORK = {
nextjs: 'npx @stackframe/stack-cli@latest init',
react: 'npm install @stackframe/react',
javascript: 'npm install @stackframe/js',
python: 'pip install requests',
} as const;
const buildInstallPrompt = (command: string) => deindent`
Please run the following command in my project's terminal:
${command}
After it finishes, verify that the Stack Auth MCP server is registered in your AI client config name: \`stack-auth\`, transport: \`http\`, URL: \`https://mcp.stack-auth.com/\`. The command above should handle this automatically; if for any reason it didn't, please add the MCP server manually so you have live access to Stack Auth docs and APIs.
`;
export default function SetupPage(props: { toMetrics: () => void }) {
const adminApp = useAdminApp();
const [selectedFramework, setSelectedFramework] = useState<'nextjs' | 'react' | 'javascript' | 'python'>('nextjs');
@ -461,7 +476,18 @@ export default function SetupPage(props: { toMetrics: () => void }) {
</div>
</div>
<div className="flex flex-col mt-10 mx-4">
<div className="flex justify-end mt-8 mx-4">
<CopyPromptButton
variant="outline"
size="sm"
content={buildInstallPrompt(INSTALL_COMMAND_BY_FRAMEWORK[selectedFramework])}
>
<SparkleIcon className="w-4 h-4 mr-2 text-purple-500 dark:text-purple-400" weight="fill" />
Copy prompt
</CopyPromptButton>
</div>
<div className="flex flex-col mt-4 mx-4">
<ol className="relative text-gray-500 border-s border-gray-200 dark:border-gray-700 dark:text-gray-400 ">
{[
{

View File

@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { CopyIcon } from "@phosphor-icons/react";
import { CopyIcon, SparkleIcon } from "@phosphor-icons/react";
import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react";
import React from "react";
import { Button } from "./button";
@ -35,5 +35,32 @@ const CopyButton = forwardRefIfNeeded<
});
CopyButton.displayName = "CopyButton";
export { CopyButton };
const CopyPromptButton = forwardRefIfNeeded<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button> & { content: string }
>(({ content, children, onClick, ...props }, ref) => {
const { toast } = useToast();
return (
<Button
variant="secondary"
{...props}
ref={ref}
onClick={async (...args) => {
await onClick?.(...args);
try {
await navigator.clipboard.writeText(content);
toast({ description: 'Prompt copied — paste it into your AI agent', variant: 'success' });
} catch (e) {
toast({ description: 'Failed to copy to clipboard', variant: 'destructive' });
}
}}
>
{children ?? <SparkleIcon className="text-purple-500 dark:text-purple-400" />}
</Button>
);
});
CopyPromptButton.displayName = "CopyPromptButton";
export { CopyButton, CopyPromptButton };