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 2 months ago
parent
commit
dcd5d2d041

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

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

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **22 expert agents** for specialized domains (React, Python, Go, Rust, AWS, etc.)
 - **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)
 
 ## Installation

+ 1 - 1
docs/PLAN.md

@@ -13,7 +13,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | 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) |
 | Rules | 5 | CLI tools, thinking, commit style, naming, skill-agent-updates |
 | Output Styles | 1 | Vesper personality |

File diff suppressed because it is too large
+ 490 - 0
skills/unfold-admin/SKILL.md


+ 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
+```