Product polish
Designing the authenticated app surface: the home page decision procedure, theme creation, the Base UI primitives kit, feedback discipline, and the verification gate.
On this page
This guide covers the dynamic home, theme, primitives, and interaction feedback — the product itself, not the marketing page. For a marketing, landing, or splash page, use the landing workflow instead.
The scaffold UI and themes are placeholders, not a house style. Design from the product's layout, typography, density, and tone. The copilot template's sidebar/main/chat-dock structure stays, but its content and theme still need product-specific design.
Home page and first-run state#
The scaffold has two front-of-house pages:
src/pages/index.tsx— the static landing at/. It lives at the top level ofsrc/pages/, so no DeepSpace providers mount: no auth fetch, no WebSocket, and no data hooks. It's the marketing front door; design it with the landing workflow, not this procedure.src/pages/(app)/home.tsx— the dynamic home at/home, inside auth/record providers (including signed-outallowAnonymous). To put this surface at/, use(app)/index.tsxafter removing the static landing; top-levelindex.tsxcannot use data hooks.
Replace the home.tsx stub rather than extending it. Any placeholder page or Your app goes here hit means the home is unfinished.
Build the home page with this decision procedure — in order, no skipping:
Name the primary surface
Board, list, feed, document. That surface — not a poster describing it — is home.
Pick the home skeleton by what the product is, and declare it
The skeletons:
product-preview-first— the real primary surface, rendered with sample/preview data for visitorsdata-forward— a dashboard/grid of live numbers, statuses, streaks above the foldsearch-first— a search bar + the list, single columnsplit-hero— one-line pitch on one side, the live product surface on the othersingle-column-narrative— for content/reading apps
Write the declaration as the first line of src/pages/(app)/home.tsx, before any code, then make the JSX agree with it:
/* home pattern: data-forward — today's habit grid above the fold */
The verification gate requires this comment. Pick for the product, not implementation convenience; use the landing pattern library only for section-level structure.
Signed-in home = the primary surface
Above the fold, with the user's real data.
Signed-out home = the same surface in preview form
Sample or read-only data with an inline sign-in CTA; never an empty auth gate or an icon/H1/button poster.
Content bar
App-specific H1, one-sentence purpose, primary action above the fold, and an actionable EmptyState for signed-in users with no data.
Theme — create one for the app#
slate (src/styles.css) and paper (src/themes.css) are rendering examples, not product themes. Replace them before first deploy.
Themes are [data-theme="<id>"] CSS blocks overriding the shadcn tokens, activated via <html data-theme="..."> in index.html. Switching is one attribute change; no JS, no FOUC. This is the retheming surface for 95% of cases — not DeepSpaceThemeProvider.
The standard path#
Design the product palette
Background, foreground, card, primary (+foreground), secondary, muted, accent, border, ring. If unspecified, choose and state a one-line rationale.
Add a theme block
Copy the paper block in src/themes.css, rename the selector, set your colors. Light themes must keep color-scheme: light; so native form controls match.
Register it
Add an entry to the THEMES array in src/themes.ts (type safety + catalog), then set data-theme="<your-id>" in index.html.
Shape
Set --radius smaller for sharp/technical or larger for soft/friendly.
Update the title and favicon
Update <title> in index.html and replace the favicon. The defaults say "DeepSpace App".
Wordmark and nav
Rebuild the starter Navigation.tsx freely; restyle but retain the copilot AppSidebar shell and fixed-icon collapse. Preserve sign-in/out, src/nav.ts links, and the test ids app-navigation, nav-sign-in-button, nav-user-name.
Set at least background, foreground, card, primary, secondary, accent, and ring. Edit the baseline @theme block in styles.css only when intentionally replacing the default rather than adding a theme.
Shadows caveat#
Tailwind v4's @theme bakes baseline shadow values into compiled utilities, so runtime [data-theme] overrides of --shadow-* tokens can't fully cancel them. For per-theme shadows on your own components, use literal arbitrary classes (shadow-[0_2px_8px_0_rgba(0,0,0,0.08)]) or scope a small utility under your [data-theme] block, and verify in the browser that the shadow changes when you switch themes.
When to use DeepSpaceThemeProvider / applyDeepSpaceTheme instead#
These are exported from deepspace (the root package — there is no deepspace/theme subpath) and drive --theme-* CSS variables consumed by cross-app / deployed DeepSpace components (pills, directory panels, mini-apps). They read from --color-* by default (readThemeFromDOM), so the token setup is usually enough and they just follow. Reach for them explicitly only when embedding DeepSpace surfaces on a deployed site or mini-app that needs a different theme from the main app.
UI dark/light mode#
Light themes set color-scheme: light inside the theme block, so native form controls (calendar icons, scrollbars) match. The SDK also reads data-ui-theme="dark" | "light" on <html> to switch between UI_TOKENS_DARK and UI_TOKENS_LIGHT (see applyUIThemeTokens) — set this if the app supports a light/dark toggle distinct from the theme picker.
No emoji in UI chrome#
Do not use emoji as app chrome (titles, nav, buttons, empty states, headers).
Allowed emoji contexts:
- User-authored content — messages, comments, posts. Users type what they type.
- Message reactions — the reaction picker itself (the selectable set of thumbs-up, hearts, and so on).
- The user explicitly asks for emoji ("add a grocery emoji to the header").
Otherwise use lucide-react, inline SVG, or text. Build wordmarks through font, weight, tracking, and case.
UI primitives — use the scaffolded Base UI kit, never browser defaults#
The scaffold ships a copy-paste primitives kit in src/components/ui/ (index at src/components/ui/index.ts), built on Base UI (@base-ui/react — headless, from the Radix/Floating-UI/MUI team) and styled entirely with the app's semantic theme tokens. The components are the app's own files — restyle or extend them freely; their look follows the theme tokens automatically. Overlay positioning, focus trapping, select label rendering, and nested-dialog stacking are already correct — do not hand-roll replacements, and never use browser-default controls (they ignore theme tokens and render as native widgets).
| Use case | Use this | Don't use |
|---|---|---|
| Select one of N options | Select + SelectTrigger/SelectValue/SelectContent/SelectItem | <select> / <option> |
| Menu / overflow / "…" actions | DropdownMenu + Trigger/Content/Item (+ CheckboxItem, RadioItem, Separator, Sub) | hacked <select>, raw <ul> dropdown |
| Confirm ("Are you sure?") | ConfirmModal (dedicated confirmation primitive) | window.confirm() |
| Modal dialog | Modal (simple controlled: open/onClose, Modal.Header/Body/Footer) or the Dialog family (DialogTrigger/DialogContent/… for triggers, nesting, custom composition) | positioned <div> hacks |
| Prompt for a string | Modal (or Dialog) with an Input inside | window.prompt() |
| Alerts / info banners | useToast for transient; inline token-styled banner (border border-border bg-card + lucide icon) for persistent | window.alert() |
| Success/error toast feedback | app-local useToast — success() / error() / warning() / info() | alert(), inline console text, silent mutations |
| Empty lists / no data | EmptyState (icon + title + description + action) | raw "No items" text |
| Loading placeholders | animate-pulse divs on bg-muted sized like the content; Button loading for pending actions | blank screens, hand-rolled CSS spinners |
| Form fields | Input, Textarea, Label, Checkbox, Switch | raw HTML equivalents |
| Search box | SearchInput (wraps Input with search icon + clear) | raw <input type="search"> |
| Tabs | Tabs, TabsList, TabsTrigger, TabsContent | hand-rolled tab buttons |
| Anchored popups | Popover, PopoverTrigger, PopoverContent | absolutely-positioned divs |
| Tooltips | Tooltip, TooltipTrigger, TooltipContent — one app-level TooltipProvider (already mounted in _app.tsx, 200ms) owns the delay and groups nearby triggers so they switch instantly. Don't wrap individual tooltips in their own provider — a nested provider shadows the app-level one and breaks the grouping. Pass delay on a single Tooltip for one-off timing | title="" attribute |
| Avatars | Avatar, AvatarImage, AvatarFallback | raw <img> |
| Status pills | Badge | hand-rolled rounded divs |
| Cards / tables / separators | No primitive — token-styled elements (rounded-lg border border-border bg-card p-4; styled <table> with border-border rows; border-t border-border) | hardcoded colors |
Toast and confirmed-write feedback discipline#
useToast is the default feedback channel for any mutation. const { success, error, warning, info } = useToast() then:
success('Saved', 'Your changes have been saved.')after the mutation resolves. Plaincreate/put/removeresolve optimistically (before the server accepts), so use the*Confirmedvariant before toasting success on anything the user must trust.error('Failed to save', err.message)in thecatch— only*Confirmedvariants throw on a server denial. Plain mutations never hit the catch; their rejections surface throughRecordProvider'sonWriteErrortoasts, already wired in the scaffold's(app)/_layout.tsx.- No silent mutations — the user should always see confirmation.
Base UI gotchas (already handled in the kit — don't undo them)#
- Custom trigger elements use the
renderprop, notasChild:<DialogTrigger render={<Button>Open</Button>} />. The kit'sButtonis a native<button>and works as arendertarget. SelectValuelabel rendering — the kit derives an items map from itsSelectItemchildren so the trigger shows the label (not the raw value) even before the popup ever opens. RenderSelectItems inline (direct children /.map(...)/ fragments) — items inside your own wrapper component are invisible to the walk; passitems={{ value: 'Label' }}explicitly instead. Option values must be non-empty strings (''is the cleared state).- Nested dialogs — the kit passes
forceRenderon backdrops so a modal-in-modal deepens the scrim. Opening aDialogfrom inside aModaljust works. - Tabs active state styles via
data-active(notdata-selected). - Open/close animations depend on the custom
animate-in/animate-oututilities insrc/styles.css(withanimation-fill-mode: both). Don't remove that block; components animate viadata-[open]/data-[closed].
Prop shapes you'll otherwise forget#
Button — has a built-in loading prop. Do not hand-roll {pending && <Spinner />} + disabled={pending}:
<Button loading={creating} onClick={handleCreate}>Create</Button>
// variants: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
// sizes: 'default' | 'sm' | 'lg' | 'icon'
ConfirmModal — dedicated confirmation; use it instead of composing a dialog + footer + two buttons:
<ConfirmModal
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={handleDelete}
title={`Delete task '${task.title}'?`}
description="This cannot be undone."
confirmText="Delete" // default 'Confirm'
cancelText="Cancel" // default 'Cancel'
variant="destructive" // default 'destructive' — pass 'default' for non-destructive confirms
loading={deleting}
/>
EmptyState:
<EmptyState
icon={<Inbox />}
title="No tasks yet"
description="Create your first task to get started."
action={{ label: 'New task', onClick: openCreate }}
secondaryAction={{ label: 'Import', onClick: openImport }} // optional
/>
AuthOverlay — render without onClose and gate with !isSignedIn. Returns null automatically when signed in or still loading:
<AuthOverlay providers={['google', 'github']} /> // providers optional — defaults to both
useToast — four-level API, plus a generic toast({ type, title, description, duration }):
const { success, error, warning, info, toast, dismiss, dismissAll } = useToast()
success('Saved', 'Changes saved successfully.')
error('Upload failed', err.message)
Interaction polish (free wins)#
- Every async action (mutate, upload, send):
Button loading={pending}— it disables and shows the spinner. Use optimistic UI where the collection supports it. - Every destructive action (delete, remove, leave):
ConfirmModalthat names the item in the body ("Delete task 'Buy milk'?"), not a generic "Are you sure?". - Every mutation: follow the
useToastand confirmed-write rules above. - Every form: inline validation next to the field, not a global banner. Use
Label+Input+ a small<p>with the error. - Every list during initial load:
animate-pulseplaceholder blocks (bg-muted rounded-md) shaped like the content. Never a blank screen with just "Loading…". - Hover/focus states on every clickable element — the primitives handle this; raw
<div onClick>does not. The scaffold also ships*:focus-visibleoutlines instyles.css— keep them. - Keyboard accessibility:
Dialog/Modal,DropdownMenu, andSelectall handle Esc/arrow keys + focus trapping; roll-your-own usually doesn't.
Verify with a smoke test#
After customizing home, theme, and primitives, extend smoke.spec.ts (see Testing):
- Home page renders the real H1 (assert the app-specific title, not the app id or "Welcome").
- The placeholder copy is not in the DOM (assert absence of "placeholder page").
- Primary CTA is visible and clickable.
- At least one real primitive opens on interaction (e.g., a
DropdownMenuopens; clicking Delete opens theConfirmModal). - Page
<title>is app-specific, not "DeepSpace". - Spot-check a mutation and assert a toast appears.
- Keep the nav test hooks intact:
app-navigation,nav-sign-in-button,nav-user-name.
Before declaring done, run both halves:
# Half 1 — ABSENCE: any hit below means the app is NOT ready
grep -REn '<select|(^|[^[:alnum:]_.])(window\.)?(confirm|alert|prompt)[[:space:]]*\(' src/
grep -rn "placeholder page\|Your app goes here" src/
grep -rn 'data-theme="slate"' index.html
# Half 2 — PRESENCE: any MISS below means the home page is NOT done
grep "home pattern:" 'src/pages/(app)/home.tsx' # the skeleton declaration, first line (quote the path — parentheses)
grep 'data-theme="' src/themes.css # your own theme block exists