diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog
 
+## 0.1.0.1
+
+* Add example apps: hello-world (warp/servant/channel), activity-feed, heap-view
+* Add DatastarLogger to sseResponse for configurable logging
+* Add ExecuteScript and PatchSignals test specs
+* Move repo to starfederation/datastar-haskell
+* Add hie.yaml for HLS multi-component support
+
 ## 0.1.0.0
 
 * Initial release.
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,7 @@
-MIT License
-
-Copyright (c) 2026 Carlo Hamalainen
+Copyright © Star Federation
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
 
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,71 @@
+<p align="center"><img width="150" height="150" src="https://data-star.dev/static/images/rocket-512x512.png"></p>
+
+# Datastar Haskell SDK
+
+[![Test](https://github.com/starfederation/datastar-haskell/actions/workflows/test.yml/badge.svg)](https://github.com/starfederation/datastar-haskell/actions/workflows/test.yml)
+
+A Haskell implementation of the [Datastar](https://data-star.dev/) SDK for building real-time hypermedia applications with server-sent events (SSE).
+
+Live examples: <https://hamalainen.dev>
+
+## License
+
+This package is licensed for free under the [MIT License](LICENSE).
+
+## Design
+
+The SDK is built on [WAI](https://github.com/yesodweb/wai) (Web Application
+Interface), Haskell's standard interface for HTTP servers. This means it works
+with any WAI-compatible server (Warp, etc.) and any framework built on WAI
+(Yesod, Scotty, Servant, etc.) without framework-specific adapters.
+
+Key design decisions:
+
+- **Minimal dependencies** -- the library depends only on `aeson`, `bytestring`,
+`http-types`, `text`, and `wai`.
+- **WAI streaming** -- SSE responses use WAI's native `responseStream`, giving
+you a `ServerSentEventGenerator` callback with `sendPatchElements`,
+`sendPatchSignals`, and `sendExecuteScript`.
+- **No routing opinion** -- the SDK provides request helpers (`readSignals`,
+`isDatastarRequest`) but doesn't impose a routing framework. The examples use
+simple pattern matching on `(requestMethod, pathInfo)`.
+
+## API Overview
+
+```haskell
+import Hypermedia.Datastar
+
+-- Create an SSE response
+sseResponse :: DatastarLogger -> (ServerSentEventGenerator -> IO ()) -> Response
+
+-- Send events
+sendPatchElements  :: ServerSentEventGenerator -> PatchElements  -> IO ()
+sendPatchSignals   :: ServerSentEventGenerator -> PatchSignals   -> IO ()
+sendExecuteScript  :: ServerSentEventGenerator -> ExecuteScript  -> IO ()
+
+-- Read signals from a request (query string for GET, body for POST)
+readSignals :: FromJSON a => Request -> IO (Either String a)
+```
+
+## Quick Start
+
+Add `datastar-hs` to your `build-depends`, then:
+
+```haskell
+import Hypermedia.Datastar
+import Network.Wai
+import Network.Wai.Handler.Warp qualified as Warp
+
+app :: Application
+app req respond =
+  case (requestMethod req, pathInfo req) of
+    ("GET", ["hello"]) -> do
+      Right signals <- readSignals req
+      respond $ sseResponse nullLogger $ \gen -> do
+        sendPatchElements gen (patchElements "<div id=\"message\">Hello!</div>")
+    _ ->
+      respond $ responseLBS status404 [] "Not found"
+
+main :: IO ()
+main = Warp.run 3000 app
+```
diff --git a/datastar-hs.cabal b/datastar-hs.cabal
--- a/datastar-hs.cabal
+++ b/datastar-hs.cabal
@@ -1,6 +1,6 @@
 cabal-version:   3.0
 name:            datastar-hs
-version:         0.1.0.0
+version:         0.1.0.1
 synopsis:        Haskell bindings for Datastar
 description:
   Server-side SDK for building real-time hypermedia applications with
@@ -8,16 +8,20 @@
   signal updates, and scripts to the browser over server-sent events
   (SSE). Built on WAI so it works with Warp, Scotty, Servant, Yesod,
   and any other WAI-compatible framework.
-homepage:        https://github.com/carlohamalainen/datastar-hs
-bug-reports:     https://github.com/carlohamalainen/datastar-hs/issues
+homepage:        https://github.com/starfederation/datastar-haskell
+bug-reports:     https://github.com/starfederation/datastar-haskell/issues
 license:         MIT
 license-file:    LICENSE
 author:          Carlo Hamalainen
 maintainer:      carlo@carlo-hamalainen.net
 category:        Web, Hypermedia
 build-type:      Simple
-extra-doc-files: CHANGELOG.md
+extra-doc-files: CHANGELOG.md README.md
 
+source-repository head
+  type:     git
+  location: https://github.com/starfederation/datastar-haskell.git
+
 library
   exposed-modules:
     Hypermedia.Datastar
@@ -45,7 +49,9 @@
   hs-source-dirs:   test
   main-is:          Main.hs
   other-modules:
+    Hypermedia.Datastar.ExecuteScriptSpec
     Hypermedia.Datastar.PatchElementsSpec
+    Hypermedia.Datastar.PatchSignalsSpec
     Hypermedia.Datastar.SSESpec
   build-depends:
     , base
@@ -58,3 +64,105 @@
   default-extensions:
     ImportQualifiedPost
     OverloadedStrings
+
+executable hello-world-warp
+  main-is:          hello-world-warp.hs
+  hs-source-dirs:   examples
+  default-language:  Haskell2010
+  default-extensions:
+    ImportQualifiedPost
+    OverloadedStrings
+  build-depends:
+    , base >= 4.7
+    , aeson
+    , bytestring
+    , http-types
+    , text
+    , wai
+    , warp              >= 3.2   && < 4
+    , datastar-hs
+  ghc-options: -threaded
+
+executable hello-world-servant
+  main-is:          hello-world-servant.hs
+  hs-source-dirs:   examples
+  default-language:  Haskell2010
+  default-extensions:
+    DataKinds
+    ImportQualifiedPost
+    MultiParamTypeClasses
+    OverloadedStrings
+    TypeOperators
+  build-depends:
+    , base >= 4.7
+    , aeson
+    , bytestring
+    , http-media        >= 0.8   && < 1
+    , http-types
+    , servant-server    >= 0.19  && < 1
+    , tagged            >= 0.8   && < 1
+    , text
+    , wai
+    , warp              >= 3.2   && < 4
+    , datastar-hs
+  ghc-options: -threaded
+
+executable hello-world-channel
+  main-is:          hello-world-channel.hs
+  hs-source-dirs:   examples
+  default-language:  Haskell2010
+  default-extensions:
+    ImportQualifiedPost
+    OverloadedStrings
+  build-depends:
+    , base >= 4.7
+    , aeson
+    , bytestring
+    , http-types
+    , stm               >= 2.4   && < 3
+    , text
+    , wai
+    , warp              >= 3.2   && < 4
+    , datastar-hs
+  ghc-options: -threaded
+
+executable activity-feed
+  main-is:          activity-feed.hs
+  hs-source-dirs:   examples
+  default-language:  Haskell2010
+  default-extensions:
+    ImportQualifiedPost
+    OverloadedStrings
+  build-depends:
+    , base >= 4.7
+    , aeson
+    , bytestring
+    , text
+    , time              >= 1.9   && < 2
+    , http-types
+    , wai
+    , warp              >= 3.2   && < 4
+    , datastar-hs
+  ghc-options: -threaded
+
+executable heap-view
+  main-is:          heap-view.hs
+  hs-source-dirs:   examples
+  default-language:  Haskell2010
+  default-extensions:
+    ImportQualifiedPost
+    OverloadedStrings
+    ScopedTypeVariables
+  build-depends:
+    , base >= 4.7
+    , bytestring
+    , containers        >= 0.6   && < 1
+    , ghc-heap          >= 9.0   && < 10
+    , http-types
+    , stm               >= 2.4   && < 3
+    , text
+    , uuid              >= 1.3   && < 2
+    , wai
+    , warp              >= 3.2   && < 4
+    , datastar-hs
+  ghc-options: -threaded -O0
diff --git a/examples/activity-feed.hs b/examples/activity-feed.hs
new file mode 100644
--- /dev/null
+++ b/examples/activity-feed.hs
@@ -0,0 +1,148 @@
+module Main (main) where
+
+import Control.Concurrent (threadDelay)
+import Data.Aeson (FromJSON (..), withObject, (.:))
+import Data.ByteString qualified as BS
+import Data.ByteString.Char8 qualified as BS8
+import Data.ByteString.Lazy qualified as LBS
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Time.Clock (getCurrentTime)
+import Data.Time.Format (defaultTimeLocale, formatTime)
+import Hypermedia.Datastar
+import Network.HTTP.Types (status200, status404)
+import Network.Wai (Application, pathInfo, requestMethod, responseLBS)
+import Network.Wai qualified as Wai
+import Network.Wai.Handler.Warp qualified as Warp
+import System.Environment (getArgs)
+
+data Signals = Signals
+  { _sInterval :: Int
+  , _sEvents :: Int
+  , _sGenerating :: Bool
+  , _sTotal :: Int
+  , _sDone :: Int
+  , _sWarn :: Int
+  , _sFail :: Int
+  , _sInfo :: Int
+  }
+
+instance FromJSON Signals where
+  parseJSON = withObject "Signals" $ \o ->
+    Signals
+      <$> o .: "interval"
+      <*> o .: "events"
+      <*> o .: "generating"
+      <*> o .: "total"
+      <*> o .: "done"
+      <*> o .: "warn"
+      <*> o .: "fail"
+      <*> o .: "info"
+
+data Status = Done | Warn | Fail | Info
+
+statusFromText :: Text -> Maybe Status
+statusFromText "done" = Just Done
+statusFromText "warn" = Just Warn
+statusFromText "fail" = Just Fail
+statusFromText "info" = Just Info
+statusFromText _ = Nothing
+
+statusColor :: Status -> Text
+statusColor Done = "green"
+statusColor Warn = "yellow"
+statusColor Fail = "red"
+statusColor Info = "blue"
+
+statusIndicator :: Status -> Text
+statusIndicator Done = "Done"
+statusIndicator Warn = "Warn"
+statusIndicator Fail = "Fail"
+statusIndicator Info = "Info"
+
+eventEntry :: Status -> Int -> Text -> IO Text
+eventEntry status index source = do
+  now <- getCurrentTime
+  let timestamp = T.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S%3Q" now
+      color = statusColor status
+      indicator = statusIndicator status
+  pure $
+    "<div id='event-"
+      <> T.pack (show index)
+      <> "' class='text-"
+      <> color
+      <> "-500'>"
+      <> timestamp
+      <> " [ "
+      <> indicator
+      <> " ] "
+      <> source
+      <> " event "
+      <> T.pack (show index)
+      <> "</div>"
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let port = case args of
+        (p : _) -> read p
+        _ -> 3000
+  htmlContent <- BS.readFile "examples/activity-feed.html"
+  putStrLn $ "Listening on http://localhost:" <> show port
+  Warp.run port (app htmlContent)
+
+app :: BS.ByteString -> Application
+app htmlContent req respond =
+  case (requestMethod req, pathInfo req) of
+    ("GET", []) ->
+      respond $ responseLBS status200 [("Content-Type", "text/html")] (LBS.fromStrict htmlContent)
+    ("POST", ["event", "generate"]) ->
+      handleGenerate req respond
+    ("POST", ["event", statusText])
+      | Just status <- statusFromText statusText ->
+          handleEvent status req respond
+    _ ->
+      respond $ responseLBS status404 [] "Not found"
+
+handleGenerate :: Wai.Request -> (Wai.Response -> IO b) -> IO b
+handleGenerate req respond = do
+  signalsResult <- readSignals req :: IO (Either String Signals)
+  case signalsResult of
+    Left err -> respond $ responseLBS status404 [] (LBS.fromStrict $ BS8.pack $ "Bad signals: " <> err)
+    Right signals -> respond $ sseResponse nullLogger $ \gen -> do
+      sendPatchSignals gen (patchSignals "{\"generating\": true}")
+
+      let loop 0 _ _ = pure ()
+          loop n total' done' = do
+            let newTotal = total' + 1
+                newDone = done' + 1
+            html <- eventEntry Done newTotal "Auto"
+            sendPatchElements gen $
+              (patchElements html){peSelector = Just "#feed", peMode = After}
+            sendPatchSignals gen $
+              patchSignals $
+                "{\"total\": " <> T.pack (show newTotal) <> ", \"done\": " <> T.pack (show newDone) <> "}"
+            threadDelay (_sInterval signals * 1000)
+            loop (n - 1) newTotal newDone
+
+      loop (_sEvents signals) (_sTotal signals) (_sDone signals)
+
+      sendPatchSignals gen (patchSignals "{\"generating\": false}")
+
+handleEvent :: Status -> Wai.Request -> (Wai.Response -> IO b) -> IO b
+handleEvent status req respond = do
+  signalsResult <- readSignals req :: IO (Either String Signals)
+  case signalsResult of
+    Left err -> respond $ responseLBS status404 [] (LBS.fromStrict $ BS8.pack $ "Bad signals: " <> err)
+    Right signals -> respond $ sseResponse nullLogger $ \gen -> do
+      let newTotal = _sTotal signals + 1
+          counterSignals = case status of
+            Done -> "{\"total\": " <> T.pack (show newTotal) <> ", \"done\": " <> T.pack (show (_sDone signals + 1)) <> "}"
+            Warn -> "{\"total\": " <> T.pack (show newTotal) <> ", \"warn\": " <> T.pack (show (_sWarn signals + 1)) <> "}"
+            Fail -> "{\"total\": " <> T.pack (show newTotal) <> ", \"fail\": " <> T.pack (show (_sFail signals + 1)) <> "}"
+            Info -> "{\"total\": " <> T.pack (show newTotal) <> ", \"info\": " <> T.pack (show (_sInfo signals + 1)) <> "}"
+      sendPatchSignals gen (patchSignals counterSignals)
+
+      html <- eventEntry status newTotal "Manual"
+      sendPatchElements gen $
+        (patchElements html){peSelector = Just "#feed", peMode = After}
diff --git a/examples/heap-view.hs b/examples/heap-view.hs
new file mode 100644
--- /dev/null
+++ b/examples/heap-view.hs
@@ -0,0 +1,558 @@
+{- HLINT ignore "Use head" -}
+{- HLINT ignore "Use void" -}
+module Main (main) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO, writeTVar)
+import Control.Exception (SomeException, catch, evaluate)
+import Control.Monad (replicateM_)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Set (Set)
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.UUID (UUID)
+import Data.UUID qualified as UUID
+import Data.UUID.V4 qualified as UUID
+import GHC.Exts.Heap (Box (..), GenClosure (..), asBox, getClosureData)
+import Hypermedia.Datastar
+import Network.HTTP.Types (queryToQueryText, status200, status400, status404)
+import Network.Wai (Application, Request, Response, pathInfo, queryString, requestMethod, responseLBS)
+import Network.Wai.Handler.Warp qualified as Warp
+import System.Environment (getArgs)
+import System.Exit (exitFailure)
+import System.IO (hPutStrLn, stderr)
+import System.Mem (performGC)
+
+-- Per-user session state
+data Session = Session
+  { sessId :: UUID
+  , sessExpression :: TVar Box
+  , sessExprDesc :: TVar Text
+  , sessBoxMap :: TVar (Map Text Box)
+  }
+
+-- Shared application state
+data AppState = AppState
+  { appSessions :: TVar (Map UUID Session)
+  , appMode :: Mode
+  , appHasRun :: Bool
+  }
+
+-- Mode configuration
+data Mode = Mode
+  { modeName :: Text
+  , modeDesc :: Text
+  , modeSetup :: Session -> IO ()
+  , modeRun :: Maybe (Session -> IO ())
+  }
+
+-- Expression constructors - NOINLINE + () argument ensures each call
+-- allocates fresh thunks at -O0 (which heap-view uses). The () forces
+-- function entry, and at -O0 GHC doesn't float subexpressions out as CAFs.
+-- An IO [Int] with `pure $` does NOT work: the thunk is part of the CAF
+-- and shared across all calls, so forced thunks stay forced after reset.
+
+mkSimpleExpr :: () -> [Int]
+mkSimpleExpr () = [1, 2, 3, 4, 5] ++ map (* 10) [6, 7, 8]
+{-# NOINLINE mkSimpleExpr #-}
+
+mkMapExpr :: () -> [Int]
+mkMapExpr () = map (* 2) [1 .. 10]
+{-# NOINLINE mkMapExpr #-}
+
+mkFibsExpr :: () -> [Int]
+mkFibsExpr () = take 15 fibs
+ where
+  fibs :: [Int]
+  fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
+{-# NOINLINE mkFibsExpr #-}
+
+-- Available modes
+
+simpleList :: Mode
+simpleList =
+  Mode
+    { modeName = "simple-list"
+    , modeDesc = "[1,2,3,4,5] ++ map (*10) [6,7,8]"
+    , modeSetup = \sess -> do
+        let expr = mkSimpleExpr ()
+        _ <- evaluate expr
+        atomically $ do
+          writeTVar (sessExpression sess) (asBox expr)
+          writeTVar (sessExprDesc sess) "[1,2,3,4,5] ++ map (*10) [6,7,8]"
+    , modeRun = Nothing
+    }
+
+liveMap :: Mode
+liveMap =
+  Mode
+    { modeName = "live-map"
+    , modeDesc = "map (*2) [1..10]"
+    , modeSetup = \sess -> do
+        let expr = mkMapExpr ()
+        atomically $ do
+          writeTVar (sessExpression sess) (asBox expr)
+          writeTVar (sessExprDesc sess) "map (*2) [1..10]"
+    , modeRun = Just $ \sess -> do
+        let expr = mkMapExpr ()
+        atomically $ do
+          writeTVar (sessExpression sess) (asBox expr)
+          writeTVar (sessExprDesc sess) "map (*2) [1..10]"
+        _ <- forkIO $ forceListSlowly expr
+        pure ()
+    }
+
+liveFibs :: Mode
+liveFibs =
+  Mode
+    { modeName = "live-fibs"
+    , modeDesc = "take 15 fibs"
+    , modeSetup = \sess -> do
+        let expr = mkFibsExpr ()
+        atomically $ do
+          writeTVar (sessExpression sess) (asBox expr)
+          writeTVar (sessExprDesc sess) "take 15 fibs"
+    , modeRun = Just $ \sess -> do
+        let expr = mkFibsExpr ()
+        atomically $ do
+          writeTVar (sessExpression sess) (asBox expr)
+          writeTVar (sessExprDesc sess) "take 15 fibs"
+        _ <- forkIO $ forceListSlowly expr
+        pure ()
+    }
+
+allModes :: [(String, Mode)]
+allModes =
+  [ ("simple-list", simpleList)
+  , ("live-map", liveMap)
+  , ("live-fibs", liveFibs)
+  ]
+
+-- Session management
+
+newSession :: AppState -> IO Session
+newSession appState = do
+  sid <- UUID.nextRandom
+  sess <-
+    Session sid
+      <$> newTVarIO (asBox ())
+      <*> newTVarIO ""
+      <*> newTVarIO Map.empty
+  atomically $ modifyTVar' (appSessions appState) (Map.insert sid sess)
+  pure sess
+
+lookupSession :: AppState -> UUID -> IO (Maybe Session)
+lookupSession appState sid =
+  Map.lookup sid <$> readTVarIO (appSessions appState)
+
+getSessionId :: Request -> Maybe UUID
+getSessionId req =
+  case lookup "s" (queryToQueryText (queryString req)) of
+    Just (Just s) -> UUID.fromText s
+    _ -> Nothing
+
+withSession :: AppState -> Request -> (Response -> IO b) -> (Session -> IO b) -> IO b
+withSession appState req respond action =
+  case getSessionId req of
+    Just sid -> do
+      msess <- lookupSession appState sid
+      case msess of
+        Just sess -> action sess
+        Nothing -> respond $ responseLBS status404 [] "Session not found"
+    Nothing -> respond $ responseLBS status400 [] "Missing session"
+
+-- Force a list spine + elements one by one with delay
+forceListSlowly :: [Int] -> IO ()
+forceListSlowly [] = pure ()
+forceListSlowly (x : xs) = do
+  _ <- evaluate x
+  threadDelay 1000000
+  forceListSlowly xs
+
+-- Heap node representation
+data HeapNode = HeapNode
+  { nodeType :: Text
+  , nodeValue :: Maybe Text
+  , nodePointers :: [Text]
+  }
+
+-- Get closure data from a Box (unwrap the Box to see what's inside)
+getBoxClosure :: Box -> IO (GenClosure Box)
+getBoxClosure (Box a) = getClosureData a
+
+boxAddr :: Box -> Text
+boxAddr = T.pack . show
+
+-- Walk the heap from a root Box via DFS
+walkHeap :: Session -> Box -> Int -> IO (Text, Map Text HeapNode)
+walkHeap sess startBox maxDepth = do
+  nodesRef <- newTVarIO Map.empty
+  boxMapRef <- newTVarIO Map.empty
+  visitedRef <- newTVarIO Set.empty
+
+  walkNode nodesRef boxMapRef visitedRef startBox 0
+
+  nodes <- readTVarIO nodesRef
+  boxMap <- readTVarIO boxMapRef
+  atomically $ writeTVar (sessBoxMap sess) boxMap
+
+  pure (boxAddr startBox, nodes)
+ where
+  walkNode nodesRef boxMapRef visitedRef box depth
+    | depth > maxDepth = pure ()
+    | otherwise = do
+        let addr = boxAddr box
+        visited <- readTVarIO visitedRef
+        if Set.member addr visited
+          then pure ()
+          else do
+            atomically $ do
+              modifyTVar' visitedRef (Set.insert addr)
+              modifyTVar' boxMapRef (Map.insert addr box)
+
+            closure <- getBoxClosure box
+
+            -- Follow indirections transparently
+            closure' <- case closure of
+              IndClosure{indirectee = ptr} -> getBoxClosure ptr
+              BlackholeClosure{indirectee = ptr} -> getBoxClosure ptr
+              _ -> pure closure
+
+            case closure' of
+              ConstrClosure{ptrArgs = ptrs, dataArgs = dargs, name = n, modl = m} -> do
+                let fullName = T.pack m <> "." <> T.pack n
+                    ptrAddrs = map boxAddr ptrs
+                if length ptrs >= 2 && T.pack n == ":"
+                  then do
+                    -- Cons cell: first ptr is head, second is tail
+                    let headBox = ptrs !! 0
+                        tailBox = ptrs !! 1
+                    headVal <- getHeadValue headBox
+                    let node = HeapNode "cons" headVal [boxAddr tailBox]
+                    atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                    walkNode nodesRef boxMapRef visitedRef headBox (depth + 1)
+                    walkNode nodesRef boxMapRef visitedRef tailBox (depth + 1)
+                  else
+                    if null ptrs && (T.pack n == "[]")
+                      then do
+                        let node = HeapNode "nil" Nothing []
+                        atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                      else do
+                        let val = case dargs of
+                              [] -> Nothing
+                              [v] -> Just (T.pack (show v))
+                              vs -> Just (T.pack (show vs))
+                        let node = HeapNode ("constr:" <> fullName) val ptrAddrs
+                        atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                        mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
+              ThunkClosure{ptrArgs = ptrs} -> do
+                let ptrAddrs = map boxAddr ptrs
+                    node = HeapNode "thunk" Nothing ptrAddrs
+                atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
+              FunClosure{ptrArgs = ptrs} -> do
+                let ptrAddrs = map boxAddr ptrs
+                    node = HeapNode "function" Nothing ptrAddrs
+                atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
+              PAPClosure{payload = ptrs} -> do
+                let ptrAddrs = map boxAddr ptrs
+                    node = HeapNode "pap" Nothing ptrAddrs
+                atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
+              APClosure{payload = ptrs} -> do
+                let ptrAddrs = map boxAddr ptrs
+                    node = HeapNode "ap" Nothing ptrAddrs
+                atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                mapM_ (\p -> walkNode nodesRef boxMapRef visitedRef p (depth + 1)) ptrs
+              SelectorClosure{selectee = ptr} -> do
+                let node = HeapNode "selector" Nothing [boxAddr ptr]
+                atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                walkNode nodesRef boxMapRef visitedRef ptr (depth + 1)
+              MutVarClosure{var = ptr} -> do
+                let node = HeapNode "mutvar" Nothing [boxAddr ptr]
+                atomically $ modifyTVar' nodesRef (Map.insert addr node)
+                walkNode nodesRef boxMapRef visitedRef ptr (depth + 1)
+              _ -> do
+                let node = HeapNode "other" (Just (T.pack (take 80 (show closure')))) []
+                atomically $ modifyTVar' nodesRef (Map.insert addr node)
+
+  -- Try to extract a displayable value from a cons head pointer
+  getHeadValue :: Box -> IO (Maybe Text)
+  getHeadValue box = do
+    c <- getBoxClosure box
+    pure $ case c of
+      ConstrClosure{dataArgs = (v : _), name = n}
+        | n == "I#" -> Just (T.pack (show v))
+        | n == "W#" -> Just (T.pack (show v))
+        | n == "C#" -> Just (T.pack (show (toEnum (fromIntegral v) :: Char)))
+        | otherwise -> Just (T.pack n <> " " <> T.pack (show v))
+      _ -> Nothing
+
+-- Force a thunk by its Box address
+forceThunk :: Session -> Text -> IO ()
+forceThunk sess addr = do
+  boxMap <- readTVarIO (sessBoxMap sess)
+  case Map.lookup addr boxMap of
+    Nothing -> putStrLn $ "Box not found: " <> T.unpack addr
+    Just (Box a) -> do
+      _ <-
+        (evaluate a >> pure ())
+          `catch` \(e :: SomeException) ->
+            putStrLn $ "Exception while forcing: " <> show e
+      pure ()
+
+-- Render the heap as an HTML table
+renderHeapTable :: UUID -> Bool -> Text -> Text -> Map Text HeapNode -> Text
+renderHeapTable sid showRunBtn exprDesc rootAddr nodes =
+  "<div id='main' class='max-w-5xl mx-auto'>"
+    <> "<div class='flex items-center justify-between mb-6'>"
+    <> "<h1 class='text-2xl font-bold text-gray-900 dark:text-gray-100'>GHC Heap Visualizer</h1>"
+    <> "<div class='flex gap-2'>"
+    <> runButton
+    <> "<button data-on:click=\"@get('reset?s="
+    <> s
+    <> "')\" "
+    <> "class='px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium cursor-pointer'>"
+    <> "Reset</button>"
+    <> "<button data-on:click='$dark = !$dark' "
+    <> "class='px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium cursor-pointer'>"
+    <> "<span data-show='$dark'>Light</span>"
+    <> "<span data-show='!$dark'>Dark</span>"
+    <> "</button>"
+    <> "<button data-on:click=\"@get('heap?s="
+    <> s
+    <> "')\" "
+    <> "class='px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium cursor-pointer'>"
+    <> "Refresh</button>"
+    <> "</div></div>"
+    <> "<div class='text-sm text-gray-500 mb-4'>Expression: <code class='text-gray-600 dark:text-gray-400'>"
+    <> exprDesc
+    <> "</code>"
+    <> " &middot; Root: <code class='text-gray-600 dark:text-gray-400'>"
+    <> cleanAddr rootAddr
+    <> "</code>"
+    <> " &middot; "
+    <> T.pack (show (Map.size nodes))
+    <> " nodes</div>"
+    <> "<div class='overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-800'>"
+    <> "<table class='w-full text-sm'>"
+    <> "<thead><tr class='bg-gray-100 dark:bg-gray-900 text-gray-500 dark:text-gray-400 text-left'>"
+    <> "<th class='px-4 py-3 font-medium'>Address</th>"
+    <> "<th class='px-4 py-3 font-medium'>Type</th>"
+    <> "<th class='px-4 py-3 font-medium'>Value</th>"
+    <> "<th class='px-4 py-3 font-medium'>Pointers</th>"
+    <> "<th class='px-4 py-3 font-medium'>Actions</th>"
+    <> "</tr></thead><tbody>"
+    <> mconcat (map renderRow orderedNodes)
+    <> "</tbody></table></div></div>"
+ where
+  s = UUID.toText sid
+
+  runButton
+    | showRunBtn =
+        "<button data-on:click=\"@get('run?s="
+          <> s
+          <> "')\" "
+          <> "class='px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium cursor-pointer'>"
+          <> "Run Demo</button>"
+    | otherwise = ""
+
+  -- Root first, then the rest sorted by address
+  orderedNodes =
+    let rootNode = case Map.lookup rootAddr nodes of
+          Just n -> [(rootAddr, n)]
+          Nothing -> []
+        rest = Map.toAscList (Map.delete rootAddr nodes)
+     in rootNode <> rest
+
+  renderRow (addr, node) =
+    let isRoot = addr == rootAddr
+        ca = cleanAddr addr
+        rowClass =
+          if isRoot
+            then "bg-blue-50 dark:bg-gray-800/50 border-l-2 border-blue-500"
+            else "border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-50 dark:hover:bg-gray-800/30"
+        highlight =
+          " data-class=\"{"
+            <> "'ring-2 ring-inset ring-cyan-400 dark:ring-cyan-500 bg-cyan-50 dark:bg-cyan-900/30': $highlight === '"
+            <> ca
+            <> "'"
+            <> "}\""
+     in "<tr id='addr-"
+          <> ca
+          <> "' class='"
+          <> rowClass
+          <> "'"
+          <> highlight
+          <> ">"
+          <> "<td class='px-4 py-2.5 font-mono text-xs text-gray-500'>"
+          <> ca
+          <> (if isRoot then " <span class='text-blue-500 dark:text-blue-400 text-xs font-sans'>(root)</span>" else "")
+          <> "</td>"
+          <> "<td class='px-4 py-2.5'>"
+          <> typeBadge (nodeType node)
+          <> "</td>"
+          <> "<td class='px-4 py-2.5 font-mono'>"
+          <> maybe "<span class='text-gray-400 dark:text-gray-600'>-</span>" (\v -> "<span class='text-gray-800 dark:text-gray-200'>" <> v <> "</span>") (nodeValue node)
+          <> "</td>"
+          <> "<td class='px-4 py-2.5 font-mono text-xs'>"
+          <> renderPointers (nodePointers node)
+          <> "</td>"
+          <> "<td class='px-4 py-2.5'>"
+          <> renderActions addr node
+          <> "</td>"
+          <> "</tr>"
+
+  cleanAddr addr = T.replace "/1" "" (T.replace "/2" "" addr)
+
+  typeBadge ty
+    | ty == "cons" = badge "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" ":"
+    | ty == "nil" = badge "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" "[]"
+    | ty == "thunk" = badge "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300" "thunk"
+    | ty == "function" = badge "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300" "fun"
+    | ty == "pap" = badge "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300" "PAP"
+    | ty == "ap" = badge "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300" "AP"
+    | ty == "selector" = badge "bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300" "sel"
+    | ty == "other" = badge "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" "other"
+    | "constr:GHC.Types.I#" `T.isPrefixOf` ty = badge "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300" "I#"
+    | "constr:" `T.isPrefixOf` ty = badge "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300" (T.drop 7 ty)
+    | otherwise = badge "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" ty
+
+  badge cls label =
+    "<span class='inline-block px-2 py-0.5 rounded text-xs font-medium "
+      <> cls
+      <> "'>"
+      <> label
+      <> "</span>"
+
+  renderPointers [] = "<span class='text-gray-400 dark:text-gray-600'>-</span>"
+  renderPointers ptrs = T.intercalate " " (map renderPtr ptrs)
+
+  renderPtr p =
+    let cp = cleanAddr p
+     in "<a href='#addr-"
+          <> cp
+          <> "'"
+          <> " data-on:mouseenter=\"$highlight = '"
+          <> cp
+          <> "'\""
+          <> " data-on:mouseleave=\"$highlight = ''\""
+          <> " class='text-cyan-700 dark:text-cyan-600 hover:underline cursor-pointer'>"
+          <> cp
+          <> "</a>"
+
+  renderActions addr node
+    | nodeType node == "thunk" || nodeType node == "ap" || nodeType node == "selector" =
+        "<button data-on:click=\"@get('force?s="
+          <> s
+          <> "&addr="
+          <> addr
+          <> "')\" "
+          <> "class='px-3 py-1 bg-amber-200 hover:bg-amber-300 text-amber-800 dark:bg-amber-800 dark:hover:bg-amber-700 dark:text-amber-200 rounded text-xs font-medium cursor-pointer'>"
+          <> "Force</button>"
+    | otherwise = ""
+
+-- Application
+
+main :: IO ()
+main = do
+  args <- getArgs
+  case args of
+    [name] | Just mode <- lookup name allModes -> startServer 3000 mode
+    [name, portStr] | Just mode <- lookup name allModes -> startServer (read portStr) mode
+    _ -> do
+      hPutStrLn stderr "Usage: heap-view <mode> [port]"
+      hPutStrLn stderr ""
+      hPutStrLn stderr "Modes:"
+      mapM_ (\(name, mode) -> hPutStrLn stderr $ "  " <> name <> replicate (16 - length name) ' ' <> T.unpack (modeDesc mode)) allModes
+      exitFailure
+
+startServer :: Int -> Mode -> IO ()
+startServer port mode = do
+  htmlContent <- BS.readFile "examples/heap-view.html"
+  let hasRun = case modeRun mode of Just _ -> True; Nothing -> False
+  appState <-
+    AppState
+      <$> newTVarIO Map.empty
+      <*> pure mode
+      <*> pure hasRun
+  putStrLn $ "GHC Heap Visualizer [" <> T.unpack (modeName mode) <> "]"
+  putStrLn $ "Listening on http://localhost:" <> show port
+  Warp.run port (app htmlContent appState)
+
+app :: BS.ByteString -> AppState -> Application
+app htmlContent appState req respond =
+  case (requestMethod req, pathInfo req) of
+    ("GET", []) ->
+      respond $ responseLBS status200 [("Content-Type", "text/html")] (LBS.fromStrict htmlContent)
+    ("GET", ["heap"]) ->
+      withSession appState req respond $ \sess ->
+        handleHeap appState sess respond
+    ("GET", ["force"]) ->
+      withSession appState req respond $ \sess ->
+        handleForce appState sess req respond
+    ("GET", ["run"])
+      | Just _ <- modeRun (appMode appState) ->
+          withSession appState req respond $ \sess ->
+            handleRun appState sess respond
+    ("GET", ["reset"]) ->
+      handleReset appState req respond
+    _ ->
+      respond $ responseLBS status404 [] "Not found"
+
+sendHeapUpdate :: AppState -> Session -> ServerSentEventGenerator -> IO ()
+sendHeapUpdate appState sess gen = do
+  box <- readTVarIO (sessExpression sess)
+  desc <- readTVarIO (sessExprDesc sess)
+  performGC
+  (rootAddr, nodes) <- walkHeap sess box 20
+  let html = renderHeapTable (sessId sess) (appHasRun appState) desc rootAddr nodes
+  sendPatchElements gen (patchElements html)
+
+handleHeap :: AppState -> Session -> (Response -> IO b) -> IO b
+handleHeap appState sess respond =
+  respond $ sseResponse nullLogger $ \gen ->
+    sendHeapUpdate appState sess gen
+
+handleForce :: AppState -> Session -> Request -> (Response -> IO b) -> IO b
+handleForce appState sess req respond = do
+  let params = queryToQueryText (queryString req)
+  case lookup "addr" params of
+    Just (Just addr) -> do
+      forceThunk sess addr
+      respond $ sseResponse nullLogger $ \gen ->
+        sendHeapUpdate appState sess gen
+    _ ->
+      respond $ responseLBS status400 [] "Missing addr parameter"
+
+handleReset :: AppState -> Request -> (Response -> IO b) -> IO b
+handleReset appState req respond = do
+  -- Reuse existing session on explicit reset, create new on first page load
+  sess <- case getSessionId req of
+    Just sid -> do
+      msess <- lookupSession appState sid
+      case msess of
+        Just s -> pure s
+        Nothing -> newSession appState
+    Nothing -> newSession appState
+  modeSetup (appMode appState) sess
+  respond $ sseResponse nullLogger $ \gen ->
+    sendHeapUpdate appState sess gen
+
+handleRun :: AppState -> Session -> (Response -> IO b) -> IO b
+handleRun appState sess respond = do
+  case modeRun (appMode appState) of
+    Just run -> do
+      run sess
+      respond $ sseResponse nullLogger $ \gen -> do
+        -- Stream live updates every 200ms for ~12 seconds
+        replicateM_ 60 $ do
+          sendHeapUpdate appState sess gen
+          threadDelay 200000
+    Nothing ->
+      respond $ responseLBS status404 [] "Not found"
diff --git a/examples/hello-world-channel.hs b/examples/hello-world-channel.hs
new file mode 100644
--- /dev/null
+++ b/examples/hello-world-channel.hs
@@ -0,0 +1,92 @@
+module Main (main) where
+
+import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVar, readTVarIO, registerDelay, retry, writeTVar)
+import Control.Monad (forever)
+import Data.Aeson (FromJSON (..), withObject, (.:))
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Text qualified as T
+import Hypermedia.Datastar
+import Network.HTTP.Types (status200, status404)
+import Network.Wai (Application, pathInfo, requestMethod, responseLBS)
+import Network.Wai qualified as Wai
+import Network.Wai.Handler.Warp qualified as Warp
+import System.Environment (getArgs)
+
+newtype Signals = Signals {_delay :: Int}
+  deriving (Show)
+
+instance FromJSON Signals where
+  parseJSON = withObject "Signals" $ \o ->
+    Signals <$> o .: "delay"
+
+data SharedState = SharedState
+  { _delayVar :: TVar Int
+  , _versionVar :: TVar Int
+  }
+
+message :: String
+message = "Hello, world!"
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let port = case args of
+        (p : _) -> read p
+        _ -> 3000
+  htmlContent <- BS.readFile "examples/hello-world-channel.html"
+  state <- SharedState <$> newTVarIO 400 <*> newTVarIO 0
+  putStrLn $ "Listening on http://localhost:" <> show port
+  Warp.run port (app htmlContent state)
+
+app :: BS.ByteString -> SharedState -> Application
+app htmlContent state req respond =
+  case (requestMethod req, pathInfo req) of
+    ("GET", []) ->
+      respond $ responseLBS status200 [("Content-Type", "text/html")] (LBS.fromStrict htmlContent)
+    ("GET", ["set-delay"]) ->
+      handleSetDelay state req respond
+    ("GET", ["hello-world"]) ->
+      handleHelloWorld state respond
+    _ ->
+      respond $ responseLBS status404 [] "Not found"
+
+handleSetDelay :: SharedState -> Wai.Request -> (Wai.Response -> IO b) -> IO b
+handleSetDelay state req respond = do
+  signalsResult <- readSignals req :: IO (Either String Signals)
+  case signalsResult of
+    Left _ -> respond $ responseLBS status404 [] "Bad signals"
+    Right signals -> do
+      atomically $ do
+        writeTVar (_delayVar state) (_delay signals)
+        v <- readTVar (_versionVar state)
+        writeTVar (_versionVar state) (v + 1)
+      respond $ sseResponse nullLogger $ \_ -> pure ()
+
+handleHelloWorld :: SharedState -> (Wai.Response -> IO b) -> IO b
+handleHelloWorld state respond =
+  respond $ sseResponse nullLogger $ \gen ->
+    forever $ do
+      version <- readTVarIO (_versionVar state)
+      d <- readTVarIO (_delayVar state)
+      animate gen state d version [0 .. length message]
+
+-- Animate character by character; breaks out early if the version changes
+-- (i.e. Start was clicked), letting `forever` restart from the beginning.
+animate :: ServerSentEventGenerator -> SharedState -> Int -> Int -> [Int] -> IO ()
+animate _ _ _ _ [] = pure ()
+animate gen state d version (i : is) = do
+  let html = "<div id='message'>" <> T.pack (take i message) <> "</div>"
+  sendPatchElements gen (patchElements html)
+  -- Race the delay against a version change
+  timedOut <- registerDelay (d * 1000)
+  interrupted <- atomically $ do
+    timeout <- readTVar timedOut
+    v <- readTVar (_versionVar state)
+    case (timeout, v /= version) of
+      (_, True) -> pure True -- version changed, interrupt
+      (True, _) -> pure False -- delay elapsed, continue
+      _ -> retry -- neither yet, keep waiting
+  if interrupted
+    then pure () -- break out; forever will restart
+    else animate gen state d version is
diff --git a/examples/hello-world-servant.hs b/examples/hello-world-servant.hs
new file mode 100644
--- /dev/null
+++ b/examples/hello-world-servant.hs
@@ -0,0 +1,71 @@
+module Main (main) where
+
+import Control.Concurrent (threadDelay)
+import Data.Aeson (FromJSON (..), withObject, (.:))
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Text qualified as T
+import Hypermedia.Datastar
+import Network.HTTP.Media ((//))
+import Network.HTTP.Types (status404)
+import Network.Wai qualified as Wai
+import Network.Wai.Handler.Warp qualified as Warp
+import Servant
+import System.Environment (getArgs)
+
+data HTML
+
+instance Accept HTML where
+  contentType _ = "text" // "html"
+
+instance MimeRender HTML LBS.ByteString where
+  mimeRender _ = id
+
+newtype Signals = Signals {delay :: Int}
+
+instance FromJSON Signals where
+  parseJSON = withObject "Signals" $ \o ->
+    Signals <$> o .: "delay"
+
+message :: String
+message = "Hello, world!"
+
+type API =
+  Get '[HTML] LBS.ByteString
+    :<|> "hello-world" :> Raw
+
+server :: BS.ByteString -> Server API
+server htmlContent =
+  serveIndex htmlContent
+    :<|> serveHelloWorld
+
+serveIndex :: BS.ByteString -> Handler LBS.ByteString
+serveIndex htmlContent =
+  pure $ LBS.fromStrict htmlContent
+
+serveHelloWorld :: Tagged Handler Application
+serveHelloWorld = Tagged $ \req respond -> do
+  signalsResult <- readSignals req :: IO (Either String Signals)
+  case signalsResult of
+    Left _ -> respond $ Wai.responseLBS status404 [] "Bad signals"
+    Right signals -> respond $ sseResponse nullLogger $ \gen ->
+      mapM_
+        ( \i -> do
+            let html = "<div id='message'>" <> T.pack (take i message) <> "</div>"
+            sendPatchElements gen (patchElements html)
+            threadDelay (delay signals * 1000)
+        )
+        [1 .. length message]
+
+app :: BS.ByteString -> Application
+app htmlContent = serve (Proxy :: Proxy API) (server htmlContent)
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let port = case args of
+        (p : _) -> read p
+        _ -> 3000
+  htmlContent <- BS.readFile "examples/hello-world.html"
+  putStrLn $ "Listening on http://localhost:" <> show port
+  Warp.run port (app htmlContent)
diff --git a/examples/hello-world-warp.hs b/examples/hello-world-warp.hs
new file mode 100644
--- /dev/null
+++ b/examples/hello-world-warp.hs
@@ -0,0 +1,56 @@
+module Main (main) where
+
+import Control.Concurrent (threadDelay)
+import Data.Aeson (FromJSON (..), withObject, (.:))
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Text qualified as T
+import Hypermedia.Datastar
+import Network.HTTP.Types (status200, status404)
+import Network.Wai (Application, pathInfo, requestMethod, responseLBS)
+import Network.Wai qualified as Wai
+import Network.Wai.Handler.Warp qualified as Warp
+import System.Environment (getArgs)
+
+newtype Signals = Signals {delay :: Int}
+
+instance FromJSON Signals where
+  parseJSON = withObject "Signals" $ \o ->
+    Signals <$> o .: "delay"
+
+message :: String
+message = "Hello, world!"
+
+main :: IO ()
+main = do
+  args <- getArgs
+  let port = case args of
+        (p : _) -> read p
+        _ -> 3000
+  htmlContent <- BS.readFile "examples/hello-world.html"
+  putStrLn $ "Listening on http://localhost:" <> show port
+  Warp.run port (app htmlContent)
+
+app :: BS.ByteString -> Application
+app htmlContent req respond =
+  case (requestMethod req, pathInfo req) of
+    ("GET", []) ->
+      respond $ responseLBS status200 [("Content-Type", "text/html")] (LBS.fromStrict htmlContent)
+    ("GET", ["hello-world"]) ->
+      handleHelloWorld req respond
+    _ ->
+      respond $ responseLBS status404 [] "Not found"
+
+handleHelloWorld :: Wai.Request -> (Wai.Response -> IO b) -> IO b
+handleHelloWorld req respond = do
+  signalsResult <- readSignals req :: IO (Either String Signals)
+  case signalsResult of
+    Left _ -> respond $ responseLBS status404 [] "Bad signals"
+    Right signals -> respond $ sseResponse nullLogger $ \gen ->
+      mapM_
+        ( \i -> do
+            let html = "<div id='message'>" <> T.pack (take i message) <> "</div>"
+            sendPatchElements gen (patchElements html)
+            threadDelay (delay signals * 1000)
+        )
+        [1 .. length message]
diff --git a/src/Hypermedia/Datastar.hs b/src/Hypermedia/Datastar.hs
--- a/src/Hypermedia/Datastar.hs
+++ b/src/Hypermedia/Datastar.hs
@@ -1,52 +1,52 @@
--- |
--- Module      : Hypermedia.Datastar
--- Description : Haskell SDK for building real-time hypermedia apps with Datastar
---
--- <https://data-star.dev/ Datastar> is a hypermedia framework: instead of
--- building a JSON API and a JavaScript SPA, you write HTML on the server and
--- let Datastar handle the interactivity. The browser sends requests, the
--- server holds the connection open as a server-sent event (SSE) stream, and
--- pushes HTML fragments, signal updates, or scripts back to the browser as
--- things change.
---
--- This SDK provides the server-side Haskell API. It builds on
--- <https://hackage.haskell.org/package/wai WAI> so it works with Warp, Scotty,
--- Servant, Yesod, or any other WAI-compatible framework.
---
--- === Minimal example
---
--- @
--- import Hypermedia.Datastar
--- import Network.Wai (Application, responseLBS, requestMethod, pathInfo)
--- import Network.Wai.Handler.Warp qualified as Warp
--- import Network.HTTP.Types (status404)
---
--- app :: Application
--- app req respond =
---   case (requestMethod req, pathInfo req) of
---     (\"GET\", [\"hello\"]) ->
---       respond $ sseResponse $ \\gen ->
---         sendPatchElements gen (patchElements \"\<div id=\\\"msg\\\"\>Hello!\<\/div\>\")
---     _ ->
---       respond $ responseLBS status404 [] \"Not found\"
---
--- main :: IO ()
--- main = Warp.run 3000 app
--- @
---
--- === Module guide
---
--- * "Hypermedia.Datastar.PatchElements" — send HTML to morph into the DOM
--- * "Hypermedia.Datastar.PatchSignals" — update the browser's reactive signals
--- * "Hypermedia.Datastar.ExecuteScript" — run JavaScript in the browser
--- * "Hypermedia.Datastar.WAI" — SSE streaming, signal decoding, request helpers
--- * "Hypermedia.Datastar.Types" — protocol types and defaults
---
--- === Further reading
---
--- * <https://data-star.dev/ Datastar homepage> — guides, reference, and examples
--- * <https://github.com/carlohamalainen/datastar-hs-examples Examples repository> — full working Haskell examples
--- * <https://cljdoc.org/d/dev.data-star.clojure/http-kit/1.0.0-RC7/doc/sdk-docs/using-datastar Clojure SDK docs> — excellent Datastar walkthrough that applies across SDKs
+{- |
+Module      : Hypermedia.Datastar
+Description : Haskell SDK for building real-time hypermedia apps with Datastar
+
+<https://data-star.dev/ Datastar> is a hypermedia framework: instead of
+building a JSON API and a JavaScript SPA, you write HTML on the server and
+let Datastar handle the interactivity. The browser sends requests, the
+server holds the connection open as a server-sent event (SSE) stream, and
+pushes HTML fragments, signal updates, or scripts back to the browser as
+things change.
+
+This SDK provides the server-side Haskell API. It builds on
+<https://hackage.haskell.org/package/wai WAI> so it works with Warp, Scotty,
+Servant, Yesod, or any other WAI-compatible framework.
+
+=== Minimal example
+
+@
+import Hypermedia.Datastar
+import Network.Wai (Application, responseLBS, requestMethod, pathInfo)
+import Network.Wai.Handler.Warp qualified as Warp
+import Network.HTTP.Types (status404)
+
+app :: Application
+app req respond =
+  case (requestMethod req, pathInfo req) of
+    (\"GET\", [\"hello\"]) ->
+      respond $ sseResponse $ \\gen ->
+        sendPatchElements gen (patchElements \"\<div id=\\\"msg\\\"\>Hello!\<\/div\>\")
+    _ ->
+      respond $ responseLBS status404 [] \"Not found\"
+
+main :: IO ()
+main = Warp.run 3000 app
+@
+
+=== Module guide
+
+* "Hypermedia.Datastar.PatchElements" — send HTML to morph into the DOM
+* "Hypermedia.Datastar.PatchSignals" — update the browser's reactive signals
+* "Hypermedia.Datastar.ExecuteScript" — run JavaScript in the browser
+* "Hypermedia.Datastar.WAI" — SSE streaming, signal decoding, request helpers
+* "Hypermedia.Datastar.Types" — protocol types and defaults
+
+=== Further reading
+
+* <https://data-star.dev/ Datastar homepage> — guides, reference, and examples
+* <https://cljdoc.org/d/dev.data-star.clojure/http-kit/1.0.0-RC7/doc/sdk-docs/using-datastar Clojure SDK docs> — excellent Datastar walkthrough that applies across SDKs
+-}
 module Hypermedia.Datastar
   ( -- * Types
     EventType (..)
@@ -74,6 +74,11 @@
   , sendExecuteScript
   , readSignals
   , isDatastarRequest
+
+    -- * Logger
+  , DatastarLogger(..)
+  , nullLogger
+  , stderrLogger
   )
 where
 
@@ -90,3 +95,4 @@
   , sendPatchSignals
   , sseResponse
   )
+import Hypermedia.Datastar.Logger (DatastarLogger, nullLogger, stderrLogger)
diff --git a/src/Hypermedia/Datastar/ExecuteScript.hs b/src/Hypermedia/Datastar/ExecuteScript.hs
--- a/src/Hypermedia/Datastar/ExecuteScript.hs
+++ b/src/Hypermedia/Datastar/ExecuteScript.hs
@@ -1,50 +1,54 @@
--- |
--- Module      : Hypermedia.Datastar.ExecuteScript
--- Description : Execute JavaScript in the browser via SSE
---
--- Sometimes you need a one-shot browser-side effect that doesn't fit neatly
--- into DOM patching or signal updates — redirecting to another page, focusing
--- an input, triggering a download, or calling a browser API.
--- 'executeScript' lets the server push arbitrary JavaScript to the browser.
---
--- Under the hood, Datastar appends a @\<script\>@ tag to @\<body\>@. By default
--- the script tag removes itself from the DOM after executing (see 'esAutoRemove').
---
--- Note: per the Datastar protocol, script execution uses the
--- @datastar-patch-elements@ event type — there is no separate event type for
--- scripts.
---
--- @
--- sendExecuteScript gen (executeScript \"window.location = \\\"/dashboard\\\"\")
--- @
---
--- To add attributes to the generated @\<script\>@ tag:
---
--- @
--- sendExecuteScript gen
---   (executeScript \"import(\\\"/modules\/chart.js\\\").then(m => m.render())\")
---     { esAttributes = [\"type=\\\"module\\\"\"] }
--- @
+{- |
+Module      : Hypermedia.Datastar.ExecuteScript
+Description : Execute JavaScript in the browser via SSE
+
+Sometimes you need a one-shot browser-side effect that doesn't fit neatly
+into DOM patching or signal updates — redirecting to another page, focusing
+an input, triggering a download, or calling a browser API.
+'executeScript' lets the server push arbitrary JavaScript to the browser.
+
+Under the hood, Datastar appends a @\<script\>@ tag to @\<body\>@. By default
+the script tag removes itself from the DOM after executing (see 'esAutoRemove').
+
+Note: per the Datastar protocol, script execution uses the
+@datastar-patch-elements@ event type — there is no separate event type for
+scripts.
+
+@
+sendExecuteScript gen (executeScript \"window.location = \\\"/dashboard\\\"\")
+@
+
+To add attributes to the generated @\<script\>@ tag:
+
+@
+sendExecuteScript gen
+  (executeScript \"import(\\\"/modules\/chart.js\\\").then(m => m.render())\")
+    { esAttributes = [\"type=\\\"module\\\"\"] }
+@
+-}
 module Hypermedia.Datastar.ExecuteScript where
 
 import Data.Text (Text)
 import Data.Text qualified as T
 import Hypermedia.Datastar.Types
 
--- | Configuration for executing a script in the browser.
---
--- Construct values with 'executeScript', then customise with record updates.
+{- | Configuration for executing a script in the browser.
+
+Construct values with 'executeScript', then customise with record updates.
+-}
 data ExecuteScript = ExecuteScript
   { esScript :: Text
   -- ^ The JavaScript code to execute.
   , esAutoRemove :: Bool
-  -- ^ Whether the @\<script\>@ tag should remove itself from the DOM after
-  -- executing. Default: 'True'. Set to 'False' if the script defines
-  -- functions or variables that need to persist in the page.
+  {- ^ Whether the @\<script\>@ tag should remove itself from the DOM after
+  executing. Default: 'True'. Set to 'False' if the script defines
+  functions or variables that need to persist in the page.
+  -}
   , esAttributes :: [Text]
-  -- ^ Extra attributes to add to the @\<script\>@ tag. For example,
-  -- @[\"type=\\\"module\\\"\"]@ to use ES module imports, or
-  -- @[\"nonce=\\\"abc123\\\"\"]@ for CSP compliance.
+  {- ^ Extra attributes to add to the @\<script\>@ tag. For example,
+  @[\"type=\\\"module\\\"\"]@ to use ES module imports, or
+  @[\"nonce=\\\"abc123\\\"\"]@ for CSP compliance.
+  -}
   , esEventId :: Maybe Text
   -- ^ Optional SSE event ID for reconnection.
   , esRetryDuration :: Int
@@ -52,13 +56,14 @@
   }
   deriving (Eq, Show)
 
--- | Build an 'ExecuteScript' event with sensible defaults.
---
--- The argument is the JavaScript source code to run in the browser.
---
--- @
--- executeScript \"document.getElementById(\\\"name\\\").focus()\"
--- @
+{- | Build an 'ExecuteScript' event with sensible defaults.
+
+The argument is the JavaScript source code to run in the browser.
+
+@
+executeScript \"document.getElementById(\\\"name\\\").focus()\"
+@
+-}
 executeScript :: Text -> ExecuteScript
 executeScript js =
   ExecuteScript
diff --git a/src/Hypermedia/Datastar/Logger.hs b/src/Hypermedia/Datastar/Logger.hs
--- a/src/Hypermedia/Datastar/Logger.hs
+++ b/src/Hypermedia/Datastar/Logger.hs
@@ -19,10 +19,10 @@
 nullLogger :: DatastarLogger
 nullLogger =
   DatastarLogger
-    { logDebug = const (pure ())
-    , logInfo = const (pure ())
-    , logWarn = const (pure ())
-    , logError = const (pure ())
+    { logDebug = \_ -> pure ()
+    , logInfo = \_ -> pure ()
+    , logWarn = \_ -> pure ()
+    , logError = \_ -> pure ()
     }
 
 stderrLogger :: DatastarLogger
diff --git a/src/Hypermedia/Datastar/PatchElements.hs b/src/Hypermedia/Datastar/PatchElements.hs
--- a/src/Hypermedia/Datastar/PatchElements.hs
+++ b/src/Hypermedia/Datastar/PatchElements.hs
@@ -1,80 +1,89 @@
--- |
--- Module      : Hypermedia.Datastar.PatchElements
--- Description : Send HTML fragments to patch the browser DOM
---
--- \"Patch elements\" is Datastar's primary mechanism for updating the page: the
--- server sends an HTML fragment and the browser morphs it into the DOM. The
--- morphing algorithm preserves focus, scroll position, and CSS transitions,
--- so partial page updates feel seamless.
---
--- The simplest case — send HTML and let Datastar match elements by @id@ —
--- needs only 'patchElements':
---
--- @
--- sendPatchElements gen (patchElements "\<div id=\\\"count\\\"\>42\<\/div\>")
--- @
---
--- To customise the patching behaviour, use record update syntax on the result
--- of 'patchElements':
---
--- @
--- sendPatchElements gen
---   (patchElements "\<li\>new item\<\/li\>")
---     { peSelector = Just \"#todo-list\"
---     , peMode = Append
---     }
--- @
---
--- To remove elements from the DOM, use 'removeElements' with a CSS selector:
---
--- @
--- sendPatchElements gen (removeElements \"#flash-message\")
--- @
+{- |
+Module      : Hypermedia.Datastar.PatchElements
+Description : Send HTML fragments to patch the browser DOM
+
+\"Patch elements\" is Datastar's primary mechanism for updating the page: the
+server sends an HTML fragment and the browser morphs it into the DOM. The
+morphing algorithm preserves focus, scroll position, and CSS transitions,
+so partial page updates feel seamless.
+
+The simplest case — send HTML and let Datastar match elements by @id@ —
+needs only 'patchElements':
+
+@
+sendPatchElements gen (patchElements "\<div id=\\\"count\\\"\>42\<\/div\>")
+@
+
+To customise the patching behaviour, use record update syntax on the result
+of 'patchElements':
+
+@
+sendPatchElements gen
+  (patchElements "\<li\>new item\<\/li\>")
+    { peSelector = Just \"#todo-list\"
+    , peMode = Append
+    }
+@
+
+To remove elements from the DOM, use 'removeElements' with a CSS selector:
+
+@
+sendPatchElements gen (removeElements \"#flash-message\")
+@
+-}
 module Hypermedia.Datastar.PatchElements where
 
 import Data.Text (Text)
 import Data.Text qualified as T
 import Hypermedia.Datastar.Types
 
--- | Configuration for a @datastar-patch-elements@ SSE event.
---
--- Construct values with 'patchElements' or 'removeElements', then customise
--- with record updates.
+{- | Configuration for a @datastar-patch-elements@ SSE event.
+
+Construct values with 'patchElements' or 'removeElements', then customise
+with record updates.
+-}
 data PatchElements = PatchElements
   { peElements :: Maybe Text
-  -- ^ The HTML fragment to patch into the DOM. 'Nothing' when removing
-  -- elements (see 'removeElements').
+  {- ^ The HTML fragment to patch into the DOM. 'Nothing' when removing
+  elements (see 'removeElements').
+  -}
   , peSelector :: Maybe Text
-  -- ^ CSS selector for the target element. When 'Nothing' (the default),
-  -- Datastar matches by the @id@ attribute of the root element in
-  -- 'peElements'.
+  {- ^ CSS selector for the target element. When 'Nothing' (the default),
+  Datastar matches by the @id@ attribute of the root element in
+  'peElements'.
+  -}
   , peMode :: ElementPatchMode
-  -- ^ How to apply the patch. Default: 'Outer' (replace the matched
-  -- element and its contents via morphing).
+  {- ^ How to apply the patch. Default: 'Outer' (replace the matched
+  element and its contents via morphing).
+  -}
   , peUseViewTransition :: Bool
-  -- ^ Whether to wrap the DOM update in a
-  -- <https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API View Transition>.
-  -- Default: 'False'.
+  {- ^ Whether to wrap the DOM update in a
+  <https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API View Transition>.
+  Default: 'False'.
+  -}
   , peNamespace :: ElementNamespace
-  -- ^ XML namespace for the patched elements. Default: 'HtmlNs'. Use
-  -- 'SvgNs' or 'MathmlNs' when patching inline SVG or MathML.
+  {- ^ XML namespace for the patched elements. Default: 'HtmlNs'. Use
+  'SvgNs' or 'MathmlNs' when patching inline SVG or MathML.
+  -}
   , peEventId :: Maybe Text
-  -- ^ Optional SSE event ID. The browser uses this for reconnection —
-  -- after a dropped connection it sends @Last-Event-ID@ so the server can
-  -- resume from the right point.
+  {- ^ Optional SSE event ID. The browser uses this for reconnection —
+  after a dropped connection it sends @Last-Event-ID@ so the server can
+  resume from the right point.
+  -}
   , peRetryDuration :: Int
   -- ^ SSE retry interval in milliseconds. Default: @1000@.
   }
   deriving (Eq, Show)
 
--- | Build a 'PatchElements' event with sensible defaults.
---
--- The HTML is sent as-is and Datastar matches target elements by their @id@
--- attribute.
---
--- @
--- patchElements "\<div id=\\\"greeting\\\"\>Hello!\<\/div\>"
--- @
+{- | Build a 'PatchElements' event with sensible defaults.
+
+The HTML is sent as-is and Datastar matches target elements by their @id@
+attribute.
+
+@
+patchElements "\<div id=\\\"greeting\\\"\>Hello!\<\/div\>"
+@
+-}
 patchElements :: Text -> PatchElements
 patchElements html =
   PatchElements
@@ -87,12 +96,13 @@
     , peRetryDuration = defaultRetryDuration
     }
 
--- | Remove elements from the DOM matching a CSS selector.
---
--- @
--- removeElements \"#notification\"
--- removeElements \".stale-row\"
--- @
+{- | Remove elements from the DOM matching a CSS selector.
+
+@
+removeElements \"#notification\"
+removeElements \".stale-row\"
+@
+-}
 removeElements :: Text -> PatchElements
 removeElements sel =
   PatchElements
diff --git a/src/Hypermedia/Datastar/PatchSignals.hs b/src/Hypermedia/Datastar/PatchSignals.hs
--- a/src/Hypermedia/Datastar/PatchSignals.hs
+++ b/src/Hypermedia/Datastar/PatchSignals.hs
@@ -1,44 +1,48 @@
--- |
--- Module      : Hypermedia.Datastar.PatchSignals
--- Description : Update the browser's reactive signal store
---
--- Signals are Datastar's reactive state — key-value pairs that live in the
--- browser and drive the UI. The server can update signals at any time by
--- sending a @datastar-patch-signals@ event.
---
--- Signal patching uses JSON Merge Patch semantics: set a key to update it,
--- set a key to @null@ to remove it, and omit a key to leave it unchanged.
---
--- @
--- sendPatchSignals gen (patchSignals \"{\\\"count\\\": 42}\")
--- @
---
--- To set initial state without overwriting values the user may have already
--- changed (e.g. form inputs), use 'psOnlyIfMissing':
---
--- @
--- sendPatchSignals gen
---   (patchSignals \"{\\\"name\\\": \\\"default\\\"}\")
---     { psOnlyIfMissing = True }
--- @
+{- |
+Module      : Hypermedia.Datastar.PatchSignals
+Description : Update the browser's reactive signal store
+
+Signals are Datastar's reactive state — key-value pairs that live in the
+browser and drive the UI. The server can update signals at any time by
+sending a @datastar-patch-signals@ event.
+
+Signal patching uses JSON Merge Patch semantics: set a key to update it,
+set a key to @null@ to remove it, and omit a key to leave it unchanged.
+
+@
+sendPatchSignals gen (patchSignals \"{\\\"count\\\": 42}\")
+@
+
+To set initial state without overwriting values the user may have already
+changed (e.g. form inputs), use 'psOnlyIfMissing':
+
+@
+sendPatchSignals gen
+  (patchSignals \"{\\\"name\\\": \\\"default\\\"}\")
+    { psOnlyIfMissing = True }
+@
+-}
 module Hypermedia.Datastar.PatchSignals where
 
 import Data.Text (Text)
 import Data.Text qualified as T
 import Hypermedia.Datastar.Types
 
--- | Configuration for a @datastar-patch-signals@ SSE event.
---
--- Construct values with 'patchSignals', then customise with record updates.
+{- | Configuration for a @datastar-patch-signals@ SSE event.
+
+Construct values with 'patchSignals', then customise with record updates.
+-}
 data PatchSignals = PatchSignals
   { psSignals :: Text
-  -- ^ JSON object containing the signal values to patch. Uses JSON Merge
-  -- Patch semantics: set a key to update it, set to @null@ to remove it.
+  {- ^ JSON object containing the signal values to patch. Uses JSON Merge
+  Patch semantics: set a key to update it, set to @null@ to remove it.
+  -}
   , psOnlyIfMissing :: Bool
-  -- ^ When 'True', signal values are only set if the key doesn't already
-  -- exist in the browser's store. Useful for setting initial state that
-  -- shouldn't overwrite user changes (e.g. form input defaults).
-  -- Default: 'False'.
+  {- ^ When 'True', signal values are only set if the key doesn't already
+  exist in the browser's store. Useful for setting initial state that
+  shouldn't overwrite user changes (e.g. form input defaults).
+  Default: 'False'.
+  -}
   , psEventId :: Maybe Text
   -- ^ Optional SSE event ID for reconnection. See 'Hypermedia.Datastar.PatchElements.PatchElements' for details.
   , psRetryDuration :: Int
@@ -46,13 +50,14 @@
   }
   deriving (Eq, Show)
 
--- | Build a 'PatchSignals' event with sensible defaults.
---
--- The argument is a JSON object (as 'Text') describing the signals to update.
---
--- @
--- patchSignals \"{\\\"count\\\": 42, \\\"label\\\": \\\"hello\\\"}\"
--- @
+{- | Build a 'PatchSignals' event with sensible defaults.
+
+The argument is a JSON object (as 'Text') describing the signals to update.
+
+@
+patchSignals \"{\\\"count\\\": 42, \\\"label\\\": \\\"hello\\\"}\"
+@
+-}
 patchSignals :: Text -> PatchSignals
 patchSignals sigs =
   PatchSignals
diff --git a/src/Hypermedia/Datastar/Types.hs b/src/Hypermedia/Datastar/Types.hs
--- a/src/Hypermedia/Datastar/Types.hs
+++ b/src/Hypermedia/Datastar/Types.hs
@@ -1,31 +1,35 @@
--- |
--- Module      : Hypermedia.Datastar.Types
--- Description : Core types for the Datastar SSE protocol
---
--- This module defines the types that model the Datastar server-sent events
--- protocol. Most users won't import this module directly — the types are
--- re-exported from "Hypermedia.Datastar".
---
--- Default values for protocol fields ('defaultPatchMode', 'defaultRetryDuration',
--- etc.) follow the Datastar ADR specification at
--- <https://data-star.dev/reference/action_plugins>.
+{- |
+Module      : Hypermedia.Datastar.Types
+Description : Core types for the Datastar SSE protocol
+
+This module defines the types that model the Datastar server-sent events
+protocol. Most users won't import this module directly — the types are
+re-exported from "Hypermedia.Datastar".
+
+Default values for protocol fields ('defaultPatchMode', 'defaultRetryDuration',
+etc.) follow the Datastar ADR specification at
+<https://data-star.dev/reference/action_plugins>.
+-}
 module Hypermedia.Datastar.Types where
 
 import Data.Text (Text)
 
--- | The two SSE event types defined by the Datastar protocol.
---
--- Every event the server sends is one of these. 'EventPatchElements' covers
--- both DOM patching and script execution (the protocol encodes @executeScript@
--- as a special case of element patching). 'EventPatchSignals' updates the
--- browser's reactive signal store.
+{- | The two SSE event types defined by the Datastar protocol.
+
+Every event the server sends is one of these. 'EventPatchElements' covers
+both DOM patching and script execution (the protocol encodes @executeScript@
+as a special case of element patching). 'EventPatchSignals' updates the
+browser's reactive signal store.
+-}
 data EventType
-  = -- | Sent as @datastar-patch-elements@ on the wire. Used by both
-    -- 'Hypermedia.Datastar.PatchElements.PatchElements' and
-    -- 'Hypermedia.Datastar.ExecuteScript.ExecuteScript'.
+  = {- | Sent as @datastar-patch-elements@ on the wire. Used by both
+    'Hypermedia.Datastar.PatchElements.PatchElements' and
+    'Hypermedia.Datastar.ExecuteScript.ExecuteScript'.
+    -}
     EventPatchElements
-  | -- | Sent as @datastar-patch-signals@ on the wire. Used by
-    -- 'Hypermedia.Datastar.PatchSignals.PatchSignals'.
+  | {- | Sent as @datastar-patch-signals@ on the wire. Used by
+    'Hypermedia.Datastar.PatchSignals.PatchSignals'.
+    -}
     EventPatchSignals
   deriving (Eq, Show)
 
@@ -33,12 +37,13 @@
 eventTypeToText EventPatchElements = "datastar-patch-elements"
 eventTypeToText EventPatchSignals = "datastar-patch-signals"
 
--- | How the patched HTML should be applied to the DOM.
---
--- The default mode is 'Outer', which replaces the target element (matched by
--- its @id@ attribute) including the element itself. This works well with
--- Datastar's morphing algorithm, which preserves focus, scroll position, and
--- CSS transitions during the replacement.
+{- | How the patched HTML should be applied to the DOM.
+
+The default mode is 'Outer', which replaces the target element (matched by
+its @id@ attribute) including the element itself. This works well with
+Datastar's morphing algorithm, which preserves focus, scroll position, and
+CSS transitions during the replacement.
+-}
 data ElementPatchMode
   = -- | Replace the target element and its contents (the default).
     Outer
@@ -68,11 +73,12 @@
 patchModeToText Before = "before"
 patchModeToText After = "after"
 
--- | The XML namespace for the patched elements.
---
--- Almost all content uses 'HtmlNs' (the default). Use 'SvgNs' or 'MathmlNs'
--- when patching inline SVG or MathML elements so that Datastar creates them
--- in the correct namespace.
+{- | The XML namespace for the patched elements.
+
+Almost all content uses 'HtmlNs' (the default). Use 'SvgNs' or 'MathmlNs'
+when patching inline SVG or MathML elements so that Datastar creates them
+in the correct namespace.
+-}
 data ElementNamespace
   = -- | Standard HTML namespace (the default).
     HtmlNs
@@ -87,11 +93,12 @@
 namespaceToText SvgNs = "svg"
 namespaceToText MathmlNs = "mathml"
 
--- | Internal representation of a rendered SSE event.
---
--- Users don't construct these directly. Instead, use 'Hypermedia.Datastar.PatchElements.patchElements',
--- 'Hypermedia.Datastar.PatchSignals.patchSignals', or 'Hypermedia.Datastar.ExecuteScript.executeScript'
--- to build events, and 'Hypermedia.Datastar.WAI.sendPatchElements' (etc.) to send them.
+{- | Internal representation of a rendered SSE event.
+
+Users don't construct these directly. Instead, use 'Hypermedia.Datastar.PatchElements.patchElements',
+'Hypermedia.Datastar.PatchSignals.patchSignals', or 'Hypermedia.Datastar.ExecuteScript.executeScript'
+to build events, and 'Hypermedia.Datastar.WAI.sendPatchElements' (etc.) to send them.
+-}
 data DatastarEvent = DatastarEvent
   { eventType :: EventType
   , eventId :: Maybe Text
@@ -99,39 +106,44 @@
   , dataLines :: [Text]
   }
 
--- | Default SSE retry duration in milliseconds.
---
--- If the connection drops, the browser waits this long before reconnecting.
--- Per the Datastar ADR spec, the default is @1000@ ms.
+{- | Default SSE retry duration in milliseconds.
+
+If the connection drops, the browser waits this long before reconnecting.
+Per the Datastar ADR spec, the default is @1000@ ms.
+-}
 defaultRetryDuration :: Int
 defaultRetryDuration = 1000
 
--- | Default element patch mode: 'Outer'.
---
--- Replaces the target element (matched by @id@) and its contents using
--- Datastar's morphing algorithm.
+{- | Default element patch mode: 'Outer'.
+
+Replaces the target element (matched by @id@) and its contents using
+Datastar's morphing algorithm.
+-}
 defaultPatchMode :: ElementPatchMode
 defaultPatchMode = Outer
 
--- | Default for the @useViewTransition@ flag: 'False'.
---
--- When 'True', Datastar wraps the DOM update in a
--- <https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API View Transition>,
--- enabling CSS-animated transitions between states.
+{- | Default for the @useViewTransition@ flag: 'False'.
+
+When 'True', Datastar wraps the DOM update in a
+<https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API View Transition>,
+enabling CSS-animated transitions between states.
+-}
 defaultUseViewTransition :: Bool
 defaultUseViewTransition = False
 
--- | Default for the @onlyIfMissing@ flag: 'False'.
---
--- When 'True', signal values are only set if they don't already exist in the
--- browser's store. See 'Hypermedia.Datastar.PatchSignals.psOnlyIfMissing'.
+{- | Default for the @onlyIfMissing@ flag: 'False'.
+
+When 'True', signal values are only set if they don't already exist in the
+browser's store. See 'Hypermedia.Datastar.PatchSignals.psOnlyIfMissing'.
+-}
 defaultOnlyIfMissing :: Bool
 defaultOnlyIfMissing = False
 
--- | Default for the @autoRemove@ flag: 'True'.
---
--- When 'True', scripts executed via 'Hypermedia.Datastar.ExecuteScript.executeScript'
--- are automatically removed from the DOM after running.
+{- | Default for the @autoRemove@ flag: 'True'.
+
+When 'True', scripts executed via 'Hypermedia.Datastar.ExecuteScript.executeScript'
+are automatically removed from the DOM after running.
+-}
 defaultAutoRemove :: Bool
 defaultAutoRemove = True
 
diff --git a/src/Hypermedia/Datastar/WAI.hs b/src/Hypermedia/Datastar/WAI.hs
--- a/src/Hypermedia/Datastar/WAI.hs
+++ b/src/Hypermedia/Datastar/WAI.hs
@@ -1,40 +1,41 @@
--- |
--- Module      : Hypermedia.Datastar.WAI
--- Description : SSE streaming and request handling for WAI
---
--- This module connects Datastar to WAI (Web Application Interface), Haskell's
--- standard HTTP server interface. It provides:
---
--- * 'sseResponse' — create a streaming SSE response with a
---   'ServerSentEventGenerator' callback
--- * 'sendPatchElements', 'sendPatchSignals', 'sendExecuteScript' — send
---   Datastar events through the open connection
--- * 'readSignals' — decode signals sent by the browser (from query params on
---   GET, or from the request body on POST)
--- * 'isDatastarRequest' — distinguish Datastar SSE requests from normal
---   page loads
---
--- === Streaming architecture
---
--- 'sseResponse' uses WAI's @responseStream@ to hold the HTTP connection open.
--- You receive a 'ServerSentEventGenerator' and call @send*@ functions as many
--- times as needed. The connection stays open until your callback returns (or
--- the client disconnects).
---
--- === Thread safety
---
--- The 'ServerSentEventGenerator' uses an internal 'Control.Concurrent.MVar.MVar'
--- lock, so it is safe to call @send*@ functions from multiple threads
--- concurrently.
---
--- === How signals flow from browser to server
---
--- When the browser makes a Datastar request, signals are sent as JSON:
---
--- * __GET requests__: signals are URL-encoded in the @datastar@ query parameter
--- * __POST requests__: signals are in the request body as JSON
---
--- Use 'readSignals' with any 'Data.Aeson.FromJSON' instance to decode them.
+{- |
+Module      : Hypermedia.Datastar.WAI
+Description : SSE streaming and request handling for WAI
+
+This module connects Datastar to WAI (Web Application Interface), Haskell's
+standard HTTP server interface. It provides:
+
+* 'sseResponse' — create a streaming SSE response with a
+  'ServerSentEventGenerator' callback
+* 'sendPatchElements', 'sendPatchSignals', 'sendExecuteScript' — send
+  Datastar events through the open connection
+* 'readSignals' — decode signals sent by the browser (from query params on
+  GET, or from the request body on POST)
+* 'isDatastarRequest' — distinguish Datastar SSE requests from normal
+  page loads
+
+=== Streaming architecture
+
+'sseResponse' uses WAI's @responseStream@ to hold the HTTP connection open.
+You receive a 'ServerSentEventGenerator' and call @send*@ functions as many
+times as needed. The connection stays open until your callback returns (or
+the client disconnects).
+
+=== Thread safety
+
+The 'ServerSentEventGenerator' uses an internal 'Control.Concurrent.MVar.MVar'
+lock, so it is safe to call @send*@ functions from multiple threads
+concurrently.
+
+=== How signals flow from browser to server
+
+When the browser makes a Datastar request, signals are sent as JSON:
+
+* __GET requests__: signals are URL-encoded in the @datastar@ query parameter
+* __POST requests__: signals are in the request body as JSON
+
+Use 'readSignals' with any 'Data.Aeson.FromJSON' instance to decode them.
+-}
 module Hypermedia.Datastar.WAI where
 
 import Control.Concurrent.MVar
@@ -56,33 +57,37 @@
 import Hypermedia.Datastar.ExecuteScript qualified as ES
 import Hypermedia.Datastar.PatchElements qualified as PE
 import Hypermedia.Datastar.PatchSignals qualified as PS
+import Hypermedia.Datastar.Logger qualified as Logger
+import qualified Hypermedia.Datastar.Logger as Logger
 
--- | An opaque handle for sending SSE events to the browser.
---
--- Obtain one from the callback passed to 'sseResponse'. The handle is
--- thread-safe — you can send events from multiple threads concurrently.
---
--- You don't construct these directly; 'sseResponse' creates one for you.
+{- | An opaque handle for sending SSE events to the browser.
+
+Obtain one from the callback passed to 'sseResponse'. The handle is
+thread-safe — you can send events from multiple threads concurrently.
+
+You don't construct these directly; 'sseResponse' creates one for you.
+-}
 data ServerSentEventGenerator = ServerSentEventGenerator
   { sseWrite :: BSB.Builder -> IO ()
   , sseFlush :: IO ()
   , sseLock :: MVar ()
-  -- , sseLogger :: DatastarLogger -- FIXME
+  , sseLogger :: Logger.DatastarLogger 
   }
 
--- | Create a WAI 'WAI.Response' that streams SSE events.
---
--- The callback receives a 'ServerSentEventGenerator' for sending events.
--- The SSE connection stays open until the callback returns.
---
--- @
--- app :: WAI.Request -> (WAI.Response -> IO b) -> IO b
--- app req respond =
---   respond $ sseResponse $ \\gen -> do
---     sendPatchElements gen (patchElements "\<div id=\\\"msg\\\"\>Hello\<\/div\>")
--- @
-sseResponse :: (ServerSentEventGenerator -> IO ()) -> WAI.Response
-sseResponse callback =
+{- | Create a WAI 'WAI.Response' that streams SSE events.
+
+The callback receives a 'ServerSentEventGenerator' for sending events.
+The SSE connection stays open until the callback returns.
+
+@
+app :: WAI.Request -> (WAI.Response -> IO b) -> IO b
+app req respond =
+  respond $ sseResponse $ \\gen -> do
+    sendPatchElements gen (patchElements "\<div id=\\\"msg\\\"\>Hello\<\/div\>")
+@
+-}
+sseResponse :: Logger.DatastarLogger -> (ServerSentEventGenerator -> IO ()) -> WAI.Response
+sseResponse logger callback =
   WAI.responseStream
     WAI.status200
     headers
@@ -101,6 +106,7 @@
         { sseWrite = write
         , sseFlush = flush
         , sseLock = lock
+        , sseLogger = logger
         }
 
 send :: ServerSentEventGenerator -> DatastarEvent -> IO ()
@@ -126,24 +132,25 @@
 sendExecuteScript :: ServerSentEventGenerator -> ES.ExecuteScript -> IO ()
 sendExecuteScript gen es = send gen $ ES.toDatastarEvent es
 
--- | Decode signals sent by the browser in a Datastar request.
---
--- For GET requests, signals are URL-encoded in the @datastar@ query parameter.
--- For POST requests (and other methods), signals are read from the request
--- body as JSON.
---
--- Define a Haskell data type with a 'FromJSON' instance to decode into:
---
--- @
--- data MySignals = MySignals { count :: Int, label :: Text }
---   deriving (Generic)
---   deriving anyclass (FromJSON)
---
--- handler :: WAI.Request -> IO ()
--- handler req = do
---   Right signals <- readSignals req
---   putStrLn $ \"Count is: \" <> show (count signals)
--- @
+{- | Decode signals sent by the browser in a Datastar request.
+
+For GET requests, signals are URL-encoded in the @datastar@ query parameter.
+For POST requests (and other methods), signals are read from the request
+body as JSON.
+
+Define a Haskell data type with a 'FromJSON' instance to decode into:
+
+@
+data MySignals = MySignals { count :: Int, label :: Text }
+  deriving (Generic)
+  deriving anyclass (FromJSON)
+
+handler :: WAI.Request -> IO ()
+handler req = do
+  Right signals <- readSignals req
+  putStrLn $ \"Count is: \" <> show (count signals)
+@
+-}
 readSignals :: (FromJSON a) => WAI.Request -> IO (Either String a)
 readSignals req
   | WAI.requestMethod req == "GET" =
@@ -161,19 +168,20 @@
 parseFromBody :: (FromJSON a) => WAI.Request -> IO (Either String a)
 parseFromBody req = A.eitherDecode <$> WAI.strictRequestBody req
 
--- | Check whether a request was initiated by Datastar.
---
--- Datastar adds a @datastar-request@ header to its SSE requests. Use this to
--- distinguish Datastar requests from normal page loads — for example, to
--- serve either an SSE stream or a full HTML page from the same route.
---
--- @
--- app req respond
---   | isDatastarRequest req =
---       respond $ sseResponse $ \\gen -> ...
---   | otherwise =
---       respond $ responseLBS status200 [] fullPageHtml
--- @
+{- | Check whether a request was initiated by Datastar.
+
+Datastar adds a @datastar-request@ header to its SSE requests. Use this to
+distinguish Datastar requests from normal page loads — for example, to
+serve either an SSE stream or a full HTML page from the same route.
+
+@
+app req respond
+  | isDatastarRequest req =
+      respond $ sseResponse $ \\gen -> ...
+  | otherwise =
+      respond $ responseLBS status200 [] fullPageHtml
+@
+-}
 isDatastarRequest :: WAI.Request -> Bool
 isDatastarRequest req = any ((== "datastar-request") . fst) (WAI.requestHeaders req)
 
diff --git a/test/Hypermedia/Datastar/ExecuteScriptSpec.hs b/test/Hypermedia/Datastar/ExecuteScriptSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hypermedia/Datastar/ExecuteScriptSpec.hs
@@ -0,0 +1,98 @@
+module Hypermedia.Datastar.ExecuteScriptSpec (spec) where
+
+import Test.Hspec
+
+import Hypermedia.Datastar.ExecuteScript
+import Hypermedia.Datastar.Types
+
+spec :: Spec
+spec = describe "Hypermedia.Datastar.ExecuteScript.toDatastarEvent" $ do
+  it "minimal: single-line script with auto-remove" $ do
+    let event = toDatastarEvent $ executeScript "console.log('Here')"
+
+    eventType event `shouldBe` EventPatchElements
+    eventId event `shouldBe` Nothing
+    retry event `shouldBe` defaultRetryDuration
+    dataLines event
+      `shouldBe` [ "selector body"
+                 , "mode append"
+                 , "elements <script data-effect=\"el.remove()\">console.log('Here')</script>"
+                 ]
+
+  it "full: all options set" $ do
+    let pAutoRemove = True
+        pAttrs = ["type=\"application/javascript\""]
+        pEventId = Just "123"
+        pRetryDuration = 2000
+        es =
+          (executeScript "console.log('Here')")
+            { esAutoRemove = pAutoRemove
+            , esAttributes = pAttrs
+            , esEventId = pEventId
+            , esRetryDuration = pRetryDuration
+            }
+        event = toDatastarEvent es
+
+    eventType event `shouldBe` EventPatchElements
+    eventId event `shouldBe` pEventId
+    retry event `shouldBe` pRetryDuration
+    dataLines event
+      `shouldBe` [ "selector body"
+                 , "mode append"
+                 , "elements <script data-effect=\"el.remove()\" type=\"application/javascript\">console.log('Here')</script>"
+                 ]
+
+  it "auto-remove disabled" $ do
+    let es = (executeScript "alert(1)"){esAutoRemove = False}
+        event = toDatastarEvent es
+    dataLines event
+      `shouldBe` [ "selector body"
+                 , "mode append"
+                 , "elements <script>alert(1)</script>"
+                 ]
+
+  it "multi-line script" $ do
+    let es = executeScript "var x = 1;\nconsole.log(x);"
+        event = toDatastarEvent es
+    dataLines event
+      `shouldBe` [ "selector body"
+                 , "mode append"
+                 , "elements <script data-effect=\"el.remove()\">"
+                 , "elements var x = 1;"
+                 , "elements console.log(x);"
+                 , "elements </script>"
+                 ]
+
+  it "multi-line script with three lines" $ do
+    let es = executeScript "var x = 1;\nvar y = 2;\nconsole.log(x + y);"
+        event = toDatastarEvent es
+    dataLines event
+      `shouldBe` [ "selector body"
+                 , "mode append"
+                 , "elements <script data-effect=\"el.remove()\">"
+                 , "elements var x = 1;"
+                 , "elements var y = 2;"
+                 , "elements console.log(x + y);"
+                 , "elements </script>"
+                 ]
+
+  it "empty script" $ do
+    let es = executeScript ""
+        event = toDatastarEvent es
+    dataLines event
+      `shouldBe` [ "selector body"
+                 , "mode append"
+                 , "elements <script data-effect=\"el.remove()\"></script>"
+                 ]
+
+  it "multiple attributes" $ do
+    let es =
+          (executeScript "go()")
+            { esAttributes = ["type=\"module\"", "defer"]
+            }
+        event = toDatastarEvent es
+    dataLines event
+      `shouldBe` [ "selector body"
+                 , "mode append"
+                 , "elements <script data-effect=\"el.remove()\" type=\"module\" defer>go()</script>"
+                 ]
diff --git a/test/Hypermedia/Datastar/PatchSignalsSpec.hs b/test/Hypermedia/Datastar/PatchSignalsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Hypermedia/Datastar/PatchSignalsSpec.hs
@@ -0,0 +1,58 @@
+module Hypermedia.Datastar.PatchSignalsSpec (spec) where
+
+import Test.Hspec
+
+import Hypermedia.Datastar.PatchSignals
+import Hypermedia.Datastar.Types
+
+spec :: Spec
+spec = describe "Hypermedia.Datastar.PatchSignals.toDatastarEvent" $ do
+  it "minimal: single-line signals, all defaults" $ do
+    let event = toDatastarEvent $ patchSignals "{\"output\":\"Patched Output Test\",\"show\":true}"
+
+    eventType event `shouldBe` EventPatchSignals
+    eventId event `shouldBe` Nothing
+    retry event `shouldBe` defaultRetryDuration
+    dataLines event `shouldBe` ["signals {\"output\":\"Patched Output Test\",\"show\":true}"]
+
+  it "full: all options set" $ do
+    let pOnlyIfMissing = True
+        pEventId = Just "123"
+        pRetryDuration = 2000
+
+        ps =
+          (patchSignals "{\"output\":\"Patched Output Test\",\"show\":true}")
+            { psOnlyIfMissing = pOnlyIfMissing
+            , psEventId = pEventId
+            , psRetryDuration = pRetryDuration
+            }
+        event = toDatastarEvent ps
+
+    eventType event `shouldBe` EventPatchSignals
+    eventId event `shouldBe` pEventId
+    retry event `shouldBe` pRetryDuration
+
+    dataLines event
+      `shouldBe` [ "onlyIfMissing true"
+                 , "signals {\"output\":\"Patched Output Test\",\"show\":true}"
+                 ]
+
+  it "omits onlyIfMissing when false (default)" $ do
+    let ps = patchSignals "{\"foo\":1}"
+        event = toDatastarEvent ps
+    dataLines event `shouldBe` ["signals {\"foo\":1}"]
+
+  it "handles multi-line JSON" $ do
+    let event = toDatastarEvent $ patchSignals "{\n  \"foo\": 1,\n  \"bar\": 2\n}"
+
+    dataLines event
+      `shouldBe` [ "signals {"
+                 , "signals   \"foo\": 1,"
+                 , "signals   \"bar\": 2"
+                 , "signals }"
+                 ]
+
+  it "documents null removal via JSON merge patch" $ do
+    let ps = patchSignals "{\"key\": null}"
+        event = toDatastarEvent ps
+    dataLines event `shouldBe` ["signals {\"key\": null}"]
diff --git a/test/Hypermedia/Datastar/SSESpec.hs b/test/Hypermedia/Datastar/SSESpec.hs
--- a/test/Hypermedia/Datastar/SSESpec.hs
+++ b/test/Hypermedia/Datastar/SSESpec.hs
@@ -13,6 +13,7 @@
 import Hypermedia.Datastar.PatchSignals
 import Hypermedia.Datastar.Types
 import Hypermedia.Datastar.WAI (renderEvent)
+import Hypermedia.Datastar.Logger (nullLogger)
 import Network.Wai.Internal (Response (..))
 
 render :: DatastarEvent -> String
@@ -51,7 +52,7 @@
           e1 = "<div id=\"b\">2</div>"
           e2 = "{\"count\": 42}"
 
-          resp = sseResponse $ \gen -> do
+          resp = sseResponse nullLogger $ \gen -> do
             sendPatchElements gen (patchElements e0)
             sendPatchElements gen (patchElements e1)
             sendPatchSignals gen (patchSignals e2)
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -2,10 +2,15 @@
 
 import Test.Hspec
 
+import Hypermedia.Datastar qualified
+import Hypermedia.Datastar.ExecuteScriptSpec qualified
 import Hypermedia.Datastar.PatchElementsSpec qualified
-import Hypermedia.Datastar.SSESpec qualified 
+import Hypermedia.Datastar.PatchSignalsSpec qualified
+import Hypermedia.Datastar.SSESpec qualified
 
 main :: IO ()
 main = hspec $ do
+  Hypermedia.Datastar.ExecuteScriptSpec.spec
   Hypermedia.Datastar.PatchElementsSpec.spec
+  Hypermedia.Datastar.PatchSignalsSpec.spec
   Hypermedia.Datastar.SSESpec.spec
