Ensure complete UI state coverage (loading, empty, error, populated) and state-to-component mapping for Stripe App UI extensions
Ensure every interactive element and screen has complete state coverage and meets accessibility standards.
Every interactive element and screen MUST define these states:
For the full accessibility checklist (ARIA roles, keyboard navigation, contrast, forms), see the web-design-guidelines skill. This section covers only state-specific accessibility concerns.
aria-live="polite" regions to announce state transitions (loading to populated, error appearance)aria-busy="true" on the containerrole="alert" to immediately announce errorsaria-disabled="true" instead of removing elements from tab orderaria-describedby explaining why the element is disabledUse this template to document flows and screens:
## Flow: {Flow Name}
### Entry Point
{How the user reaches this flow}
### Screens
#### {Screen Name}
- **States**: loading | empty | populated | error
- **Key elements**: {interactive elements and their behavior}
- **Accessibility**: {ARIA roles, keyboard nav, focus management}
### Transitions
{Screen A} → {action} → {Screen B}
### Error Handling
{What happens on failure at each step}
## Flow: Customer Metadata Editor
### Entry Point
Stripe Dashboard → Customer detail page → Javelin drawer
### Screens
#### Metadata List
- **States**: loading (Spinner) | empty ("No metadata found" + "Add Metadata" button) | populated (list of key-value pairs) | error (Banner with retry)
- **Key elements**: Add button, edit inline fields, delete with confirmation
- **Accessibility**: List uses appropriate ARIA labels, edit fields have descriptive labels
### Transitions
Metadata List → click "Add" → Add Metadata Form
Metadata List → click row → Edit Metadata Inline
### Error Handling
- Network error: Show Banner with retry action
- Permission error: Show "Insufficient permissions" Banner
- Rate limit: Show throttle message with countdown
Map screen states to component props:
## Component: {ComponentName}
### States
- **Loading**: {what renders}
- **Empty**: {what renders}
- **Populated**: {what renders}
- **Error**: {what renders}
### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| isLoading | boolean | no | Shows loading state |
| error | Error | null | no | Shows error state |
| data | T[] | no | Data to render |
| isEmpty | boolean | no | Explicitly shows empty state |
## Component: CustomerMetadataView
### States
- **Loading**: ContextView + Spinner
- **Empty**: ContextView + Box("No metadata found") + Button("Add Metadata")
- **Populated**: ContextView + List of metadata key-value pairs
- **Error**: ContextView + Banner(type="critical") with retry
### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| — | — | — | View component, receives ExtensionContextValue |
### Children
- MetadataList — displays key-value pairs
- AddMetadataForm — FocusView for adding new metadata
- EditMetadataInline — inline editing of existing values
### Data Requirements
- `GET /functions/v1/customer-metadata?customer_id={id}` via Edge Function
UX state coverage must be addressed at every phase of the OpenSpec pipeline:
openspec/changes/<change-name>/specs/<capability>/spec.md MUST enumerate all states: loading, empty, populated, erroropenspec/changes/<change-name>/design.mdopenspec/changes/<change-name>/tasks.mdAll UI in this project uses Stripe UI Toolkit components only. No custom HTML/CSS. No React 18/19 APIs.
// src/views/CustomerMetadataView.tsx
import { ContextView, Box, Spinner } from '@stripe/ui-extension-sdk/ui'
const CustomerMetadataView = ({ userContext, environment }: ExtensionContextValue) => {
const [isLoading, setIsLoading] = useState(true)
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
fetchMetadata(environment.objectContext?.id)
.then(setData)
.catch(setError)
.finally(() => setIsLoading(false))
}, [environment.objectContext?.id])
if (isLoading) {
return (
<ContextView title="Customer Metadata">
<Box css={{ stack: 'y', alignX: 'center', padding: 'large' }}>
<Spinner size="large" />
</Box>
</ContextView>
)
}
// ... render populated/empty/error states
}
import { ContextView, Banner, Button } from '@stripe/ui-extension-sdk/ui'
// Error state
if (error) {
return (
<ContextView title="Customer Metadata">
<Banner
type="critical"
title="Failed to load metadata"
description={error.message}
actions={
<Button onPress={() => retry()}>Try again</Button>
}
/>
</ContextView>
)
}
import { ContextView, Box, Button, Icon } from '@stripe/ui-extension-sdk/ui'
// Empty state
if (!data || data.length === 0) {
return (
<ContextView title="Customer Metadata">
<Box css={{ stack: 'y', alignX: 'center', padding: 'xlarge', gap: 'medium' }}>
<Box css={{ font: 'heading' }}>No metadata found</Box>
<Box css={{ font: 'body', color: 'secondary' }}>
Add metadata to organize customer information.
</Box>
<Button type="primary" onPress={() => setShowAddForm(true)}>
Add Metadata
</Button>
</Box>
</ContextView>
)
}
For interactive components requiring client-side state transitions (React 17 patterns only):
import { useState } from 'react'
import { Button } from '@stripe/ui-extension-sdk/ui'
const DeleteMetadataButton = ({ metadataKey, onDelete }) => {
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
setIsDeleting(true)
try {
await onDelete(metadataKey)
} catch (err) {
// Error handling
} finally {
setIsDeleting(false)
}
}
return (
<Button
type="destructive"
onPress={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)
}
When defining any UI element, ask:
Missing any of these = incomplete spec. Address before implementation.