mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 20:51:08 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
510
docs/ux/developer-guidelines.md
Normal file
510
docs/ux/developer-guidelines.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# Developer Guidelines
|
||||
|
||||
Best practices for UX development in ThrillWiki.
|
||||
|
||||
## General Principles
|
||||
|
||||
### 1. Progressive Enhancement
|
||||
|
||||
All features should work without JavaScript, with HTMX and Alpine.js enhancing the experience:
|
||||
|
||||
```html
|
||||
<!-- Form works with or without JS -->
|
||||
<form method="post" action="/parks/create/"
|
||||
hx-post="/parks/create/"
|
||||
hx-target="#form-container">
|
||||
{% csrf_token %}
|
||||
<!-- Form fields -->
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### 2. Consistent Feedback
|
||||
|
||||
Every user action should provide immediate feedback:
|
||||
|
||||
- **Loading states**: Show progress during requests
|
||||
- **Success messages**: Confirm completed actions
|
||||
- **Error messages**: Explain what went wrong
|
||||
- **Validation**: Inline feedback on form fields
|
||||
|
||||
### 3. Accessibility First
|
||||
|
||||
All components must be accessible:
|
||||
|
||||
- Proper ARIA attributes
|
||||
- Keyboard navigation
|
||||
- Screen reader support
|
||||
- Focus management
|
||||
- Sufficient color contrast
|
||||
|
||||
### 4. Mobile-First Design
|
||||
|
||||
Design for mobile first, then enhance for larger screens:
|
||||
|
||||
```html
|
||||
<!-- Mobile: stacked, Desktop: side-by-side -->
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="w-full md:w-1/2">Left</div>
|
||||
<div class="w-full md:w-1/2">Right</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
### Template Structure
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base/
|
||||
│ └── base.html # Base template
|
||||
├── components/
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── modals/ # Modal components
|
||||
│ ├── navigation/ # Navigation components
|
||||
│ ├── skeletons/ # Skeleton screens
|
||||
│ └── ui/ # UI primitives
|
||||
├── forms/
|
||||
│ ├── layouts/ # Form layouts
|
||||
│ └── partials/ # Form field components
|
||||
├── htmx/
|
||||
│ └── components/ # HTMX-specific components
|
||||
└── [app_name]/
|
||||
├── list.html # Full page templates
|
||||
├── list_partial.html # HTMX partials
|
||||
└── _card.html # Reusable fragments
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Full page | `name.html` | `list.html` |
|
||||
| HTMX partial | `name_partial.html` | `list_partial.html` |
|
||||
| Fragment | `_name.html` | `_card.html` |
|
||||
| Component | `name.html` in components/ | `button.html` |
|
||||
|
||||
## Django Integration
|
||||
|
||||
### Context Processors
|
||||
|
||||
Use context processors for global data:
|
||||
|
||||
```python
|
||||
# apps/core/context_processors.py
|
||||
def breadcrumbs(request):
|
||||
return {'breadcrumbs': getattr(request, 'breadcrumbs', [])}
|
||||
|
||||
def page_meta(request):
|
||||
return {'page_meta': getattr(request, 'page_meta', {})}
|
||||
```
|
||||
|
||||
### View Patterns
|
||||
|
||||
#### Standard View
|
||||
|
||||
```python
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
return render(request, 'parks/list.html', {'parks': parks})
|
||||
```
|
||||
|
||||
#### HTMX-Aware View
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import is_htmx_request
|
||||
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
template = 'parks/list_partial.html' if is_htmx_request(request) else 'parks/list.html'
|
||||
return render(request, template, {'parks': parks})
|
||||
```
|
||||
|
||||
#### Using Decorator
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import htmx_partial
|
||||
|
||||
@htmx_partial('parks/list.html')
|
||||
def park_list(request):
|
||||
parks = Park.objects.all()
|
||||
return ({'parks': parks},)
|
||||
```
|
||||
|
||||
### Breadcrumbs
|
||||
|
||||
Set breadcrumbs in views:
|
||||
|
||||
```python
|
||||
from apps.core.utils import BreadcrumbBuilder
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
|
||||
request.breadcrumbs = (
|
||||
BreadcrumbBuilder()
|
||||
.add_home()
|
||||
.add('Parks', reverse('parks:list'))
|
||||
.add_current(park.name)
|
||||
.build()
|
||||
)
|
||||
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
### Page Meta
|
||||
|
||||
Set meta information in views:
|
||||
|
||||
```python
|
||||
from apps.core.utils import build_meta_context
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
|
||||
request.page_meta = build_meta_context(
|
||||
title=park.name,
|
||||
description=park.description,
|
||||
instance=park,
|
||||
request=request,
|
||||
)
|
||||
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
## HTMX Best Practices
|
||||
|
||||
### 1. Use Appropriate HTTP Methods
|
||||
|
||||
```html
|
||||
<!-- GET for read operations -->
|
||||
<button hx-get="/parks/">Load Parks</button>
|
||||
|
||||
<!-- POST for create -->
|
||||
<form hx-post="/parks/create/">...</form>
|
||||
|
||||
<!-- PUT/PATCH for update -->
|
||||
<form hx-put="/parks/123/edit/">...</form>
|
||||
|
||||
<!-- DELETE for remove -->
|
||||
<button hx-delete="/parks/123/">Delete</button>
|
||||
```
|
||||
|
||||
### 2. Target Specific Elements
|
||||
|
||||
```html
|
||||
<!-- Good: Specific target -->
|
||||
<button hx-get="/parks/" hx-target="#parks-list">Load</button>
|
||||
|
||||
<!-- Avoid: Replacing large sections unnecessarily -->
|
||||
<button hx-get="/parks/" hx-target="#main-content">Load</button>
|
||||
```
|
||||
|
||||
### 3. Use Loading Indicators
|
||||
|
||||
```html
|
||||
<button hx-get="/parks/"
|
||||
hx-target="#parks-list"
|
||||
hx-indicator="#loading">
|
||||
Load Parks
|
||||
</button>
|
||||
|
||||
<div id="loading" class="htmx-indicator">
|
||||
{% include 'htmx/components/loading_indicator.html' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Handle Errors Gracefully
|
||||
|
||||
```python
|
||||
def create_park(request):
|
||||
form = ParkForm(request.POST)
|
||||
|
||||
if not form.is_valid():
|
||||
# Return form with errors, status 422
|
||||
return render(
|
||||
request,
|
||||
'parks/_form.html',
|
||||
{'form': form},
|
||||
status=422
|
||||
)
|
||||
|
||||
park = form.save()
|
||||
return htmx_success(f'{park.name} created!')
|
||||
```
|
||||
|
||||
### 5. Use Response Headers
|
||||
|
||||
```python
|
||||
from apps.core.htmx_utils import (
|
||||
htmx_success,
|
||||
htmx_error,
|
||||
htmx_redirect,
|
||||
htmx_modal_close,
|
||||
)
|
||||
|
||||
# Success with toast
|
||||
return htmx_success('Park saved!')
|
||||
|
||||
# Error with toast
|
||||
return htmx_error('Validation failed', status=422)
|
||||
|
||||
# Redirect
|
||||
return htmx_redirect('/parks/')
|
||||
|
||||
# Close modal and refresh
|
||||
return htmx_modal_close(
|
||||
message='Saved!',
|
||||
refresh_target='#parks-list'
|
||||
)
|
||||
```
|
||||
|
||||
## Alpine.js Best Practices
|
||||
|
||||
### 1. Keep Components Small
|
||||
|
||||
```html
|
||||
<!-- Good: Focused component -->
|
||||
<div x-data="{ open: false }">
|
||||
<button @click="open = !open">Toggle</button>
|
||||
<div x-show="open">Content</div>
|
||||
</div>
|
||||
|
||||
<!-- Avoid: Large monolithic components -->
|
||||
<div x-data="{
|
||||
open: false,
|
||||
items: [],
|
||||
selectedItem: null,
|
||||
loading: false,
|
||||
// ... many more properties
|
||||
}">
|
||||
```
|
||||
|
||||
### 2. Use Stores for Global State
|
||||
|
||||
```javascript
|
||||
// stores/index.js
|
||||
Alpine.store('toast', {
|
||||
messages: [],
|
||||
success(message, duration = 5000) { /* ... */ },
|
||||
error(message, duration = 0) { /* ... */ },
|
||||
});
|
||||
|
||||
// Usage anywhere
|
||||
Alpine.store('toast').success('Saved!');
|
||||
```
|
||||
|
||||
### 3. Initialize from Server Data
|
||||
|
||||
```html
|
||||
<div x-data="{ items: {{ items_json|safe }} }">
|
||||
<template x-for="item in items">
|
||||
<div x-text="item.name"></div>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Use Magic Properties
|
||||
|
||||
```html
|
||||
<div x-data="{ message: '' }">
|
||||
<!-- $refs for DOM access -->
|
||||
<input x-ref="input" x-model="message">
|
||||
<button @click="$refs.input.focus()">Focus</button>
|
||||
|
||||
<!-- $watch for reactive updates -->
|
||||
<div x-init="$watch('message', value => console.log(value))">
|
||||
|
||||
<!-- $nextTick for DOM updates -->
|
||||
<button @click="items.push('new'); $nextTick(() => scrollToBottom())">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS/Tailwind Best Practices
|
||||
|
||||
### 1. Use Design Tokens
|
||||
|
||||
```css
|
||||
/* Use CSS variables */
|
||||
.custom-element {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use Component Classes
|
||||
|
||||
```html
|
||||
<!-- Good: Semantic classes -->
|
||||
<button class="btn btn-primary btn-lg">Save</button>
|
||||
|
||||
<!-- Avoid: Long utility chains for common patterns -->
|
||||
<button class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium">
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
### 3. Responsive Design
|
||||
|
||||
```html
|
||||
<!-- Mobile-first -->
|
||||
<div class="p-4 md:p-6 lg:p-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Items -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Dark Mode Support
|
||||
|
||||
```html
|
||||
<!-- Automatic dark mode via CSS variables -->
|
||||
<div class="bg-background text-foreground">
|
||||
Content adapts to theme
|
||||
</div>
|
||||
|
||||
<!-- Explicit dark mode variants -->
|
||||
<div class="bg-white dark:bg-gray-800">
|
||||
Explicit dark override
|
||||
</div>
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### 1. Test User Flows
|
||||
|
||||
```python
|
||||
def test_create_park_flow(self):
|
||||
# Navigate to create page
|
||||
response = self.client.get('/parks/create/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Submit form
|
||||
response = self.client.post('/parks/create/', {
|
||||
'name': 'Test Park',
|
||||
'location': 'Test Location',
|
||||
})
|
||||
|
||||
# Verify redirect or success
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(Park.objects.filter(name='Test Park').exists())
|
||||
```
|
||||
|
||||
### 2. Test HTMX Responses
|
||||
|
||||
```python
|
||||
def test_htmx_park_list(self):
|
||||
response = self.client.get('/parks/', HTTP_HX_REQUEST='true')
|
||||
|
||||
# Should return partial template
|
||||
self.assertTemplateUsed(response, 'parks/list_partial.html')
|
||||
|
||||
# Should not include base template chrome
|
||||
self.assertNotContains(response, '<html')
|
||||
```
|
||||
|
||||
### 3. Test Error States
|
||||
|
||||
```python
|
||||
def test_form_validation_errors(self):
|
||||
response = self.client.post('/parks/create/', {
|
||||
'name': '', # Required field
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertContains(response, 'This field is required')
|
||||
```
|
||||
|
||||
## Performance Guidelines
|
||||
|
||||
### 1. Minimize DOM Updates
|
||||
|
||||
```html
|
||||
<!-- Good: Update specific element -->
|
||||
<button hx-get="/count/" hx-target="#count-badge">Refresh</button>
|
||||
|
||||
<!-- Avoid: Replacing entire page section -->
|
||||
<button hx-get="/dashboard/" hx-target="#main">Refresh</button>
|
||||
```
|
||||
|
||||
### 2. Use Skeleton Screens
|
||||
|
||||
```html
|
||||
<div id="content"
|
||||
hx-get="/parks/"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Lazy Load Content
|
||||
|
||||
```html
|
||||
<!-- Load when visible -->
|
||||
<div hx-get="/stats/"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="skeleton h-20"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Debounce Input
|
||||
|
||||
```html
|
||||
<!-- Debounce search input -->
|
||||
<input hx-get="/search/"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#results">
|
||||
```
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
### 1. CSRF Protection
|
||||
|
||||
```html
|
||||
<!-- Include token in body -->
|
||||
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
|
||||
<!-- Or in forms -->
|
||||
<form hx-post="/create/">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
```
|
||||
|
||||
### 2. Validate on Server
|
||||
|
||||
Always validate data server-side, even with client-side validation:
|
||||
|
||||
```python
|
||||
def create_park(request):
|
||||
form = ParkForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return htmx_error('Invalid data', status=422)
|
||||
# Process valid data
|
||||
```
|
||||
|
||||
### 3. Escape User Content
|
||||
|
||||
```django
|
||||
<!-- Auto-escaped by Django -->
|
||||
{{ user_input }}
|
||||
|
||||
<!-- Explicitly mark as safe only for trusted content -->
|
||||
{{ trusted_html|safe }}
|
||||
```
|
||||
|
||||
### 4. Use Appropriate Permissions
|
||||
|
||||
```python
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
|
||||
@login_required
|
||||
@permission_required('parks.add_park')
|
||||
def create_park(request):
|
||||
# Only authenticated users with permission
|
||||
pass
|
||||
```
|
||||
Reference in New Issue
Block a user