14 KiB
Wizard Architecture Documentation
Version: 3.0 - Two-Phase Wizard System Last Updated: 2025-10-04
Overview
The Wizarr wizard is a mobile-first, app-like onboarding system with:
- Two-phase flow (pre-invite and post-invite steps)
- Fixed UI chrome (progress bar + navigation buttons)
- Smooth content transitions (slide animations)
- Swipe gesture support
- Zero UI duplication (HTMX partial swaps)
- Phase-aware routing (separate endpoints for each phase)
Two-Phase Wizard System
Phase Overview
The wizard operates in two distinct phases:
-
Pre-Invite Phase (
/pre-wizard)- Shown before user accepts invitation
- No authentication required (invite code in session)
- Validates invite code on each request
- Completes with redirect to join page
- Use cases: Terms of service, requirements, welcome messages
-
Post-Invite Phase (
/post-wizard)- Shown after user accepts invitation
- Requires authentication or
wizard_accesssession - Completes with redirect to completion page
- Use cases: App setup, library selection, feature tours
User Flow
User clicks invite link
↓
[Invite code stored in session]
↓
Pre-Invite Steps (if configured)
↓
Join Page (accept invitation)
↓
[Account created]
↓
Post-Invite Steps (if configured)
↓
Completion Page
Architecture
File Structure
app/templates/wizard/
├── frame.html # Initial page load wrapper
├── steps.html # UI chrome (progress + buttons)
└── _content.html # Content-only partial (HTMX swaps)
app/blueprints/wizard/
└── routes.py # Flask routes with phase-aware template selection
app/services/
└── invite_code_manager.py # Session management for invite codes
Routing Structure
/wizard/ # Legacy entry point (redirects based on context)
/pre-wizard/<idx> # Pre-invite phase steps
/pre-wizard/complete # Pre-invite completion (redirects to join page)
/post-wizard/<idx> # Post-invite phase steps
/wizard/complete # Post-invite completion page
/wizard/<server>/<idx> # Preview mode (admin/testing)
/wizard/combo/<idx> # Multi-server invitations
Request Flow
-
Initial Page Load
- User visits
/pre-wizardor/post-wizard/<idx> - Flask returns
frame.html frame.htmlincludessteps.htmlsteps.htmlincludes_content.html- Result: Full page with UI chrome + initial content
- User visits
-
HTMX Navigation
- User clicks button or swipes
- HTMX sends request with
HX-Requestheader - Flask detects header and returns
_content.htmlONLY - HTMX swaps
#wizard-contentelement - JavaScript updates progress bars and button states
- Result: Content slides in, UI chrome stays static
-
Phase Completion
- Pre-invite: Redirects to join page (
/invite/<code>) - Post-invite: Redirects to completion page (
/wizard/complete)
- Pre-invite: Redirects to join page (
State Management
Server → Client Communication
Flask sends custom headers on HTMX requests:
resp.headers['X-Wizard-Idx'] = str(idx)
resp.headers['X-Require-Interaction'] = 'true' | 'false'
resp.headers['X-Wizard-Step-Phase'] = 'pre' | 'post' | ''
Client State Storage
The #wizard-wrapper element stores:
<div id="wizard-wrapper"
data-current-idx="0"
data-max-idx="5"
data-server-type="plex"
data-phase="pre|post|preview"
data-step-phase="pre|post"
data-completion-url="/pre-wizard/complete"
data-completion-label="Continue to Invite">
Phase Attributes:
data-phase: Overall wizard phase context (pre, post, or preview)data-step-phase: Current step's specific phase (for mixed-phase flows)data-completion-url: URL to redirect when completing this phasedata-completion-label: Button text for completion action
JavaScript Controller
WizardController handles all UI updates:
WizardController.updateUI(xhr)
→ updateProgress(idx, maxIdx) // Animate progress bars
→ updateButtons(idx, maxIdx, ...) // Update URLs & visibility (phase-aware)
→ updatePhaseIndicator(phase) // Update phase badge
Mobile Experience
Layout Structure
┌─────────────────────────────┐
│ Fixed Progress Bar (top) │ ← position: fixed, z-index: 40
├─────────────────────────────┤
│ │
│ Scrollable Content Area │ ← overflow-y: auto, flex: 1
│ (only this scrolls) │
│ │
├─────────────────────────────┤
│ Fixed Nav Buttons (bottom) │ ← position: fixed, z-index: 40
│ [←] [→] │ (circular, gradient)
└─────────────────────────────┘
CSS Classes
.wizard-container- Fixed viewport height container.wizard-progress-mobile- Fixed progress bar.wizard-content-mobile- Scrollable content area.wizard-nav-mobile- Fixed button container.wizard-btn-mobile- Circular gradient buttons
Swipe Gestures
- Swipe Left → Navigate to next step
- Swipe Right → Navigate to previous step
- Threshold: 75px
- Respects disabled states and boundaries
Template Logic
Flask Route Pattern
All wizard routes follow this pattern:
def _serve_wizard(server: str, idx: int, steps: list, phase: str,
*, completion_url: str | None = None,
completion_label: str | None = None,
current_step_phase: str | None = None):
# ... build HTML content ...
# Smart template selection
if not request.headers.get("HX-Request"):
page = "wizard/frame.html" # Initial load
else:
page = "wizard/_content.html" # HTMX swap
response = render_template(
page,
body_html=html,
idx=idx,
max_idx=len(steps) - 1,
server_type=server,
phase=phase, # NEW: Phase context
step_phase=display_phase, # NEW: Current step phase
completion_url=completion_url, # NEW: Completion redirect
completion_label=completion_label # NEW: Completion button text
)
# Add headers for HTMX requests
if request.headers.get("HX-Request"):
resp = make_response(response)
resp.headers['X-Wizard-Idx'] = str(idx)
resp.headers['X-Require-Interaction'] = 'true' | 'false'
resp.headers['X-Wizard-Step-Phase'] = display_phase or '' # NEW
return resp
return response
Phase-Specific Routes
Pre-Wizard Route:
@wizard_bp.route("/pre-wizard/<int:idx>")
def pre_wizard(idx: int = 0):
# Validate invite code
invite_code = InviteCodeManager.get_invite_code()
# ... validation logic ...
# Get pre-invite steps
steps = _steps(server_type, cfg, category="pre_invite")
# Render with pre-phase context
return _serve_wizard(
server_type, idx, steps, "pre",
completion_url=url_for("wizard.pre_wizard_complete"),
completion_label=_("Continue to Invite")
)
Post-Wizard Route:
@wizard_bp.route("/post-wizard/<int:idx>")
def post_wizard(idx: int = 0):
# Check authentication
if not current_user.is_authenticated and not session.get("wizard_access"):
return redirect(url_for("auth.login"))
# Get post-invite steps
steps = _steps(server_type, cfg, category="post_invite")
# Render with post-phase context
return _serve_wizard(server_type, idx, steps, "post")
Button URL Updates
Buttons dynamically update their hx-get attribute based on phase:
// Phase-aware URL generation
if (phase === 'pre') {
btn.setAttribute('hx-get', `/pre-wizard/${targetIdx}`);
} else if (phase === 'post') {
btn.setAttribute('hx-get', `/post-wizard/${targetIdx}`);
} else {
btn.setAttribute('hx-get', `/wizard/${serverType}/${targetIdx}`);
}
htmx.process(wrapper); // Reinitialize HTMX
Step Filtering by Category
The _steps() function filters steps by category:
def _steps(server: str, cfg: dict, category: str = "post_invite"):
"""Return ordered wizard steps for server and category.
Args:
server: Server type (plex, jellyfin, etc.)
cfg: Configuration dictionary
category: 'pre_invite' or 'post_invite' (default: 'post_invite')
Returns:
List of wizard steps filtered by category
"""
db_rows = (
WizardStep.query
.filter_by(server_type=server, category=category)
.order_by(WizardStep.position)
.all()
)
# ... fallback to legacy markdown files (post_invite only) ...
Key Design Decisions
✅ DO
- Only swap
#wizard-content - Update progress/buttons via JavaScript
- Use
_content.htmlfor HTMX responses - Send state via custom headers (including phase)
- Validate all indices before updates
- Filter steps by category (
pre_invitevspost_invite) - Validate invite codes on each pre-wizard request
- Use phase-specific completion URLs
❌ DON'T
- Never swap
#wizard-wrapper(causes duplication) - Never return
steps.htmlfor HTMX requests - Never modify progress bars via HTMX swaps
- Never skip header validation
- Never mix pre-invite and post-invite steps in the same phase
- Never allow pre-wizard access without valid invite code
- Never hardcode server types in fallback logic
Debugging
Console Logging
The wizard logs key events:
WizardController initialized
✅ Wizard update: { newIdx: 1, maxIdx: 5, serverType: 'plex', phase: 'pre', requireInteraction: false }
Button wizard-prev-btn: URL=/pre-wizard/0, visible=true
Button wizard-next-btn: URL=/pre-wizard/2, visible=true
Phase indicator updated: pre
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Duplicate progress bars | Swapping wrapper instead of content | Check Flask returns _content.html for HTMX |
| Button URLs don't update | HTMX not reprocessed | Ensure htmx.process(wrapper) is called |
| Content doesn't scroll | Container height wrong | Verify .wizard-container has height: 100vh |
| Buttons behind blur | Z-index issue | Ensure .wizard-btn-mobile has z-index: 50 |
| Wrong phase steps shown | Category filter incorrect | Verify _steps() receives correct category parameter |
| Pre-wizard accessible without invite | Session validation missing | Check InviteCodeManager.validate_invite_code() is called |
| Completion redirects to wrong page | Phase-specific completion URL not set | Verify completion_url parameter in _serve_wizard() |
Phase-Specific Behavior
Pre-Invite Phase
Access Control:
- Requires valid invite code in session
- No authentication required
- Validates invite code on each request
- Redirects to home page if invite code is invalid/expired
Completion:
- Final step shows "Continue to Invite" button
- Redirects to
/pre-wizard/complete - Completion endpoint redirects to join page (
/invite/<code>) - Marks pre-wizard as complete in session
Session Management:
InviteCodeManager.get_invite_code() # Retrieve invite code
InviteCodeManager.validate_invite_code(code) # Validate invite code
InviteCodeManager.mark_pre_wizard_complete() # Mark phase complete
Post-Invite Phase
Access Control:
- Requires authentication OR
wizard_accesssession - User must have accepted invitation
- No invite code validation needed
Completion:
- Final step shows "Next" button (or custom label)
- Redirects to
/wizard/complete - Completion endpoint clears all session data
- Shows success message and redirects to app
Session Cleanup:
InviteCodeManager.clear_invite_data() # Clear invite-related data
session.pop("wizard_access", None) # Clear wizard access flag
session.pop("wizard_server_order", None)
session.pop("wizard_bundle_id", None)
Backward Compatibility
The /wizard/ endpoint provides backward compatibility:
@wizard_bp.route("/")
def start():
"""Redirect to appropriate wizard based on context."""
if current_user.is_authenticated or session.get("wizard_access"):
return redirect(url_for("wizard.post_wizard"))
invite_code = InviteCodeManager.get_invite_code()
if invite_code:
return redirect(url_for("wizard.pre_wizard"))
return redirect(url_for("public.root"))
Future Enhancements
- Keyboard navigation (arrow keys)
- Accessibility improvements (ARIA live regions)
- Progress persistence (localStorage)
- Prefetch next step for instant navigation
- Step validation hooks
- Custom step transitions
- Phase-specific analytics tracking
- Conditional phase skipping based on invitation settings
Testing Checklist
Core Functionality
- Initial page load shows all UI chrome
- Navigation updates only content area
- Progress bar animates smoothly
- Previous button hidden on first step
- Next button hidden on last step
- Swipe gestures work correctly
- Required interaction blocks navigation
- Mobile layout fits single viewport
- Desktop layout stays centered
- Dark mode works correctly
Phase-Specific Tests
- Pre-wizard validates invite code on each request
- Pre-wizard redirects to home if invite code invalid
- Pre-wizard completion redirects to join page
- Post-wizard requires authentication
- Post-wizard completion clears session data
- Post-wizard completion shows success message
- Legacy
/wizard/route redirects correctly - Multi-server invitations show correct phase steps
- Phase badges display correctly
- Completion buttons show correct labels