diff --git a/.planning/phases/08-admin-dashboard/08-RESEARCH.md b/.planning/phases/08-admin-dashboard/08-RESEARCH.md
new file mode 100644
index 0000000..31313f6
--- /dev/null
+++ b/.planning/phases/08-admin-dashboard/08-RESEARCH.md
@@ -0,0 +1,1099 @@
+# Phase 8: Admin Dashboard - Research
+
+**Researched:** 2026-02-05
+**Domain:** Admin dashboard with customizable widgets, real-time updates, and plugin settings pages
+**Confidence:** HIGH (based on WinterCMS reference + official library documentation + CONTEXT.md decisions)
+
+## Summary
+
+This research covers the implementation of a customizable admin dashboard for SummerCMS. The dashboard provides a container for plugin-registered widgets (report widgets) with per-user layout customization, real-time updates via SSE/WebSocket, and a centralized settings system for plugin configuration.
+
+The WinterCMS reference implementation provides a comprehensive blueprint:
+1. **ReportContainer** widget hosts multiple ReportWidgetBase instances with YAML-configured defaults
+2. **Per-user preferences** stored in a dedicated table with JSON-serialized layouts
+3. **WidgetManager** singleton collects widgets from plugin registrations
+4. **SettingsManager** centralizes plugin settings with categories and permissions
+5. **SettingsModel** behavior provides YAML-driven forms for plugin configuration
+
+The CONTEXT.md decisions specify:
+- Masonry/fluid layout with drag-and-drop customization
+- Real-time updates via WebSocket/SSE for live data
+- Widget configuration (title, sizing, custom parameters)
+- WinterCMS-style centralized Settings area grouped by category
+
+**Primary recommendation:** Use Gridstack.js for dashboard grid layout (masonry, drag-and-drop, resize), ZIO HTTP SSE for real-time widget updates, HTMX SSE extension for frontend integration, and the Phase 7 YAML-driven form system for widget configuration and settings pages.
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| Gridstack.js | 10.x | Dashboard grid layout | Native drag-drop, resize, save/restore, TypeScript, no deps |
+| ZIO HTTP | 3.4.x | SSE/WebSocket endpoints | Already in stack, native streaming support |
+| HTMX SSE Extension | 2.2.x | Frontend SSE integration | Declarative, pairs with existing HTMX usage |
+| circe-yaml | 1.x | Widget/settings config | Already in stack from Phase 7 |
+| ScalaTags | 0.13.x | HTML generation | Already in stack from Phase 7 |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| Chart.js | 4.x | Widget charts/graphs | Stats widgets with visualizations |
+| Flatpickr | 4.6.x | Date range picker | Widget date configuration |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Gridstack.js | Muuri | Muuri more flexible but Gridstack specialized for dashboards |
+| Gridstack.js | React Grid Layout | React Grid requires React; we use HTMX/ScalaTags |
+| SSE | Full WebSocket | SSE simpler, unidirectional is sufficient for dashboard updates |
+| Gridstack.js | Packery/Isotope | Packery older, Gridstack actively maintained with TypeScript |
+
+**Mill dependencies:**
+```scala
+// Phase 7 deps already included (circe-yaml, scalatags, etc.)
+// ZIO HTTP already in stack
+```
+
+**Frontend (included via CDN or bundled):**
+```html
+
+
+
+```
+
+## Architecture Patterns
+
+### Recommended Project Structure
+```
+summercms/
+├── admin/
+│ ├── dashboard/
+│ │ ├── Dashboard.scala # Dashboard container
+│ │ ├── DashboardWidget.scala # Widget base trait/class
+│ │ ├── WidgetRegistry.scala # Collects registered widgets
+│ │ ├── UserDashboardPrefs.scala # Per-user layout storage
+│ │ ├── widgets/ # Core widgets
+│ │ │ ├── WelcomeWidget.scala
+│ │ │ ├── SystemStatusWidget.scala
+│ │ │ └── QuickActionsWidget.scala
+│ │ └── sse/
+│ │ └── WidgetEventStream.scala # SSE endpoint for real-time updates
+│ ├── settings/
+│ │ ├── SettingsManager.scala # Collects registered settings
+│ │ ├── SettingsController.scala # Generic settings CRUD
+│ │ ├── SettingsModel.scala # Base trait for settings models
+│ │ └── categories.scala # Standard category constants
+│ └── controllers/
+│ └── IndexController.scala # Dashboard route handler
+```
+
+### Pattern 1: Dashboard Widget Base Trait
+**What:** Define a base trait for all dashboard (report) widgets
+**When to use:** All plugins extending dashboard with widgets
+**Example:**
+```scala
+// Source: WinterCMS ReportWidgetBase pattern + CONTEXT.md
+import scalatags.Text.all._
+import zio._
+
+trait DashboardWidget:
+ /** Unique widget identifier */
+ def widgetId: String
+
+ /** Default alias for this widget */
+ def defaultAlias: String = widgetId
+
+ /** Widget display name (translation key) */
+ def name: String
+
+ /** Widget description (translation key) */
+ def description: String
+
+ /** Define configurable properties */
+ def defineProperties: Map[String, PropertyDef] = Map(
+ "title" -> PropertyDef(
+ title = "backend::lang.dashboard.widget_title_label",
+ `type` = PropertyType.String,
+ default = Some(name)
+ ),
+ "ocWidgetWidth" -> PropertyDef(
+ title = "backend::lang.dashboard.widget_columns_label",
+ `type` = PropertyType.Dropdown,
+ options = (1 to 12).map(i => i.toString -> s"$i columns").toMap,
+ default = Some("6")
+ )
+ )
+
+ /** Render widget content (can be ZIO effect for async data loading) */
+ def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag]
+
+ /** Optional: Stream of updates for real-time widgets */
+ def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] = None
+
+case class PropertyDef(
+ title: String,
+ `type`: PropertyType,
+ description: Option[String] = None,
+ default: Option[String] = None,
+ options: Map[String, String] = Map.empty,
+ required: Boolean = false
+)
+
+enum PropertyType:
+ case String, Number, Checkbox, Dropdown, DateRange
+
+case class WidgetConfig(
+ alias: String,
+ properties: Map[String, Any],
+ position: WidgetPosition
+)
+
+case class WidgetPosition(
+ x: Int, y: Int, // Grid position
+ w: Int, h: Int // Width and height in grid units
+)
+
+case class WidgetUpdate(
+ widgetAlias: String,
+ content: Frag
+)
+```
+
+### Pattern 2: Widget Registry with Plugin Registration
+**What:** Singleton registry collecting widgets from plugins during boot
+**When to use:** Plugin system collects all available dashboard widgets
+**Example:**
+```scala
+// Source: WinterCMS WidgetManager pattern
+import zio._
+
+trait WidgetRegistry:
+ def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit]
+ def listWidgets: UIO[Map[String, WidgetInfo]]
+ def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]]
+ def resolveWidget(className: String): UIO[Option[DashboardWidget]]
+
+case class WidgetInfo(
+ name: String,
+ description: String,
+ context: List[String] = List("dashboard"), // Where widget can appear
+ permissions: List[String] = Nil // Required permissions
+)
+
+object WidgetRegistry:
+ val live: ZLayer[Any, Nothing, WidgetRegistry] =
+ ZLayer.fromZIO {
+ Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef =>
+ new WidgetRegistry:
+ def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit] =
+ for
+ instance <- ZIO.succeed(widgetClass.getDeclaredConstructor().newInstance())
+ _ <- widgetsRef.update(_ + (widgetClass.getName -> (info, instance)))
+ yield ()
+
+ def listWidgets: UIO[Map[String, WidgetInfo]] =
+ widgetsRef.get.map(_.view.mapValues(_._1).toMap)
+
+ def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]] =
+ listWidgets.map(_.filter(_._2.context.contains(context)))
+
+ def resolveWidget(className: String): UIO[Option[DashboardWidget]] =
+ widgetsRef.get.map(_.get(className).map(_._2))
+ }
+ }
+
+// Plugin registration in boot
+def registerReportWidgets: Map[Class[? <: DashboardWidget], WidgetInfo] = Map(
+ classOf[SystemStatusWidget] -> WidgetInfo(
+ name = "system::lang.dashboard.status.name",
+ description = "system::lang.dashboard.status.description",
+ permissions = List("system.access_dashboard")
+ ),
+ classOf[WelcomeWidget] -> WidgetInfo(
+ name = "backend::lang.dashboard.welcome.name",
+ description = "backend::lang.dashboard.welcome.description"
+ )
+)
+```
+
+### Pattern 3: Per-User Dashboard Preferences
+**What:** Store and retrieve per-user widget layouts using database
+**When to use:** Dashboard layout customization per admin user
+**Example:**
+```scala
+// Source: WinterCMS UserPreference + ReportContainer patterns
+import io.getquill._
+import zio._
+
+case class DashboardPreference(
+ userId: Long,
+ context: String, // "dashboard", "analytics", etc.
+ widgets: Json // JSON array of widget configurations
+)
+
+case class StoredWidgetConfig(
+ className: String,
+ alias: String,
+ sortOrder: Int,
+ configuration: Map[String, Any], // Including position: {x, y, w, h}
+)
+
+trait DashboardPreferenceService:
+ def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]]
+ def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit]
+ def resetToDefault(userId: Long, context: String): Task[Unit]
+ def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]]
+
+object DashboardPreferenceService:
+ def live(quill: Quill.Postgres[SnakeCase]): ZLayer[Any, Nothing, DashboardPreferenceService] =
+ ZLayer.succeed {
+ import quill._
+ new DashboardPreferenceService:
+ def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] =
+ // First try user preferences, fall back to system defaults
+ run(
+ query[DashboardPreference]
+ .filter(p => p.userId == lift(userId) && p.context == lift(context))
+ ).flatMap {
+ case Nil => getDefaultLayout(context)
+ case prefs => ZIO.succeed(parseWidgets(prefs.head.widgets))
+ }
+
+ def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] =
+ run(
+ query[DashboardPreference]
+ .filter(p => p.userId == lift(userId) && p.context == lift(context))
+ .updateValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
+ ).unit.catchAll { _ =>
+ run(
+ query[DashboardPreference]
+ .insertValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets))))
+ ).unit
+ }
+
+ def resetToDefault(userId: Long, context: String): Task[Unit] =
+ run(
+ query[DashboardPreference]
+ .filter(p => p.userId == lift(userId) && p.context == lift(context))
+ .delete
+ ).unit
+
+ def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]] =
+ // Load from YAML config or system parameter
+ ZIO.attempt {
+ val config = loadYamlConfig(s"config_dashboard_$context.yaml")
+ parseDefaultWidgets(config)
+ }
+ }
+```
+
+### Pattern 4: Dashboard Container with Gridstack Integration
+**What:** Render dashboard with Gridstack.js grid layout
+**When to use:** Main dashboard view rendering
+**Example:**
+```scala
+// Source: WinterCMS ReportContainer + Gridstack.js patterns
+import scalatags.Text.all._
+
+def renderDashboard(
+ widgets: List[(String, DashboardWidget, WidgetConfig)],
+ canAddAndDelete: Boolean
+)(using ctx: DashboardContext): Frag = {
+ div(cls := "dashboard-container",
+ // Toolbar
+ div(cls := "dashboard-toolbar",
+ canAddAndDelete.option(
+ button(cls := "btn btn-primary",
+ attr("hx-get") := "/admin/dashboard/add-widget-popup",
+ attr("hx-target") := "#modal-container",
+ "Add Widget"
+ )
+ ),
+ button(cls := "btn btn-secondary",
+ attr("hx-post") := "/admin/dashboard/reset",
+ attr("hx-confirm") := "Reset dashboard to default layout?",
+ "Reset to Default"
+ )
+ ),
+
+ // Grid container - Gridstack.js will initialize this
+ div(cls := "grid-stack",
+ id := "dashboard-grid",
+ widgets.map { case (alias, widget, config) =>
+ div(cls := "grid-stack-item",
+ attr("gs-id") := alias,
+ attr("gs-x") := config.position.x.toString,
+ attr("gs-y") := config.position.y.toString,
+ attr("gs-w") := config.position.w.toString,
+ attr("gs-h") := config.position.h.toString,
+ attr("gs-min-w") := "2",
+ attr("gs-min-h") := "2",
+
+ div(cls := "grid-stack-item-content widget-container",
+ // SSE connection for real-time updates
+ widget.updates(config).map { _ =>
+ Seq(
+ attr("hx-ext") := "sse",
+ attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias",
+ attr("sse-swap") := "content"
+ )
+ }.getOrElse(Nil),
+
+ // Widget header
+ div(cls := "widget-header",
+ span(cls := "widget-title", config.properties.getOrElse("title", widget.name).toString),
+ div(cls := "widget-controls",
+ button(cls := "btn-widget-config",
+ attr("hx-get") := s"/admin/dashboard/widget-config/$alias",
+ attr("hx-target") := "#modal-container",
+ i(cls := "icon-cog")
+ ),
+ canAddAndDelete.option(
+ button(cls := "btn-widget-remove",
+ attr("hx-post") := s"/admin/dashboard/remove-widget/$alias",
+ attr("hx-confirm") := "Remove this widget?",
+ i(cls := "icon-trash")
+ )
+ )
+ )
+ ),
+
+ // Widget body (rendered content)
+ div(cls := "widget-body", id := s"widget-content-$alias",
+ // Initial render - will be swapped via SSE for real-time widgets
+ renderWidgetContent(widget, config, ctx)
+ )
+ )
+ )
+ }
+ ),
+
+ // Gridstack initialization script
+ script(raw("""
+ document.addEventListener('DOMContentLoaded', function() {
+ var grid = GridStack.init({
+ cellHeight: 80,
+ margin: 10,
+ float: true,
+ removable: false
+ });
+
+ // Save layout on change
+ grid.on('change', function(event, items) {
+ var layout = items.map(function(item) {
+ return {
+ id: item.id,
+ x: item.x, y: item.y,
+ w: item.w, h: item.h
+ };
+ });
+
+ htmx.ajax('POST', '/admin/dashboard/save-layout', {
+ values: { layout: JSON.stringify(layout) }
+ });
+ });
+ });
+ """))
+ )
+}
+```
+
+### Pattern 5: SSE Endpoint for Real-Time Widget Updates
+**What:** ZIO HTTP SSE endpoint streaming widget updates
+**When to use:** Widgets requiring live data (stats, activity feeds)
+**Example:**
+```scala
+// Source: ZIO HTTP SSE documentation + HTMX SSE extension
+import zio._
+import zio.http._
+import zio.stream._
+
+object WidgetStreamEndpoint:
+ def routes: Routes[WidgetRegistry & DashboardPreferenceService, Nothing] =
+ Routes(
+ Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") ->
+ handler { (alias: String, req: Request) =>
+ for
+ registry <- ZIO.service[WidgetRegistry]
+ prefs <- ZIO.service[DashboardPreferenceService]
+ userId <- extractUserId(req)
+ configs <- prefs.getPreferences(userId, "dashboard")
+ config <- ZIO.fromOption(configs.find(_.alias == alias))
+ .orElseFail(Response.notFound)
+ widget <- registry.resolveWidget(config.className)
+ .someOrFail(Response.notFound)
+
+ stream = widget.updates(toWidgetConfig(config)) match
+ case Some(updateStream) =>
+ updateStream.map { update =>
+ ServerSentEvent(
+ data = Some(update.content.render),
+ eventType = Some("content"),
+ id = Some(java.util.UUID.randomUUID().toString)
+ )
+ }
+ case None =>
+ // No updates - just keep connection alive with heartbeat
+ ZStream.tick(30.seconds).map { _ =>
+ ServerSentEvent(
+ data = Some(":heartbeat"),
+ eventType = Some("heartbeat")
+ )
+ }
+ yield Response(
+ status = Status.Ok,
+ headers = Headers(
+ Header.ContentType(MediaType.text.`event-stream`),
+ Header.CacheControl.NoCache
+ ),
+ body = Body.fromCharSequenceStreamChunked(
+ stream.map(sse => sse.encode)
+ )
+ )
+ }
+ )
+
+// Example widget with real-time updates
+class ActiveUsersWidget extends DashboardWidget:
+ def widgetId = "active-users"
+ def name = "backend::lang.dashboard.active_users"
+ def description = "Shows currently active users"
+
+ def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
+ getActiveUserCount.map { count =>
+ div(cls := "stat-widget",
+ span(cls := "stat-value", count.toString),
+ span(cls := "stat-label", "Active Users")
+ )
+ }
+
+ override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] =
+ Some(
+ ZStream.repeatZIOWithSchedule(
+ getActiveUserCount.map { count =>
+ WidgetUpdate(
+ config.alias,
+ div(cls := "stat-widget",
+ span(cls := "stat-value", count.toString),
+ span(cls := "stat-label", "Active Users")
+ )
+ )
+ }.orDie,
+ Schedule.fixed(5.seconds)
+ )
+ )
+```
+
+### Pattern 6: Settings Manager for Plugin Settings
+**What:** Centralized registry of plugin settings pages with categories
+**When to use:** Plugins registering configuration pages
+**Example:**
+```scala
+// Source: WinterCMS SettingsManager pattern
+import zio._
+
+// Standard categories (translation keys)
+object SettingsCategory:
+ val System = "system::lang.system.categories.system"
+ val CMS = "system::lang.system.categories.cms"
+ val Users = "system::lang.system.categories.users"
+ val Mail = "system::lang.system.categories.mail"
+ val Misc = "system::lang.system.categories.misc"
+
+case class SettingsItem(
+ code: String,
+ owner: String, // Plugin ID: "golem15.blog"
+ label: String, // Translation key
+ description: Option[String],
+ category: String, // SettingsCategory constant
+ icon: Option[String],
+ url: Option[String], // Custom URL, or auto-generated
+ settingsClass: Option[String], // Settings model class name
+ permissions: List[String],
+ order: Int = 500,
+ keywords: Option[String] = None // For search
+)
+
+trait SettingsManager:
+ def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit]
+ def listItems(context: Option[String] = None): UIO[Map[String, List[SettingsItem]]]
+ def findItem(owner: String, code: String): UIO[Option[SettingsItem]]
+ def setContext(owner: String, code: String): UIO[Unit]
+ def getContext: UIO[(String, String)]
+
+object SettingsManager:
+ val live: ZLayer[Any, Nothing, SettingsManager] =
+ ZLayer.fromZIO {
+ for
+ itemsRef <- Ref.make(Map.empty[String, SettingsItem])
+ contextRef <- Ref.make(("", ""))
+ yield new SettingsManager:
+ def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit] =
+ itemsRef.update { current =>
+ items.foldLeft(current) { case (acc, (code, item)) =>
+ val key = s"${owner.toUpperCase}.$${code.toUpperCase}"
+ val itemWithOwner = item.copy(
+ code = code,
+ owner = owner,
+ url = item.url.orElse(Some(generateSettingsUrl(owner, code)))
+ )
+ acc + (key -> itemWithOwner)
+ }
+ }
+
+ def listItems(context: Option[String]): UIO[Map[String, List[SettingsItem]]] =
+ itemsRef.get.map { items =>
+ items.values
+ .groupBy(_.category)
+ .view.mapValues(_.toList.sortBy(_.order))
+ .toMap
+ }
+
+ def findItem(owner: String, code: String): UIO[Option[SettingsItem]] =
+ itemsRef.get.map(_.get(s"${owner.toUpperCase}.${code.toUpperCase}"))
+
+ def setContext(owner: String, code: String): UIO[Unit] =
+ contextRef.set((owner.toLowerCase, code.toLowerCase))
+
+ def getContext: UIO[(String, String)] =
+ contextRef.get
+
+ private def generateSettingsUrl(owner: String, code: String): String =
+ val parts = owner.split('.')
+ s"/admin/system/settings/update/${parts.mkString("/")}/$code"
+ }
+
+// Plugin registration
+def registerSettings: Map[String, SettingsItem] = Map(
+ "blog" -> SettingsItem(
+ code = "blog",
+ owner = "golem15.blog",
+ label = "golem15.blog::lang.settings.label",
+ description = Some("golem15.blog::lang.settings.description"),
+ category = SettingsCategory.CMS,
+ icon = Some("icon-pencil"),
+ settingsClass = Some("golem15.blog.models.Settings"),
+ permissions = List("golem15.blog.manage_settings"),
+ order = 500,
+ keywords = Some("blog post category")
+ )
+)
+```
+
+### Pattern 7: Settings Model Behavior
+**What:** Model behavior for YAML-driven settings storage
+**When to use:** Plugin settings models stored in system_settings table
+**Example:**
+```scala
+// Source: WinterCMS SettingsModel behavior
+import zio._
+import io.circe.Json
+
+trait SettingsModel:
+ def settingsCode: String
+ def settingsFields: String // Path to fields.yaml
+
+ // Mixin implementation
+ private var fieldValues: Map[String, Any] = Map.empty
+ private var instance: Option[SettingsModel] = None
+
+ /** Get singleton instance (loads from DB or creates default) */
+ def getInstance: Task[SettingsModel] =
+ instance match
+ case Some(inst) => ZIO.succeed(inst)
+ case None =>
+ loadFromDatabase.flatMap {
+ case Some(record) =>
+ ZIO.succeed {
+ fieldValues = record.values
+ instance = Some(this)
+ this
+ }
+ case None =>
+ ZIO.succeed {
+ initSettingsData()
+ instance = Some(this)
+ this
+ }
+ }
+
+ /** Override to set default values */
+ def initSettingsData(): Unit = ()
+
+ /** Get a setting value */
+ def get[T](key: String, default: T): T =
+ fieldValues.get(key).map(_.asInstanceOf[T]).getOrElse(default)
+
+ /** Set setting values */
+ def set(values: Map[String, Any]): Task[Unit] =
+ ZIO.attempt {
+ fieldValues = fieldValues ++ values
+ } *> saveToDatabase
+
+ /** Reset to defaults */
+ def resetDefault: Task[Unit] =
+ deleteFromDatabase *> ZIO.succeed {
+ fieldValues = Map.empty
+ instance = None
+ }
+
+ // Database operations (using system_settings table)
+ private def loadFromDatabase: Task[Option[SettingsRecord]] = ???
+ private def saveToDatabase: Task[Unit] = ???
+ private def deleteFromDatabase: Task[Unit] = ???
+
+// Example settings model
+class BlogSettings extends SettingsModel:
+ val settingsCode = "golem15_blog_settings"
+ val settingsFields = "fields.yaml"
+
+ override def initSettingsData(): Unit =
+ fieldValues = Map(
+ "posts_per_page" -> 10,
+ "show_author" -> true,
+ "excerpt_length" -> 200
+ )
+
+// Usage
+val postsPerPage = BlogSettings.getInstance.flatMap { settings =>
+ ZIO.succeed(settings.get("posts_per_page", 10))
+}
+```
+
+### Anti-Patterns to Avoid
+- **Polling for updates:** Use SSE streams instead of periodic AJAX polling
+- **Global layout storage:** Each user must have their own layout preferences
+- **Inline widget configuration:** Use YAML-driven property definitions for consistency
+- **Blocking widget renders:** Widget render should be ZIO Task, not blocking
+- **Shared mutable state:** Use ZIO Ref for widget registry and settings state
+- **Direct database queries in widgets:** Use service layer for data access
+
+## Don't Hand-Roll
+
+Problems that look simple but have existing solutions:
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Dashboard grid layout | Custom CSS Grid + JS | Gridstack.js | Drag-drop, resize, save/restore, mobile support |
+| Real-time updates | Custom WebSocket protocol | ZIO HTTP SSE + HTMX ext | Standard protocol, automatic reconnection |
+| User preferences storage | Session/cookie storage | Database per-user table | Persists across sessions, multi-device |
+| Widget property forms | Custom form generation | Phase 7 form system | Consistency, YAML-driven, validation |
+| Settings form generation | Custom forms per plugin | SettingsModel behavior | Standardized, YAML-driven, cached |
+
+**Key insight:** Dashboard widgets are deceptively complex. Each widget needs configuration UI, persistence, permissions, and potentially real-time updates. Reuse WinterCMS patterns for the container/widget relationship rather than building ad-hoc.
+
+## Common Pitfalls
+
+### Pitfall 1: SSE Connection Limits
+**What goes wrong:** Browser limits concurrent SSE connections (typically 6 per domain)
+**Why it happens:** Each widget opens its own SSE connection
+**How to avoid:**
+- Single SSE connection per dashboard page with multiplexed events
+- Use event names to route updates to correct widgets
+- Send widget alias in event type: `event: widget-$alias`
+**Warning signs:** Later widgets don't receive updates; "ERR_CONNECTION_LIMIT" in browser console
+
+### Pitfall 2: Layout Save Race Conditions
+**What goes wrong:** Rapid drag/resize operations cause lost updates
+**Why it happens:** Multiple save requests overlap, last one wins
+**How to avoid:**
+- Debounce save operations (300-500ms after last change)
+- Include full layout state in each save (not incremental updates)
+- Server acknowledges save, client can retry on failure
+**Warning signs:** Layout reverts unexpectedly; saved layout doesn't match visual
+
+### Pitfall 3: Widget Configuration State Loss
+**What goes wrong:** Widget config changes lost on page reload
+**Why it happens:** Config saved to layout but not persisted to database
+**How to avoid:**
+- Widget config stored in preferences alongside position
+- Config changes trigger preferences save immediately
+- Default config comes from widget definition, overrides from preferences
+**Warning signs:** "Reset" doesn't restore expected defaults; config resets on browser refresh
+
+### Pitfall 4: Memory Leaks in Real-Time Widgets
+**What goes wrong:** Dashboard becomes slow/unresponsive over time
+**Why it happens:** SSE event handlers accumulate; old widget content not garbage collected
+**How to avoid:**
+- HTMX's SSE extension manages this automatically via sse-swap
+- Avoid manual JavaScript event listeners without cleanup
+- Use `sse-close` attribute for explicit cleanup on navigation
+**Warning signs:** Growing memory usage; event handlers stack on widget reconfigure
+
+### Pitfall 5: Settings Cache Invalidation
+**What goes wrong:** Changed settings not reflected immediately
+**Why it happens:** Settings cached at application level for performance
+**How to avoid:**
+- Clear cache on save (WinterCMS calls queue:restart)
+- Use short TTL for frequently-changed settings
+- Provide "clear cache" admin action
+**Warning signs:** Changes require restart; inconsistent behavior across requests
+
+### Pitfall 6: Category Translation and Ordering
+**What goes wrong:** Settings categories display incorrectly or in wrong order
+**Why it happens:** Categories are translation keys, not display strings; no explicit ordering
+**How to avoid:**
+- Define standard categories with explicit sort order
+- Translate category keys at render time, not storage
+- Allow plugins to register new categories with order hints
+**Warning signs:** Untranslated category keys visible; random category order
+
+## Code Examples
+
+### Complete Dashboard Controller
+```scala
+// Source: WinterCMS backend/controllers/Index.php + Gridstack patterns
+import zio._
+import zio.http._
+import scalatags.Text.all._
+
+class DashboardController(
+ widgetRegistry: WidgetRegistry,
+ prefsService: DashboardPreferenceService,
+ auth: AuthService
+):
+
+ def index: Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ for
+ user <- auth.getUser(req)
+ widgets <- loadUserDashboard(user.id, "dashboard")
+ html <- renderDashboard(widgets, canAddAndDelete = true)
+ yield Response.html(html)
+ }
+
+ def saveLayout: Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ for
+ user <- auth.getUser(req)
+ layout <- req.body.asString.flatMap(parseLayoutJson)
+ _ <- prefsService.setPreferences(user.id, "dashboard", layout)
+ yield Response.ok
+ }
+
+ def addWidgetPopup: Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ for
+ user <- auth.getUser(req)
+ widgets <- widgetRegistry.listWidgetsForContext("dashboard")
+ // Filter by user permissions
+ available = widgets.filter { case (_, info) =>
+ info.permissions.isEmpty || user.hasAnyAccess(info.permissions)
+ }
+ yield Response.html(renderAddWidgetPopup(available))
+ }
+
+ def addWidget: Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ for
+ user <- auth.getUser(req)
+ form <- req.body.asURLEncodedForm
+ className <- ZIO.fromOption(form.get("className"))
+ size <- ZIO.fromOption(form.get("size").flatMap(_.toIntOption))
+ widget <- widgetRegistry.resolveWidget(className)
+ .someOrFail(Response.badRequest("Invalid widget class"))
+
+ // Get current layout to determine new widget position
+ current <- prefsService.getPreferences(user.id, "dashboard")
+ nextOrder = current.map(_.sortOrder).maxOption.getOrElse(0) + 1
+ alias = s"widget_${user.id}_$nextOrder"
+
+ newWidget = StoredWidgetConfig(
+ className = className,
+ alias = alias,
+ sortOrder = nextOrder,
+ configuration = Map(
+ "ocWidgetWidth" -> size,
+ "x" -> 0, "y" -> current.size, // Add at bottom
+ "w" -> size, "h" -> 2
+ )
+ )
+
+ _ <- prefsService.setPreferences(user.id, "dashboard", current :+ newWidget)
+
+ // Return partial HTML for the new widget
+ config = toWidgetConfig(newWidget)
+ html <- widget.render(config, DashboardContext.forUser(user))
+ yield Response.html(renderGridstackItem(alias, widget, config, html))
+ }
+
+ def removeWidget: Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ for
+ user <- auth.getUser(req)
+ alias <- req.url.queryParams.get("alias").flatMap(_.headOption)
+ .fold(ZIO.fail(Response.badRequest("Missing alias")))(ZIO.succeed)
+ current <- prefsService.getPreferences(user.id, "dashboard")
+ updated = current.filterNot(_.alias == alias)
+ _ <- prefsService.setPreferences(user.id, "dashboard", updated)
+ yield Response.ok
+ }
+
+ def resetDashboard: Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ for
+ user <- auth.getUser(req)
+ _ <- prefsService.resetToDefault(user.id, "dashboard")
+ yield Response.redirect("/admin")
+ }
+```
+
+### Settings Controller with Generic CRUD
+```scala
+// Source: WinterCMS system/controllers/Settings.php
+import zio._
+import zio.http._
+
+class SettingsController(
+ settingsManager: SettingsManager,
+ formRenderer: FormRenderer,
+ auth: AuthService
+):
+
+ def index: Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ for
+ user <- auth.getUser(req)
+ items <- settingsManager.listItems()
+ // Filter by permissions
+ filtered = items.view.mapValues(_.filter { item =>
+ item.permissions.isEmpty || user.hasAnyAccess(item.permissions)
+ }).filterNot(_._2.isEmpty).toMap
+ yield Response.html(renderSettingsIndex(filtered))
+ }
+
+ def update(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ val owner = s"$author.$plugin"
+ for
+ user <- auth.getUser(req)
+ item <- settingsManager.findItem(owner, code)
+ .someOrFail(Response.notFound)
+ _ <- ZIO.when(item.permissions.nonEmpty && !user.hasAnyAccess(item.permissions)) {
+ ZIO.fail(Response.forbidden)
+ }
+ _ <- settingsManager.setContext(owner, code)
+
+ // Load settings model
+ model <- loadSettingsModel(item.settingsClass.get)
+ instance <- model.getInstance
+
+ // Parse YAML fields definition
+ fieldsConfig <- parseFieldsYaml(model.settingsFields)
+
+ // Render form
+ form <- formRenderer.render(fieldsConfig, instance.getValues)
+ yield Response.html(renderSettingsForm(item, form))
+ }
+
+ def save(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] =
+ handler { (req: Request) =>
+ val owner = s"$author.$plugin"
+ for
+ user <- auth.getUser(req)
+ item <- settingsManager.findItem(owner, code)
+ .someOrFail(Response.notFound)
+ model <- loadSettingsModel(item.settingsClass.get)
+ instance <- model.getInstance
+
+ // Parse form data
+ formData <- req.body.asURLEncodedForm
+ values = formData.view.mapValues(_.head).toMap
+
+ // Validate (optional - use model validation rules)
+ _ <- validateSettings(model, values)
+
+ // Save
+ _ <- instance.set(values)
+
+ // Flash success message
+ yield Response.redirect(s"/admin/system/settings/update/$author/$plugin/$code")
+ .addHeader(Header.Custom("X-Flash", "Settings saved successfully"))
+ }
+```
+
+### Default Dashboard Configuration YAML
+```yaml
+# config_dashboard.yaml
+# Source: WinterCMS backend/controllers/index/config_dashboard.yaml
+
+defaultWidgets:
+ welcome:
+ class: summercms.admin.widgets.WelcomeWidget
+ sortOrder: 50
+ configuration:
+ ocWidgetWidth: 7
+ x: 0
+ y: 0
+ w: 7
+ h: 3
+
+ systemStatus:
+ class: summercms.admin.widgets.SystemStatusWidget
+ sortOrder: 60
+ configuration:
+ ocWidgetWidth: 5
+ x: 7
+ y: 0
+ w: 5
+ h: 3
+
+ recentActivity:
+ class: summercms.admin.widgets.RecentActivityWidget
+ sortOrder: 70
+ configuration:
+ ocWidgetWidth: 12
+ x: 0
+ y: 3
+ w: 12
+ h: 4
+ itemCount: 10
+```
+
+### Widget Property Configuration Popup
+```scala
+// Source: WinterCMS ReportContainer property config patterns
+def renderWidgetConfigPopup(
+ alias: String,
+ widget: DashboardWidget,
+ currentConfig: WidgetConfig
+): Frag = {
+ val properties = widget.defineProperties
+
+ div(cls := "modal-dialog",
+ div(cls := "modal-content",
+ div(cls := "modal-header",
+ h5(cls := "modal-title", "Widget Configuration"),
+ button(`type` := "button", cls := "btn-close",
+ attr("data-bs-dismiss") := "modal")
+ ),
+ form(
+ attr("hx-post") := s"/admin/dashboard/widget-config/$alias",
+ attr("hx-target") := s"#widget-content-$alias",
+ attr("hx-swap") := "innerHTML",
+
+ div(cls := "modal-body",
+ properties.map { case (propName, propDef) =>
+ div(cls := "form-group",
+ label(cls := "form-label", propDef.title),
+ propDef.description.map(d => p(cls := "help-block", d)),
+ renderPropertyInput(propName, propDef, currentConfig.properties.get(propName))
+ )
+ }
+ ),
+
+ div(cls := "modal-footer",
+ button(`type` := "button", cls := "btn btn-secondary",
+ attr("data-bs-dismiss") := "modal", "Cancel"),
+ button(`type` := "submit", cls := "btn btn-primary", "Save")
+ )
+ )
+ )
+ )
+}
+
+def renderPropertyInput(name: String, propDef: PropertyDef, value: Option[Any]): Frag = {
+ val currentValue = value.orElse(propDef.default).map(_.toString).getOrElse("")
+
+ propDef.`type` match
+ case PropertyType.String =>
+ input(cls := "form-control", `type` := "text",
+ attr("name") := name, attr("value") := currentValue)
+
+ case PropertyType.Number =>
+ input(cls := "form-control", `type` := "number",
+ attr("name") := name, attr("value") := currentValue)
+
+ case PropertyType.Checkbox =>
+ div(cls := "form-check",
+ input(cls := "form-check-input", `type` := "checkbox",
+ attr("name") := name,
+ (currentValue == "true").option(attr("checked") := "checked"))
+ )
+
+ case PropertyType.Dropdown =>
+ select(cls := "form-select", attr("name") := name,
+ propDef.options.map { case (optValue, optLabel) =>
+ option(attr("value") := optValue,
+ (currentValue == optValue).option(attr("selected") := "selected"),
+ optLabel)
+ }
+ )
+
+ case PropertyType.DateRange =>
+ input(cls := "form-control flatpickr",
+ `type` := "text", attr("name") := name, attr("value") := currentValue,
+ attr("data-mode") := "range")
+}
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| jQuery UI sortable | Gridstack.js | 2020+ | Better mobile support, TypeScript, maintained |
+| Polling for updates | SSE/WebSocket | 2018+ | Real-time, lower overhead |
+| Session-based prefs | Database per-user | Standard | Persists, multi-device |
+| Manual AJAX widget refresh | HTMX SSE extension | 2023+ | Declarative, automatic reconnection |
+| PHP serialized layout | JSON layout storage | Standard | Interoperable, queryable |
+
+**Deprecated/outdated:**
+- jQuery UI Sortable: Use Gridstack.js for dashboard grids
+- Isotope/Packery: Gridstack specifically designed for dashboards
+- Long-polling: Use SSE for server-push updates
+- Cookie-based preferences: Use database storage for persistence
+
+## Open Questions
+
+1. **Multiplexed SSE vs Multiple Connections**
+ - What we know: Browser limits concurrent connections (6 per domain)
+ - What's unclear: Best pattern for many real-time widgets
+ - Recommendation: Single SSE connection with event routing by widget alias
+
+2. **Widget Caching Strategy**
+ - What we know: Some widgets expensive to render (database queries)
+ - What's unclear: How to cache widget HTML while maintaining real-time updates
+ - Recommendation: Cache initial render, SSE updates bypass cache
+
+3. **Admin-Set Default Layout**
+ - What we know: WinterCMS allows admins to "make layout default"
+ - What's unclear: Should super-admin override affect existing user layouts?
+ - Recommendation: System default separate from user prefs; "reset" restores system default
+
+4. **Widget Asset Loading**
+ - What we know: Some widgets need additional JS/CSS (Chart.js)
+ - What's unclear: How to lazy-load widget assets only when widget is used
+ - Recommendation: Define asset dependencies in widget class, inject on dashboard load
+
+## Sources
+
+### Primary (HIGH confidence)
+- WinterCMS reference implementation:
+ - `modules/backend/widgets/ReportContainer.php` - Dashboard container pattern
+ - `modules/backend/classes/ReportWidgetBase.php` - Widget base class
+ - `modules/backend/classes/WidgetManager.php` - Widget registration
+ - `modules/system/classes/SettingsManager.php` - Settings registration
+ - `modules/system/behaviors/SettingsModel.php` - Settings persistence
+ - `modules/backend/models/UserPreference.php` - Per-user storage
+- [HTMX SSE Extension](https://htmx.org/extensions/sse/) - SSE frontend integration
+- [Gridstack.js](https://gridstackjs.com/) - Dashboard grid layout library
+- [ZIO HTTP SSE Examples](https://ziohttp.com/examples/server-sent-events-in-endpoints/) - SSE endpoint implementation
+
+### Secondary (MEDIUM confidence)
+- [Designing Scalable Database for User Settings](https://medium.com/@jorgemudry/designing-a-scalable-database-for-user-settings-8e189a678a85) - Preferences schema patterns
+- [Building Real-Time Dashboards with FastAPI and HTMX](https://medium.com/codex/building-real-time-dashboards-with-fastapi-and-htmx-01ea458673cb) - SSE dashboard patterns
+
+### Tertiary (LOW confidence)
+- WebSearch results on dashboard widget best practices 2025-2026
+- Community discussions on SSE vs WebSocket tradeoffs
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH - Gridstack.js docs + ZIO HTTP SSE verified
+- Architecture: HIGH - Based on thorough WinterCMS reference analysis
+- Pitfalls: MEDIUM - Mix of WinterCMS patterns and web development experience
+- Real-time patterns: MEDIUM - ZIO SSE documented, HTMX integration well-established
+
+**Research date:** 2026-02-05
+**Valid until:** 2026-03-05 (30 days - stable domain)