Interview Editor Architecture
Status: Living document — last updated 2026-02-06
Purpose: Document the shared flow editor system across hello, admin, and library apps. All six structural problems (A–F) have been resolved in the refactor described below.
Table of Contents
- 1. Component Hierarchy
- 2. Data Flow
- 3. Per-App Integration
- 4. Translation Pipeline (Registry-Based)
- 5. Resolved Structural Problems
- 6. Key Files
1. Component Hierarchy
The interview editor is shared across three apps (library, admin, hello) via the @repo/app-hello package. Each app provides a thin wrapper that wires server actions and auth, then delegates to shared components.
Library and admin app wrappers use the shared useFlowEditHandlers hook to eliminate duplication of asset upload, image generation, and Unsplash handler setup.
Apps (thin shells — Next.js routes + server actions)
├── apps/library/app/edit/[flowId]/
│ ├── page.tsx → SSR: auth, load FlowConfig, convert
│ └── EditFlowClient.tsx → Uses useFlowEditHandlers + FlowEditClient
│
├── apps/admin/app/flows/[videoFlowId]/edit/
│ ├── page.tsx → SSR: admin auth, load, convert
│ └── AdminEditFlowClient.tsx → Uses useFlowEditHandlers + FlowEditClient
│
└── apps/hello/app/d/[draftId]/studio/
├── page.tsx → SSR: draft auth, load from Redis
└── StudioStepForm.tsx → Different integration path
@repo/app-hello/web (shared package)
├── FlowEditClient.tsx
│ ├── Translation orchestration (handleAddLanguage,
│ │ handleTranslateMissing, handleTranslateAllMissing)
│ │ → Uses registry-based functions (extractTranslatableContent,
│ │ buildFullLanguageTranslationUpdate, buildMissingContent, etc.)
│ │ → Updates store directly via applyFullLanguageTranslation
│ ├── Question generation orchestration
│ │ → Uses extractQuestionTexts, appendQuestions
│ ├── Publish orchestration (build FlowConfig → server action)
│ ├── Analytics tracking
│ └── Renders FlowStudioEditor
│
├── FlowStudioEditor.tsx
│ ├── DraftStore initialization & auto-save hookup
│ ├── Language add/remove (delegates to FlowEditClient callback)
│ ├── Translation dialog (pre-publish check)
│ ├── Version history panel
│ ├── Asset upload / image generation (delegates to store methods)
│ └── Renders StudioStep + WithFlowPreview + OnboardingPanel
│
├── DraftStore (Zustand — global singleton)
│ ├── draft: OnboardingDraft (SINGLE SOURCE OF TRUTH)
│ ├── pendingUpdate: DraftUpdate (accumulated changes)
│ ├── isDirty: boolean
│ ├── _saveGeneration: number (incremented on every updateDraft)
│ ├── editingLanguage, activeTab (UI state)
│ └── Actions: updateDraft, updateQuestions, updateGenerated,
│ updateOrgProfile (partial merge),
│ updateQuestion, updateQuestionRequired, resetQuestionRequired,
│ updateQuestionHint, toggleQuestionHint,
│ addQuestion, removeQuestion, reorderQuestions,
│ updateRewardTitle, updateRewardItem,
│ addAvailableUsp, removeAvailableUsp (cascade),
│ editAvailableUsp (cascade + dup check), toggleSelectedUsp,
│ applyFullLanguageTranslation, appendQuestions,
│ addBackgroundImage, removeBackgroundImage,
│ reorderBackgroundImages, markClean(generation?), etc.
│
├── useAutoSave hook
│ ├── Subscribes to DraftStore changes
│ ├── Debounces 1000ms
│ ├── Captures _saveGeneration before save
│ ├── Calls onSave(draftId, pendingUpdate)
│ └── On success → markClean(generation) → skips if stale
│
├── useFlowEditHandlers hook (shared wrapper logic)
│ ├── Asset upload setup (useAssetUpload)
│ ├── Unsplash handlers (createUnsplashHandlers)
│ └── Returns: assetUpload, unsplashHandlers, imageGeneration
│
└── Translation Pipeline (registry-based)
├── TRANSLATABLE_FIELDS registry (single source of truth)
├── extractTranslatableContent (reads draft directly, no FlowConfig)
├── buildGeneratedTranslationUpdate (applies with !== undefined)
├── buildFullLanguageTranslationUpdate (combines genUpdate + questions + rewards)
├── extractQuestionTexts (utility for question generation context)
├── getRegistryMissingTranslations (registry-driven detection)
├── buildMissingContent (extracts only missing items)
└── buildMissingTranslationUpdates (applies missing translations)
Mermaid: Component Ownership
2. Data Flow
Edit → Save → Publish
Translation Flow: Add Language
FlowEditClient owns the full translation orchestration — it extracts, translates, and applies results to the store in one operation. FlowStudioEditor simply delegates to the callback and receives a success/error status.
3. Per-App Integration
| Aspect | Hello | Library | Admin |
|---|---|---|---|
| Draft source | Redis (OnboardingDraftRegistry) | FlowConfig → buildDraftFromFlowConfig | Same as library |
| Auto-save target | Redis (persists) | No-op (client-only) | No-op (client-only) |
| Publish action | Creates NEW flow | Updates existing (versioned) | Updates existing (versioned) |
| Version history | No | Yes | Yes |
| Auth model | HMAC-signed cookie | Clerk org ownership | Clerk admin role |
| Client wrapper | StudioStepForm (different) | EditFlowClient (uses useFlowEditHandlers) | AdminEditFlowClient (uses useFlowEditHandlers) |
| Translation action | translateContentAction | translateFlowContentAction | translateAdminFlowContentAction |
4. Translation Pipeline (Registry-Based)
TRANSLATABLE_FIELDS Registry
The registry is the single source of truth for all simple translatable fields. Adding a new translatable field requires adding one line to the registry — extraction, detection, and application all derive from it automatically.
// packages/app-hello/src/web/utils/translatable-field-registry.ts
export const TRANSLATABLE_FIELDS: readonly TranslatableField[] = [
{ key: "welcomeTitle", path: (d) => d.generated.welcomeTitle },
{ key: "welcomeSubtitle", path: (d) => d.generated.welcomeSubtitle },
{ key: "welcomeDescription", path: (d) => d.generated.welcomeDescription },
{ key: "tipsTitle", path: (d) => d.generated.tipsTitle },
{ key: "confirmTitle", path: (d) => d.generated.confirmTitle },
{ key: "rewardsTitle", path: (d) => d.generated.rewardsTitle, guard: (d) => !!d.flow.rewardsEnabled },
{ key: "rewardsSubtitle", path: (d) => d.generated.rewardsSubtitle, guard: (d) => !!d.flow.rewardsEnabled },
] as const;
Functions Derived from Registry
| Function | Purpose |
|---|---|
extractTranslatableContent(draft, lang) | Extract all translatable content from draft for a given language. Reads draft directly — no FlowConfig round-trip. |
buildGeneratedTranslationUpdate(draft, lang, translated) | Build Partial<DraftGenerated> from translated content. Uses !== undefined — empty strings are preserved. |
buildFullLanguageTranslationUpdate(draft, lang, translated) | Combined operation: builds genUpdate + maps questions + maps rewards from translated content. Returns { genUpdate, questions?, rewards? } for use with applyFullLanguageTranslation. |
extractQuestionTexts(draft) | Extract question texts in the default language. Used as context for question generation/regeneration. |
getRegistryMissingTranslations(draft, lang) | Detect all fields/questions/rewards/customFields missing translations for a language. |
registryHasMissingTranslations(draft, lang) | Boolean: does this language have any missing translations? |
getRegistryMissingTranslationCount(draft, lang) | Count of all missing items. |
getRegistryLanguagesWithMissingTranslations(draft) | List of languages (excluding default) that have missing translations. |
buildMissingContent(draft, lang) | Extract content for only the missing items (for translate-missing workflow). |
buildMissingTranslationUpdates(draft, lang, translated, missing) | Apply translated content for only the missing items. |
Backward Compatibility
translationUtils.ts re-exports registry-backed functions under the original names (getMissingTranslations, hasMissingTranslations, etc.) for backward compatibility. FlowConfig-based functions (extractContentForTranslation, mergeTranslationsIntoFlowConfig, hasContentToTranslate) remain for non-draft callers.
Adding a New Translatable Field
- Add one entry to
TRANSLATABLE_FIELDSintranslatable-field-registry.ts - If the field has a guard condition (like
rewardsEnabled), add theguardfunction - Done — all extraction, detection, application, and status hooks pick it up automatically
5. Resolved Structural Problems
All six structural problems identified in the original architecture review have been resolved:
Problem A: Translation mapping drops fields silently ✅ FIXED
Solution: The registry-based functions (buildGeneratedTranslationUpdate, buildMissingTranslationUpdates) use !== undefined checks by construction. All field iteration happens through registry loops, so no field can accidentally use truthiness checks.
Problem B: welcomeDescription not wired through pipeline ✅ FIXED
Solution: welcomeDescription is in the TRANSLATABLE_FIELDS registry. All six pipeline points (extraction, add-language application, translate-missing extraction, translate-missing application, store helper, detection) derive from the registry and include it automatically.
Problem C: Auto-save race condition with markClean ✅ FIXED
Solution: DraftStore._saveGeneration counter increments on every updateDraft. markClean(savedGeneration?) skips clearing if the generation has changed since the save started. useAutoSave captures the generation before saving and passes it to markClean.
Problem D: 6-place changes per translatable field ✅ FIXED
Solution: The TRANSLATABLE_FIELDS registry is the single source of truth. Adding a new field is one line. All pipeline functions derive from the registry.
Problem E: Duplicated app wrappers ✅ FIXED
Solution: useFlowEditHandlers hook in @repo/app-hello/web extracts the shared asset upload, image generation, and Unsplash handler setup. Both EditFlowClient (library) and AdminEditFlowClient (admin) use this hook, eliminating ~40 lines of duplicated callback code each.
Problem F: Wasteful Draft→FlowConfig→extract round-trip ✅ FIXED
Solution: extractTranslatableContent(draft, lang) reads directly from the draft object. No buildFlowConfigFromDraft → extractContentForTranslation conversion needed.
6. Key Files
| File | Role |
|---|---|
packages/app-hello/src/web/utils/translatable-field-registry.ts | TRANSLATABLE_FIELDS registry — single source of truth for translatable fields. All translation functions. |
packages/app-hello/src/web/utils/__tests__/translatable-field-registry.test.ts | Tests for registry-based translation functions (19 tests) |
packages/app-hello/src/web/components/FlowEditClient.tsx | Translation orchestration, publish, analytics — uses registry functions |
packages/app-hello/src/web/components/FlowStudioEditor.tsx | Store init, auto-save, language add/remove, UI |
packages/app-hello/src/web/stores/draft-store.ts | DraftStore — state, mutations, generation-aware markClean |
packages/app-hello/src/web/hooks/use-auto-save.ts | Debounced auto-save — captures generation before save |
packages/app-hello/src/web/hooks/use-flow-edit-handlers.ts | Shared wrapper hook for library/admin app wrappers |
packages/app-hello/src/web/utils/translationUtils.ts | Backward-compatible re-exports + FlowConfig-based functions |
packages/app-hello/src/web/stores/draft-hooks.ts | Translation status hooks — delegates to registry |
packages/app-hello/src/web/types/onboarding.ts | TranslationContent type |
apps/library/app/edit/[flowId]/EditFlowClient.tsx | Library app wrapper (uses useFlowEditHandlers) |
apps/admin/app/flows/[videoFlowId]/edit/AdminEditFlowClient.tsx | Admin app wrapper (uses useFlowEditHandlers) |