diff --git a/.planning/phases/03-component-system/03-RESEARCH.md b/.planning/phases/03-component-system/03-RESEARCH.md
new file mode 100644
index 0000000..a1011fb
--- /dev/null
+++ b/.planning/phases/03-component-system/03-RESEARCH.md
@@ -0,0 +1,822 @@
+# Phase 3: Component System - Research
+
+**Researched:** 2026-02-05
+**Domain:** Reusable UI components with YAML-defined properties, lifecycle hooks, HTMX handlers, and Twig-compatible templating
+**Confidence:** HIGH
+
+## Summary
+
+This phase implements the component system that enables reusable UI components responding to HTMX interactions. Components are defined with YAML property schemas (similar to WinterCMS Inspector configuration), registered per-plugin, and render via Twig-compatible templates. HTMX handlers (methods prefixed with `on`) respond to client-side triggers and return HTML fragments for swapping.
+
+The recommended stack uses: ZIO HTTP 3.3.0+ with the official `zio-http-htmx` module for HTMX response headers, **Pebble 4.1.1** for Twig/Jinja2-compatible templating (chosen over ScalaTags per CONTEXT.md decision for Twig-compatible templates), circe-yaml for YAML property schema parsing (reusing Phase 2 infrastructure), and ZIO for component lifecycle management. The htmx4s library provides type-safe HTMX constants and ScalaTags integration for any programmatic HTML needs.
+
+**Primary recommendation:** Define components as Scala traits with YAML property schemas parsed at plugin registration time. Use Pebble template engine for Twig-compatible rendering with `default.htm` convention. Implement HTMX handlers as `on*` methods returning `ZIO[ComponentEnv, ComponentError, Html]`. Integrate CSRF protection via request middleware injecting tokens, with validation on all HTMX POST requests.
+
+## Standard Stack
+
+The established libraries/tools for this domain:
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| zio-http-htmx | 3.3.0 | HTMX response headers | Official ZIO HTTP module for HTMX integration |
+| Pebble | 4.1.1 | Twig-compatible templates | Java templating with Jinja2/Twig syntax, i18n, autoescaping |
+| circe-yaml | 0.16.1 | YAML property parsing | Already in stack from Phase 2, proven for YAML parsing |
+| htmx4s-constants | latest | HTMX vocabulary | Type-safe HTMX attribute/header constants |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| htmx4s-scalatags | latest | ScalaTags HTMX attrs | When generating HTML programmatically (not templates) |
+| ScalaTags | 0.13.x | Programmatic HTML | Component helper methods, fragment generation |
+| zio-http | 3.3.0+ | HTTP server + SSE | Base HTTP server, Server-Sent Events streaming |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Pebble | ScalaTags only | ScalaTags is type-safe but not Twig-compatible; Pebble enables WinterCMS migration |
+| Pebble | Twirl | Twirl has Scala syntax, not Jinja2/Twig-compatible |
+| Pebble | Jinjava | Jinjava is also Jinja2-compatible; Pebble has better Twig inheritance |
+| htmx4s | Hand-roll constants | htmx4s provides IDE docs from htmx markdown, type safety |
+
+**Installation (Mill build.mill):**
+```scala
+def mvnDeps = Seq(
+ // Existing deps from Phase 1/2...
+
+ // HTMX integration
+ mvn"dev.zio::zio-http-htmx:3.3.0",
+
+ // Twig-compatible templating
+ mvn"io.pebbletemplates:pebble:4.1.1",
+
+ // HTMX constants (optional, for type-safe attributes)
+ mvn"com.github.eikek::htmx4s-constants:latest",
+
+ // ScalaTags for programmatic HTML fragments
+ mvn"com.lihaoyi::scalatags:0.13.1"
+)
+```
+
+## Architecture Patterns
+
+### Recommended Component Directory Structure
+```
+plugins/
+├── golem15/
+│ └── blog/
+│ ├── plugin.yaml
+│ ├── Plugin.scala # registerComponents method
+│ └── components/
+│ ├── Posts.scala # Component class
+│ └── posts/
+│ ├── component.yaml # Property schema
+│ ├── default.htm # Main template (required)
+│ ├── pagination.htm # Partial template
+│ └── item.htm # Item template
+```
+
+### Pattern 1: Component Trait with Lifecycle Hooks
+**What:** Define components as Scala traits with `init`, `onRun`, and HTMX handler methods
+**When to use:** All components must implement this trait
+**Example:**
+```scala
+// Source: WinterCMS ComponentBase pattern adapted to ZIO
+trait SummerComponent:
+ /** Component identifier (alias used in pages) */
+ def alias: String
+
+ /** Component metadata from YAML */
+ def details: ComponentDetails
+
+ /** Property schema from component.yaml */
+ def propertySchema: List[PropertyDef]
+
+ /** Current property values (set from page/layout) */
+ def properties: Ref[Map[String, PropertyValue]]
+
+ /** Access to page context for rendering */
+ def pageContext: PageContext
+
+ /** Called once when component is initialized (before HTMX handlers) */
+ def init: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
+
+ /** Called when component is bound to page/layout, part of page lifecycle */
+ def onRun: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
+
+ /** Render the default template with current context */
+ def render: ZIO[ComponentEnv, ComponentError, Html]
+
+ /** Render a specific partial template */
+ def renderPartial(name: String): ZIO[ComponentEnv, ComponentError, Html]
+
+case class ComponentDetails(
+ name: String,
+ description: String,
+ icon: Option[String] = None
+)
+```
+
+### Pattern 2: YAML Property Schema (WinterCMS Inspector Compatible)
+**What:** Define component properties in YAML with explicit types and validation
+**When to use:** Every component with configurable properties
+**Example:**
+```yaml
+# plugins/golem15/blog/components/posts/component.yaml
+# Source: WinterCMS Inspector configuration format
+name: Blog Posts
+description: Displays a list of blog posts with pagination
+icon: pencil
+
+properties:
+ postsPerPage:
+ type: string
+ title: Posts per page
+ description: Number of posts to display per page
+ default: "10"
+ validation:
+ integer:
+ message: Posts per page must be a positive integer
+ min:
+ value: 1
+ message: Must show at least 1 post
+ max:
+ value: 100
+ message: Maximum 100 posts per page
+
+ categoryFilter:
+ type: dropdown
+ title: Category Filter
+ description: Filter posts by category
+ default: ""
+ showExternalParam: true # Allow {{ :category }} binding
+ # Options loaded dynamically from getCategoryFilterOptions()
+
+ sortOrder:
+ type: dropdown
+ title: Sort Order
+ options:
+ published_at desc: Published date (newest first)
+ published_at asc: Published date (oldest first)
+ title asc: Title (A-Z)
+ default: published_at desc
+
+ noPostsMessage:
+ type: string
+ title: No posts message
+ default: No posts found.
+ group: Messages
+
+ throwNotFound:
+ type: checkbox
+ title: Throw 404 on empty
+ description: Show 404 page when no posts match filter
+ default: false
+ group: Advanced
+```
+
+```scala
+// Source: circe-yaml parsing + WinterCMS property types
+import io.circe.generic.auto.*
+import io.circe.yaml.parser
+
+case class ComponentSchema(
+ name: String,
+ description: String,
+ icon: Option[String],
+ properties: Map[String, PropertyDef]
+)
+
+case class PropertyDef(
+ `type`: PropertyType,
+ title: String,
+ description: Option[String] = None,
+ default: Option[PropertyValue] = None,
+ options: Option[Map[String, String]] = None, // For dropdown/set
+ group: Option[String] = None,
+ showExternalParam: Boolean = false,
+ validation: Option[ValidationRules] = None
+)
+
+enum PropertyType:
+ case String, Text, Checkbox, Dropdown, Set, Dictionary
+ case Object, ObjectList, StringList, Autocomplete
+
+sealed trait PropertyValue
+object PropertyValue:
+ case class StringVal(value: String) extends PropertyValue
+ case class BoolVal(value: Boolean) extends PropertyValue
+ case class IntVal(value: Int) extends PropertyValue
+ case class ListVal(values: List[PropertyValue]) extends PropertyValue
+ case class ObjectVal(fields: Map[String, PropertyValue]) extends PropertyValue
+```
+
+### Pattern 3: HTMX Handler Convention (on* Methods)
+**What:** Methods starting with `on` become HTMX handlers, routed via component alias
+**When to use:** All interactive component behaviors
+**Example:**
+```scala
+// Source: WinterCMS AJAX handler pattern + CONTEXT.md decisions
+trait BlogPostsComponent extends SummerComponent:
+
+ // HTMX handler: POST /summer/component/blogPosts/onLoadMore
+ def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
+ for
+ page <- getFormValue("page").map(_.toIntOption.getOrElse(1))
+ posts <- loadPosts(page)
+ html <- renderPartial("items", Map("posts" -> posts))
+ hasMore <- ZIO.succeed(posts.nonEmpty)
+ yield HtmxResponse(
+ html = html,
+ triggers = if hasMore then Map.empty else Map("postsExhausted" -> "true"),
+ retarget = None // Use original hx-target
+ )
+
+ // HTMX handler: POST /summer/component/blogPosts/onFilter
+ def onFilter: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
+ for
+ category <- getFormValue("category")
+ posts <- loadPosts(1, category)
+ html <- renderPartial("default")
+ yield HtmxResponse(html)
+
+case class HtmxResponse(
+ html: Html,
+ triggers: Map[String, String] = Map.empty, // HX-Trigger header
+ retarget: Option[String] = None, // HX-Retarget header
+ reswap: Option[String] = None, // HX-Reswap header
+ oobSwaps: List[OobSwap] = List.empty // Out-of-band swaps
+)
+
+case class OobSwap(
+ targetId: String,
+ html: Html,
+ swapStrategy: String = "outerHTML"
+)
+```
+
+### Pattern 4: Component Registration in Plugin
+**What:** Plugins register components via `registerComponents` method returning alias -> class mapping
+**When to use:** Plugin boot phase, collected by ComponentManager
+**Example:**
+```scala
+// Source: WinterCMS Plugin.registerComponents pattern
+class BlogPlugin extends SummerPlugin:
+ override def id = PluginId("golem15", "blog")
+
+ override def registerComponents: Map[String, ComponentFactory] =
+ Map(
+ "blogPost" -> ComponentFactory[BlogPostComponent],
+ "blogPosts" -> ComponentFactory[BlogPostsComponent],
+ "blogCategories" -> ComponentFactory[BlogCategoriesComponent],
+ "blogRssFeed" -> ComponentFactory[BlogRssFeedComponent]
+ )
+
+trait ComponentFactory[C <: SummerComponent]:
+ def create(alias: String, properties: Map[String, PropertyValue], ctx: PageContext): ZIO[Any, Nothing, C]
+
+// ComponentManager collects from all plugins
+trait ComponentManager:
+ def listComponents: UIO[Map[String, ComponentFactory[?]]]
+ def resolve(aliasOrClass: String): UIO[Option[ComponentFactory[?]]]
+ def make[C <: SummerComponent](
+ alias: String,
+ properties: Map[String, PropertyValue],
+ ctx: PageContext
+ ): IO[ComponentError, C]
+```
+
+### Pattern 5: Pebble Template Integration
+**What:** Use Pebble engine for Twig-compatible templates with component context
+**When to use:** All component template rendering
+**Example:**
+```java
+// Source: Pebble Templates documentation
+// Pebble template syntax (Twig-compatible)
+// plugins/golem15/blog/components/posts/default.htm
+{% extends "layout" %}
+
+{% block content %}
+
+ {% if posts is empty %}
+
{{ noPostsMessage }}
+ {% else %}
+ {% for post in posts %}
+ {% include "posts/item" %}
+ {% endfor %}
+
+ {% if hasMorePosts %}
+
+ {% endif %}
+ {% endif %}
+
+{% endblock %}
+```
+
+```scala
+// Source: Pebble Java API wrapped for ZIO
+import io.pebbletemplates.pebble.PebbleEngine
+import io.pebbletemplates.pebble.loader.FileLoader
+
+trait TemplateService:
+ def render(template: String, context: Map[String, Any]): Task[Html]
+ def renderString(templateContent: String, context: Map[String, Any]): Task[Html]
+
+object TemplateService:
+ val live: ZLayer[PluginManager, Nothing, TemplateService] =
+ ZLayer.fromFunction { (plugins: PluginManager) =>
+ new TemplateService:
+ private val engine = new PebbleEngine.Builder()
+ .loader(new ComponentTemplateLoader(plugins))
+ .autoEscaping(true)
+ .defaultLocale(Locale.ENGLISH)
+ .build()
+
+ def render(template: String, context: Map[String, Any]): Task[Html] =
+ ZIO.attemptBlocking {
+ val writer = new StringWriter()
+ val ctx = context.asJava
+ engine.getTemplate(template).evaluate(writer, ctx)
+ Html(writer.toString)
+ }
+ }
+
+// Custom Twig functions/filters
+// {{ asset('img/logo.png') }} -> ThemeService.asset()
+// {{ componentHandler('onRefresh') }} -> component URL builder
+// {{ 'text'|_ }} -> Translation filter
+```
+
+### Pattern 6: CSRF Protection for HTMX Requests
+**What:** Inject CSRF token via middleware, validate on all HTMX POST/PUT/DELETE
+**When to use:** All HTMX handlers (per CONTEXT.md: CSRF always required)
+**Example:**
+```scala
+// Source: HTMX CSRF pattern + OWASP recommendations
+// Middleware injects CSRF token into response headers and validates requests
+val csrfMiddleware: HandlerAspect[Any, Unit] = HandlerAspect.interceptHandler { handler =>
+ Handler.fromFunctionZIO[Request] { request =>
+ for
+ session <- SessionService.get
+ token <- session.csrfToken
+ _ <- ZIO.when(request.method.isUnsafe) {
+ val headerToken = request.header("X-CSRF-Token").map(_.value)
+ val formToken = request.bodyAsMultipartForm.flatMap(_.get("_token"))
+ val requestToken = headerToken.orElse(formToken)
+ ZIO.when(requestToken != Some(token)) {
+ ZIO.fail(CsrfViolation("Invalid CSRF token"))
+ }
+ }
+ response <- handler(request)
+ yield response.addHeader("X-CSRF-Token", token)
+ }
+}
+
+// Template helper for forms
+//
+
+// HTMX global header injection (in base layout)
+//
+```
+
+### Pattern 7: Server-Sent Events for Real-Time Updates
+**What:** Components can push updates to clients via SSE stream
+**When to use:** Live updates (chat, notifications, progress bars)
+**Example:**
+```scala
+// Source: ZIO HTTP SSE documentation
+// Component declares SSE capability
+trait LiveNotifications extends SummerComponent:
+
+ def onSubscribe: ZIO[ComponentEnv, ComponentError, Response] =
+ for
+ userId <- getCurrentUserId
+ stream <- NotificationService.subscribeUser(userId)
+ sse <- ZIO.succeed(
+ stream.map { notification =>
+ ServerSentEvent(
+ data = renderNotification(notification),
+ event = Some("notification"),
+ id = Some(notification.id.toString)
+ )
+ }
+ )
+ yield Response(
+ status = Status.Ok,
+ headers = Headers(
+ Header.ContentType(MediaType.text.`event-stream`),
+ Header.CacheControl.NoCache
+ ),
+ body = Body.fromStreamSSE(sse)
+ )
+
+// Template usage
+//
+```
+
+### Pattern 8: Out-of-Band Swaps for Multiple Updates
+**What:** Single HTMX response updates multiple DOM elements
+**When to use:** Handler needs to update content outside its target
+**Example:**
+```scala
+// Source: htmx hx-swap-oob documentation
+def onAddToCart: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
+ for
+ productId <- getFormValue("product_id")
+ _ <- CartService.addItem(productId)
+ cart <- CartService.getCart
+ itemHtml <- renderPartial("cart-item", Map("item" -> cart.lastItem))
+ countHtml <- renderFragment(s"""${cart.itemCount}""")
+ yield HtmxResponse(
+ html = itemHtml,
+ oobSwaps = List(
+ OobSwap("cart-count", countHtml),
+ OobSwap("cart-total", renderCartTotal(cart))
+ )
+ )
+
+// Response includes primary HTML plus OOB fragments:
+// ... item HTML ...
+// 5
+// $99.00
+```
+
+### Anti-Patterns to Avoid
+- **Blocking in handlers:** Always return `ZIO`, never block threads
+- **Direct database access in components:** Use services, not repositories directly
+- **Mutable component state:** Use `Ref` for any state, properties are immutable per request
+- **Missing CSRF on unsafe methods:** All POST/PUT/DELETE require validation
+- **String concatenation for HTML:** Use templates or ScalaTags, never raw strings
+- **Synchronous template rendering:** Wrap Pebble in `ZIO.attemptBlocking`
+- **Hard-coded component aliases:** Use `__SELF__` macro in templates for uniqueness
+
+## Don't Hand-Roll
+
+Problems that look simple but have existing solutions:
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Twig-compatible templates | Custom parser | Pebble | Complex inheritance, filters, autoescaping |
+| HTMX response headers | Manual string headers | zio-http-htmx | Type-safe headers, correct formatting |
+| HTMX attribute constants | String literals | htmx4s-constants | IDE docs, typo prevention, refactoring |
+| HTML escaping | Manual escaping | Pebble autoescaping | XSS vulnerabilities, edge cases |
+| CSRF token generation | Random strings | Secure random + HMAC | Timing attacks, entropy issues |
+| Property validation | Custom validators | Use WinterCMS patterns | Complex rules (regex, ranges, dependencies) |
+| Component discovery | Manual registration | Reflective scan or explicit registration | Miss components, ordering issues |
+
+**Key insight:** Component systems appear simple but involve complex concerns: property validation, template inheritance, security (XSS, CSRF), lifecycle management, and state isolation. Use proven patterns from WinterCMS adapted to ZIO.
+
+## Common Pitfalls
+
+### Pitfall 1: Template Escaping Disabled Accidentally
+**What goes wrong:** XSS vulnerabilities when rendering user content
+**Why it happens:** Using `|raw` filter or disabling autoescaping for convenience
+**How to avoid:** Keep autoescaping enabled, use `|raw` only for known-safe HTML fragments
+**Warning signs:** HTML entities appearing in output, or HTML rendering unexpectedly
+
+### Pitfall 2: Component State Leaking Between Requests
+**What goes wrong:** User A sees User B's data
+**Why it happens:** Storing state in class variables instead of request-scoped Ref
+**How to avoid:** All component state via `Ref` created per-request, never `var`
+**Warning signs:** Inconsistent behavior under load, data appearing randomly
+
+### Pitfall 3: HTMX Handler Not Found (404)
+**What goes wrong:** HTMX POST returns 404, component method exists
+**Why it happens:** Handler method doesn't start with `on`, or component not registered
+**How to avoid:** Naming convention enforcement, handler discovery at boot time
+**Warning signs:** Console shows POST to correct URL, but 404 returned
+
+### Pitfall 4: CSRF Token Mismatch After Cache
+**What goes wrong:** Form submissions fail with CSRF error after page was cached
+**Why it happens:** CDN/browser cached page with old CSRF token
+**How to avoid:** Use `Vary: Cookie` header, or refresh token via HTMX header on each response
+**Warning signs:** Works in development, fails in production with caching
+
+### Pitfall 5: SSE Connection Exhaustion
+**What goes wrong:** Server runs out of connections, new SSE subscriptions fail
+**Why it happens:** Clients not closing SSE connections, no timeout
+**How to avoid:** Set connection timeout, use heartbeat to detect dead connections
+**Warning signs:** Gradual slowdown, connection refused errors
+
+### Pitfall 6: Template Path Traversal
+**What goes wrong:** Attacker reads arbitrary files via template include
+**Why it happens:** User input in template path without validation
+**How to avoid:** Whitelist allowed templates, never use user input directly in paths
+**Warning signs:** Error messages revealing file paths, unexpected template content
+
+### Pitfall 7: Blocking Pebble Rendering on ZIO Runtime
+**What goes wrong:** Thread pool exhaustion, timeout errors under load
+**Why it happens:** Pebble is synchronous, blocking ZIO fibers
+**How to avoid:** Wrap Pebble calls in `ZIO.attemptBlocking`, use blocking thread pool
+**Warning signs:** High latency spikes, fiber count explosion
+
+## Code Examples
+
+Verified patterns from official sources:
+
+### Complete Component Implementation
+```scala
+// Source: WinterCMS Account component pattern adapted to Scala/ZIO
+package golem15.blog.components
+
+import zio.*
+import io.circe.yaml.parser
+import io.pebbletemplates.pebble.PebbleEngine
+
+class BlogPostsComponent(
+ val alias: String,
+ val propertySchema: List[PropertyDef],
+ val properties: Ref[Map[String, PropertyValue]],
+ val pageContext: PageContext,
+ templateService: TemplateService,
+ postService: PostService
+) extends SummerComponent:
+
+ override val details = ComponentDetails(
+ name = "Blog Posts",
+ description = "Displays a list of blog posts",
+ icon = Some("pencil")
+ )
+
+ // Page variables set in onRun, available in template
+ private var posts: List[Post] = List.empty
+ private var category: Option[Category] = None
+ private var pageParam: String = "page"
+
+ override def onRun: ZIO[ComponentEnv, ComponentError, Unit] =
+ for
+ props <- properties.get
+ catSlug = props.get("categoryFilter").collect { case StringVal(s) => s }
+ cat <- catSlug.fold(ZIO.none)(slug =>
+ categoryService.findBySlug(slug).map(Some(_))
+ )
+ perPage = props.get("postsPerPage")
+ .collect { case StringVal(s) => s.toIntOption }
+ .flatten.getOrElse(10)
+ page = pageContext.param("page").flatMap(_.toIntOption).getOrElse(1)
+ result <- postService.listFrontend(
+ page = page,
+ perPage = perPage,
+ category = cat.map(_.id),
+ published = !pageContext.isBackendEditor
+ )
+ yield
+ this.posts = result.items
+ this.category = cat
+ this.pageParam = paramName("pageNumber").getOrElse("page")
+
+ override def render: ZIO[ComponentEnv, ComponentError, Html] =
+ for
+ props <- properties.get
+ noPostsMsg = props.get("noPostsMessage")
+ .collect { case StringVal(s) => s }
+ .getOrElse("No posts found.")
+ html <- templateService.render(
+ s"$alias/default",
+ Map(
+ "__SELF__" -> alias,
+ "posts" -> posts.asJava,
+ "category" -> category.orNull,
+ "noPostsMessage" -> noPostsMsg,
+ "pageParam" -> pageParam
+ )
+ )
+ yield html
+
+ // HTMX Handler: Load more posts
+ def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
+ for
+ page <- ZIO.serviceWithZIO[Request](_.formField("page"))
+ .map(_.flatMap(_.toIntOption).getOrElse(1))
+ props <- properties.get
+ perPage = props.get("postsPerPage")
+ .collect { case StringVal(s) => s.toIntOption }
+ .flatten.getOrElse(10)
+ result <- postService.listFrontend(page = page, perPage = perPage)
+ html <- templateService.render(s"$alias/items", Map("posts" -> result.items.asJava))
+ yield HtmxResponse(
+ html = html,
+ triggers = if result.hasMore then Map.empty else Map("postsExhausted" -> "true")
+ )
+
+ // Dynamic options for categoryFilter dropdown
+ def getCategoryFilterOptions: UIO[Map[String, String]] =
+ categoryService.listAll.map { cats =>
+ Map("" -> "- All Categories -") ++ cats.map(c => c.slug -> c.name).toMap
+ }
+```
+
+### HTMX Handler Router
+```scala
+// Source: ZIO HTTP routing + WinterCMS component handler pattern
+object ComponentRoutes:
+
+ val routes: Routes[ComponentManager & TemplateService & SessionService, Response] =
+ Routes(
+ // Component HTMX handler endpoint
+ Method.POST / "summer" / "component" / string("alias") / string("handler") ->
+ handler { (alias: String, handlerName: String, req: Request) =>
+ for
+ _ <- CsrfService.validate(req)
+ manager <- ZIO.service[ComponentManager]
+ factory <- manager.resolve(alias).someOrFail(ComponentNotFound(alias))
+ ctx <- PageContext.fromRequest(req)
+ props <- extractProperties(req)
+ component <- factory.create(alias, props, ctx)
+ result <- invokeHandler(component, handlerName)
+ response <- buildHtmxResponse(result)
+ yield response
+ }
+ )
+
+ private def invokeHandler(
+ component: SummerComponent,
+ handlerName: String
+ ): IO[ComponentError, HtmxResponse] =
+ // Validate handler name starts with "on"
+ if !handlerName.startsWith("on") then
+ ZIO.fail(InvalidHandler(s"Handler must start with 'on': $handlerName"))
+ else
+ // Use reflection or macro to invoke handler method
+ component.invokeHandler(handlerName)
+```
+
+### YAML Property Schema Parser
+```scala
+// Source: circe-yaml + WinterCMS Inspector format
+import io.circe.*
+import io.circe.generic.auto.*
+import io.circe.yaml.parser
+
+case class ComponentYaml(
+ name: String,
+ description: String,
+ icon: Option[String] = None,
+ properties: Map[String, PropertyDefYaml] = Map.empty
+)
+
+case class PropertyDefYaml(
+ `type`: String,
+ title: String,
+ description: Option[String] = None,
+ default: Option[Json] = None,
+ options: Option[Map[String, String]] = None,
+ group: Option[String] = None,
+ showExternalParam: Option[Boolean] = None,
+ validation: Option[ValidationYaml] = None
+)
+
+def parseComponentYaml(path: Path): IO[YamlError, ComponentSchema] =
+ for
+ content <- ZIO.attemptBlocking(Files.readString(path))
+ .mapError(e => YamlError.ReadError(path, e))
+ yaml <- ZIO.fromEither(parser.parse(content))
+ .mapError(e => YamlError.ParseError(path, e))
+ schema <- ZIO.fromEither(yaml.as[ComponentYaml])
+ .mapError(e => YamlError.DecodeError(path, e))
+ yield toComponentSchema(schema)
+
+def toComponentSchema(yaml: ComponentYaml): ComponentSchema =
+ ComponentSchema(
+ name = yaml.name,
+ description = yaml.description,
+ icon = yaml.icon,
+ properties = yaml.properties.map { case (name, prop) =>
+ name -> PropertyDef(
+ `type` = PropertyType.valueOf(prop.`type`.capitalize),
+ title = prop.title,
+ description = prop.description,
+ default = prop.default.flatMap(parsePropertyValue),
+ options = prop.options,
+ group = prop.group,
+ showExternalParam = prop.showExternalParam.getOrElse(false),
+ validation = prop.validation.map(parseValidation)
+ )
+ }
+ )
+```
+
+### Pebble Custom Functions for Components
+```scala
+// Source: Pebble extension API
+import io.pebbletemplates.pebble.extension.*
+import io.pebbletemplates.pebble.template.EvaluationContext
+
+class SummerPebbleExtension(
+ themeService: ThemeService,
+ componentContext: ComponentContext
+) extends AbstractExtension:
+
+ override def getFunctions: java.util.Map[String, Function] =
+ Map(
+ "asset" -> new AssetFunction(themeService),
+ "componentHandler" -> new ComponentHandlerFunction(componentContext),
+ "csrfToken" -> new CsrfTokenFunction(componentContext)
+ ).asJava
+
+ override def getFilters: java.util.Map[String, Filter] =
+ Map(
+ "_" -> new TranslateFilter(componentContext) // {{ 'text'|_ }}
+ ).asJava
+
+class ComponentHandlerFunction(ctx: ComponentContext) extends Function:
+ override def getArgumentNames = List("handler").asJava
+
+ override def execute(
+ args: java.util.Map[String, Object],
+ self: PebbleTemplate,
+ context: EvaluationContext,
+ lineNumber: Int
+ ): Object =
+ val handler = args.get("handler").toString
+ val alias = context.getVariable("__SELF__").toString
+ s"/summer/component/$alias/$handler"
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| AJAX with JSON responses | HTMX with HTML fragments | 2020+ | Simpler, less JS, server-rendered |
+| SPA with React/Vue | Server-side + HTMX | 2023+ | Lower complexity for CMS-style apps |
+| Custom templating | Pebble (Twig-compatible) | Established | Migration from WinterCMS themes |
+| Callback-based handlers | ZIO effect-based handlers | ZIO 2.0 | Type-safe async, resource safety |
+| Session-based CSRF | Per-request tokens + SameSite | 2024+ | Better security, stateless option |
+| Polling for updates | Server-Sent Events | 2020+ | Efficient real-time, browser-native |
+
+**Deprecated/outdated:**
+- jQuery AJAX: Use HTMX data attributes instead
+- PHP component classes: Scala traits with ZIO
+- `defineProperties()` method: YAML file at `component.yaml`
+- Synchronous handlers: All handlers return `ZIO`
+
+## Open Questions
+
+Things that couldn't be fully resolved:
+
+1. **Template Hot-Reload in Development**
+ - What we know: Pebble caches compiled templates
+ - What's unclear: Best pattern for dev-mode reloading without restart
+ - Recommendation: Use Pebble's cache.invalidate() on file change, integrate with Mill -w
+
+2. **Component Inheritance Depth**
+ - What we know: CONTEXT.md allows component inheritance
+ - What's unclear: How deep inheritance should go, multiple inheritance
+ - Recommendation: Single-level inheritance only (extend one base), use composition for reuse
+
+3. **Property Type Extensibility**
+ - What we know: WinterCMS has fixed property types (string, dropdown, etc.)
+ - What's unclear: How plugins can define custom property types
+ - Recommendation: Start with WinterCMS types, add extension mechanism in v2 if needed
+
+4. **Pebble vs ScalaTags for Fragments**
+ - What we know: Pebble for templates, ScalaTags for programmatic HTML
+ - What's unclear: When to use which, can they interop?
+ - Recommendation: Pebble for component templates, ScalaTags for helper methods returning `Html`
+
+5. **HTMX Extension Loading**
+ - What we know: HTMX has extensions (sse, json-enc, etc.)
+ - What's unclear: How to manage extension loading per-component
+ - Recommendation: Load common extensions in base layout, document per-component needs
+
+## Sources
+
+### Primary (HIGH confidence)
+- [ZIO HTTP HTMX Module](https://index.scala-lang.org/zio/zio-http/artifacts/zio-http-htmx/3.3.0) - Official module, version 3.3.0
+- [ZIO HTTP SSE Examples](https://ziohttp.com/examples/server-sent-events-in-endpoints/) - Server-Sent Events pattern
+- [Pebble Templates](https://pebbletemplates.io/) - Twig-compatible templating, version 4.1.1
+- [htmx4s Library](https://github.com/eikek/htmx4s) - Type-safe HTMX constants for Scala
+- [ScalaTags Documentation](https://com-lihaoyi.github.io/scalatags/) - Programmatic HTML generation
+- [HTMX OOB Swaps](https://htmx.org/attributes/hx-swap-oob/) - Out-of-band swap patterns
+- [HTMX CSRF Patterns](https://htmx.org/essays/web-security-basics-with-htmx/) - Security best practices
+
+### Secondary (MEDIUM confidence)
+- [rockthejvm/scalatags-htmx-demo](https://github.com/rockthejvm/scalatags-htmx-demo) - ZIO + ScalaTags + HTMX architecture
+- WinterCMS ComponentBase.php - Reference implementation (local file)
+- WinterCMS Inspector documentation - Property schema format (local file)
+- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) - Token patterns
+
+### Tertiary (LOW confidence)
+- Phase 2 Research - circe-yaml patterns (reuse)
+- Phase 1 Research - ZIO service patterns (reuse)
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH - zio-http-htmx and Pebble verified via official sources
+- Architecture patterns: HIGH - Based on WinterCMS reference + ZIO official patterns
+- HTMX integration: HIGH - Official HTMX docs and zio-http module
+- Template engine: MEDIUM - Pebble is established but interop with ZIO needs validation
+- Property system: MEDIUM - Adapted from WinterCMS, Scala-specific validation untested
+
+**Research date:** 2026-02-05
+**Valid until:** 2026-03-05 (30 days - stable patterns, Pebble/HTMX established)