Browse Source

feat(skills): Add comprehensive unfold-admin skill

Progressive-disclosure skill for Django Unfold admin theme covering:
- Site configuration, sidebar, tabs, theming (OKLCH colors)
- Four action types with permissions, groups, and intermediate forms
- Display decorators (label, header, dropdown, boolean)
- 22+ filter classes with custom filter patterns
- 35+ widget classes with automatic field mapping table
- Inlines (tabular, stacked, generic, nonrelated) with tabs/pagination/sorting
- Dashboard components, sections, datasets, template injection
- 10 third-party integrations (import-export, celery-beat, guardian, etc.)
- 16 built-in template components
- 9 common patterns and 15 community tips/gotchas
- Command palette with SearchResult dataclass
- Complete import paths reference

Structure: SKILL.md (490 lines) + 5 reference files (2700+ lines)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter 5 months ago
parent
commit
a905fe30fd

+ 2 - 1
.claude/settings.local.json

@@ -140,7 +140,8 @@
       "Bash(choco:*)",
       "Bash(choco:*)",
       "Bash(scoop:*)",
       "Bash(scoop:*)",
       "Bash(winget:*)",
       "Bash(winget:*)",
-      "Bash(powershell -ExecutionPolicy Bypass -File \"./scripts/install.ps1\")"
+      "Bash(powershell -ExecutionPolicy Bypass -File \"./scripts/install.ps1\")",
+      "Bash(just.exe test:*)"
     ],
     ],
     "deny": [],
     "deny": [],
     "ask": [
     "ask": [

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **22 expert agents** for specialized domains (React, Python, Go, Rust, AWS, etc.)
 - **22 expert agents** for specialized domains (React, Python, Go, Rust, AWS, etc.)
 - **3 commands** for session management (/sync, /save) and experimental features (/canvas)
 - **3 commands** for session management (/sync, /save) and experimental features (/canvas)
-- **42 skills** for CLI tools, patterns, workflows, and development tasks
+- **43 skills** for CLI tools, patterns, workflows, and development tasks
 - **Custom output styles** for response personality (e.g., Vesper)
 - **Custom output styles** for response personality (e.g., Vesper)
 
 
 ## Installation
 ## Installation

+ 1 - 1
docs/PLAN.md

@@ -13,7 +13,7 @@
 | Component | Count | Notes |
 | Component | Count | Notes |
 |-----------|-------|-------|
 |-----------|-------|-------|
 | Agents | 22 | Domain experts (Python, Go, Rust, React, etc.) |
 | Agents | 22 | Domain experts (Python, Go, Rust, React, etc.) |
-| Skills | 42 | Pattern libraries, CLI tools, workflows, dev tasks |
+| Skills | 43 | Pattern libraries, CLI tools, workflows, dev tasks |
 | Commands | 3 | Session management (sync, save) + experimental (canvas) |
 | Commands | 3 | Session management (sync, save) + experimental (canvas) |
 | Rules | 5 | CLI tools, thinking, commit style, naming, skill-agent-updates |
 | Rules | 5 | CLI tools, thinking, commit style, naming, skill-agent-updates |
 | Output Styles | 1 | Vesper personality |
 | Output Styles | 1 | Vesper personality |

+ 490 - 0
skills/unfold-admin/SKILL.md

@@ -0,0 +1,490 @@
+---
+name: unfold-admin
+description: "Django Unfold admin theme - build, configure, and enhance modern Django admin interfaces with Unfold. Use when working with: (1) Django admin UI customisation or theming, (2) Unfold ModelAdmin, inlines, actions, filters, widgets, or decorators, (3) Admin dashboard components and KPI cards, (4) Sidebar navigation, tabs, or conditional fields, (5) Any mention of 'unfold', 'django-unfold', or 'unfold admin'. Covers the full Unfold feature set: site configuration, actions system, display decorators, filter types, widget overrides, inline variants, dashboard components, datasets, sections, theming, and third-party integrations."
+---
+
+# Django Unfold Admin
+
+Modern Django admin theme with Tailwind CSS, HTMX, and Alpine.js. Replaces Django's default admin with a polished, feature-rich interface.
+
+## Quick Start
+
+### Installation
+
+```python
+# settings.py - unfold MUST be before django.contrib.admin
+INSTALLED_APPS = [
+    "unfold",
+    "unfold.contrib.filters",          # advanced filters
+    "unfold.contrib.forms",            # array/wysiwyg widgets
+    "unfold.contrib.inlines",          # nonrelated inlines
+    "unfold.contrib.import_export",    # styled import/export
+    "unfold.contrib.guardian",         # django-guardian integration
+    "unfold.contrib.simple_history",   # django-simple-history integration
+    "unfold.contrib.constance",        # django-constance integration
+    "unfold.contrib.location_field",   # django-location-field integration
+    # ...
+    "django.contrib.admin",
+]
+```
+
+### Minimal Admin
+
+```python
+from unfold.admin import ModelAdmin
+
+@admin.register(MyModel)
+class MyModelAdmin(ModelAdmin):
+    pass  # inherits Unfold styling
+```
+
+### Site Configuration
+
+Replace the default `AdminSite` or configure via `UNFOLD` dict in settings. See [references/configuration.md](references/configuration.md) for the complete settings reference.
+
+```python
+UNFOLD = {
+    "SITE_TITLE": "My Admin",
+    "SITE_HEADER": "My Admin",
+    "SITE_SYMBOL": "dashboard",  # Material Symbols icon name
+    "SIDEBAR": {
+        "show_search": True,
+        "navigation": [
+            {
+                "title": _("Navigation"),
+                "items": [
+                    {
+                        "title": _("Dashboard"),
+                        "icon": "dashboard",
+                        "link": reverse_lazy("admin:index"),
+                    },
+                ],
+            },
+        ],
+    },
+}
+```
+
+## Core Workflow
+
+When building Unfold admin interfaces, follow this sequence:
+
+1. **Configure site** - UNFOLD settings dict (branding, sidebar, theme)
+2. **Register models** - Extend `unfold.admin.ModelAdmin`
+3. **Enhance display** - `@display` decorator for list columns
+4. **Add actions** - `@action` decorator for row/list/detail/submit actions
+5. **Configure filters** - Replace default filters with Unfold filter classes
+6. **Override widgets** - Apply Unfold widgets via `formfield_overrides`
+7. **Set up inlines** - Use Unfold's inline classes with tabs, pagination, sorting
+8. **Build dashboard** - `@register_component` + `BaseComponent` for KPI cards
+
+## ModelAdmin Attributes
+
+Unfold extends Django's `ModelAdmin` with these additional attributes:
+
+| Attribute | Type | Purpose |
+|-----------|------|---------|
+| `list_fullwidth` | bool | Full-width changelist (no sidebar) |
+| `list_filter_submit` | bool | Add submit button to filters |
+| `list_filter_sheet` | bool | Filters in sliding sheet panel |
+| `compressed_fields` | bool | Compact field spacing in forms |
+| `warn_unsaved_form` | bool | Warn before leaving unsaved form |
+| `ordering_field` | str | Field name for drag-to-reorder |
+| `hide_ordering_field` | bool | Hide the ordering field column |
+| `list_horizontal_scrollbar_top` | bool | Scrollbar at top of list |
+| `list_disable_select_all` | bool | Disable "select all" checkbox |
+| `change_form_show_cancel_button` | bool | Show cancel button on form |
+| `actions_list` | list | Global changelist actions |
+| `actions_row` | list | Per-row actions in changelist |
+| `actions_detail` | list | Actions on change form |
+| `actions_submit_line` | list | Actions in form submit area |
+| `actions_list_hide_default` | bool | Hide default list actions |
+| `actions_detail_hide_default` | bool | Hide default detail actions |
+| `conditional_fields` | dict | JS expressions for field visibility |
+| `change_form_datasets` | list | BaseDataset subclasses for change form |
+| `list_sections` | list | TableSection/TemplateSection for list |
+| `list_sections_classes` | str | CSS grid classes for sections |
+| `readonly_preprocess_fields` | dict | Transform readonly field content |
+| `add_fieldsets` | list | Separate fieldsets for add form (like UserAdmin) |
+
+### Template Injection Points
+
+Insert custom HTML before/after changelist or change form:
+
+```python
+class MyAdmin(ModelAdmin):
+    # Changelist
+    list_before_template = "myapp/list_before.html"
+    list_after_template = "myapp/list_after.html"
+    # Change form (inside <form> tag)
+    change_form_before_template = "myapp/form_before.html"
+    change_form_after_template = "myapp/form_after.html"
+    # Change form (outside <form> tag)
+    change_form_outer_before_template = "myapp/outer_before.html"
+    change_form_outer_after_template = "myapp/outer_after.html"
+```
+
+### Conditional Fields
+
+Show/hide fields based on other field values (Alpine.js expressions):
+
+```python
+class MyAdmin(ModelAdmin):
+    conditional_fields = {
+        "premium_features": "plan == 'PRO'",
+        "discount_amount": "has_discount == true",
+    }
+```
+
+## Actions System
+
+Four action types, each with different signatures. See [references/actions-filters.md](references/actions-filters.md) for complete reference.
+
+```python
+from unfold.decorators import action
+from unfold.enums import ActionVariant
+
+# List action (no object context)
+@action(description=_("Rebuild Index"), icon="sync", variant=ActionVariant.PRIMARY)
+def rebuild_index(self, request):
+    # process...
+    return redirect(request.headers["referer"])
+
+# Row action (receives object_id)
+@action(description=_("Approve"), url_path="approve")
+def approve_row(self, request, object_id):
+    obj = self.model.objects.get(pk=object_id)
+    return redirect(request.headers["referer"])
+
+# Detail action (receives object_id, shown on change form)
+@action(description=_("Send Email"), permissions=["send_email"])
+def send_email(self, request, object_id):
+    return redirect(reverse_lazy("admin:myapp_mymodel_change", args=[object_id]))
+
+# Submit line action (receives obj instance, runs on save)
+@action(description=_("Save & Publish"))
+def save_and_publish(self, request, obj):
+    obj.published = True
+```
+
+### Action Groups (Dropdown Menus)
+
+```python
+actions_list = [
+    "primary_action",
+    {
+        "title": _("More"),
+        "variant": ActionVariant.PRIMARY,
+        "items": ["secondary_action", "tertiary_action"],
+    },
+]
+```
+
+### Permissions
+
+```python
+@action(permissions=["can_export", "auth.view_user"])
+def export_data(self, request):
+    pass
+
+def has_can_export_permission(self, request):
+    return request.user.is_superuser
+```
+
+## Display Decorator
+
+Enhance list_display columns. See [references/actions-filters.md](references/actions-filters.md).
+
+```python
+from unfold.decorators import display
+
+# Colored status labels
+@display(description=_("Status"), ordering="status", label={
+    "active": "success",    # green
+    "pending": "info",      # blue
+    "warning": "warning",   # orange
+    "inactive": "danger",   # red
+})
+def show_status(self, obj):
+    return obj.status
+
+# Rich header with avatar
+@display(description=_("User"), header=True)
+def show_header(self, obj):
+    return [
+        obj.full_name,           # primary text
+        obj.email,               # secondary text
+        obj.initials,            # badge text
+        {"path": obj.avatar.url, "width": 24, "height": 24, "borderless": True},
+    ]
+
+# Interactive dropdown
+@display(description=_("Teams"), dropdown=True)
+def show_teams(self, obj):
+    return {
+        "title": f"{obj.teams.count()} teams",
+        "items": [{"title": t.name, "link": t.get_admin_url()} for t in obj.teams.all()],
+        "striped": True,
+        "max_height": 200,
+    }
+
+# Boolean checkmark
+@display(description=_("Active"), boolean=True)
+def is_active(self, obj):
+    return obj.is_active
+```
+
+## Filters
+
+Unfold provides advanced filter classes. See [references/actions-filters.md](references/actions-filters.md).
+
+```python
+from unfold.contrib.filters.admin import (
+    TextFilter, RangeNumericFilter, RangeDateFilter, RangeDateTimeFilter,
+    SingleNumericFilter, SliderNumericFilter, RelatedDropdownFilter,
+    RelatedCheckboxFilter, ChoicesCheckboxFilter, AllValuesCheckboxFilter,
+    BooleanRadioFilter, CheckboxFilter, AutocompleteSelectMultipleFilter,
+)
+
+class MyAdmin(ModelAdmin):
+    list_filter_submit = True  # required for input-based filters
+    list_filter = [
+        ("salary", RangeNumericFilter),
+        ("status", ChoicesCheckboxFilter),
+        ("created_at", RangeDateFilter),
+        ("category", RelatedDropdownFilter),
+        ("is_active", BooleanRadioFilter),
+    ]
+```
+
+### Custom Text Filter
+
+```python
+class NameFilter(TextFilter):
+    title = _("Name")
+    parameter_name = "name"
+
+    def queryset(self, request, queryset):
+        if self.value() in EMPTY_VALUES:
+            return queryset
+        return queryset.filter(name__icontains=self.value())
+```
+
+## Widgets
+
+Override form widgets for Unfold styling. See [references/widgets-inlines.md](references/widgets-inlines.md).
+
+```python
+from unfold.widgets import (
+    UnfoldAdminTextInputWidget, UnfoldAdminSelectWidget, UnfoldAdminSelect2Widget,
+    UnfoldBooleanSwitchWidget, UnfoldAdminColorInputWidget,
+    UnfoldAdminSplitDateTimeWidget, UnfoldAdminImageFieldWidget,
+)
+from unfold.contrib.forms.widgets import WysiwygWidget, ArrayWidget
+
+class MyAdmin(ModelAdmin):
+    formfield_overrides = {
+        models.TextField: {"widget": WysiwygWidget},
+        models.ImageField: {"widget": UnfoldAdminImageFieldWidget},
+    }
+```
+
+### Text Input with Icons
+
+```python
+widget = UnfoldAdminTextInputWidget(attrs={
+    "prefix_icon": "search",
+    "suffix_icon": "euro",
+})
+```
+
+## Inlines
+
+Unfold inlines support tabs, pagination, sorting, and nonrelated models. See [references/widgets-inlines.md](references/widgets-inlines.md).
+
+```python
+from unfold.admin import TabularInline, StackedInline
+from unfold.contrib.inlines.admin import NonrelatedStackedInline
+
+class OrderItemInline(TabularInline):
+    model = OrderItem
+    tab = True          # show as tab
+    per_page = 10       # paginated
+    ordering_field = "weight"  # drag-to-reorder
+    hide_title = True
+    collapsible = True
+```
+
+## Fieldset Tabs
+
+Group fieldsets into tabs using `"classes": ["tab"]`:
+
+```python
+fieldsets = [
+    (None, {"fields": ["name", "email"]}),  # always visible
+    (_("Profile"), {"classes": ["tab"], "fields": ["bio", "avatar"]}),
+    (_("Settings"), {"classes": ["tab"], "fields": ["theme", "notifications"]}),
+]
+```
+
+## Dashboard Components
+
+Build KPI cards and custom dashboard widgets. See [references/dashboard.md](references/dashboard.md).
+
+```python
+from unfold.components import BaseComponent, register_component
+from django.template.loader import render_to_string
+
+@register_component
+class ActiveUsersComponent(BaseComponent):
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["children"] = render_to_string("myapp/kpi_card.html", {
+            "total": User.objects.filter(is_active=True).count(),
+            "label": "Active Users",
+        })
+        return context
+```
+
+Configure in settings:
+
+```python
+UNFOLD = {
+    "DASHBOARD_CALLBACK": "myapp.views.dashboard_callback",
+}
+```
+
+## Sections (Changelist Panels)
+
+Embed related data panels in changelist views:
+
+```python
+from unfold.sections import TableSection, TemplateSection
+
+class RecentOrdersSection(TableSection):
+    related_name = "order_set"
+    fields = ["id", "total", "status"]
+    height = 380
+
+class ChartSection(TemplateSection):
+    template_name = "myapp/chart.html"
+
+class MyAdmin(ModelAdmin):
+    list_sections = [RecentOrdersSection, ChartSection]
+    list_sections_classes = "lg:grid-cols-2"
+```
+
+## Datasets (Change Form Panels)
+
+Embed model listings within change forms:
+
+```python
+from unfold.datasets import BaseDataset
+
+class RelatedItemsDatasetAdmin(ModelAdmin):
+    list_display = ["name", "status"]
+    search_fields = ["name"]
+
+class RelatedItemsDataset(BaseDataset):
+    model = RelatedItem
+    model_admin = RelatedItemsDatasetAdmin
+    tab = True  # show as tab
+
+class MyAdmin(ModelAdmin):
+    change_form_datasets = [RelatedItemsDataset]
+```
+
+## Paginator
+
+Use infinite scroll pagination:
+
+```python
+from unfold.paginator import InfinitePaginator
+
+class MyAdmin(ModelAdmin):
+    paginator = InfinitePaginator
+    show_full_result_count = False
+    list_per_page = 20
+```
+
+## Third-Party Integrations
+
+Unfold provides styled wrappers for common Django packages. See [references/resources.md](references/resources.md) for complete setup guides.
+
+| Package | Unfold Module | Setup |
+|---------|--------------|-------|
+| django-import-export | `unfold.contrib.import_export` | Use `ImportForm`, `ExportForm`, `SelectableFieldsExportForm` |
+| django-guardian | `unfold.contrib.guardian` | Styled guardian integration |
+| django-simple-history | `unfold.contrib.simple_history` | Styled history integration |
+| django-constance | `unfold.contrib.constance` | Styled constance config |
+| django-location-field | `unfold.contrib.location_field` | Location widget |
+| django-modeltranslation | Compatible | Mix `TabbedTranslationAdmin` with `ModelAdmin` |
+| django-celery-beat | Compatible (rewire) | Unregister 5 models, re-register with Unfold |
+| django-money | `unfold.widgets` | `UnfoldAdminMoneyWidget` |
+| djangoql | Compatible | Mix `DjangoQLSearchMixin` with `ModelAdmin` |
+| django-crispy-forms | Compatible | Unfold template pack available |
+
+```python
+# Multiple inheritance - Unfold ModelAdmin always last
+@admin.register(MyModel)
+class MyAdmin(DjangoQLSearchMixin, SimpleHistoryAdmin, GuardedModelAdmin, ModelAdmin):
+    pass
+```
+
+## Built-In Template Components
+
+Unfold ships reusable template components for dashboards and custom pages:
+
+| Component | Path | Key Variables |
+|-----------|------|---------------|
+| Card | `unfold/components/card.html` | `title`, `footer`, `label`, `icon` |
+| Bar Chart | `unfold/components/chart/bar.html` | `data` (JSON), `height`, `width` |
+| Line Chart | `unfold/components/chart/line.html` | `data` (JSON), `height`, `width` |
+| Progress | `unfold/components/progress.html` | `value`, `title`, `description` |
+| Table | `unfold/components/table.html` | `table`, `card_included`, `striped` |
+| Button | `unfold/components/button.html` | `name`, `href`, `submit` |
+| Tracker | `unfold/components/tracker.html` | `data` |
+| Cohort | `unfold/components/cohort.html` | `data` |
+
+```html
+{% load unfold %}
+{% component "MyKPIComponent" %}{% endcomponent %}
+```
+
+## User Admin Forms
+
+Unfold provides styled versions of Django's auth admin forms:
+
+```python
+from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin, ModelAdmin):
+    form = UserChangeForm
+    add_form = UserCreationForm
+    change_password_form = AdminPasswordChangeForm
+```
+
+## Reference Files
+
+Detailed documentation split by topic:
+
+- **[references/configuration.md](references/configuration.md)** - Complete UNFOLD settings dict, sidebar, tabs, theming, environment, login
+- **[references/actions-filters.md](references/actions-filters.md)** - Action types and signatures, display decorator, all filter classes
+- **[references/widgets-inlines.md](references/widgets-inlines.md)** - Complete widget class list (35+), inline variants, nonrelated inlines, forms
+- **[references/dashboard.md](references/dashboard.md)** - Dashboard components, sections, datasets, custom templates, Tailwind patterns
+- **[references/resources.md](references/resources.md)** - Official links, all third-party integrations, common patterns, version compatibility, Unfold Studio
+
+Read the relevant reference file when you need detailed configuration options, the full list of available classes, complete code examples, or integration setup guides for a specific feature area.
+
+### Key External References
+
+| Resource | URL |
+|----------|-----|
+| Docs | https://unfoldadmin.com/docs/ |
+| GitHub | https://github.com/unfoldadmin/django-unfold |
+| Demo App (Formula) | https://github.com/unfoldadmin/formula |
+| Live Demo | https://demo.unfoldadmin.com |
+| Material Symbols (Icons) | https://fonts.google.com/icons |
+
+When uncertain about an implementation pattern, consult `formula/admin.py` and `formula/settings.py` in the Formula demo repo - it covers virtually every Unfold feature.

+ 484 - 0
skills/unfold-admin/references/actions-filters.md

@@ -0,0 +1,484 @@
+# Actions, Display Decorators, and Filters Reference
+
+## Table of Contents
+
+- [Action System](#action-system)
+- [Display Decorator](#display-decorator)
+- [Filter Classes](#filter-classes)
+- [Custom Filters](#custom-filters)
+
+## Action System
+
+### Imports
+
+```python
+from unfold.decorators import action
+from unfold.enums import ActionVariant
+```
+
+### @action Decorator Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `description` | str | Button label text |
+| `icon` | str | Material Symbols icon name |
+| `variant` | ActionVariant | Button color/style |
+| `permissions` | list[str] | Required permission names |
+| `url_path` | str | Custom URL path segment |
+| `attrs` | dict | Extra HTML attributes |
+
+### ActionVariant Enum
+
+```python
+from unfold.enums import ActionVariant
+
+ActionVariant.DEFAULT   # neutral
+ActionVariant.PRIMARY   # primary color
+ActionVariant.SUCCESS   # green
+ActionVariant.INFO      # blue
+ActionVariant.WARNING   # orange
+ActionVariant.DANGER    # red
+```
+
+### Four Action Types
+
+Each type has a different method signature and registration attribute:
+
+#### 1. List Actions (Global)
+
+Appear at top of changelist. No object context.
+
+```python
+class MyAdmin(ModelAdmin):
+    actions_list = ["rebuild_index"]
+
+    @action(description=_("Rebuild Index"), icon="sync", variant=ActionVariant.PRIMARY)
+    def rebuild_index(self, request):
+        # perform global operation
+        messages.success(request, _("Index rebuilt."))
+        return redirect(request.headers["referer"])
+```
+
+#### 2. Row Actions
+
+Per-row buttons in changelist. Receive `object_id`.
+
+```python
+class MyAdmin(ModelAdmin):
+    actions_row = ["approve_item"]
+
+    @action(description=_("Approve"), url_path="approve-item")
+    def approve_item(self, request, object_id):
+        obj = self.model.objects.get(pk=object_id)
+        obj.approved = True
+        obj.save()
+        messages.success(request, f"Approved {obj}")
+        return redirect(
+            request.headers.get("referer")
+            or reverse_lazy("admin:myapp_mymodel_changelist")
+        )
+```
+
+#### 3. Detail Actions
+
+Buttons on change form toolbar. Receive `object_id`.
+
+```python
+class MyAdmin(ModelAdmin):
+    actions_detail = ["send_notification"]
+
+    @action(
+        description=_("Send Notification"),
+        url_path="send-notification",
+        permissions=["send_notification"],
+    )
+    def send_notification(self, request, object_id):
+        obj = get_object_or_404(self.model, pk=object_id)
+        # can render a custom form page
+        return render(request, "myapp/notification_form.html", {
+            "object": obj,
+            **self.admin_site.each_context(request),
+        })
+
+    def has_send_notification_permission(self, request, object_id=None):
+        return request.user.has_perm("myapp.send_notification")
+```
+
+#### 4. Submit Line Actions
+
+Execute during form save. Receive the model instance `obj`.
+
+```python
+class MyAdmin(ModelAdmin):
+    actions_submit_line = ["save_and_publish"]
+
+    @action(description=_("Save & Publish"), permissions=["publish"])
+    def save_and_publish(self, request, obj):
+        obj.published = True
+        messages.success(request, f"Published {obj}")
+
+    def has_publish_permission(self, request, obj=None):
+        return request.user.has_perm("myapp.publish_item")
+```
+
+### Action Groups (Dropdown Menus)
+
+Group multiple actions under a dropdown button:
+
+```python
+actions_list = [
+    "primary_action",          # standalone button
+    {
+        "title": _("More Actions"),
+        "variant": ActionVariant.PRIMARY,
+        "items": [
+            "action_two",
+            "action_three",
+            "action_four",
+        ],
+    },
+]
+
+actions_detail = [
+    "main_detail_action",
+    {
+        "title": _("More"),
+        "items": ["detail_action_a", "detail_action_b"],
+    },
+]
+```
+
+### Permission System
+
+Two approaches work together:
+
+```python
+# Method 1: Method-based (custom logic)
+@action(permissions=["can_export"])
+def export_data(self, request):
+    pass
+
+def has_can_export_permission(self, request):
+    return request.user.groups.filter(name="Exporters").exists()
+
+# Method 2: Django built-in permissions
+@action(permissions=["myapp.export_data", "auth.view_user"])
+def export_with_django_perms(self, request):
+    pass
+```
+
+When multiple permissions are listed, ALL must be satisfied (AND logic).
+
+### Action with Custom Form
+
+Render an intermediate form page from a detail action:
+
+```python
+@action(description=_("Action with Form"), url_path="custom-form")
+def action_with_form(self, request, object_id):
+    obj = get_object_or_404(self.model, pk=object_id)
+
+    class ActionForm(forms.Form):
+        note = forms.CharField(widget=UnfoldAdminTextInputWidget)
+        date = forms.SplitDateTimeField(widget=UnfoldAdminSplitDateTimeWidget)
+
+    form = ActionForm(request.POST or None)
+
+    if request.method == "POST" and form.is_valid():
+        # process form
+        messages.success(request, _("Done."))
+        return redirect(reverse_lazy("admin:myapp_mymodel_change", args=[object_id]))
+
+    return render(request, "myapp/action_form.html", {
+        "form": form,
+        "object": obj,
+        "title": _("Custom Action"),
+        **self.admin_site.each_context(request),
+    })
+```
+
+### Hide Default Actions
+
+```python
+class MyAdmin(ModelAdmin):
+    actions_list_hide_default = True    # hide "Delete selected" etc.
+    actions_detail_hide_default = True
+```
+
+### Custom URLs
+
+Register custom URL patterns via `get_urls()`:
+
+```python
+def get_urls(self):
+    return super().get_urls() + [
+        path("custom-page/", self.admin_site.admin_view(CustomView.as_view(model_admin=self)), name="custom_page"),
+    ]
+```
+
+## Display Decorator
+
+### Imports
+
+```python
+from unfold.decorators import display
+```
+
+### @display Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `description` | str | Column header text |
+| `ordering` | str | Enable sorting via this field |
+| `boolean` | bool | Render as check/cross icon |
+| `label` | bool/dict | Colored label badge |
+| `header` | bool | Rich header with avatar/initials |
+| `dropdown` | bool | Interactive dropdown menu |
+| `image` | bool | Render as image thumbnail |
+
+### Label Colors
+
+Map field values to color schemes:
+
+```python
+@display(description=_("Status"), ordering="status", label={
+    "active": "success",      # green
+    "pending": "info",        # blue
+    "suspended": "warning",   # orange
+    "banned": "danger",       # red
+})
+def show_status(self, obj):
+    return obj.status
+```
+
+For generic styling without color mapping:
+
+```python
+@display(description=_("Code"), label=True)
+def show_code(self, obj):
+    return obj.code
+```
+
+### Header Display
+
+Returns a list: `[primary_text, secondary_text, badge_text, image_config]`
+
+```python
+@display(description=_("Employee"), header=True)
+def show_employee(self, obj):
+    return [
+        obj.full_name,                    # line 1 (bold)
+        obj.department,                   # line 2 (muted)
+        obj.initials,                     # circular badge
+        {
+            "path": obj.photo.url if obj.photo else None,
+            "squared": False,             # circular crop (default)
+            "borderless": True,
+            "width": 24,
+            "height": 24,
+        },
+    ]
+```
+
+Any element can be `None` to skip it.
+
+### Dropdown Display
+
+Returns a dict with items or custom HTML:
+
+```python
+# List-based dropdown
+@display(description=_("Roles"), dropdown=True)
+def show_roles(self, obj):
+    roles = obj.roles.all()
+    if not roles:
+        return "-"
+
+    return {
+        "title": f"{roles.count()} roles",
+        "striped": True,               # alternating row colors
+        "height": 400,                 # fixed height
+        "max_height": 200,             # max before scrolling
+        "width": 240,                  # custom width
+        "items": [
+            {"title": role.name, "link": role.get_admin_url()}
+            for role in roles
+        ],
+    }
+
+# Custom HTML dropdown
+@display(description=_("Preview"), dropdown=True)
+def show_preview(self, obj):
+    return {
+        "title": "Preview",
+        "content": render_to_string("myapp/preview_dropdown.html", {"obj": obj}),
+    }
+```
+
+### Boolean Display
+
+```python
+@display(description=_("Active"), boolean=True)
+def show_active(self, obj):
+    return obj.is_active
+```
+
+## Filter Classes
+
+### Installation
+
+```python
+INSTALLED_APPS = [
+    "unfold",
+    "unfold.contrib.filters",  # must follow unfold
+    # ...
+]
+```
+
+### Important: `list_filter_submit`
+
+Input-based filters (text, numeric, date) require a submit button:
+
+```python
+class MyAdmin(ModelAdmin):
+    list_filter_submit = True   # adds submit button to filter panel
+    list_filter_sheet = False   # True = filters in sliding sheet panel
+```
+
+### Available Filter Classes
+
+All from `unfold.contrib.filters.admin`:
+
+| Filter Class | Input Type | Use Case |
+|-------------|------------|----------|
+| `TextFilter` | Text input | Custom text search (abstract - subclass it) |
+| `RangeNumericFilter` | Two number inputs | Numeric range (min-max) |
+| `SingleNumericFilter` | One number input | Single numeric value (__gte) |
+| `SliderNumericFilter` | Slider control | Numeric range with slider |
+| `RangeNumericListFilter` | Two number inputs | Numeric range (not tied to model field) |
+| `RangeDateFilter` | Two date pickers | Date range |
+| `RangeDateTimeFilter` | Two datetime pickers | DateTime range |
+| `DropdownFilter` | Select dropdown | Custom dropdown (abstract - subclass it) |
+| `MultipleDropdownFilter` | Multi-select dropdown | Custom multi-select dropdown (abstract) |
+| `ChoicesDropdownFilter` | Select dropdown | CharField with choices |
+| `MultipleChoicesDropdownFilter` | Multi-select dropdown | CharField choices multi-select |
+| `RelatedDropdownFilter` | Select dropdown | ForeignKey selection |
+| `MultipleRelatedDropdownFilter` | Multi-select dropdown | ForeignKey multi-select |
+| `RelatedCheckboxFilter` | Checkbox group | ForeignKey as checkboxes |
+| `ChoicesCheckboxFilter` | Checkbox group | CharField choices as checkboxes |
+| `AllValuesCheckboxFilter` | Checkbox group | All distinct field values |
+| `RadioFilter` | Radio buttons | Custom radio (abstract - subclass it) |
+| `BooleanRadioFilter` | Radio buttons | Boolean field |
+| `ChoicesRadioFilter` | Radio buttons | CharField choices as radios |
+| `CheckboxFilter` | Checkbox group | Custom choices (abstract - subclass it) |
+| `AutocompleteSelectFilter` | Autocomplete single | Related model single search |
+| `AutocompleteSelectMultipleFilter` | Autocomplete multi-select | Related model multi search |
+| `FieldTextFilter` | Text input | Field-based text filter (__icontains) |
+
+### Usage Patterns
+
+```python
+from unfold.contrib.filters.admin import (
+    TextFilter, RangeNumericFilter, RangeDateFilter, RangeDateTimeFilter,
+    SingleNumericFilter, SliderNumericFilter, RangeNumericListFilter,
+    DropdownFilter, MultipleDropdownFilter,
+    ChoicesDropdownFilter, MultipleChoicesDropdownFilter,
+    RelatedDropdownFilter, MultipleRelatedDropdownFilter,
+    RelatedCheckboxFilter, ChoicesCheckboxFilter, AllValuesCheckboxFilter,
+    BooleanRadioFilter, CheckboxFilter, AutocompleteSelectMultipleFilter,
+)
+
+class MyAdmin(ModelAdmin):
+    list_filter_submit = True
+    list_filter = [
+        # Tuple syntax: (field_name, FilterClass)
+        ("price", RangeNumericFilter),
+        ("status", ChoicesDropdownFilter),       # dropdown for choices
+        ("status", ChoicesCheckboxFilter),        # or checkboxes
+        ("created_at", RangeDateFilter),
+        ("category", RelatedDropdownFilter),
+        ("category", MultipleRelatedDropdownFilter),  # multi-select variant
+        ("is_active", BooleanRadioFilter),
+        ("tags", AutocompleteSelectMultipleFilter),
+        ("rating", SingleNumericFilter),
+
+        # Direct class (for custom filters)
+        NameSearchFilter,
+    ]
+```
+
+### Slider Filter with Decimals
+
+```python
+class PriceSliderFilter(SliderNumericFilter):
+    MAX_DECIMALS = 2
+    STEP = 0.01
+```
+
+## Custom Filters
+
+### Custom Dropdown Filter
+
+Subclass `DropdownFilter` and implement `lookups()` and `queryset()`:
+
+```python
+from unfold.contrib.filters.admin import DropdownFilter
+
+class RegionFilter(DropdownFilter):
+    title = _("Region")
+    parameter_name = "region"
+
+    def lookups(self, request, model_admin):
+        return [
+            ["north", _("North")],
+            ["south", _("South")],
+            ["east", _("East")],
+            ["west", _("West")],
+        ]
+
+    def queryset(self, request, queryset):
+        if self.value() not in EMPTY_VALUES:
+            return queryset.filter(region=self.value())
+        return queryset
+```
+
+### Custom Text Filter
+
+Subclass `TextFilter` and implement `queryset()`:
+
+```python
+from django.core.validators import EMPTY_VALUES
+from unfold.contrib.filters.admin import TextFilter
+
+class FullNameFilter(TextFilter):
+    title = _("Full name")
+    parameter_name = "fullname"
+
+    def queryset(self, request, queryset):
+        if self.value() in EMPTY_VALUES:
+            return queryset
+        return queryset.filter(
+            Q(first_name__icontains=self.value()) |
+            Q(last_name__icontains=self.value())
+        )
+```
+
+### Custom Checkbox Filter
+
+Subclass `CheckboxFilter` and implement `lookups()` and `queryset()`:
+
+```python
+from unfold.contrib.filters.admin import CheckboxFilter
+
+class StatusCheckboxFilter(CheckboxFilter):
+    title = _("Status")
+    parameter_name = "custom_status"
+
+    def lookups(self, request, model_admin):
+        return [("active", _("Active")), ("inactive", _("Inactive"))]
+
+    def queryset(self, request, queryset):
+        if self.value() not in EMPTY_VALUES:
+            return queryset.filter(status__in=self.value())
+        return queryset
+```

+ 443 - 0
skills/unfold-admin/references/configuration.md

@@ -0,0 +1,443 @@
+# Unfold Configuration Reference
+
+## Table of Contents
+
+- [UNFOLD Settings Dict](#unfold-settings-dict)
+- [Site Branding](#site-branding)
+- [Sidebar Navigation](#sidebar-navigation)
+- [Tabs](#tabs)
+- [Theming and Colors](#theming-and-colors)
+- [Environment Indicator](#environment-indicator)
+- [Login Page](#login-page)
+- [Command Palette](#command-palette)
+- [Custom Styles and Scripts](#custom-styles-and-scripts)
+- [Language Switcher](#language-switcher)
+- [Complete Settings Example](#complete-settings-example)
+
+## UNFOLD Settings Dict
+
+All configuration lives in `UNFOLD` dict in Django settings:
+
+```python
+# settings.py
+from django.templatetags.static import static
+from django.urls import reverse_lazy
+from django.utils.translation import gettext_lazy as _
+
+UNFOLD = {
+    # Site identity
+    "SITE_TITLE": _("My Admin"),          # <title> tag suffix
+    "SITE_HEADER": _("My Admin"),          # sidebar header text
+    "SITE_SUBHEADER": _("Admin portal"),   # text below header
+    "SITE_URL": "/",                       # header link target
+
+    # Icons and logos
+    "SITE_SYMBOL": "dashboard",            # Material Symbols icon name
+    "SITE_ICON": lambda request: static("img/icon.svg"),
+    "SITE_LOGO": lambda request: static("img/logo.svg"),
+    "SITE_FAVICONS": [
+        {"rel": "icon", "sizes": "32x32", "type": "image/svg+xml", "href": lambda request: static("img/favicon.svg")},
+    ],
+
+    # Light/dark variants for icon and logo
+    # "SITE_ICON": {
+    #     "light": lambda request: static("img/icon-light.svg"),
+    #     "dark": lambda request: static("img/icon-dark.svg"),
+    # },
+
+    # UI toggles
+    "SHOW_HISTORY": True,          # history button on change form
+    "SHOW_VIEW_ON_SITE": True,     # "view on site" button
+    "SHOW_BACK_BUTTON": False,     # back button on change forms
+
+    # Theme
+    "THEME": None,                 # None (auto), "dark", or "light"
+    "BORDER_RADIUS": "6px",        # global border radius
+
+    # Colors (OKLCH format)
+    "COLORS": {
+        "base": {
+            "50": "250 250 250",
+            "100": "244 245 245",
+            # ... full scale 50-950
+            "950": "10 10 10",
+        },
+        "primary": {
+            "50": "250 245 255",
+            "100": "243 232 255",
+            # ... full scale 50-950
+            "950": "59 7 100",
+        },
+        "font": {
+            "subtle-light": "var(--color-base-500)",
+            "subtle-dark": "var(--color-base-400)",
+            "default-light": "var(--color-base-700)",
+            "default-dark": "var(--color-base-200)",
+            "important-light": "var(--color-base-900)",
+            "important-dark": "var(--color-base-100)",
+        },
+    },
+
+    # Callbacks
+    "ENVIRONMENT": "myapp.utils.environment_callback",
+    "ENVIRONMENT_TITLE_PREFIX": None,
+    "DASHBOARD_CALLBACK": "myapp.views.dashboard_callback",
+
+    # Login page
+    "LOGIN": {
+        "image": lambda request: static("img/login-bg.jpg"),
+        "redirect_after": "/admin/",
+        "form": "myapp.forms.LoginForm",
+    },
+
+    # Custom assets
+    "STYLES": [lambda request: static("css/custom.css")],
+    "SCRIPTS": [lambda request: static("js/custom.js")],
+
+    # Language
+    "SHOW_LANGUAGES": False,
+    "LANGUAGE_FLAGS": {"en": "US", "de": "DE"},
+
+    # Sidebar
+    "SIDEBAR": { ... },   # see below
+
+    # Tabs
+    "TABS": [ ... ],      # see below
+
+    # Command palette
+    "COMMAND": { ... },    # see below
+}
+```
+
+## Site Branding
+
+### Icons
+
+Use [Material Symbols](https://fonts.google.com/icons) names for `SITE_SYMBOL` and all navigation icons.
+
+```python
+"SITE_SYMBOL": "speed",  # displayed in sidebar when logo not set
+```
+
+### Logo vs Icon
+
+- `SITE_LOGO` - larger logo for sidebar header
+- `SITE_ICON` - smaller icon (fallback when no logo)
+- Both accept a callable `lambda request: url_string` or a dict with `light`/`dark` keys
+
+### Site Dropdown
+
+Add links to the site header dropdown:
+
+```python
+"SITE_DROPDOWN": [
+    {
+        "icon": "description",
+        "title": _("Documentation"),
+        "link": "https://docs.example.com",
+    },
+    {
+        "icon": "code",
+        "title": _("API Reference"),
+        "link": "/api/docs/",
+    },
+],
+```
+
+## Sidebar Navigation
+
+```python
+"SIDEBAR": {
+    "show_search": True,               # search bar in sidebar
+    "show_all_applications": True,      # "All Applications" link
+    "command_search": True,             # command palette integration
+    "navigation": [
+        {
+            "title": _("Main"),
+            "collapsible": False,       # default: not collapsible
+            "items": [
+                {
+                    "title": _("Dashboard"),
+                    "icon": "dashboard",
+                    "link": reverse_lazy("admin:index"),
+                },
+                {
+                    "title": _("Users"),
+                    "icon": "people",
+                    "link": reverse_lazy("admin:auth_user_changelist"),
+                    "badge": "myapp.utils.users_badge",    # callable returning badge text
+                    "badge_variant": "danger",              # primary|success|info|warning|danger
+                    "badge_style": "solid",                 # solid|outline
+                    "permission": "myapp.utils.perm_check", # callable(request) -> bool
+                },
+                {
+                    "title": _("Products"),
+                    "icon": "inventory_2",
+                    "active": "myapp.utils.products_active_callback",  # custom active logic
+                    "items": [  # nested subitems
+                        {
+                            "title": _("All Products"),
+                            "link": reverse_lazy("admin:shop_product_changelist"),
+                        },
+                        {
+                            "title": _("Categories"),
+                            "link": reverse_lazy("admin:shop_category_changelist"),
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            "title": _("Settings"),
+            "collapsible": True,
+            "items": [ ... ],
+        },
+    ],
+},
+```
+
+### Navigation Item Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `title` | str | Display text |
+| `icon` | str | Material Symbols icon name |
+| `link` | str/lazy | URL (use `reverse_lazy`) |
+| `badge` | str/callable | Badge text or `"dotted_path.to.callback"` |
+| `badge_variant` | str | primary, success, info, warning, danger |
+| `badge_style` | str | solid, outline |
+| `permission` | str/callable | `"dotted_path.to.callback"` returning bool |
+| `active` | str/callable | Custom active state logic |
+| `items` | list | Nested sub-navigation items |
+| `collapsible` | bool | Group-level: allow collapse |
+
+### Badge Callback
+
+```python
+def users_badge(request):
+    return User.objects.filter(is_active=False).count() or None
+```
+
+### Permission Callback
+
+```python
+def permission_callback(request):
+    return request.user.has_perm("myapp.view_sensitive")
+```
+
+### Active Callback
+
+```python
+def products_active_callback(request):
+    return request.path.startswith("/admin/shop/")
+```
+
+## Tabs
+
+Tabs appear above the changelist, linking related models or filtered views:
+
+```python
+"TABS": [
+    {
+        "models": [
+            "shop.product",
+            "shop.category",
+            {"name": "shop.order", "detail": True},  # show tabs on detail/change pages too
+        ],
+        "items": [
+            {
+                "title": _("Products"),
+                "link": reverse_lazy("admin:shop_product_changelist"),
+            },
+            {
+                "title": _("Categories"),
+                "link": reverse_lazy("admin:shop_category_changelist"),
+            },
+        ],
+    },
+    {
+        "page": "users",  # custom page identifier
+        "models": ["auth.user"],
+        "items": [
+            {
+                "title": _("All Users"),
+                "link": reverse_lazy("admin:auth_user_changelist"),
+                "active": lambda request: "status" not in request.GET,
+            },
+            {
+                "title": _("Active"),
+                "link": lambda request: f"{reverse_lazy('admin:auth_user_changelist')}?is_active__exact=1",
+            },
+            {
+                "title": _("Staff"),
+                "link": lambda request: f"{reverse_lazy('admin:auth_user_changelist')}?is_staff__exact=1",
+            },
+        ],
+    },
+],
+```
+
+### Tab Model Formats
+
+Models in the `models` list accept two formats:
+
+```python
+"models": [
+    "app.model",                          # string: tabs on changelist only
+    {"name": "app.model", "detail": True},  # dict: also show tabs on change form
+]
+```
+
+### Tab Item Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `title` | str | Tab label |
+| `link` | str/callable | URL or `lambda request: url` |
+| `permission` | str/callable | Visibility control |
+| `active` | callable | `lambda request: bool` for custom active state |
+
+## Theming and Colors
+
+### Color System
+
+Unfold uses OKLCH color space with a 50-950 scale. Override `base` and `primary` palettes:
+
+```python
+"COLORS": {
+    "base": {
+        "50": "250 250 250",
+        "100": "245 245 245",
+        "200": "229 229 229",
+        "300": "212 212 212",
+        "400": "163 163 163",
+        "500": "115 115 115",
+        "600": "82 82 82",
+        "700": "64 64 64",
+        "800": "38 38 38",
+        "900": "23 23 23",
+        "950": "10 10 10",
+    },
+    "primary": {
+        "50": "238 242 255",
+        "100": "224 231 255",
+        "200": "199 210 254",
+        "300": "165 180 252",
+        "400": "129 140 248",
+        "500": "99 102 241",
+        "600": "79 70 229",
+        "700": "67 56 202",
+        "800": "55 48 163",
+        "900": "49 46 129",
+        "950": "30 27 75",
+    },
+},
+```
+
+### Border Radius
+
+```python
+"BORDER_RADIUS": "6px",   # applies globally
+```
+
+### Forced Theme
+
+```python
+"THEME": "dark",   # disables the light/dark toggle
+```
+
+## Environment Indicator
+
+Display a colored badge in the header (e.g., "Development", "Staging"):
+
+```python
+"ENVIRONMENT": "myapp.utils.environment_callback",
+```
+
+Callback implementation:
+
+```python
+def environment_callback(request):
+    """Return (name, color_type) or None."""
+    if settings.DEBUG:
+        return _("Development"), "danger"    # red badge
+    if "staging" in request.get_host():
+        return _("Staging"), "warning"       # orange badge
+    return None  # no badge in production
+```
+
+Color types: `"danger"`, `"warning"`, `"info"`, `"success"`.
+
+Optional title prefix:
+
+```python
+"ENVIRONMENT_TITLE_PREFIX": _("DEV"),  # prepends to <title>
+```
+
+## Login Page
+
+```python
+"LOGIN": {
+    "image": lambda request: static("img/login-bg.jpg"),
+    "redirect_after": "/admin/",
+    "form": "myapp.forms.LoginForm",  # custom form class
+},
+```
+
+## Command Palette
+
+Configure the Ctrl+K / Cmd+K command palette:
+
+```python
+"COMMAND": {
+    "search_models": True,           # Default: False. Also accepts list/tuple or callback
+    # "search_models": ["myapp.mymodel"],           # specific models
+    # "search_models": "myapp.utils.search_models_callback",  # callback
+    "search_callback": "myapp.utils.search_callback",  # custom search hook
+    "show_history": True,            # command history in localStorage
+},
+```
+
+### Custom Search Callback
+
+```python
+from unfold.dataclasses import SearchResult
+
+def search_callback(request, search_term):
+    return [
+        SearchResult(
+            title="Some title",
+            description="Extra content",
+            link="https://example.com",
+            icon="database",
+        )
+    ]
+```
+
+## Custom Styles and Scripts
+
+Load additional CSS/JS files:
+
+```python
+"STYLES": [
+    lambda request: static("css/admin-overrides.css"),
+],
+"SCRIPTS": [
+    lambda request: static("js/admin-charts.js"),
+],
+```
+
+## Language Switcher
+
+```python
+"SHOW_LANGUAGES": True,
+"LANGUAGE_FLAGS": {
+    "en": "US",
+    "de": "DE",
+    "fr": "FR",
+},
+```
+
+## Complete Settings Example
+
+See the [Formula demo settings](https://github.com/unfoldadmin/formula/blob/main/formula/settings.py) for a production-ready reference.

+ 328 - 0
skills/unfold-admin/references/dashboard.md

@@ -0,0 +1,328 @@
+# Dashboard, Sections, and Datasets Reference
+
+## Table of Contents
+
+- [Dashboard Components](#dashboard-components)
+- [Dashboard Callback](#dashboard-callback)
+- [Sections (Changelist Panels)](#sections)
+- [Datasets (Change Form Panels)](#datasets)
+- [Template Injection](#template-injection)
+- [Custom Dashboard Templates](#custom-dashboard-templates)
+
+## Dashboard Components
+
+### Imports
+
+```python
+from unfold.components import BaseComponent, register_component
+from django.template.loader import render_to_string
+```
+
+### Creating a Component
+
+Components are registered globally and rendered on the admin index page:
+
+```python
+@register_component
+class ActiveUsersKPI(BaseComponent):
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["children"] = render_to_string("myapp/kpi_card.html", {
+            "total": User.objects.filter(is_active=True).count(),
+            "label": "Active Users",
+            "progress": "positive",    # or "negative"
+            "percentage": "+5.2%",
+        })
+        return context
+```
+
+### KPI Card Template Pattern
+
+```html
+<!-- templates/myapp/kpi_card.html -->
+<div class="flex flex-col gap-1">
+    <div class="text-2xl font-bold text-base-900 dark:text-base-100">
+        {{ total }}
+    </div>
+    <div class="flex items-center gap-2">
+        <span class="text-sm text-base-500 dark:text-base-400">{{ label }}</span>
+        {% if percentage %}
+        <span class="text-xs {% if progress == 'positive' %}text-green-600{% else %}text-red-600{% endif %}">
+            {{ percentage }}
+        </span>
+        {% endif %}
+    </div>
+</div>
+```
+
+### Chart Component
+
+Unfold supports Chart.js via custom components:
+
+```python
+@register_component
+class SalesChartComponent(BaseComponent):
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["data"] = json.dumps({
+            "labels": ["Mon", "Tue", "Wed", "Thu", "Fri"],
+            "datasets": [{
+                "data": [[1, 5], [1, 8], [1, 12], [1, 7], [1, 15]],
+                "backgroundColor": "var(--color-primary-600)",
+            }],
+        })
+        return context
+```
+
+### Component Rendering
+
+Components are rendered in the admin index template. Customize the index template to control layout:
+
+```html
+<!-- templates/admin/index.html -->
+{% extends "unfold/layouts/base_simple.html" %}
+{% load unfold %}
+
+{% block content %}
+<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-8">
+    {% component "ActiveUsersKPI" %}{% endcomponent %}
+    {% component "RevenueKPI" %}{% endcomponent %}
+    {% component "OrdersKPI" %}{% endcomponent %}
+    {% component "ConversionKPI" %}{% endcomponent %}
+</div>
+<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
+    {% component "SalesChartComponent" %}{% endcomponent %}
+    {% component "RecentOrdersComponent" %}{% endcomponent %}
+</div>
+{% endblock %}
+```
+
+## Dashboard Callback
+
+Configure in settings:
+
+```python
+UNFOLD = {
+    "DASHBOARD_CALLBACK": "myapp.views.dashboard_callback",
+}
+```
+
+The callback prepares template context for the admin index:
+
+```python
+# myapp/views.py
+def dashboard_callback(request, context):
+    """Add extra context to admin dashboard."""
+    context.update({
+        "custom_variable": "value",
+        "stats": get_dashboard_stats(),
+    })
+    return context
+```
+
+## Sections
+
+Sections are panels displayed below the changelist table.
+
+### Imports
+
+```python
+from unfold.sections import TableSection, TemplateSection
+```
+
+### TableSection
+
+Renders a related model's data as a table:
+
+```python
+class RecentOrdersSection(TableSection):
+    related_name = "order_set"      # related manager name
+    fields = ["id", "total", "status", "custom_field"]
+    height = 380                     # fixed height in px
+
+    @admin.display(description=_("Formatted Total"))
+    def custom_field(self, instance):
+        return f"${instance.total:.2f}"
+```
+
+### TemplateSection
+
+Renders a custom template:
+
+```python
+class AnalyticsSection(TemplateSection):
+    template_name = "myapp/analytics_chart.html"
+```
+
+### Registering Sections
+
+```python
+class MyAdmin(ModelAdmin):
+    list_sections = [RecentOrdersSection, AnalyticsSection]
+    list_sections_classes = "lg:grid-cols-2"  # CSS grid layout
+```
+
+## Datasets
+
+Datasets embed full mini-admin listings within change forms. Think of them as "related admin views" rendered inside another model's edit page.
+
+### Imports
+
+```python
+from unfold.datasets import BaseDataset
+from unfold.admin import ModelAdmin
+```
+
+### Creating a Dataset
+
+```python
+# Step 1: Define a mini-admin for the dataset
+class RelatedItemDatasetAdmin(ModelAdmin):
+    list_display = ["name", "status", "created_at"]
+    search_fields = ["name"]
+    actions = ["bulk_approve"]
+
+    def bulk_approve(self, request, queryset):
+        queryset.update(status="approved")
+        messages.success(request, "Approved.")
+        return redirect(request.headers.get("referer"))
+
+    def get_queryset(self, request):
+        obj = self.extra_context.get("object")  # parent object
+        if not obj:
+            return super().get_queryset(request).none()
+        return super().get_queryset(request).filter(parent=obj)
+
+# Step 2: Define the dataset
+class RelatedItemDataset(BaseDataset):
+    model = RelatedItem
+    model_admin = RelatedItemDatasetAdmin
+    tab = True  # render as tab on change form
+
+# Step 3: Register on the parent admin
+class ParentAdmin(ModelAdmin):
+    change_form_datasets = [RelatedItemDataset]
+```
+
+### Dataset Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `model` | Model class | The related model |
+| `model_admin` | ModelAdmin class | Mini-admin configuration |
+| `tab` | bool | Show as tab on change form |
+
+### Key Pattern: Access Parent Object
+
+In the dataset's `model_admin`, access the parent object via `self.extra_context`:
+
+```python
+def get_queryset(self, request):
+    obj = self.extra_context.get("object")
+    if not obj:
+        return super().get_queryset(request).none()
+    return super().get_queryset(request).filter(owner=obj)
+```
+
+## Template Injection
+
+### Changelist Templates
+
+```python
+class MyAdmin(ModelAdmin):
+    list_before_template = "myapp/list_before.html"  # above table
+    list_after_template = "myapp/list_after.html"     # below table
+```
+
+### Change Form Templates
+
+```python
+class MyAdmin(ModelAdmin):
+    change_form_before_template = "myapp/form_before.html"  # above form
+    change_form_after_template = "myapp/form_after.html"     # below form
+```
+
+### Template Context
+
+Injected templates receive the standard Django admin template context plus the Unfold context. Use `{{ cl }}` for changelist context, `{{ original }}` for the object in change form templates.
+
+```html
+<!-- templates/myapp/list_before.html -->
+<div class="rounded-lg border border-base-200 dark:border-base-700 p-4 mb-4">
+    <h3 class="text-lg font-semibold text-base-900 dark:text-base-100">
+        Quick Stats
+    </h3>
+    <p class="text-base-500 dark:text-base-400">
+        Showing {{ cl.result_count }} results
+    </p>
+</div>
+```
+
+## Custom Dashboard Templates
+
+### Override admin/index.html
+
+```html
+<!-- templates/admin/index.html -->
+{% extends "unfold/layouts/base_simple.html" %}
+{% load i18n unfold %}
+
+{% block title %}
+    {{ title }} | {{ site_title }}
+{% endblock %}
+
+{% block content %}
+    {% include "myapp/dashboards.html" %}
+{% endblock %}
+```
+
+### Dashboard Layout Pattern
+
+```html
+<!-- templates/myapp/dashboards.html -->
+{% load unfold %}
+
+<!-- KPI Row -->
+<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
+    {% component "KPI1" %}{% endcomponent %}
+    {% component "KPI2" %}{% endcomponent %}
+    {% component "KPI3" %}{% endcomponent %}
+    {% component "KPI4" %}{% endcomponent %}
+</div>
+
+<!-- Charts Row -->
+<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
+    {% component "Chart1" %}{% endcomponent %}
+    {% component "Chart2" %}{% endcomponent %}
+</div>
+
+<!-- Tables Row -->
+<div class="grid grid-cols-1 gap-4">
+    {% component "RecentActivity" %}{% endcomponent %}
+</div>
+```
+
+### Tailwind CSS Classes
+
+Unfold uses Tailwind CSS. Common utility patterns for custom templates:
+
+| Pattern | Classes |
+|---------|---------|
+| Card | `rounded-lg border border-base-200 dark:border-base-700 bg-white dark:bg-base-900 p-4` |
+| Heading | `text-lg font-semibold text-base-900 dark:text-base-100` |
+| Muted text | `text-sm text-base-500 dark:text-base-400` |
+| Grid layout | `grid grid-cols-1 lg:grid-cols-2 gap-4` |
+| Flex row | `flex items-center gap-2` |
+
+### Paginator
+
+```python
+from unfold.paginator import InfinitePaginator
+
+class MyAdmin(ModelAdmin):
+    paginator = InfinitePaginator
+    show_full_result_count = False
+    list_per_page = 20
+```
+
+`InfinitePaginator` provides infinite scroll instead of page numbers.

+ 425 - 0
skills/unfold-admin/references/resources.md

@@ -0,0 +1,425 @@
+# Resources, Integrations, and Patterns
+
+## Table of Contents
+
+- [Official Resources](#official-resources)
+- [Third-Party Integrations](#third-party-integrations)
+- [Built-In Template Components](#built-in-template-components)
+- [Common Patterns](#common-patterns)
+- [Version Compatibility](#version-compatibility)
+- [Unfold Studio](#unfold-studio)
+- [Community and Learning](#community-and-learning)
+
+## Official Resources
+
+| Resource | URL |
+|----------|-----|
+| Documentation | https://unfoldadmin.com/docs/ |
+| GitHub Repository | https://github.com/unfoldadmin/django-unfold |
+| PyPI | https://pypi.org/project/django-unfold/ |
+| Live Demo | https://demo.unfoldadmin.com |
+| Formula Demo App | https://github.com/unfoldadmin/formula |
+| Turbo Boilerplate (Django + Next.js) | https://github.com/unfoldadmin/turbo |
+| Material Symbols (Icons) | https://fonts.google.com/icons |
+| Discord Community | Referenced on unfoldadmin.com |
+
+### Formula Demo App
+
+The [Formula](https://github.com/unfoldadmin/formula) demo is the authoritative reference implementation. It demonstrates:
+- Every action type (list, row, detail, submit line) with permissions
+- All filter classes with custom filters
+- Display decorators (header, dropdown, label, boolean)
+- Dashboard components with KPI cards and charts
+- Datasets embedded in change forms
+- Sections (TableSection, TemplateSection)
+- Conditional fields, fieldset tabs, inline tabs
+- Third-party integrations (celery-beat, guardian, simple-history, import-export, modeltranslation, crispy-forms, djangoql)
+- Custom form views and URL registration
+- Template injection points
+- InfinitePaginator
+- Nonrelated inlines
+
+When unsure about implementation, consult `formula/admin.py` and `formula/settings.py` in the Formula repo.
+
+## Third-Party Integrations
+
+Unfold provides styled wrappers for these packages. Use the multiple inheritance pattern:
+
+### Supported Packages
+
+| Package | Unfold Module | What It Provides |
+|---------|--------------|------------------|
+| django-import-export | `unfold.contrib.import_export` | `ImportForm`, `ExportForm`, `SelectableFieldsExportForm` |
+| django-guardian | `unfold.contrib.guardian` | Styled guardian admin integration |
+| django-simple-history | `unfold.contrib.simple_history` | Styled history admin |
+| django-constance | `unfold.contrib.constance` | Styled constance config admin |
+| django-location-field | `unfold.contrib.location_field` | `UnfoldAdminLocationWidget` |
+| django-celery-beat | Compatible (requires rewiring) | Unregister/re-register all 5 models |
+| django-modeltranslation | Compatible | Mix `TabbedTranslationAdmin` with `ModelAdmin` |
+| django-money | `unfold.widgets` | `UnfoldAdminMoneyWidget` |
+| djangoql | Compatible | Mix `DjangoQLSearchMixin` with `ModelAdmin` |
+| django-json-widget | Compatible | Use Unfold form overrides |
+| django-crispy-forms | Compatible | Unfold template pack available |
+
+### django-import-export Setup
+
+```python
+from import_export.admin import ImportExportModelAdmin, ExportActionModelAdmin
+from unfold.contrib.import_export.forms import ImportForm, ExportForm, SelectableFieldsExportForm
+
+@admin.register(MyModel)
+class MyModelAdmin(ModelAdmin, ImportExportModelAdmin, ExportActionModelAdmin):
+    resource_classes = [MyResource, AnotherResource]
+    import_form_class = ImportForm
+    export_form_class = SelectableFieldsExportForm  # or ExportForm
+```
+
+### django-celery-beat Setup
+
+Requires unregistering and re-registering all celery-beat models:
+
+```python
+from django_celery_beat.admin import (
+    ClockedScheduleAdmin as BaseClockedScheduleAdmin,
+    CrontabScheduleAdmin as BaseCrontabScheduleAdmin,
+    PeriodicTaskAdmin as BasePeriodicTaskAdmin,
+    PeriodicTaskForm, TaskSelectWidget,
+)
+from django_celery_beat.models import (
+    ClockedSchedule, CrontabSchedule, IntervalSchedule,
+    PeriodicTask, SolarSchedule,
+)
+
+admin.site.unregister(PeriodicTask)
+admin.site.unregister(IntervalSchedule)
+admin.site.unregister(CrontabSchedule)
+admin.site.unregister(SolarSchedule)
+admin.site.unregister(ClockedSchedule)
+
+# Merge TaskSelectWidget with Unfold's select
+class UnfoldTaskSelectWidget(UnfoldAdminSelectWidget, TaskSelectWidget):
+    pass
+
+class UnfoldPeriodicTaskForm(PeriodicTaskForm):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields["task"].widget = UnfoldAdminTextInputWidget()
+        self.fields["regtask"].widget = UnfoldTaskSelectWidget()
+
+@admin.register(PeriodicTask)
+class PeriodicTaskAdmin(BasePeriodicTaskAdmin, ModelAdmin):
+    form = UnfoldPeriodicTaskForm
+
+@admin.register(IntervalSchedule)
+class IntervalScheduleAdmin(ModelAdmin):
+    pass
+
+@admin.register(CrontabSchedule)
+class CrontabScheduleAdmin(BaseCrontabScheduleAdmin, ModelAdmin):
+    pass
+
+@admin.register(SolarSchedule)
+class SolarScheduleAdmin(ModelAdmin):
+    pass
+
+@admin.register(ClockedSchedule)
+class ClockedScheduleAdmin(BaseClockedScheduleAdmin, ModelAdmin):
+    pass
+```
+
+### Multiple Inheritance Pattern
+
+Order matters - Unfold `ModelAdmin` should be last:
+
+```python
+# Correct order: specific mixins first, Unfold ModelAdmin last
+@admin.register(MyModel)
+class MyModelAdmin(DjangoQLSearchMixin, SimpleHistoryAdmin, GuardedModelAdmin, ModelAdmin):
+    pass
+```
+
+## Built-In Template Components
+
+Unfold ships with reusable template components for dashboards and custom pages. Use with Django's `{% include %}` tag:
+
+### Component Templates
+
+| Component | Template Path | Key Variables |
+|-----------|--------------|---------------|
+| Button | `unfold/components/button.html` | `class`, `name`, `href`, `submit` |
+| Card | `unfold/components/card.html` | `class`, `title`, `footer`, `label`, `icon` |
+| Bar Chart | `unfold/components/chart/bar.html` | `class`, `data` (JSON), `height`, `width` |
+| Line Chart | `unfold/components/chart/line.html` | `class`, `data` (JSON), `height`, `width` |
+| Cohort | `unfold/components/cohort.html` | `data` |
+| Container | `unfold/components/container.html` | `class` |
+| Flex | `unfold/components/flex.html` | `class`, `col` |
+| Icon | `unfold/components/icon.html` | `class` |
+| Navigation | `unfold/components/navigation.html` | `class`, `items` |
+| Progress | `unfold/components/progress.html` | `class`, `value`, `title`, `description` |
+| Separator | `unfold/components/separator.html` | `class` |
+| Table | `unfold/components/table.html` | `table`, `card_included`, `striped` |
+| Text | `unfold/components/text.html` | `class` |
+| Title | `unfold/components/title.html` | `class` |
+| Tracker | `unfold/components/tracker.html` | `class`, `data` |
+| Layer | `unfold/components/layer.html` | Wrapper component |
+
+### Using Components in Templates
+
+```html
+{% load unfold %}
+
+{# KPI Card #}
+{% component "MyKPIComponent" %}{% endcomponent %}
+
+{# Include with variables #}
+{% include "unfold/components/card.html" with title="Revenue" icon="payments" %}
+    <div class="text-2xl font-bold">$42,000</div>
+{% endinclude %}
+
+{# Chart with JSON data #}
+{% include "unfold/components/chart/bar.html" with data=chart_data height=300 %}
+```
+
+### Chart Data Format (Chart.js)
+
+```python
+import json
+
+chart_data = json.dumps({
+    "labels": ["Jan", "Feb", "Mar", "Apr"],
+    "datasets": [{
+        "label": "Revenue",
+        "data": [4000, 5200, 4800, 6100],
+        "backgroundColor": "var(--color-primary-600)",
+    }],
+})
+```
+
+## Common Patterns
+
+### Pattern 1: Proxy Model for Alternate Views
+
+Use Django proxy models to create different admin views of the same data:
+
+```python
+# models.py
+class ActiveUser(User):
+    class Meta:
+        proxy = True
+
+# admin.py
+@admin.register(ActiveUser)
+class ActiveUserAdmin(ModelAdmin):
+    def get_queryset(self, request):
+        return super().get_queryset(request).filter(is_active=True)
+```
+
+### Pattern 2: Custom Admin Site
+
+```python
+# sites.py
+from unfold.sites import UnfoldAdminSite
+
+class MyAdminSite(UnfoldAdminSite):
+    site_header = "My Admin"
+    site_title = "My Admin"
+
+admin_site = MyAdminSite(name="myadmin")
+
+# urls.py
+urlpatterns = [
+    path("admin/", admin_site.urls),
+]
+```
+
+### Pattern 3: Optimized Querysets
+
+Always optimize querysets for list views with annotations and prefetches:
+
+```python
+def get_queryset(self, request):
+    return (
+        super().get_queryset(request)
+        .annotate(total_points=Sum("standing__points"))
+        .select_related("author", "category")
+        .prefetch_related("tags", "teams")
+    )
+```
+
+### Pattern 4: Conditional Registration
+
+Conditionally register admin classes based on installed apps:
+
+```python
+if "django_celery_beat" in settings.INSTALLED_APPS:
+    @admin.register(PeriodicTask)
+    class PeriodicTaskAdmin(BasePeriodicTaskAdmin, ModelAdmin):
+        pass
+```
+
+### Pattern 5: Dynamic Sidebar Badges
+
+```python
+# utils.py
+def pending_orders_badge(request):
+    count = Order.objects.filter(status="pending").count()
+    return str(count) if count > 0 else None
+```
+
+```python
+# settings.py
+"SIDEBAR": {
+    "navigation": [{
+        "items": [{
+            "title": "Orders",
+            "badge": "myapp.utils.pending_orders_badge",
+            "badge_variant": "danger",
+        }],
+    }],
+}
+```
+
+### Pattern 6: Environment-Aware Configuration
+
+```python
+def environment_callback(request):
+    if settings.DEBUG:
+        return _("Development"), "danger"
+    host = request.get_host()
+    if "staging" in host:
+        return _("Staging"), "warning"
+    if "demo" in host:
+        return _("Demo"), "info"
+    return None  # production - no badge
+```
+
+### Pattern 7: Admin Actions with Intermediate Forms
+
+For actions that need user input before executing:
+
+```python
+@action(description=_("Schedule Export"), url_path="schedule-export")
+def schedule_export(self, request, object_id):
+    obj = get_object_or_404(self.model, pk=object_id)
+
+    class ExportForm(forms.Form):
+        format = forms.ChoiceField(
+            choices=[("csv", "CSV"), ("xlsx", "Excel")],
+            widget=UnfoldAdminSelectWidget,
+        )
+        date_range = forms.SplitDateTimeField(
+            widget=UnfoldAdminSplitDateTimeWidget,
+            required=False,
+        )
+
+    form = ExportForm(request.POST or None)
+    if request.method == "POST" and form.is_valid():
+        # schedule export task
+        messages.success(request, "Export scheduled.")
+        return redirect(reverse_lazy("admin:myapp_mymodel_change", args=[object_id]))
+
+    return render(request, "myapp/export_form.html", {
+        "form": form,
+        "object": obj,
+        "title": f"Schedule Export for {obj}",
+        **self.admin_site.each_context(request),
+    })
+```
+
+### Pattern 8: Full-Width Changelist with Sheet Filters
+
+```python
+class MyAdmin(ModelAdmin):
+    list_fullwidth = True       # no sidebar, full width
+    list_filter_submit = True   # submit button
+    list_filter_sheet = True    # filters in sliding sheet panel
+```
+
+### Pattern 9: Sortable Model with Hidden Weight Field
+
+```python
+# models.py
+class MenuItem(models.Model):
+    name = models.CharField(max_length=100)
+    weight = models.PositiveIntegerField(default=0)
+
+    class Meta:
+        ordering = ["weight"]
+
+# admin.py
+class MenuItemAdmin(ModelAdmin):
+    ordering_field = "weight"
+    hide_ordering_field = True  # hides weight column from list_display
+```
+
+## Version Compatibility
+
+| django-unfold | Python | Django |
+|---------------|--------|--------|
+| 0.78.x (latest) | >=3.10, <4.0 | 4.2, 5.0, 5.1, 5.2, 6.0 |
+
+### Required INSTALLED_APPS Order
+
+```python
+INSTALLED_APPS = [
+    # Unfold MUST come before django.contrib.admin
+    "unfold",
+    "unfold.contrib.filters",     # optional
+    "unfold.contrib.forms",       # optional
+    "unfold.contrib.inlines",     # optional
+    "unfold.contrib.import_export",  # optional
+    # Then Django
+    "django.contrib.admin",
+    "django.contrib.auth",
+    # ...
+]
+```
+
+## Unfold Studio
+
+Unfold Studio is the commercial offering built on top of the open-source django-unfold:
+
+- **Pre-built dashboard templates** - ready-made KPI layouts
+- **Additional components** - extended component library
+- **Studio settings** - `UNFOLD["STUDIO"]` with options like `header_sticky`, `layout_style` (boxed), `header_variant`, `sidebar_style` (minimal), `sidebar_variant`, `site_banner`
+
+Studio settings (all optional, only available with Studio license):
+
+```python
+UNFOLD = {
+    "STUDIO": {
+        "header_sticky": True,
+        "layout_style": "boxed",
+        "header_variant": "dark",
+        "sidebar_style": "minimal",
+        "sidebar_variant": "dark",
+        "site_banner": "Important announcement",
+    },
+}
+```
+
+## Community and Learning
+
+### Tutorials and Articles
+
+- **Official docs**: https://unfoldadmin.com/docs/ - comprehensive, covers all features
+- **Formula demo walkthrough**: Study `formula/admin.py` for real-world patterns
+- **GitHub Discussions**: https://github.com/unfoldadmin/django-unfold/discussions
+
+### Tips from the Community
+
+1. **Always use `list_filter_submit = True`** when using input-based filters (text, numeric, date) - without it, filters trigger on every keystroke
+2. **Prefetch/select_related in get_queryset** - Unfold's rich display decorators (header, dropdown) make this critical for performance
+3. **Use `compressed_fields = True`** for dense forms - reduces vertical space significantly
+4. **Action permissions use AND logic** - all listed permissions must be satisfied
+5. **InfinitePaginator + show_full_result_count=False** - recommended for large datasets
+6. **Tab ordering follows fieldset/inline order** - fieldset tabs appear first, then inline tabs
+7. **Conditional fields use Alpine.js expressions** - field names map directly to form field names
+8. **Sidebar badge callbacks are called on every request** - keep them fast, consider caching
+9. **Unfold ModelAdmin must come LAST** in multiple inheritance - `class MyAdmin(MixinA, MixinB, ModelAdmin):`
+10. **`formfield_overrides` are automatic** - Unfold maps all standard Django fields to styled widgets by default; only override when you need a *different* widget (e.g., switch, WYSIWYG)
+11. **`list_filter_sheet = True` requires `list_filter_submit = True`** - the sheet panel needs the submit button to function
+12. **Nonrelated inlines require `unfold.contrib.inlines`** in INSTALLED_APPS - forgetting this is a common source of import errors
+13. **Action `url_path` must be unique** per admin class - duplicate paths cause silent routing failures
+14. **`readonly_preprocess_fields`** accepts callables like `{"field": "html"}` to render HTML in readonly fields, or custom functions
+15. **`add_fieldsets`** attribute works like Django's UserAdmin - define separate fieldsets for the add form vs. the change form

+ 532 - 0
skills/unfold-admin/references/widgets-inlines.md

@@ -0,0 +1,532 @@
+# Widgets, Inlines, and Forms Reference
+
+## Table of Contents
+
+- [Widget Classes](#widget-classes)
+- [Widget Override Patterns](#widget-override-patterns)
+- [Contrib Widgets](#contrib-widgets)
+- [Inline Classes](#inline-classes)
+- [Nonrelated Inlines](#nonrelated-inlines)
+- [Inline Configuration](#inline-configuration)
+- [User Forms](#user-forms)
+- [Custom Form Fields](#custom-form-fields)
+
+## Widget Classes
+
+All from `unfold.widgets`:
+
+### Text and Input Widgets
+
+| Widget | Replaces | Notes |
+|--------|----------|-------|
+| `UnfoldAdminTextInputWidget` | `AdminTextInputWidget` | Supports prefix/suffix icons |
+| `UnfoldAdminURLInputWidget` | `AdminURLFieldWidget` | URL-specific styling |
+| `UnfoldAdminEmailInputWidget` | `AdminEmailInputWidget` | Email input |
+| `UnfoldAdminColorInputWidget` | `AdminTextInputWidget` | Color picker (`type="color"`) |
+| `UnfoldAdminUUIDInputWidget` | `AdminUUIDInputWidget` | UUID input |
+| `UnfoldAdminPasswordWidget` | `PasswordInput` | Password with `render_value` param |
+| `UnfoldAdminPasswordToggleWidget` | `PasswordInput` | Password with visibility toggle |
+| `UnfoldAdminIntegerRangeWidget` | `MultiWidget` | Two-input range widget |
+
+### Textarea Widgets
+
+| Widget | Notes |
+|--------|-------|
+| `UnfoldAdminTextareaWidget` | Standard textarea |
+| `UnfoldAdminExpandableTextareaWidget` | Starts at 2 rows, expands |
+
+### Number Widgets
+
+| Widget | Replaces |
+|--------|----------|
+| `UnfoldAdminIntegerFieldWidget` | `AdminIntegerFieldWidget` |
+| `UnfoldAdminDecimalFieldWidget` | `AdminIntegerFieldWidget` |
+| `UnfoldAdminBigIntegerFieldWidget` | `AdminBigIntegerFieldWidget` |
+
+### Select Widgets
+
+| Widget | Notes |
+|--------|-------|
+| `UnfoldAdminSelectWidget` | Basic styled select |
+| `UnfoldAdminSelect2Widget` | Select2 with search (includes jQuery/Select2 JS) |
+| `UnfoldAdminSelectMultipleWidget` | Multi-select |
+| `UnfoldAdminSelect2MultipleWidget` | Select2 multi-select with search |
+| `UnfoldAdminNullBooleanSelectWidget` | Yes/No/Unknown select |
+| `UnfoldAdminRadioSelectWidget` | Radio buttons (VERTICAL default) |
+| `UnfoldAdminCheckboxSelectMultiple` | Checkbox group |
+
+### Boolean Widgets
+
+| Widget | Notes |
+|--------|-------|
+| `UnfoldBooleanWidget` | Standard checkbox |
+| `UnfoldBooleanSwitchWidget` | Toggle switch |
+
+### Date/Time Widgets
+
+| Widget | Notes |
+|--------|-------|
+| `UnfoldAdminDateWidget` | Date picker |
+| `UnfoldAdminSingleDateWidget` | Simple date input |
+| `UnfoldAdminTimeWidget` | Time picker |
+| `UnfoldAdminSingleTimeWidget` | Simple time input |
+| `UnfoldAdminSplitDateTimeWidget` | Separate date + time inputs |
+| `UnfoldAdminSplitDateTimeVerticalWidget` | Vertical layout with labels |
+
+### File Widgets
+
+| Widget | Notes |
+|--------|-------|
+| `UnfoldAdminImageFieldWidget` | Image upload with preview |
+| `UnfoldAdminFileFieldWidget` | File upload (compact) |
+| `UnfoldAdminImageSmallFieldWidget` | Small image upload |
+
+### Relational Widgets
+
+| Widget | Notes |
+|--------|-------|
+| `UnfoldRelatedFieldWidgetWrapper` | Styled related field wrapper |
+| `UnfoldForeignKeyRawIdWidget` | Raw ID for ForeignKey |
+| `UnfoldAdminAutocompleteWidget` | Autocomplete select |
+| `UnfoldAdminMultipleAutocompleteWidget` | Autocomplete multi-select |
+
+### Third-Party Integration Widgets
+
+| Widget | Requires |
+|--------|----------|
+| `UnfoldAdminMoneyWidget` | `django-money` |
+| `UnfoldAdminLocationWidget` | `django-location-field` |
+
+## Automatic Widget Mapping
+
+Unfold automatically applies styled widgets to all standard Django fields. You do NOT need `formfield_overrides` for basic field types - they're handled by default. This mapping shows what Unfold applies behind the scenes:
+
+| Django Field | Unfold Widget (auto-applied) |
+|-------------|------------------------------|
+| `CharField` | `UnfoldAdminTextInputWidget` |
+| `TextField` | `UnfoldAdminTextareaWidget` |
+| `IntegerField` | `UnfoldAdminIntegerFieldWidget` |
+| `DecimalField` | `UnfoldAdminDecimalFieldWidget` |
+| `BigIntegerField` | `UnfoldAdminBigIntegerFieldWidget` |
+| `BooleanField` | `UnfoldBooleanWidget` |
+| `NullBooleanField` | `UnfoldAdminNullBooleanSelectWidget` |
+| `DateField` | `UnfoldAdminDateWidget` |
+| `TimeField` | `UnfoldAdminTimeWidget` |
+| `DateTimeField` | `UnfoldAdminSplitDateTimeWidget` |
+| `EmailField` | `UnfoldAdminEmailInputWidget` |
+| `URLField` | `UnfoldAdminURLInputWidget` |
+| `UUIDField` | `UnfoldAdminUUIDInputWidget` |
+| `ForeignKey` | `UnfoldAdminSelectWidget` (or autocomplete) |
+| `ManyToManyField` | `UnfoldAdminSelectMultipleWidget` |
+| `FileField` | `UnfoldAdminFileFieldWidget` |
+| `ImageField` | `UnfoldAdminImageFieldWidget` |
+
+Only use `formfield_overrides` when you want to **change** from the default (e.g., `BooleanField` to `UnfoldBooleanSwitchWidget`, or `TextField` to `WysiwygWidget`).
+
+## Widget Override Patterns
+
+### Method 1: formfield_overrides
+
+Apply widgets to all fields of a type (overrides the defaults above):
+
+```python
+from django.db import models
+from unfold.widgets import (
+    UnfoldAdminTextInputWidget, UnfoldBooleanSwitchWidget,
+    UnfoldAdminImageFieldWidget, UnfoldAdminSelect2Widget,
+)
+from unfold.contrib.forms.widgets import WysiwygWidget
+
+class MyAdmin(ModelAdmin):
+    formfield_overrides = {
+        models.TextField: {"widget": WysiwygWidget},
+        models.ImageField: {"widget": UnfoldAdminImageFieldWidget},
+        models.BooleanField: {"widget": UnfoldBooleanSwitchWidget},
+    }
+```
+
+### Method 2: get_form() Override
+
+Apply widgets to specific fields:
+
+```python
+def get_form(self, request, obj=None, change=False, **kwargs):
+    form = super().get_form(request, obj, change, **kwargs)
+    form.base_fields["color"].widget = UnfoldAdminColorInputWidget()
+    form.base_fields["name"].widget = UnfoldAdminTextInputWidget(attrs={
+        "prefix_icon": "search",
+        "suffix_icon": "check",
+    })
+    return form
+```
+
+### Method 3: Custom ModelForm
+
+Full control with a custom form class:
+
+```python
+class MyModelForm(forms.ModelForm):
+    flags = forms.MultipleChoiceField(
+        choices=[("A", "Option A"), ("B", "Option B")],
+        widget=UnfoldAdminCheckboxSelectMultiple,
+        required=False,
+    )
+    custom_select = forms.ChoiceField(
+        choices=[("show", "Show"), ("hide", "Hide")],
+        widget=UnfoldAdminSelect2Widget,
+    )
+
+class MyAdmin(ModelAdmin):
+    form = MyModelForm
+```
+
+### Text Input with Icons
+
+```python
+UnfoldAdminTextInputWidget(attrs={
+    "prefix_icon": "search",     # Material Symbols icon before input
+    "suffix_icon": "euro",       # Material Symbols icon after input
+})
+```
+
+Or set after init:
+
+```python
+def __init__(self, *args, **kwargs):
+    super().__init__(*args, **kwargs)
+    self.fields["name"].widget.attrs.update({
+        "prefix_icon": "person",
+        "suffix_icon": "verified",
+    })
+```
+
+## Contrib Widgets
+
+### WYSIWYG Editor
+
+Requires `unfold.contrib.forms` in `INSTALLED_APPS`:
+
+```python
+from unfold.contrib.forms.widgets import WysiwygWidget
+
+class MyAdmin(ModelAdmin):
+    formfield_overrides = {
+        models.TextField: {"widget": WysiwygWidget},
+    }
+```
+
+Uses the Trix editor. The field stores HTML content.
+
+### Array Widget
+
+For PostgreSQL `ArrayField`:
+
+```python
+from unfold.contrib.forms.widgets import ArrayWidget
+
+class MyAdmin(ModelAdmin):
+    formfield_overrides = {
+        ArrayField: {"widget": ArrayWidget},
+    }
+
+    # With choices (requires get_form override)
+    def get_form(self, request, obj=None, change=False, **kwargs):
+        form = super().get_form(request, obj, change, **kwargs)
+        form.base_fields["tags"].widget = ArrayWidget(choices=TagChoices)
+        return form
+```
+
+## Inline Classes
+
+### Imports
+
+```python
+from unfold.admin import TabularInline, StackedInline, GenericStackedInline
+from unfold.contrib.inlines.admin import NonrelatedStackedInline, NonrelatedTabularInline
+```
+
+### Basic Inline
+
+```python
+class OrderItemInline(TabularInline):
+    model = OrderItem
+    fields = ["product", "quantity", "price"]
+    extra = 1
+    show_change_link = True
+```
+
+### Inline as Tab
+
+```python
+class OrderItemInline(TabularInline):
+    model = OrderItem
+    tab = True  # renders as a tab instead of inline block
+```
+
+### Paginated Inline
+
+```python
+class CommentInline(StackedInline):
+    model = Comment
+    per_page = 10  # paginate after 10 items
+```
+
+### Sortable Inline (Drag-to-Reorder)
+
+```python
+class MenuItemInline(TabularInline):
+    model = MenuItem
+    ordering_field = "weight"  # integer field for sort order
+    ordering = ["weight"]
+```
+
+The model needs an integer field (typically `weight` or `order`) that stores position.
+
+### Collapsible Inline
+
+```python
+class NoteInline(StackedInline):
+    model = Note
+    collapsible = True
+    classes = ["collapse"]  # Django's built-in collapse also works
+```
+
+### Hide Title
+
+```python
+class StandingInline(StackedInline):
+    model = Standing
+    hide_title = True
+```
+
+### Combined Example
+
+```python
+class OrderItemInline(TabularInline):
+    model = OrderItem
+    tab = True
+    per_page = 5
+    ordering_field = "weight"
+    hide_title = True
+    collapsible = True
+    autocomplete_fields = ["product"]
+    show_change_link = True
+    extra = 0
+    max_num = 20
+```
+
+## Nonrelated Inlines
+
+Display models without a direct ForeignKey relationship:
+
+```python
+from unfold.contrib.inlines.admin import NonrelatedStackedInline
+
+class RecentOrdersInline(NonrelatedStackedInline):
+    model = Order
+    fields = ["id", "total", "status"]
+    extra = 0
+    tab = True
+    per_page = 10
+
+    def get_form_queryset(self, obj):
+        """Return queryset for the inline based on parent obj."""
+        return self.model.objects.filter(customer__user=obj).order_by("-created_at")
+
+    def save_new_instance(self, parent, instance):
+        """Define how to save new instances."""
+        instance.customer = parent.customer
+```
+
+Requires `unfold.contrib.inlines` in `INSTALLED_APPS`.
+
+### Generic Inlines
+
+For GenericForeignKey relationships:
+
+```python
+from unfold.admin import GenericStackedInline
+
+class TagInline(GenericStackedInline):
+    model = Tag
+```
+
+## Inline Configuration
+
+### Full Attribute Reference
+
+| Attribute | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `tab` | bool | False | Render as tab |
+| `per_page` | int | None | Items per page (pagination) |
+| `ordering_field` | str | None | Field for drag-to-reorder |
+| `hide_title` | bool | False | Hide inline heading |
+| `collapsible` | bool | False | Allow collapse/expand |
+| Standard Django inline attributes also apply |
+
+## User Forms
+
+Unfold provides styled versions of Django's user admin forms:
+
+```python
+from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm
+
+@admin.register(User)
+class UserAdmin(BaseUserAdmin, ModelAdmin):
+    form = UserChangeForm
+    add_form = UserCreationForm
+    change_password_form = AdminPasswordChangeForm
+```
+
+## Readonly Field Processing
+
+Transform readonly field display using `readonly_preprocess_fields`:
+
+```python
+class MyAdmin(ModelAdmin):
+    readonly_preprocess_fields = {
+        "description": "html",      # render as HTML (not escaped)
+        "metadata": "json",         # pretty-printed JSON
+        "avatar": "image",          # render as image thumbnail
+        "document": "file",         # render as file download link
+        "config": lambda content: f"<pre>{content}</pre>",  # custom callable
+    }
+```
+
+Built-in preprocessors: `"html"`, `"json"`, `"image"`, `"file"`. Pass a callable for custom transformations.
+
+## Custom Form Fields
+
+### Crispy Forms Integration
+
+Unfold supports django-crispy-forms for custom action forms and standalone pages. Create views that extend Unfold's base templates for consistent styling:
+
+```python
+from django.views.generic import FormView
+
+class CustomFormView(FormView):
+    template_name = "myapp/custom_form.html"
+    model_admin = None  # set when registering URL
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context.update(self.model_admin.admin_site.each_context(self.request))
+        return context
+```
+
+Register via `get_urls()`:
+
+```python
+def get_urls(self):
+    return super().get_urls() + [
+        path("custom/", self.admin_site.admin_view(
+            CustomFormView.as_view(model_admin=self)
+        ), name="custom_form"),
+    ]
+```
+
+## Custom Pages
+
+Use `UnfoldModelAdminViewMixin` for class-based views that integrate with Unfold's layout:
+
+```python
+from django.views.generic import TemplateView
+from unfold.views import UnfoldModelAdminViewMixin
+
+class MyCustomPage(UnfoldModelAdminViewMixin, TemplateView):
+    title = "Custom Page"
+    permission_required = ()  # tuple of required permissions
+    template_name = "myapp/custom_page.html"
+```
+
+Register via `get_urls()` with `model_admin=self`:
+
+```python
+def get_urls(self):
+    view = self.admin_site.admin_view(
+        MyCustomPage.as_view(model_admin=self)
+    )
+    return super().get_urls() + [
+        path("custom-page/", view, name="custom_page"),
+    ]
+```
+
+## Custom Admin Sites
+
+Override the default admin site for full control:
+
+```python
+from unfold.sites import UnfoldAdminSite
+
+class CustomAdminSite(UnfoldAdminSite):
+    pass
+
+custom_site = CustomAdminSite(name="custom_admin")
+```
+
+Register models with `site=`:
+
+```python
+@admin.register(MyModel, site=custom_site)
+class MyModelAdmin(ModelAdmin):
+    pass
+```
+
+To override the default admin site globally, use `BasicAppConfig`:
+
+```python
+# settings.py
+INSTALLED_APPS = [
+    "unfold.apps.BasicAppConfig",  # NOT just "unfold"
+    "django.contrib.admin",
+]
+
+# apps.py
+from django.contrib.admin.apps import AdminConfig
+
+class MyAdminConfig(AdminConfig):
+    default_site = "myproject.sites.CustomAdminSite"
+```
+
+## Key Import Paths
+
+Quick reference for all major Unfold imports:
+
+```python
+# Core
+from unfold.admin import ModelAdmin, StackedInline, TabularInline, GenericStackedInline
+from unfold.sites import UnfoldAdminSite
+from unfold.views import UnfoldModelAdminViewMixin
+
+# Decorators and enums
+from unfold.decorators import display, action
+from unfold.enums import ActionVariant
+
+# Forms
+from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm
+
+# Dashboard
+from unfold.components import BaseComponent, register_component
+from unfold.datasets import BaseDataset
+from unfold.sections import TableSection, TemplateSection
+from unfold.paginator import InfinitePaginator
+
+# Command palette
+from unfold.dataclasses import SearchResult
+
+# Filters
+from unfold.contrib.filters.admin import (
+    TextFilter, DropdownFilter, MultipleDropdownFilter,
+    ChoicesDropdownFilter, MultipleChoicesDropdownFilter,
+    RelatedDropdownFilter, MultipleRelatedDropdownFilter,
+    RelatedCheckboxFilter, ChoicesCheckboxFilter, AllValuesCheckboxFilter,
+    BooleanRadioFilter, CheckboxFilter, AutocompleteSelectMultipleFilter,
+    RangeNumericFilter, SingleNumericFilter, SliderNumericFilter,
+    RangeNumericListFilter, RangeDateFilter, RangeDateTimeFilter,
+)
+
+# Contrib widgets
+from unfold.contrib.forms.widgets import ArrayWidget, WysiwygWidget
+from unfold.contrib.inlines.admin import NonrelatedStackedInline, NonrelatedTabularInline
+from unfold.contrib.import_export.forms import ExportForm, ImportForm, SelectableFieldsExportForm
+```