{showIcon && (
@@ -65,7 +62,7 @@ export const SecretInput = forwardRef(
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
- data-testid={`${testId}-field`}
+ data-testid={secretInput.field(id)}
className={`
w-full rounded-lg border px-3 py-2 pr-10
${showIcon ? 'pl-10' : 'pl-3'}
@@ -85,7 +82,7 @@ export const SecretInput = forwardRef(
type="button"
onClick={() => setVisible(!visible)}
disabled={disabled}
- data-testid={`${testId}-toggle`}
+ data-testid={secretInput.toggle(id)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 disabled:opacity-50"
aria-label={visible ? 'Hide value' : 'Show value'}
>
@@ -93,7 +90,7 @@ export const SecretInput = forwardRef(
{error && (
-
+
{error}
)}
diff --git a/ushadow/frontend/src/hooks/HOOK_PATTERNS.md b/ushadow/frontend/src/hooks/HOOK_PATTERNS.md
new file mode 100644
index 00000000..8fc63043
--- /dev/null
+++ b/ushadow/frontend/src/hooks/HOOK_PATTERNS.md
@@ -0,0 +1,233 @@
+# Hook Patterns
+
+> Patterns for separating logic from presentation. Follow these to keep components thin.
+
+## Core Principle
+
+**Components render, hooks decide.**
+
+```
+┌─────────────────┐ ┌─────────────────┐
+│ Component │────▶│ Hook │
+│ (presentation) │ │ (logic) │
+│ │◀────│ │
+│ - JSX layout │ │ - State │
+│ - Styling │ │ - API calls │
+│ - Event wiring │ │ - Computed │
+└─────────────────┘ └─────────────────┘
+```
+
+## Pattern 1: Derived State Hook
+
+Use when: Component needs to make decisions based on multiple inputs.
+
+**Example**: `useServiceStatus` - decides icon, color, label from raw data.
+
+```typescript
+// hooks/useServiceStatus.ts
+export function useServiceStatus(
+ service: ServiceConfig,
+ config: Record
| undefined,
+ containerStatus: ContainerStatus | undefined
+): ServiceStatusResult {
+ return useMemo(() => {
+ // All decision logic here
+ if (!isConfigured) {
+ return { state: 'not_configured', label: 'Missing Config', icon: AlertCircle }
+ }
+ // ... more decisions
+ }, [service, config, containerStatus])
+}
+
+// Component just renders what the hook returns
+function ServiceCard({ service, config, status }) {
+ const { label, icon: Icon, color } = useServiceStatus(service, config, status)
+ return {label}
+}
+```
+
+## Pattern 2: Data Fetching Hook
+
+Use when: Component needs server data with loading/error states.
+
+```typescript
+// hooks/useResource.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+
+export function useResource(id: string) {
+ const queryClient = useQueryClient()
+
+ const query = useQuery({
+ queryKey: ['resource', id],
+ queryFn: () => api.getResource(id),
+ staleTime: 30_000,
+ })
+
+ const updateMutation = useMutation({
+ mutationFn: api.updateResource,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['resource', id] })
+ },
+ })
+
+ return {
+ data: query.data,
+ isLoading: query.isLoading,
+ error: query.error,
+ update: updateMutation.mutate,
+ isUpdating: updateMutation.isPending,
+ }
+}
+
+// Component stays simple
+function ResourcePage({ id }) {
+ const { data, isLoading, update } = useResource(id)
+ if (isLoading) return
+ return
+}
+```
+
+## Pattern 3: Form Logic Hook
+
+Use when: Form has validation, submission, and error handling.
+
+```typescript
+// hooks/useSettingsForm.ts
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { z } from 'zod'
+
+const schema = z.object({
+ apiKey: z.string().min(1, 'Required'),
+ endpoint: z.string().url('Must be a valid URL'),
+})
+
+export function useSettingsForm(defaults: SettingsData) {
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: defaults,
+ })
+
+ const onSubmit = async (data: SettingsData) => {
+ await api.saveSettings(data)
+ toast.success('Saved')
+ }
+
+ return {
+ ...form,
+ onSubmit: form.handleSubmit(onSubmit),
+ }
+}
+
+// Component wires up the form
+function SettingsPage() {
+ const { control, onSubmit, formState: { errors } } = useSettingsForm(defaults)
+
+ return (
+
+ )
+}
+```
+
+## Pattern 4: Action Hook with Confirmation
+
+Use when: Action needs confirmation dialog and async handling.
+
+```typescript
+// hooks/useDeleteWithConfirm.ts
+export function useDeleteWithConfirm(onDelete: (id: string) => Promise) {
+ const [pendingId, setPendingId] = useState(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ const requestDelete = (id: string) => setPendingId(id)
+ const cancelDelete = () => setPendingId(null)
+
+ const confirmDelete = async () => {
+ if (!pendingId) return
+ setIsDeleting(true)
+ try {
+ await onDelete(pendingId)
+ } finally {
+ setIsDeleting(false)
+ setPendingId(null)
+ }
+ }
+
+ return {
+ pendingId,
+ isDeleting,
+ isConfirmOpen: pendingId !== null,
+ requestDelete,
+ cancelDelete,
+ confirmDelete,
+ }
+}
+```
+
+## Pattern 5: UI State Hook
+
+Use when: Component has complex UI state (modals, tabs, expansion).
+
+```typescript
+// hooks/useExpandableList.ts
+export function useExpandableList(items: T[]) {
+ const [expandedIds, setExpandedIds] = useState>(new Set())
+
+ const toggle = (id: string) => {
+ setExpandedIds(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const expandAll = () => setExpandedIds(new Set(items.map(i => i.id)))
+ const collapseAll = () => setExpandedIds(new Set())
+ const isExpanded = (id: string) => expandedIds.has(id)
+
+ return { isExpanded, toggle, expandAll, collapseAll }
+}
+```
+
+## When to Extract a Hook
+
+Extract when you see:
+- `useState` + `useEffect` together doing something reusable
+- Complex conditional rendering logic (if/else chains)
+- API call + loading + error handling
+- Multiple pieces of state that change together
+- Same logic duplicated across components
+
+## Hook Composition
+
+Build complex hooks from simpler ones:
+
+```typescript
+// Compose multiple concerns
+export function useServiceManager(serviceId: string) {
+ const status = useServiceStatus(serviceId)
+ const { start, stop } = useServiceControls(serviceId)
+ const { config, updateConfig } = useServiceConfig(serviceId)
+ const deleteConfirm = useDeleteWithConfirm(deleteService)
+
+ return {
+ ...status,
+ start,
+ stop,
+ config,
+ updateConfig,
+ ...deleteConfirm,
+ }
+}
+```
+
+## File Size Guide
+
+- **Hooks**: Max 100 lines each
+- **If larger**: Split into multiple hooks and compose them
+- **Export**: Always export from `hooks/index.ts`
diff --git a/ushadow/frontend/src/testing/ui-contract.ts b/ushadow/frontend/src/testing/ui-contract.ts
new file mode 100644
index 00000000..1bca2d67
--- /dev/null
+++ b/ushadow/frontend/src/testing/ui-contract.ts
@@ -0,0 +1,259 @@
+/**
+ * UI Contract - Single source of truth for component testid patterns.
+ *
+ * This file defines the contract between:
+ * - React components (which generate data-testid attributes)
+ * - Playwright POMs (which locate elements by testid)
+ * - AI agents (which reference these patterns when writing code)
+ *
+ * If you change a pattern here, TypeScript will surface breakages.
+ *
+ * @example
+ * // In React component:
+ * import { modal } from '@/testing/ui-contract'
+ *
+ *
+ * // In Playwright POM:
+ * import { modal } from '../../src/testing/ui-contract'
+ * page.getByTestId(modal.container('my-modal'))
+ */
+
+// =============================================================================
+// MODAL
+// =============================================================================
+
+/**
+ * Modal component - REQUIRED for all modal dialogs.
+ *
+ * Import: `import Modal from '@/components/Modal'`
+ *
+ * @example
+ *
setIsOpen(false)}
+ * title="Confirm Action"
+ * maxWidth="sm" // 'sm' | 'md' | 'lg' | 'xl' | '2xl'
+ * testId="confirm-delete"
+ * >
+ * Are you sure?
+ *
+ *
+ *
+ * @forbidden Do NOT create custom modals with `fixed inset-0` divs.
+ */
+export const modal = {
+ /** The root container: `data-testid="my-modal"` */
+ container: (id: string) => id,
+ /** Clickable backdrop: `data-testid="my-modal-backdrop"` */
+ backdrop: (id: string) => `${id}-backdrop`,
+ /** Inner content wrapper: `data-testid="my-modal-content"` */
+ content: (id: string) => `${id}-content`,
+ /** Close X button: `data-testid="my-modal-close"` */
+ close: (id: string) => `${id}-close`,
+} as const
+
+// =============================================================================
+// SERVICE CARD
+// =============================================================================
+
+/**
+ * ServiceCard component - Displays a service with status, toggle, and config.
+ *
+ * Import: `import { ServiceCard } from '@/components/services/ServiceCard'`
+ *
+ * @example
+ *
toggleExpanded(service.service_id)}
+ * onStart={() => startService(service.service_id)}
+ * onStop={() => stopService(service.service_id)}
+ * onToggleEnabled={() => toggleEnabled(service.service_id)}
+ * onStartEdit={() => startEditing(service.service_id)}
+ * onSave={() => saveConfig(service.service_id)}
+ * onCancelEdit={cancelEditing}
+ * onFieldChange={setEditFormField}
+ * onRemoveField={removeField}
+ * />
+ *
+ * @note Currently uses `id=` instead of `data-testid=` - migration pending
+ */
+export const serviceCard = {
+ /** Card container: `id="service-card-{serviceId}"` */
+ container: (serviceId: string) => `service-card-${serviceId}`,
+ /** Enable/disable toggle: `id="toggle-enabled-{serviceId}"` */
+ toggleEnabled: (serviceId: string) => `toggle-enabled-${serviceId}`,
+} as const
+
+// =============================================================================
+// ENV VAR EDITOR
+// =============================================================================
+
+/**
+ * EnvVarEditor component - Edit environment variable mappings.
+ *
+ * Import: `import EnvVarEditor from '@/components/EnvVarEditor'`
+ *
+ * Used for:
+ * - Docker service configuration (ServicesPage)
+ * - K8s deployment configuration (DeployToK8sModal)
+ * - Instance configuration (ServiceConfigsPage)
+ *
+ * @example
+ * setConfig({ ...config, ...updates })}
+ * />
+ */
+export const envVarEditor = {
+ /** Row container: `data-testid="env-var-editor-{varName}"` */
+ container: (varName: string) => `env-var-editor-${varName}`,
+ /** Map to setting button: `data-testid="map-button-{varName}"` */
+ mapButton: (varName: string) => `map-button-${varName}`,
+ /** Setting path dropdown: `data-testid="map-select-{varName}"` */
+ mapSelect: (varName: string) => `map-select-${varName}`,
+ /** Value text input: `data-testid="value-input-{varName}"` */
+ valueInput: (varName: string) => `value-input-${varName}`,
+} as const
+
+// =============================================================================
+// FORM COMPONENTS (from settings/)
+// =============================================================================
+
+/**
+ * SecretInput - API key/password input with visibility toggle.
+ *
+ * Import: `import { SecretInput } from '@/components/settings/SecretInput'`
+ *
+ * @example
+ * // Standalone
+ *
+ *
+ * @example
+ * // With react-hook-form
+ * (
+ *
+ * )}
+ * />
+ */
+export const secretInput = {
+ container: (id: string) => `secret-input-${id}`,
+ field: (id: string) => `secret-input-${id}-field`,
+ toggle: (id: string) => `secret-input-${id}-toggle`,
+ error: (id: string) => `secret-input-${id}-error`,
+} as const
+
+/**
+ * SettingField - Generic setting field supporting multiple input types.
+ *
+ * Import: `import { SettingField } from '@/components/settings/SettingField'`
+ *
+ * @example
+ *
+ */
+export const settingField = {
+ container: (id: string) => `setting-field-${id}`,
+ input: (id: string) => `setting-field-${id}-input`,
+ select: (id: string) => `setting-field-${id}-select`,
+ toggle: (id: string) => `setting-field-${id}-toggle`,
+ error: (id: string) => `setting-field-${id}-error`,
+ label: (id: string) => `setting-field-${id}-label`,
+} as const
+
+/**
+ * SettingsSection - Container for grouping related settings.
+ *
+ * Import: `import { SettingsSection } from '@/components/settings/SettingsSection'`
+ *
+ * @example
+ *
+ *
+ *
+ *
+ */
+export const settingsSection = {
+ container: (id: string) => `settings-section-${id}`,
+ header: (id: string) => `settings-section-${id}-header`,
+ content: (id: string) => `settings-section-${id}-content`,
+} as const
+
+// =============================================================================
+// PAGE-LEVEL PATTERNS
+// =============================================================================
+
+/**
+ * Standard page container testid.
+ * Every page should have a root element with this testid.
+ */
+export const page = {
+ container: (name: string) => `${name}-page`,
+} as const
+
+/**
+ * Tab navigation patterns.
+ */
+export const tabs = {
+ button: (id: string) => `tab-${id}`,
+ panel: (id: string) => `tab-panel-${id}`,
+} as const
+
+/**
+ * Wizard step patterns.
+ */
+export const wizard = {
+ step: (wizardId: string, stepId: string) => `${wizardId}-step-${stepId}`,
+ nextButton: (wizardId: string) => `${wizardId}-next`,
+ backButton: (wizardId: string) => `${wizardId}-back`,
+ submitButton: (wizardId: string) => `${wizardId}-submit`,
+} as const
+
+// =============================================================================
+// GENERIC PATTERNS
+// =============================================================================
+
+/**
+ * Confirm dialog patterns.
+ * Use ConfirmDialog component from '@/components/ConfirmDialog'
+ */
+export const confirmDialog = {
+ container: (id: string) => `${id}-dialog`,
+ title: (id: string) => `${id}-title`,
+ message: (id: string) => `${id}-message`,
+ confirmButton: (id: string) => `${id}-confirm`,
+ cancelButton: (id: string) => `${id}-cancel`,
+} as const
+
+/**
+ * Action button pattern - for any clickable action within a context.
+ *
+ * @example
+ *
+ * // Results in: "settings-save"
+ */
+export const actionButton = (context: string, action: string) =>
+ `${context}-${action}` as const