diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for hyperbole
+
+## 0.1.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2023, Sean Hess
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Sean Hess nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
diff --git a/client/dist/hyperbole.js b/client/dist/hyperbole.js
new file mode 100644
--- /dev/null
+++ b/client/dist/hyperbole.js
@@ -0,0 +1,106 @@
+/*
+ * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
+ * This devtool is neither made for production nor for readable output files.
+ * It uses "eval()" calls to create a separate source file in the browser devtools.
+ * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
+ * or disable the default devtool with "devtool: false".
+ * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
+ */
+/******/ (() => { // webpackBootstrap
+/******/ 	"use strict";
+/******/ 	var __webpack_modules__ = ({
+
+/***/ "./src/events.ts":
+/*!***********************!*\
+  !*** ./src/events.ts ***!
+  \***********************/
+/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
+
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   listenChange: () => (/* binding */ listenChange),\n/* harmony export */   listenClick: () => (/* binding */ listenClick),\n/* harmony export */   listenFormSubmit: () => (/* binding */ listenFormSubmit)\n/* harmony export */ });\nfunction listenClick(cb) {\n    document.addEventListener(\"click\", function (e) {\n        var el = e.target;\n        // clicks can fire on internal elements. Find the parent with a click handler\n        var source = el.closest(\"[data-on-click]\");\n        // console.log(\"CLICK\", source?.dataset.onClick)\n        // they should all have an action and target\n        if ((source === null || source === void 0 ? void 0 : source.dataset.onClick) && (source === null || source === void 0 ? void 0 : source.dataset.target)) {\n            e.preventDefault();\n            var target = document.getElementById(source.dataset.target);\n            if (!target) {\n                console.error(\"Missing target: \", source.dataset.target);\n                return;\n            }\n            cb(target, source.dataset.onClick);\n        }\n    });\n}\nfunction listenChange(cb) {\n    document.addEventListener(\"change\", function (e) {\n        var el = e.target;\n        // clicks can fire on internal elements. Find the parent with a click handler\n        var source = el.closest(\"[data-on-change]\");\n        // console.log(\"CHANGE!\", source.value)\n        // they should all have an action and target\n        if ((source === null || source === void 0 ? void 0 : source.dataset.target) && source.value) {\n            e.preventDefault();\n            var target = document.getElementById(source.dataset.target);\n            if (!target) {\n                console.error(\"Missing target: \", source.dataset.target);\n                return;\n            }\n            cb(target, source.value);\n        }\n    });\n}\nfunction listenFormSubmit(cb) {\n    document.addEventListener(\"submit\", function (e) {\n        var form = e.target;\n        // they should all have an action and target\n        if ((form === null || form === void 0 ? void 0 : form.dataset.onSubmit) && (form === null || form === void 0 ? void 0 : form.dataset.target)) {\n            e.preventDefault();\n            var target = document.getElementById(form.dataset.target);\n            if (!target) {\n                console.error(\"Missing target: \", form.dataset.target);\n                return;\n            }\n            var formData = new FormData(form);\n            cb(target, form.dataset.onSubmit, formData);\n        }\n    });\n}\n\n\n//# sourceURL=webpack://web-ui/./src/events.ts?");
+
+/***/ }),
+
+/***/ "./src/index.ts":
+/*!**********************!*\
+  !*** ./src/index.ts ***!
+  \**********************/
+/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
+
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var omdomdom_lib_omdomdom_es_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! omdomdom/lib/omdomdom.es.js */ \"./node_modules/omdomdom/lib/omdomdom.es.js\");\n/* harmony import */ var _events__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./events */ \"./src/events.ts\");\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n    return new (P || (P = Promise))(function (resolve, reject) {\n        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n        function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n        step((generator = generator.apply(thisArg, _arguments || [])).next());\n    });\n};\nvar __generator = (undefined && undefined.__generator) || function (thisArg, body) {\n    var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;\n    return g = { next: verb(0), \"throw\": verb(1), \"return\": verb(2) }, typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n    function verb(n) { return function (v) { return step([n, v]); }; }\n    function step(op) {\n        if (f) throw new TypeError(\"Generator is already executing.\");\n        while (g && (g = 0, op[0] && (_ = 0)), _) try {\n            if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n            if (y = 0, t) op = [op[0] & 2, t.value];\n            switch (op[0]) {\n                case 0: case 1: t = op; break;\n                case 4: _.label++; return { value: op[1], done: false };\n                case 5: _.label++; y = op[1]; op = [0]; continue;\n                case 7: op = _.ops.pop(); _.trys.pop(); continue;\n                default:\n                    if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n                    if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n                    if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n                    if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n                    if (t[2]) _.ops.pop();\n                    _.trys.pop(); continue;\n            }\n            op = body.call(thisArg, _);\n        } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n        if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n    }\n};\n\n// import { listenEvents } from './events';\n// import { WEBSOCKET_ADDRESS, Messages } from './Messages'\n// import { INIT_PAGE, INIT_STATE, State, Class } from './types';\n// import { fromVDOM, VDOM } from './vdom'\n\n// const CONTENT_ID = \"yeti-root-content\"\n// console.log(\"VERSION 2\", INIT_PAGE, INIT_STATE)\nconsole.log(\"Hyperbole 0.1.4\");\n// const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n// const address = `${protocol}//${window.location.host}`\n// const socket = new WebSocket(address)\n//\nvar rootStyles;\n(0,_events__WEBPACK_IMPORTED_MODULE_1__.listenClick)(function (target, action) {\n    return __awaiter(this, void 0, void 0, function () {\n        return __generator(this, function (_a) {\n            console.log(\"CLICK\", target.id, action);\n            runAction(target, action);\n            return [2 /*return*/];\n        });\n    });\n});\n(0,_events__WEBPACK_IMPORTED_MODULE_1__.listenFormSubmit)(function (target, action, form) {\n    return __awaiter(this, void 0, void 0, function () {\n        return __generator(this, function (_a) {\n            console.log(\"FORM\", target.id, action, form);\n            runAction(target, action, form);\n            return [2 /*return*/];\n        });\n    });\n});\n(0,_events__WEBPACK_IMPORTED_MODULE_1__.listenChange)(function (target, action) {\n    return __awaiter(this, void 0, void 0, function () {\n        return __generator(this, function (_a) {\n            console.log(\"CHANGE\", target.id, action);\n            runAction(target, action);\n            return [2 /*return*/];\n        });\n    });\n});\nfunction sendAction(id, action, form) {\n    return __awaiter(this, void 0, void 0, function () {\n        var url, res;\n        return __generator(this, function (_a) {\n            switch (_a.label) {\n                case 0:\n                    url = new URL(window.location.href);\n                    url.searchParams.append(\"id\", id);\n                    url.searchParams.append(\"action\", action);\n                    console.log(\"ACTION\", url.toString());\n                    return [4 /*yield*/, fetch(url, {\n                            method: \"POST\",\n                            headers: { 'Accept': 'text/html', 'Content-Type': 'application/x-www-form-urlencoded' },\n                            body: toSearch(form)\n                        })];\n                case 1:\n                    res = _a.sent();\n                    return [2 /*return*/, res.text()];\n            }\n        });\n    });\n}\nfunction runAction(target, action, form) {\n    return __awaiter(this, void 0, void 0, function () {\n        var timeout, ret, res, next, old;\n        return __generator(this, function (_a) {\n            switch (_a.label) {\n                case 0:\n                    timeout = setTimeout(function () {\n                        // add loading after 200ms, not right away\n                        target.classList.add(\"hyp-loading\");\n                    }, 200);\n                    return [4 /*yield*/, sendAction(target.id, action, form)];\n                case 1:\n                    ret = _a.sent();\n                    res = parseResponse(ret);\n                    // First, update the stylesheet\n                    addCSS(res.css.textContent);\n                    next = (0,omdomdom_lib_omdomdom_es_js__WEBPACK_IMPORTED_MODULE_0__.create)(res.content);\n                    old = (0,omdomdom_lib_omdomdom_es_js__WEBPACK_IMPORTED_MODULE_0__.create)(target);\n                    (0,omdomdom_lib_omdomdom_es_js__WEBPACK_IMPORTED_MODULE_0__.patch)(next, old);\n                    // Remove loading and clear add timeout\n                    clearTimeout(timeout);\n                    target.classList.remove(\"hyp-loading\");\n                    return [2 /*return*/];\n            }\n        });\n    });\n}\nfunction toSearch(form) {\n    if (!form)\n        return undefined;\n    var params = new URLSearchParams();\n    form.forEach(function (value, key) {\n        params.append(key, value);\n    });\n    return params;\n}\nfunction addCSS(text) {\n    var rules = text.split(\"\\n\");\n    rules.forEach(function (rule) {\n        rootStyles.sheet.insertRule(rule);\n    });\n}\nfunction parseResponse(ret) {\n    var parser = new DOMParser();\n    var doc = parser.parseFromString(ret, 'text/html');\n    var css = doc.querySelector(\"style\");\n    var content = doc.querySelector(\"div\");\n    return {\n        content: content,\n        css: css\n    };\n}\nfunction init() {\n    rootStyles = document.querySelector('style');\n}\ndocument.addEventListener(\"DOMContentLoaded\", init);\n\n\n//# sourceURL=webpack://web-ui/./src/index.ts?");
+
+/***/ }),
+
+/***/ "./node_modules/omdomdom/lib/omdomdom.es.js":
+/*!**************************************************!*\
+  !*** ./node_modules/omdomdom/lib/omdomdom.es.js ***!
+  \**************************************************/
+/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
+
+eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   create: () => (/* binding */ create),\n/* harmony export */   patch: () => (/* binding */ patch),\n/* harmony export */   render: () => (/* binding */ render)\n/* harmony export */ });\n/*!\n  * @license MIT (https://github.com/geotrev/omdomdom/blob/master/LICENSE)\n  * Omdomdom v0.3.2 (https://github.com/geotrev/omdomdom)\n  * Copyright 2023 George Treviranus <geowtrev@gmail.com>\n  */\nvar DATA_KEY_ATTRIBUTE$1 = \"data-key\";\nvar hasProperty = function hasProperty(obj, prop) {\n  return Object.prototype.hasOwnProperty.call(obj, prop);\n};\nvar keyIsValid = function keyIsValid(map, key) {\n  if (!key) return false;\n  if (hasProperty(map, key)) {\n    console.warn(\"[OmDomDom]: Children with duplicate keys detected. Children with duplicate keys will be skipped, resulting in dropped node references. Keys must be unique and non-indexed.\");\n    return false;\n  }\n  return true;\n};\nvar forEach = function forEach(items, fn) {\n  var length = items.length;\n  var idx = -1;\n  if (!length) return;\n  while (++idx < length) {\n    if (fn(items[idx], idx) === false) break;\n  }\n};\nvar createKeyMap = function createKeyMap(children) {\n  var map = {};\n  forEach(children, function (child) {\n    var key = child.attributes[DATA_KEY_ATTRIBUTE$1];\n    if (keyIsValid(map, key)) {\n      map[key] = child;\n    }\n  });\n  for (var _ in map) {\n    return map;\n  }\n};\nvar insertBefore = function insertBefore(parent, child, refNode) {\n  return parent.node.insertBefore(child.node, refNode);\n};\nvar assignVNode = function assignVNode(template, vNode) {\n  for (var property in template) {\n    vNode[property] = template[property];\n  }\n};\n\nvar toHTML = function toHTML(htmlString) {\n  var processedDOMString = htmlString.trim().replace(/\\s+</g, \"<\").replace(/>\\s+/g, \">\");\n  var parser = new DOMParser();\n  var context = parser.parseFromString(processedDOMString, \"text/html\");\n  return context.body;\n};\n\nfunction _iterableToArrayLimit(arr, i) {\n  var _i = null == arr ? null : \"undefined\" != typeof Symbol && arr[Symbol.iterator] || arr[\"@@iterator\"];\n  if (null != _i) {\n    var _s,\n      _e,\n      _x,\n      _r,\n      _arr = [],\n      _n = !0,\n      _d = !1;\n    try {\n      if (_x = (_i = _i.call(arr)).next, 0 === i) {\n        if (Object(_i) !== _i) return;\n        _n = !1;\n      } else for (; !(_n = (_s = _x.call(_i)).done) && (_arr.push(_s.value), _arr.length !== i); _n = !0);\n    } catch (err) {\n      _d = !0, _e = err;\n    } finally {\n      try {\n        if (!_n && null != _i.return && (_r = _i.return(), Object(_r) !== _r)) return;\n      } finally {\n        if (_d) throw _e;\n      }\n    }\n    return _arr;\n  }\n}\nfunction _slicedToArray(arr, i) {\n  return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest();\n}\nfunction _arrayWithHoles(arr) {\n  if (Array.isArray(arr)) return arr;\n}\nfunction _unsupportedIterableToArray(o, minLen) {\n  if (!o) return;\n  if (typeof o === \"string\") return _arrayLikeToArray(o, minLen);\n  var n = Object.prototype.toString.call(o).slice(8, -1);\n  if (n === \"Object\" && o.constructor) n = o.constructor.name;\n  if (n === \"Map\" || n === \"Set\") return Array.from(o);\n  if (n === \"Arguments\" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);\n}\nfunction _arrayLikeToArray(arr, len) {\n  if (len == null || len > arr.length) len = arr.length;\n  for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];\n  return arr2;\n}\nfunction _nonIterableRest() {\n  throw new TypeError(\"Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\");\n}\n\nvar Types = {\n  STRING: \"string\",\n  INT: \"number\",\n  BOOL: \"boolean\"\n};\nvar DomProperties = {};\nvar createRecord = function createRecord(attrName, propName, type) {\n  return {\n    attrName: attrName,\n    propName: propName,\n    type: type\n  };\n};\nvar stringProps = [[\"style\", \"cssText\"], [\"class\", \"className\"]];\nforEach(stringProps, function (_ref) {\n  var _ref2 = _slicedToArray(_ref, 2),\n    attr = _ref2[0],\n    property = _ref2[1];\n  DomProperties[attr] = createRecord(attr, property || attr, Types.STRING);\n});\nvar booleanProps = [\"autofocus\", \"draggable\", \"hidden\", \"checked\", \"multiple\", \"muted\", \"selected\"];\nforEach(booleanProps, function (attr) {\n  DomProperties[attr] = createRecord(attr, attr, Types.BOOL);\n});\nvar integerProps = [[\"tabindex\", \"tabIndex\"]];\nforEach(integerProps, function (_ref3) {\n  var _ref4 = _slicedToArray(_ref3, 2),\n    attr = _ref4[0],\n    property = _ref4[1];\n  DomProperties[attr] = createRecord(attr, property, Types.INT);\n});\nvar Namespace = {\n  xlink: {\n    prefix: \"xlink:\",\n    resource: \"http://www.w3.org/1999/xlink\"\n  },\n  xml: {\n    prefix: \"xml:\",\n    resource: \"http://www.w3.org/XML/1998/namespace\"\n  }\n};\n\nvar setProperty = function setProperty(element, type, prop, value) {\n  switch (type) {\n    case Types.STRING:\n      {\n        if (prop === DomProperties.style.propName) {\n          if (value === null) {\n            element.style[prop] = \"\";\n          } else {\n            element.style[prop] = value;\n          }\n        } else if (value === null) {\n          element[prop] = \"\";\n        } else {\n          element[prop] = value;\n        }\n        break;\n      }\n    case Types.INT:\n      {\n        if (value === null) {\n          var attr = prop.toLowerCase();\n          element.removeAttribute(attr);\n        } else if (value === \"0\") {\n          element[prop] = 0;\n        } else if (value === \"-1\") {\n          element[prop] = -1;\n        } else {\n          var parsed = parseInt(value, 10);\n          if (!isNaN(parsed)) {\n            element[prop] = parsed;\n          }\n        }\n        break;\n      }\n    case Types.BOOL:\n      {\n        if ([\"\", \"true\"].indexOf(value) < 0) {\n          element[prop] = false;\n        } else {\n          element[prop] = true;\n        }\n        break;\n      }\n  }\n};\n\nvar removeAttributes = function removeAttributes(vNode, attributes) {\n  forEach(attributes, function (attrName) {\n    if (hasProperty(DomProperties, attrName)) {\n      var propertyRecord = DomProperties[attrName];\n      setProperty(vNode.node, propertyRecord.type, propertyRecord.propName, null);\n    } else {\n      if (attrName in vNode.node) {\n        setProperty(vNode.node, Types.STRING, attrName, null);\n      }\n      vNode.node.removeAttribute(attrName);\n    }\n    delete vNode.attributes[attrName];\n  });\n};\nvar setAttributes = function setAttributes(vNode, attributes) {\n  for (var attrName in attributes) {\n    var value = attributes[attrName];\n    vNode.attributes[attrName] = value;\n    if (hasProperty(DomProperties, attrName)) {\n      var propertyRecord = DomProperties[attrName];\n      setProperty(vNode.node, propertyRecord.type, propertyRecord.propName, value);\n      continue;\n    }\n    if (attrName.startsWith(Namespace.xlink.prefix)) {\n      vNode.node.setAttributeNS(Namespace.xlink.resource, attrName, value);\n      continue;\n    }\n    if (attrName.startsWith(Namespace.xml.prefix)) {\n      vNode.node.setAttributeNS(Namespace.xml.resource, attrName, value);\n      continue;\n    }\n    if (attrName in vNode.node) {\n      setProperty(vNode.node, Types.STRING, attrName, value);\n    }\n    vNode.node.setAttribute(attrName, value || \"\");\n  }\n};\nvar getPropertyValues = function getPropertyValues(element, attributes) {\n  for (var attrName in DomProperties) {\n    var propertyRecord = DomProperties[attrName];\n    var propName = propertyRecord.propName;\n    var attrValue = element.getAttribute(attrName);\n    if (attrName === DomProperties.style.attrName) {\n      attributes[attrName] = element.style[propName];\n    } else if (typeof attrValue === \"string\") {\n      attributes[attrName] = attrValue;\n    }\n  }\n};\nvar getBaseAttributes = function getBaseAttributes(element) {\n  return Array.prototype.reduce.call(element.attributes, function (attributes, attrName) {\n    if (!hasProperty(DomProperties, attrName.name)) {\n      attributes[attrName.name] = attrName.value;\n    }\n    return attributes;\n  }, {});\n};\nvar getAttributes = function getAttributes(element) {\n  var attributes = getBaseAttributes(element);\n  getPropertyValues(element, attributes);\n  return attributes;\n};\nvar updateAttributes = function updateAttributes(template, vNode) {\n  var removedAttributes = [];\n  var changedAttributes = {};\n  for (var attrName in vNode.attributes) {\n    var oldValue = vNode.attributes[attrName];\n    var nextValue = template.attributes[attrName];\n    if (oldValue === nextValue) continue;\n    if (typeof nextValue === \"undefined\") {\n      removedAttributes.push(attrName);\n    }\n  }\n  for (var _attrName in template.attributes) {\n    var _oldValue = vNode.attributes[_attrName];\n    var _nextValue = template.attributes[_attrName];\n    if (_oldValue === _nextValue) continue;\n    if (typeof _nextValue !== \"undefined\") {\n      changedAttributes[_attrName] = _nextValue;\n    }\n  }\n  removeAttributes(vNode, removedAttributes);\n  setAttributes(vNode, changedAttributes);\n};\n\nvar DATA_KEY_ATTRIBUTE = \"data-key\";\nfunction patchChildren(template, vNode, patch) {\n  var templateChildrenLength = template.children.length;\n  var vNodeChildrenLength = vNode.children.length;\n  if (!templateChildrenLength && !vNodeChildrenLength) return;\n  var vNodeKeyMap = createKeyMap(vNode.children);\n  var nextChildren = Array(templateChildrenLength);\n  if (vNodeKeyMap !== undefined) {\n    forEach(template.children, function (templateChild, idx) {\n      var childNodes = vNode.node.childNodes;\n      var key = templateChild.attributes[DATA_KEY_ATTRIBUTE];\n      if (Object.prototype.hasOwnProperty.call(vNodeKeyMap, key)) {\n        var keyedChild = vNodeKeyMap[key];\n        if (Array.prototype.indexOf.call(childNodes, keyedChild.node) !== idx) {\n          insertBefore(vNode, keyedChild, childNodes[idx]);\n        }\n        nextChildren[idx] = keyedChild;\n        delete vNodeKeyMap[key];\n        patch(templateChild, nextChildren[idx]);\n      } else {\n        insertBefore(vNode, templateChild, childNodes[idx]);\n        nextChildren[idx] = templateChild;\n      }\n    });\n  } else {\n    forEach(template.children, function (templateChild, idx) {\n      var vNodeChild = vNode.children[idx];\n      if (typeof vNodeChild !== \"undefined\") {\n        patch(templateChild, vNodeChild);\n        nextChildren[idx] = vNodeChild;\n      } else {\n        vNode.node.appendChild(templateChild.node);\n        nextChildren[idx] = templateChild;\n      }\n    });\n  }\n  vNode.children = nextChildren;\n  var childNodesLength = vNode.node.childNodes.length;\n  var delta = childNodesLength - templateChildrenLength;\n  if (delta > 0) {\n    while (delta > 0) {\n      vNode.node.removeChild(vNode.node.childNodes[childNodesLength - 1]);\n      childNodesLength--;\n      delta--;\n    }\n  }\n}\n\nvar patch = function patch(template, vNode, rootNode) {\n  if (!template || !vNode) return;\n  rootNode = rootNode || vNode.node.parentNode;\n  var contentChanged = template.content && template.content !== vNode.content;\n  if (template.type !== vNode.type || contentChanged) {\n    rootNode.replaceChild(template.node, vNode.node);\n    return assignVNode(template, vNode);\n  }\n  updateAttributes(template, vNode);\n  patchChildren(template, vNode, patch);\n};\nvar render = function render(vNode, root) {\n  root.appendChild(vNode.node);\n};\nvar create = function create(node) {\n  var isSVGContext = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;\n  if (typeof node === \"string\") {\n    node = toHTML(node);\n  }\n  var isRoot = node.tagName === \"BODY\";\n  var childNodes = node.childNodes;\n  var numChildNodes = childNodes ? childNodes.length : 0;\n  if (isRoot) {\n    if (numChildNodes > 1) {\n      throw new Error(\"[OmDomDom]: Your element should not have more than one root node.\");\n    } else if (numChildNodes === 0) {\n      throw new Error(\"[OmDomDom]: Your element should have at least one root node.\");\n    } else {\n      return create(childNodes[0]);\n    }\n  }\n  var type = node.nodeType === 3 ? \"text\" : node.nodeType === 8 ? \"comment\" : node.tagName.toLowerCase();\n  var isSVG = isSVGContext || type === \"svg\";\n  var attributes = node.nodeType === 1 ? getAttributes(node) : {};\n  var content = numChildNodes > 0 ? null : node.textContent;\n  var children = Array(numChildNodes);\n  forEach(childNodes, function (child, idx) {\n    children[idx] = create(child, isSVG);\n  });\n  return {\n    type: type,\n    attributes: attributes,\n    children: children,\n    content: content,\n    node: node,\n    isSVGContext: isSVG\n  };\n};\n\n\n//# sourceMappingURL=omdomdom.es.js.map\n\n\n//# sourceURL=webpack://web-ui/./node_modules/omdomdom/lib/omdomdom.es.js?");
+
+/***/ })
+
+/******/ 	});
+/************************************************************************/
+/******/ 	// The module cache
+/******/ 	var __webpack_module_cache__ = {};
+/******/ 	
+/******/ 	// The require function
+/******/ 	function __webpack_require__(moduleId) {
+/******/ 		// Check if module is in cache
+/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
+/******/ 		if (cachedModule !== undefined) {
+/******/ 			return cachedModule.exports;
+/******/ 		}
+/******/ 		// Create a new module (and put it into the cache)
+/******/ 		var module = __webpack_module_cache__[moduleId] = {
+/******/ 			// no module.id needed
+/******/ 			// no module.loaded needed
+/******/ 			exports: {}
+/******/ 		};
+/******/ 	
+/******/ 		// Execute the module function
+/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
+/******/ 	
+/******/ 		// Return the exports of the module
+/******/ 		return module.exports;
+/******/ 	}
+/******/ 	
+/************************************************************************/
+/******/ 	/* webpack/runtime/define property getters */
+/******/ 	(() => {
+/******/ 		// define getter functions for harmony exports
+/******/ 		__webpack_require__.d = (exports, definition) => {
+/******/ 			for(var key in definition) {
+/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
+/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
+/******/ 				}
+/******/ 			}
+/******/ 		};
+/******/ 	})();
+/******/ 	
+/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
+/******/ 	(() => {
+/******/ 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
+/******/ 	})();
+/******/ 	
+/******/ 	/* webpack/runtime/make namespace object */
+/******/ 	(() => {
+/******/ 		// define __esModule on exports
+/******/ 		__webpack_require__.r = (exports) => {
+/******/ 			if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
+/******/ 				Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
+/******/ 			}
+/******/ 			Object.defineProperty(exports, '__esModule', { value: true });
+/******/ 		};
+/******/ 	})();
+/******/ 	
+/************************************************************************/
+/******/ 	
+/******/ 	// startup
+/******/ 	// Load entry module and return exports
+/******/ 	// This entry module can't be inlined because the eval devtool is used.
+/******/ 	var __webpack_exports__ = __webpack_require__("./src/index.ts");
+/******/ 	
+/******/ })()
+;
diff --git a/example/BulkUpdate.hs b/example/BulkUpdate.hs
new file mode 100644
--- /dev/null
+++ b/example/BulkUpdate.hs
@@ -0,0 +1,66 @@
+module BulkUpdate where
+
+-- import Control.Monad (forM_)
+-- import Control.Monad.IO.Class (liftIO)
+-- import Data.Text (pack)
+-- import Example.Colors
+-- import Example.Effects.Users (User (..), UserStore)
+-- import Example.Effects.Users qualified as Users
+-- import Web.Htmx
+-- import Web.Hyperbole (view)
+-- import Web.Hyperbole.Htmx
+-- import Web.Scotty hiding (text)
+-- import Web.UI
+--
+-- route :: UserStore -> ScottyM ()
+-- route st = do
+--   get "/contacts/" $ do
+--     us <- Users.all st
+--     view $ viewMain us
+--
+--   -- scotty doesn't support multiple ids :(
+--   post "/contacts/activate" $ do
+--     ids <- param "ids" :: ActionM [Int]
+--     liftIO $ print ids
+--     forM_ ids $ \uid ->
+--       Users.modify st uid $ \u -> u{isActive = True}
+--     us <- Users.all st
+--     view $ viewUsers us
+--
+--   post "/contacts/deactivate" $ do
+--     ids <- param "ids" :: ActionM [Int]
+--     liftIO $ print ids
+--     forM_ ids $ \uid ->
+--       Users.modify st uid $ \u -> u{isActive = False}
+--     us <- Users.all st
+--     view $ viewUsers us
+--
+-- viewMain :: [User] -> View ()
+-- viewMain users = do
+--   row (hxTarget (Query (Id "table")) . hxSwap OuterHTML . hxInclude (Id "form")) $ do
+--     col (pad 20 . width 500) $ do
+--       button (hxPost ("contacts" // "activate")) "Activate"
+--       button (hxPost ("contacts" // "deactivate")) "Deactivate"
+--
+--     form (grow . pad 10 . att "id" "form") $ do
+--       viewUsers users
+--
+-- viewUsers :: [User] -> View ()
+-- viewUsers users = do
+--   table (border 1 . att "id" "table") users $ do
+--     tcol (cell . width 20) none $ \u -> do
+--       input (att "type" "checkbox" . name "ids" . value (pack $ show u.id))
+--
+--     tcol cell (hd "First Name") $ \u -> do
+--       el (if u.isActive then bold else id) $ text u.firstName
+--
+--     tcol cell (hd "Last Name") $ \u -> do
+--       text u.lastName
+--
+--     tcol cell (hd "Email") $ \u -> do
+--       text u.email
+--  where
+--   cell = pad 4 . border 1 . borderColor GrayLight
+--   hd = el bold
+--
+-- --
diff --git a/example/Example/Colors.hs b/example/Example/Colors.hs
new file mode 100644
--- /dev/null
+++ b/example/Example/Colors.hs
@@ -0,0 +1,33 @@
+module Example.Colors where
+
+import Web.Hyperbole
+
+data AppColor
+  = White
+  | Light
+  | GrayLight
+  | GrayDark
+  | Dark
+  | Success
+  | Error
+  | Warning
+  | Primary
+  | PrimaryLight
+  | Secondary
+  | SecondaryLight
+  deriving (Show)
+
+instance ToColor AppColor where
+  colorValue White = "#FFF"
+  colorValue Light = "#F2F2F3"
+  colorValue GrayLight = "#E3E5E9"
+  colorValue GrayDark = "#2С3С44"
+  colorValue Dark = "#2E3842" -- "#232C41"
+  colorValue Primary = "#2C74BB"
+  colorValue PrimaryLight = "#3281cf"
+  colorValue Secondary = "#5CADDB"
+  colorValue SecondaryLight = "#8CFDAB"
+  -- colorValue Success = "67C837"
+  colorValue Success = "#D5E6DE"
+  colorValue Error = "#F3D8DA"
+  colorValue Warning = "#FDF3D1"
diff --git a/example/Example/Contacts.hs b/example/Example/Contacts.hs
new file mode 100644
--- /dev/null
+++ b/example/Example/Contacts.hs
@@ -0,0 +1,189 @@
+module Example.Contacts where
+
+import Control.Monad (forM_)
+import Data.String.Conversions
+import Data.Text (Text)
+import Effectful
+import Effectful.Dispatch.Dynamic
+import Example.Colors
+import Example.Effects.Debug
+import Example.Effects.Users (User (..), Users)
+import Example.Effects.Users qualified as Users
+import GHC.Generics (Generic)
+import Web.Hyperbole
+
+
+page :: forall es. (Hyperbole :> es, Users :> es, Debug :> es) => Page es ()
+page = do
+  hyper contacts
+  hyper contact
+  load $ do
+    us <- usersAll
+    pure $ do
+      col (pad 10 . gap 10) $ do
+        viewId Contacts $ allContactsView Nothing us
+
+
+-- Contacts ----------------------------------------------
+
+data Contacts = Contacts
+  deriving (Show, Read, Param)
+
+
+data ContactsAction
+  = Reload (Maybe Filter)
+  | Delete Int
+  deriving (Show, Read, Param)
+
+
+instance HyperView Contacts where
+  type Action Contacts = ContactsAction
+
+
+data Filter
+  = Active
+  | Inactive
+  deriving (Show, Read, Eq)
+
+
+contacts :: (Hyperbole :> es, Users :> es, Debug :> es) => Contacts -> ContactsAction -> Eff es (View Contacts ())
+contacts _ (Reload mf) = do
+  us <- usersAll
+  pure $ allContactsView mf us
+contacts _ (Delete uid) = do
+  userDelete uid
+  us <- usersAll
+  pure $ allContactsView Nothing us
+
+
+allContactsView :: Maybe Filter -> [User] -> View Contacts ()
+allContactsView fil us = do
+  row (gap 10) $ do
+    button (Reload Nothing) (bg GrayLight) "Reload"
+
+    dropdown Reload (== fil) $ do
+      option Nothing id ""
+      option (Just Active) id "Active!"
+      option (Just Inactive) id "Inactive"
+
+    target (Contact 2) $ button Edit (bg GrayLight) "Edit 2"
+
+  row (pad 10 . gap 10) $ do
+    let filtered = filter (filterUsers fil) us
+    forM_ filtered $ \u -> do
+      el (border 1) $ do
+        viewId (Contact u.id) $ contactView u
+ where
+  filterUsers Nothing _ = True
+  filterUsers (Just Active) u = u.isActive
+  filterUsers (Just Inactive) u = not u.isActive
+
+
+-- Contact ----------------------------------------------------
+
+data Contact = Contact Int
+  deriving (Show, Read, Param)
+
+
+data ContactAction
+  = Edit
+  | Save
+  | View
+  deriving (Show, Read, Param)
+
+
+instance HyperView Contact where
+  type Action Contact = ContactAction
+
+
+data UserForm a = UserForm
+  { firstName :: Field a Text
+  , lastName :: Field a Text
+  , age :: Field a Int
+  }
+  deriving (Generic, Form)
+
+
+contact :: (Hyperbole :> es, Users :> es, Debug :> es) => Contact -> ContactAction -> Eff es (View Contact ())
+contact (Contact uid) a = do
+  u <- userFind uid
+  action u a
+ where
+  action u View = do
+    pure $ contactView u
+  action u Edit = do
+    pure $ contactEdit u
+  action u Save = do
+    delay 1000
+    UserForm{firstName, lastName, age} <- parseForm
+    let u' = User{id = u.id, isActive = True, firstName, lastName, age}
+    userSave u'
+    pure $ contactView u'
+
+
+contactView :: User -> View Contact ()
+contactView u = do
+  col (pad 10 . gap 10) $ do
+    row fld $ do
+      el id (text "First Name:")
+      text u.firstName
+
+    row fld $ do
+      el id (text "Last Name:")
+      text u.lastName
+
+    row fld $ do
+      el id (text "Age:")
+      text (cs $ show u.age)
+
+    row fld $ do
+      el id (text "Active:")
+      text (cs $ show u.isActive)
+
+    button Edit (bg Primary . color White . hover (bg PrimaryLight . color Dark)) "Edit"
+ where
+  fld = gap 10
+
+
+contactEdit :: User -> View Contact ()
+contactEdit u =
+  onRequest loading $ do
+    form @UserForm Save (pad 10 . gap 10) $ \f -> do
+      field fld $ do
+        label "First Name:"
+        input Name (value u.firstName) f.firstName
+
+      field fld $ do
+        label "Last Name:"
+        input Name (value u.lastName) f.lastName
+
+      field fld $ do
+        label "Age:"
+        input Number (value $ cs $ show u.age) f.age
+
+      submit id "Submit"
+
+      button View id (text "Cancel")
+
+      target Contacts $ button (Delete u.id) (bg Secondary) (text "Delete")
+ where
+  loading = el (bg Secondary) "Loading..."
+  fld = flexRow . gap 10
+
+
+userFind :: (Hyperbole :> es, Users :> es) => Int -> Eff es User
+userFind uid = do
+  mu <- send (Users.LoadUser uid)
+  maybe notFound pure mu
+
+
+usersAll :: (Users :> es) => Eff es [User]
+usersAll = send Users.LoadUsers
+
+
+userSave :: (Users :> es) => User -> Eff es ()
+userSave = send . Users.SaveUser
+
+
+userDelete :: (Users :> es) => Int -> Eff es ()
+userDelete = send . Users.DeleteUser
diff --git a/example/Example/Effects/Debug.hs b/example/Example/Effects/Debug.hs
new file mode 100644
--- /dev/null
+++ b/example/Example/Effects/Debug.hs
@@ -0,0 +1,36 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Example.Effects.Debug where
+
+import Control.Concurrent (threadDelay)
+import Data.String.Interpolate (i)
+import Effectful
+import Effectful.Dispatch.Dynamic
+
+
+type Milliseconds = Int
+data Debug :: Effect where
+  Dump :: (Show a) => String -> a -> Debug m ()
+  Delay :: Milliseconds -> Debug m ()
+
+
+type instance DispatchOf Debug = 'Dynamic
+
+
+runDebugIO
+  :: (IOE :> es)
+  => Eff (Debug : es) a
+  -> Eff es a
+runDebugIO = interpret $ \_ -> \case
+  Dump msg a -> do
+    liftIO $ putStrLn [i| [#{msg}] #{show a}|]
+  Delay ms -> liftIO $ threadDelay (ms * 1000)
+
+
+dump :: (Debug :> es, Show a) => String -> a -> Eff es ()
+dump msg a = send $ Dump msg a
+
+
+delay :: (Debug :> es) => Milliseconds -> Eff es ()
+delay n = send $ Delay n
diff --git a/example/Example/Effects/Users.hs b/example/Example/Effects/Users.hs
new file mode 100644
--- /dev/null
+++ b/example/Example/Effects/Users.hs
@@ -0,0 +1,83 @@
+{-# LANGUAGE LambdaCase #-}
+
+module Example.Effects.Users where
+
+import Control.Concurrent.MVar
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as M
+import Data.Text (Text)
+import Effectful
+import Effectful.Dispatch.Dynamic
+
+
+data User = User
+  { id :: Int
+  , firstName :: Text
+  , lastName :: Text
+  , age :: Int
+  , isActive :: Bool
+  }
+  deriving (Show)
+
+
+-- Load a user AND do next if missing?
+data Users :: Effect where
+  LoadUser :: Int -> Users m (Maybe User)
+  LoadUsers :: Users m [User]
+  SaveUser :: User -> Users m ()
+  ModifyUser :: Int -> (User -> User) -> Users m ()
+  DeleteUser :: Int -> Users m ()
+
+
+type instance DispatchOf Users = 'Dynamic
+
+
+type UserStore = MVar (Map Int User)
+
+
+runUsersIO
+  :: (IOE :> es)
+  => UserStore
+  -> Eff (Users : es) a
+  -> Eff es a
+runUsersIO var = interpret $ \_ -> \case
+  LoadUser uid -> load uid
+  LoadUsers -> loadAll
+  SaveUser u -> save u
+  ModifyUser uid f -> modify uid f
+  DeleteUser uid -> delete uid
+ where
+  load :: (MonadIO m) => Int -> m (Maybe User)
+  load uid = do
+    us <- liftIO $ readMVar var
+    pure $ M.lookup uid us
+
+  save :: (MonadIO m) => User -> m ()
+  save u = do
+    liftIO $ modifyMVar_ var $ \us -> pure $ M.insert u.id u us
+
+  loadAll :: (MonadIO m) => m [User]
+  loadAll = do
+    us <- liftIO $ readMVar var
+    pure $ M.elems us
+
+  modify :: (MonadIO m) => Int -> (User -> User) -> m ()
+  modify uid f = liftIO $ do
+    modifyMVar_ var $ \us -> do
+      pure $ M.adjust f uid us
+
+  delete :: (MonadIO m) => Int -> m ()
+  delete uid = do
+    liftIO $ modifyMVar_ var $ \us -> pure $ M.delete uid us
+
+
+initUsers :: (MonadIO m) => m UserStore
+initUsers =
+  liftIO $ newMVar $ M.fromList $ map (\u -> (u.id, u)) users
+ where
+  users =
+    [ User 1 "Joe" "Blow" 32 True
+    , User 2 "Sara" "Dane" 24 False
+    , User 3 "Billy" "Bob" 48 False
+    , User 4 "Felicia" "Korvus" 84 True
+    ]
diff --git a/example/Example/Forms.hs b/example/Example/Forms.hs
new file mode 100644
--- /dev/null
+++ b/example/Example/Forms.hs
@@ -0,0 +1,93 @@
+module Example.Forms where
+
+import Data.Functor.Identity (Identity)
+import Data.Text (Text, pack)
+import Effectful
+import Example.Colors
+import GHC.Generics (Generic)
+import Web.Hyperbole
+
+
+page :: (Hyperbole :> es) => Page es ()
+page = do
+  hyper action
+
+  load $ do
+    pure $ row (pad 20) $ do
+      viewId Main formView
+
+
+data Main = Main
+  deriving (Show, Read, Param)
+
+
+data MainAction
+  = Submit
+  | Cancel
+  deriving (Show, Read, Param)
+
+
+instance HyperView Main where
+  type Action Main = MainAction
+
+
+data UserForm a = UserForm
+  { username :: Field a Text
+  , age :: Field a Integer
+  , password1 :: Field a Text
+  , password2 :: Field a Text
+  }
+  deriving (Generic, Form)
+
+
+action :: (Hyperbole :> es) => Main -> MainAction -> Eff es (View Main ())
+action _ Submit = do
+  u <- parseForm
+  pure $ userView u
+action _ Cancel = do
+  pure $ el_ "Cancelled"
+
+
+formView :: View Main ()
+formView = do
+  form @UserForm Submit (gap 10 . pad 10) $ \f -> do
+    el h1 "Sign Up"
+
+    field id $ do
+      label "Username"
+      input Username (inp . placeholder "username") f.username
+
+    field id $ do
+      label "Age"
+      input Number (inp . placeholder "age") f.age
+
+    field id $ do
+      label "Password"
+      input NewPassword (inp . placeholder "password") f.password1
+
+    field id $ do
+      label "Repeat Password"
+      input NewPassword (inp . placeholder "repeat password") f.password2
+
+    submit id "Submit"
+    button Cancel id "Cancel"
+ where
+  placeholder = att "placeholder"
+  inp = border 1 . pad 8
+
+
+userView :: UserForm Identity -> View Main ()
+userView user = do
+  el_ "User View"
+  el_ $ text user.username
+  el_ $ text $ pack (show user.age)
+  el_ $ text user.password1
+  el_ $ text user.password2
+
+
+btn :: Mod
+btn = bg Primary . hover (bg PrimaryLight) . color White . pad 10
+
+
+h1 :: Mod
+h1 = bold . fontSize 32
diff --git a/example/Example/Transitions.hs b/example/Example/Transitions.hs
new file mode 100644
--- /dev/null
+++ b/example/Example/Transitions.hs
@@ -0,0 +1,45 @@
+module Example.Transitions where
+
+import Effectful
+import Example.Colors
+import Web.Hyperbole
+
+page :: (Hyperbole :> es) => Page es ()
+page = do
+  hyper content
+
+  load $ do
+    pure $ row (pad 20) $ do
+      viewId Contents viewSmall
+
+data Contents = Contents
+  deriving (Show, Read, Param)
+
+data ContentsAction
+  = Expand
+  | Collapse
+  deriving (Show, Read, Param)
+
+instance HyperView Contents where
+  type Action Contents = ContentsAction
+
+content :: (Hyperbole :> es) => Contents -> ContentsAction -> Eff es (View Contents ())
+content _ Expand = do
+  pure viewBig
+content _ Collapse = do
+  pure viewSmall
+
+viewSmall :: View Contents ()
+viewSmall = do
+  col (gap 10 . border 1 . pad 20 . transition 300 (Height 200)) $ do
+    el id "Hello"
+    button Expand btn "Expand"
+
+viewBig :: View Contents ()
+viewBig = col (gap 10 . border 1 . pad 20 . transition 300 (Height 400)) $ do
+  el_ "One"
+  el_ "TWO"
+  button Collapse (bg Secondary . hover (bg SecondaryLight) . color White . pad 10) "Collapse"
+
+btn :: Mod
+btn = bg Primary . hover (bg PrimaryLight) . color White . pad 10
diff --git a/example/Main.hs b/example/Main.hs
new file mode 100644
--- /dev/null
+++ b/example/Main.hs
@@ -0,0 +1,101 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# OPTIONS_GHC -Wno-unused-imports #-}
+
+module Main where
+
+import Control.Monad (forever)
+import Data.ByteString.Lazy qualified as BL
+import Data.String.Conversions (cs)
+import Data.String.Interpolate (i)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Lazy qualified as L
+import Data.Text.Lazy.Encoding qualified as L
+import Effectful
+import Effectful.Dispatch.Dynamic
+import Effectful.State.Static.Local
+import Example.Contacts qualified as Contacts
+import Example.Effects.Debug
+import Example.Effects.Users as Users
+import Example.Forms qualified as Forms
+import Example.Transitions qualified as Transitions
+import GHC.Generics (Generic)
+import Network.HTTP.Types (Method, QueryItem, methodPost, status200, status404)
+import Network.Wai ()
+import Network.Wai.Handler.Warp qualified as Warp
+import Network.Wai.Middleware.Static (addBase, staticPolicy)
+import Web.Hyperbole
+
+
+-- import Network.Wai.Handler.WebSockets (websocketsOr)
+
+main :: IO ()
+main = do
+  putStrLn "Starting Examples on http://localhost:3003"
+  users <- initUsers
+  Warp.run 3003
+    $ staticPolicy (addBase "client/dist")
+    $ app users
+
+
+data AppRoute
+  = Main
+  | Hello Hello
+  | Contacts
+  | Transitions
+  | Forms
+  deriving (Show, Generic, Eq, Route)
+
+
+data Hello
+  = Greet Text
+  deriving (Show, Generic, Eq, Route)
+
+
+app :: UserStore -> Application
+app users = waiApplication toDocument (runUsersIO users . runHyperbole . runDebugIO . router)
+ where
+  router :: (Hyperbole :> es, Users :> es, Debug :> es) => AppRoute -> Eff es ()
+  router (Hello h) = page $ hello h
+  router Contacts = page Contacts.page
+  router Transitions = page Transitions.page
+  router Forms = page Forms.page
+  router Main = view $ do
+    col (gap 10 . pad 10) $ do
+      el (bold . fontSize 32) "Examples"
+      link (Hello (Greet "World")) id "Hello World"
+      link Contacts id "Contacts"
+      link Transitions id "Transitions"
+      link Forms id "Forms"
+
+  -- example sub-router
+  hello :: (Hyperbole :> es, Debug :> es) => Hello -> Page es ()
+  hello (Greet s) = load $ do
+    pure $ do
+      el (pad 10 . gap 10) $ do
+        text "Greetings, "
+        text s
+
+  -- Use the embedded version for real applications (see below). The link to /hyperbole.js here is just to make local development easier
+  toDocument :: BL.ByteString -> BL.ByteString
+  toDocument cnt =
+    [i|<html>
+      <head>
+        <title>Hyperbole Examples</title>
+        <script type="text/javascript" src="/hyperbole.js"></script>
+        <style type type="text/css">#{cssResetEmbed}</style>
+      </head>
+      <body>#{cnt}</body>
+    </html>|]
+
+-- toDocument :: BL.ByteString -> BL.ByteString
+-- toDocument cnt =
+--   [i|<html>
+--     <head>
+--       <title>Hyperbole Examples</title>
+--       <script type="text/javascript">#{scriptEmbed}</script>
+--       <style type type="text/css">#{cssResetEmbed}</style>
+--     </head>
+--     <body>#{cnt}</body>
+--   </html>|]
diff --git a/hyperbole.cabal b/hyperbole.cabal
new file mode 100644
--- /dev/null
+++ b/hyperbole.cabal
@@ -0,0 +1,115 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.36.0.
+--
+-- see: https://github.com/sol/hpack
+
+name:           hyperbole
+version:        0.1.1
+synopsis:       Web Framework inspired by HTMX
+description:    Web Framework inspired by HTMX.
+category:       Web
+homepage:       https://github.com/seanhess/hyperbole
+bug-reports:    https://github.com/seanhess/hyperbole/issues
+author:         Sean Hess
+maintainer:     seanhess@gmail.com
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    client/dist/hyperbole.js
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/seanhess/hyperbole
+
+library
+  exposed-modules:
+      Effectful.Sockets
+      Effectful.Wai
+      Web.Hyperbole
+      Web.Hyperbole.Application
+      Web.Hyperbole.Effect
+      Web.Hyperbole.Embed
+      Web.Hyperbole.HyperView
+      Web.Hyperbole.Input
+      Web.Hyperbole.Route
+  other-modules:
+      Paths_hyperbole
+  autogen-modules:
+      Paths_hyperbole
+  hs-source-dirs:
+      src
+  default-extensions:
+      OverloadedStrings
+      OverloadedRecordDot
+      DuplicateRecordFields
+      NoFieldSelectors
+      TypeFamilies
+      DataKinds
+      DerivingStrategies
+      DeriveAnyClass
+  ghc-options: -Wall -fdefer-typed-holes
+  build-depends:
+      base >=4.16 && <5
+    , bytestring >=0.11 && <0.13
+    , casing >0.1.3.0 && <0.2
+    , containers >=0.6 && <1
+    , effectful ==2.3.*
+    , file-embed >=0.0.10 && <0.1
+    , http-api-data ==0.6.*
+    , http-types >=0.12.3 && <0.13
+    , string-conversions >=0.4 && <=0.5
+    , string-interpolate >=0.3.2 && <0.4
+    , text >=1.2 && <3
+    , wai >=3.2.3 && <3.3
+    , warp >=3.3.30 && <3.4
+    , web-view >=0.3 && <=0.4
+  default-language: GHC2021
+
+executable example
+  main-is: Main.hs
+  other-modules:
+      BulkUpdate
+      Example.Colors
+      Example.Contacts
+      Example.Effects.Debug
+      Example.Effects.Users
+      Example.Forms
+      Example.Transitions
+      Paths_hyperbole
+  autogen-modules:
+      Paths_hyperbole
+  hs-source-dirs:
+      example
+  default-extensions:
+      OverloadedStrings
+      OverloadedRecordDot
+      DuplicateRecordFields
+      NoFieldSelectors
+      TypeFamilies
+      DataKinds
+      DerivingStrategies
+      DeriveAnyClass
+  ghc-options: -Wall -fdefer-typed-holes
+  build-depends:
+      base >=4.16 && <5
+    , bytestring >=0.11 && <0.13
+    , casing >0.1.3.0 && <0.2
+    , containers >=0.6 && <1
+    , effectful ==2.3.*
+    , file-embed >=0.0.10 && <0.1
+    , http-api-data ==0.6.*
+    , http-types >=0.12.3 && <0.13
+    , hyperbole
+    , string-conversions >=0.4 && <=0.5
+    , string-interpolate >=0.3.2 && <0.4
+    , text >=1.2 && <3
+    , wai >=3.2.3 && <3.3
+    , wai-middleware-static >=0.9 && <=0.10
+    , warp >=3.3.30 && <3.4
+    , web-view >=0.3 && <=0.4
+  default-language: GHC2021
diff --git a/src/Effectful/Sockets.hs b/src/Effectful/Sockets.hs
new file mode 100644
--- /dev/null
+++ b/src/Effectful/Sockets.hs
@@ -0,0 +1,74 @@
+-- {-# LANGUAGE LambdaCase #-}
+-- {-# LANGUAGE QuasiQuotes #-}
+
+module Effectful.Sockets where
+
+
+-- import Control.Monad (forever)
+-- import Data.Aeson (ToJSON)
+-- import Data.Aeson qualified as A
+-- import Data.ByteString.Lazy qualified as BL
+-- import Data.String.Interpolate (i)
+-- import Data.Text (Text)
+-- import Effectful
+-- import Effectful.Dispatch.Dynamic
+-- import Effectful.State.Static.Local
+-- import Network.WebSockets (Connection, ConnectionOptions, ServerApp, WebSocketsData)
+-- import Network.WebSockets qualified as WS
+--
+-- data Command
+--   = Render [Content]
+--   | Test Text
+--
+-- data Socket :: Effect where
+--   SendMessage :: BL.ByteString -> Socket m ()
+--   ReceiveData :: (WebSocketsData a) => Socket m a
+--
+-- type instance DispatchOf Socket = 'Dynamic
+--
+-- -- we should assume a connection here?
+-- -- Accept :: PendingConnection -> Sockets m Connection
+--
+-- data Client = Client
+--   { counter :: Int
+--   , contents :: [Content]
+--   }
+--
+-- type instance DispatchOf Socket = 'Dynamic
+--
+-- connectionOptions :: ConnectionOptions
+-- connectionOptions = WS.defaultConnectionOptions
+--
+-- socketApplication :: Eff [Socket, IOE] () -> ServerApp
+-- socketApplication talk pending = do
+--   conn <- WS.acceptRequest pending
+--   -- WS.sendTextData conn ("HELLO CLIENT" :: Text)
+--   let client = Client 0 []
+--   runEff . runSocket conn client $ forever talk
+--
+-- runSocket
+--   :: (IOE :> es)
+--   => Connection
+--   -> Client
+--   -> Eff (Socket : es) a
+--   -> Eff es a
+-- runSocket conn client = reinterpret (evalState client) $ \_ -> \case
+--   SendMessage t -> do
+--     -- cl :: Client <- get
+--     liftIO $ WS.sendTextData conn t
+--   ReceiveData -> do
+--     a <- liftIO $ WS.receiveData conn
+--     modify $ \c -> c{counter = c.counter + 1}
+--     pure a
+--
+-- sendCommand :: (Socket :> es) => Command -> Eff es ()
+-- sendCommand (Render cnt) = send $ SendMessage (formatMessage "Render" cnt)
+-- sendCommand (Test cnt) = send $ SendMessage (formatMessage "Test" cnt)
+--
+-- receiveData :: (Socket :> es, WebSocketsData a) => Eff es a
+-- receiveData = send ReceiveData
+--
+-- formatMessage :: (ToJSON a) => BL.ByteString -> a -> BL.ByteString
+-- formatMessage flag cnt =
+--   let content = A.encode cnt
+--    in [i|#{flag} #{content}|]
diff --git a/src/Effectful/Wai.hs b/src/Effectful/Wai.hs
new file mode 100644
--- /dev/null
+++ b/src/Effectful/Wai.hs
@@ -0,0 +1,130 @@
+{-# LANGUAGE FieldSelectors #-}
+{-# LANGUAGE LambdaCase #-}
+
+module Effectful.Wai where
+
+import Control.Monad (when)
+import Data.ByteString (ByteString)
+import Data.ByteString.Lazy qualified as L
+import Data.Text (Text)
+import Effectful
+import Effectful.Dispatch.Dynamic
+import Effectful.Error.Static
+import Effectful.State.Static.Local
+import Network.HTTP.Types (Status, status200, status500)
+import Network.HTTP.Types.Header (HeaderName)
+import Network.Wai as Wai
+import Web.FormUrlEncoded
+import Web.HttpApiData (FromHttpApiData)
+import Web.View
+
+
+data Wai :: Effect where
+  ResHeader :: HeaderName -> ByteString -> Wai m ()
+  ResBody :: ContentType -> L.ByteString -> Wai m ()
+  ResStatus :: Status -> Wai m ()
+  ReqBody :: Wai m L.ByteString
+  Request :: Wai m Request
+  Continue :: Wai m ()
+  Interrupt :: Interrupt -> Wai m a
+
+
+type instance DispatchOf Wai = 'Dynamic
+
+
+data Handler = Handler
+  { request :: Request
+  , cachedRequestBody :: L.ByteString
+  , status :: Status
+  , headers :: [(HeaderName, ByteString)]
+  , contentType :: ContentType
+  , body :: L.ByteString
+  }
+
+
+data ContentType
+  = ContentHtml
+  | ContentText
+
+
+formData :: (Wai :> es) => Eff es Form
+formData = do
+  bd <- send ReqBody
+  let ef = urlDecodeForm bd
+  either (send . Interrupt . ParseError) pure ef
+
+
+parseFormData :: (Wai :> es, FromForm a) => Eff es a
+parseFormData = do
+  f <- formData
+  either (send . Interrupt . ParseError) pure $ fromForm f
+
+
+formParam :: (Wai :> es, FromHttpApiData a) => Text -> Eff es a
+formParam k = do
+  f <- formData
+  either (send . Interrupt . ParseError) pure $ parseUnique k f
+
+
+requestBody :: (Wai :> es) => Eff es L.ByteString
+requestBody = send ReqBody
+
+
+runWai
+  :: (IOE :> es)
+  => Request
+  -> Eff (Wai : es) a
+  -> Eff es (Either Interrupt Handler)
+runWai req = reinterpret runLocal $ \_ -> \case
+  Request -> do
+    gets request
+  ReqBody -> do
+    cacheReqBody
+    gets cachedRequestBody
+  ResHeader k v -> modify
+    $ \r -> r{headers = (k, v) : r.headers}
+  ResStatus s -> modify
+    $ \r -> r{status = s}
+  ResBody ct bd -> modify
+    $ \r -> r{body = bd, contentType = ct, status = status200}
+  Continue -> do
+    h <- get
+    throwError $ RespondNow h
+  Interrupt e -> do
+    throwError e
+ where
+  runLocal =
+    runErrorNoCallStack @Interrupt
+      . execState @Handler (emptyResponse req)
+
+  cacheReqBody :: forall es. (IOE :> es, State Handler :> es) => Eff es ()
+  cacheReqBody = do
+    r <- get
+    when (L.null r.cachedRequestBody) $ do
+      rb <- liftIO $ Wai.consumeRequestBodyLazy req
+      put $ r{cachedRequestBody = rb}
+
+
+-- Ends computation with whatever has already been set on the response
+continue :: (Wai :> es) => Eff es ()
+continue = send Continue
+
+
+emptyResponse :: Request -> Handler
+emptyResponse r = Handler r "" status500 [] ContentText "Response not set"
+
+
+data Interrupt
+  = NotFound
+  | Redirect Url
+  | ParseError Text
+  | RespondNow Handler
+
+
+notFound :: (Wai :> es) => Eff es a
+notFound = send $ Interrupt NotFound
+
+
+redirect :: (Wai :> es) => Url -> Eff es ()
+redirect u = do
+  send $ Interrupt $ Redirect u
diff --git a/src/Web/Hyperbole.hs b/src/Web/Hyperbole.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole.hs
@@ -0,0 +1,22 @@
+module Web.Hyperbole
+  ( module Web.Hyperbole.Route
+  , module Web.Hyperbole.Effect
+  , module Web.Hyperbole.HyperView
+  , module Web.Hyperbole.Application
+  , module Web.Hyperbole.Input
+  , module Web.View
+  , Application
+  , run
+  , scriptEmbed
+  ) where
+
+import Network.Wai (Application)
+import Network.Wai.Handler.Warp (run)
+import Web.Hyperbole.Application
+import Web.Hyperbole.Effect
+import Web.Hyperbole.Embed (scriptEmbed)
+import Web.Hyperbole.HyperView
+import Web.Hyperbole.Input
+import Web.Hyperbole.Route
+import Web.View hiding (button, form, input, label, link)
+
diff --git a/src/Web/Hyperbole/Application.hs b/src/Web/Hyperbole/Application.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole/Application.hs
@@ -0,0 +1,72 @@
+module Web.Hyperbole.Application
+  ( waiApplication
+  , Wai
+  ) where
+
+import Data.ByteString (ByteString)
+import Data.ByteString.Lazy qualified as L
+import Data.String.Conversions (cs)
+import Effectful
+import Effectful.Wai
+import Network.HTTP.Types (status200, status301, status400, status404)
+import Network.HTTP.Types.Header (HeaderName)
+import Network.Wai
+import Web.Hyperbole.Route
+import Web.View.Types (Url (..))
+
+
+waiApplication :: (Route route) => (L.ByteString -> L.ByteString) -> (route -> Eff [Wai, IOE] ()) -> Application
+waiApplication toDoc actions request respond = do
+  -- let (method, paths, query) = (requestMethod req, pathInfo req, queryString req)
+  case findRoute (pathInfo request) of
+    Nothing -> respond $ responseLBS status404 [contentType ContentText] "Not Found"
+    Just rt -> do
+      res <- runEff . runWai request $ actions rt
+      case res of
+        Left err -> interrupt err
+        Right resp -> sendResponse resp
+ where
+  findRoute [] = Just defRoute
+  findRoute ps = matchRoute (Path True ps)
+
+  -- convert to document if GET. Subsequent POST requests will only include fragments
+  addDocument "GET" bd = toDoc bd
+  addDocument _ bd = bd
+
+  contentType :: ContentType -> (HeaderName, ByteString)
+  contentType ContentHtml = ("Content-Type", "text/html; charset=utf-8")
+  contentType ContentText = ("Content-Type", "text/plain; charset=utf-8")
+
+  sendResponse :: Handler -> IO ResponseReceived
+  sendResponse resp = do
+    let headers = contentType resp.contentType : resp.headers
+        respBody = addDocument (requestMethod request) resp.body
+    respond $ responseLBS status200 headers respBody
+
+  interrupt NotFound =
+    respond $ responseLBS status404 [contentType ContentText] "Not Found"
+  interrupt (ParseError e) = do
+    -- TODO: logging!
+    putStrLn $ "Parse Error: " <> cs e
+    respond $ responseLBS status400 [contentType ContentText] $ "Parse Error: " <> cs e
+  interrupt (Redirect (Url u)) =
+    respond $ responseLBS status301 [("Location", cs u)] ""
+  interrupt (RespondNow resp) = do
+    sendResponse resp
+
+
+{- | Run both the http and ws application
+application :: (Route route) => (L.ByteString -> L.ByteString) -> (route -> Eff [Wai, IOE] ()) -> Application
+application toDoc actions =
+  websocketsOr connectionOptions (socketApplication talk)
+    $ httpApplication toDoc actions
+-}
+
+-- talk :: (Socket :> es) => Eff es ()
+-- talk = do
+--   msg :: Text <- receiveData
+--
+--   -- HANDLE ACTION?
+--   -- SEND VIEW (possibly multiple times). Need to run action...
+--
+--   sendCommand $ Render [Text $ "Updated: " <> msg]
diff --git a/src/Web/Hyperbole/Effect.hs b/src/Web/Hyperbole/Effect.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole/Effect.hs
@@ -0,0 +1,138 @@
+{-# LANGUAGE DefaultSignatures #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Web.Hyperbole.Effect where
+
+import Control.Monad (join)
+import Data.ByteString
+import Data.String.Conversions
+import Data.String.Interpolate (i)
+import Data.Text
+import Effectful
+import Effectful.Dispatch.Dynamic
+import Effectful.Wai (ContentType (..), Wai (..))
+import Effectful.Wai qualified as Wai
+import Network.HTTP.Types (Query)
+import Network.Wai
+import Web.FormUrlEncoded (Form)
+import Web.FormUrlEncoded qualified as Form
+import Web.Hyperbole.HyperView
+import Web.View
+
+
+data Hyperbole :: Effect where
+  GetForm :: Hyperbole m Form
+  ParseForm :: (Form.FromForm a) => Hyperbole m a
+  GetEvent :: (HyperView id) => Hyperbole m (Maybe (Event (Action id) id))
+  RespondView :: View () () -> Hyperbole m ()
+  HyperError :: HyperError -> Hyperbole m a
+
+
+type instance DispatchOf Hyperbole = 'Dynamic
+
+
+data Event act id = Event
+  { viewId :: id
+  , action :: act
+  }
+
+
+runHyperbole
+  :: (Wai :> es)
+  => Eff (Hyperbole : es) a
+  -> Eff es a
+runHyperbole = interpret $ \_ -> \case
+  RespondView vw -> do
+    let bd = renderLazyByteString vw
+    send $ ResHeader "Content-Type" "text/html"
+    send $ ResBody ContentHtml bd
+    Wai.continue
+  GetForm -> Wai.formData
+  ParseForm -> Wai.parseFormData
+  HyperError NotFound -> send $ Interrupt Wai.NotFound
+  HyperError (ParseError e) -> send $ Interrupt $ Wai.ParseError e
+  GetEvent -> do
+    q <- fmap queryString <$> send $ Wai.Request
+    pure $ do
+      Event ti ta <- lookupEvent q
+      vid <- parseParam ti
+      act <- parseParam ta
+      pure $ Event vid act
+ where
+  lookupParam :: ByteString -> Query -> Maybe Text
+  lookupParam p q =
+    fmap cs <$> join $ lookup p q
+
+  lookupEvent :: Query -> Maybe (Event Text Text)
+  lookupEvent q =
+    Event
+      <$> lookupParam "id" q
+      <*> lookupParam "action" q
+
+
+formData :: (Hyperbole :> es) => Eff es Form
+formData = send GetForm
+
+
+parseFormData :: (Hyperbole :> es, Form.FromForm a) => Eff es a
+parseFormData = send ParseForm
+
+
+-- | Read a required form parameter
+param :: (Hyperbole :> es, Param a) => Text -> Form -> Eff es a
+param p f = do
+  -- param is required
+  either (send . HyperError . ParseError) pure $ do
+    t <- Form.lookupUnique p f
+    maybe (Left [i|could not parseParam: '#{t}'|]) pure $ parseParam t
+
+
+notFound :: (Hyperbole :> es) => Eff es a
+notFound = send (HyperError NotFound)
+
+
+-- | Set the response to the view. Note that `page` already expects a view to be returned from the effect
+view :: (Hyperbole :> es) => View () () -> Eff es ()
+view = send . RespondView
+
+
+data HyperError
+  = NotFound
+  | ParseError Text
+
+
+newtype Page es a = Page (Eff es a)
+  deriving newtype (Applicative, Monad, Functor)
+
+
+-- | Load the entire page when no HyperViews match
+load
+  :: (Hyperbole :> es)
+  => Eff es (View () ())
+  -> Page es ()
+load run = Page $ do
+  vw <- run
+  view vw
+
+
+-- | Handle a HyperView. If the event matches our handler, respond with the fragment
+hyper
+  :: (Hyperbole :> es, HyperView id)
+  => (id -> Action id -> Eff es (View id ()))
+  -> Page es ()
+hyper run = Page $ do
+  -- Get an event matching our type. If it doesn't match, skip to the next handler
+  mev <- send GetEvent
+  case mev of
+    Just event -> do
+      vw <- run event.viewId event.action
+      view $ viewId event.viewId vw
+    _ -> pure ()
+
+
+page
+  :: (Hyperbole :> es)
+  => Page es ()
+  -> Eff es ()
+page (Page eff) = eff
diff --git a/src/Web/Hyperbole/Embed.hs b/src/Web/Hyperbole/Embed.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole/Embed.hs
@@ -0,0 +1,16 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Web.Hyperbole.Embed
+  ( cssResetEmbed
+  , cssResetLink
+  , scriptEmbed
+  )
+where
+
+import Data.ByteString
+import Data.FileEmbed
+import Web.View.Reset
+
+
+scriptEmbed :: ByteString
+scriptEmbed = $(embedFile "client/dist/hyperbole.js")
diff --git a/src/Web/Hyperbole/HyperView.hs b/src/Web/Hyperbole/HyperView.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole/HyperView.hs
@@ -0,0 +1,105 @@
+{-# LANGUAGE DefaultSignatures #-}
+{-# LANGUAGE FunctionalDependencies #-}
+
+module Web.Hyperbole.HyperView where
+
+import Data.Kind (Type)
+import Data.Text
+import Text.Read
+import Web.Hyperbole.Route (Route (..), pathUrl)
+import Web.View
+
+
+-- | Associate a live id with a set of actions
+class (Param id, Param (Action id)) => HyperView id where
+  type Action id :: Type
+
+
+viewId :: forall id ctx. (HyperView id) => id -> View id () -> View ctx ()
+viewId vid vw = do
+  el (att "id" (toParam vid) . flexCol)
+    $ addContext vid vw
+
+
+button :: (HyperView id) => Action id -> Mod -> View id () -> View id ()
+button a f cd = do
+  c <- context
+  tag "button" (att "data-on-click" (toParam a) . dataTarget c . f) cd
+
+
+onRequest :: View id () -> View id () -> View id ()
+onRequest a b = do
+  el (parent "hyp-loading" flexCol . hide) a
+  el (parent "hyp-loading" hide . flexCol) b
+
+
+-- | Internal
+dataTarget :: (Param a) => a -> Mod
+dataTarget = att "data-target" . toParam
+
+
+-- | Change the target of any code running inside, allowing actions to target other live views on the page
+target :: (HyperView id) => id -> View id () -> View a ()
+target = addContext
+
+
+dropdown
+  :: (HyperView id)
+  => (opt -> action)
+  -> (opt -> Bool)
+  -> View (Option opt id action) ()
+  -> View id ()
+dropdown toAction isSel options = do
+  c <- context
+  tag "select" (att "data-on-change" "" . dataTarget c) $ do
+    addContext (Option toAction isSel) options
+
+
+option
+  :: (HyperView id, Eq opt)
+  => opt
+  -> Mod
+  -> View (Option opt id (Action id)) ()
+  -> View (Option opt id (Action id)) ()
+option opt f cnt = do
+  os <- context
+  tag "option" (att "value" (toParam (os.toAction opt)) . selected (os.selected opt) . f) cnt
+
+
+selected :: Bool -> Mod
+selected b = if b then att "selected" "true" else id
+
+
+data Option opt id action = Option
+  { toAction :: opt -> action
+  , selected :: opt -> Bool
+  }
+
+
+class Param a where
+  -- not as flexible as FromHttpApiData, but derivable
+  parseParam :: Text -> Maybe a
+  default parseParam :: (Read a) => Text -> Maybe a
+  parseParam = readMaybe . unpack
+
+
+  toParam :: a -> Text
+  default toParam :: (Show a) => a -> Text
+  toParam = pack . show
+
+
+instance Param Integer
+instance Param Float
+instance Param Int
+instance Param ()
+
+
+instance Param Text where
+  parseParam = pure
+  toParam = id
+
+
+link :: (Route a) => a -> Mod -> View c () -> View c ()
+link r f cnt = do
+  let Url u = pathUrl . routePath $ r
+  tag "a" (att "href" u . f) cnt
diff --git a/src/Web/Hyperbole/Input.hs b/src/Web/Hyperbole/Input.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole/Input.hs
@@ -0,0 +1,145 @@
+{-# LANGUAGE DefaultSignatures #-}
+
+module Web.Hyperbole.Input where
+
+import Data.Functor.Identity (Identity)
+import Data.Kind (Type)
+import Data.Text
+import Effectful
+import Effectful.Dispatch.Dynamic
+import GHC.Generics
+import Text.Casing (kebab)
+import Web.FormUrlEncoded qualified as FE
+import Web.Hyperbole.Effect
+import Web.Hyperbole.HyperView (HyperView (..), Param (..), dataTarget)
+import Web.Internal.FormUrlEncoded (GFromForm, defaultFormOptions, genericFromForm)
+import Web.View hiding (form, input)
+
+
+-- | The only time we can use Fields is inside a form
+newtype FormFields f id = FormFields id
+  deriving newtype (Show)
+
+
+instance (Param id, Show id) => Param (FormFields f id) where
+  parseParam t = FormFields <$> parseParam t
+  toParam (FormFields i) = toParam i
+
+
+instance (HyperView id, Show id) => HyperView (FormFields f id) where
+  type Action (FormFields f id) = Action id
+
+
+-- | TODO: there are many more of these: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
+data FieldInput
+  = NewPassword
+  | CurrentPassword
+  | Username
+  | Email
+  | Number
+  | TextInput
+  | Name
+  | OneTimeCode
+  | Organization
+  | StreetAddress
+  | Country
+  | CountryName
+  | PostalCode
+  | Search
+  deriving (Show)
+
+
+data Label a
+
+
+data Input a = Input
+
+
+newtype InputName = InputName Text
+
+
+field :: Mod -> View (Input id) () -> View (FormFields form id) ()
+field f cnt =
+  tag "label" (f . flexCol)
+    $ addContext Input cnt
+
+
+label :: Text -> View (Input id) ()
+label = text
+
+
+input :: FieldInput -> Mod -> InputName -> View (Input id) ()
+input fi f (InputName n) = tag "input" (f . name n . att "type" (typ fi) . att "autocomplete" (auto fi)) none
+ where
+  typ NewPassword = "password"
+  typ CurrentPassword = "password"
+  typ Number = "number"
+  typ Email = "email"
+  typ Search = "search"
+  typ _ = "text"
+
+  auto :: FieldInput -> Text
+  auto = pack . kebab . show
+
+
+form :: forall form id. (Form form, HyperView id) => Action id -> Mod -> (form Label -> View (FormFields form id) ()) -> View id ()
+form a f fcnt = do
+  vid <- context
+  let frm = formLabels :: form Label
+  let cnt = fcnt frm
+  tag "form" (onSubmit a . dataTarget vid . f . flexCol) $ addContext (FormFields vid) cnt
+ where
+  onSubmit :: (Param a) => a -> Mod
+  onSubmit = att "data-on-submit" . toParam
+
+
+submit :: Mod -> View (FormFields form id) () -> View (FormFields form id) ()
+submit f = tag "button" (att "type" "submit" . f)
+
+
+parseForm :: forall form es. (Form form, Hyperbole :> es) => Eff es (form Identity)
+parseForm = do
+  (f :: FE.Form) <- formData
+  let ef = fromForm f :: Either Text (form Identity)
+  either (send . HyperError . ParseError) pure ef
+
+
+class Form (form :: (Type -> Type) -> Type) where
+  formLabels :: form Label
+  default formLabels :: (Generic (form Label), GFormLabels (Rep (form Label))) => form Label
+  formLabels = to gFormLabels
+
+
+  fromForm :: FE.Form -> Either Text (form Identity)
+  default fromForm :: (Generic (form Identity), GFromForm (form Identity) (Rep (form Identity))) => FE.Form -> Either Text (form Identity)
+  fromForm = genericFromForm defaultFormOptions
+
+
+type family Field (context :: Type -> Type) a
+type instance Field Identity a = a
+type instance Field Label a = InputName
+
+
+-- | Automatically derive labels from form field names
+class GFormLabels f where
+  gFormLabels :: f p
+
+
+instance GFormLabels U1 where
+  gFormLabels = U1
+
+
+instance (GFormLabels f, GFormLabels g) => GFormLabels (f :*: g) where
+  gFormLabels = gFormLabels :*: gFormLabels
+
+
+instance (Selector s) => GFormLabels (M1 S s (K1 R InputName)) where
+  gFormLabels = M1 . K1 $ InputName $ pack (selName (undefined :: M1 S s (K1 R Text) p))
+
+
+instance (GFormLabels f) => GFormLabels (M1 D d f) where
+  gFormLabels = M1 gFormLabels
+
+
+instance (GFormLabels f) => GFormLabels (M1 C c f) where
+  gFormLabels = M1 gFormLabels
diff --git a/src/Web/Hyperbole/Route.hs b/src/Web/Hyperbole/Route.hs
new file mode 100644
--- /dev/null
+++ b/src/Web/Hyperbole/Route.hs
@@ -0,0 +1,195 @@
+{-# LANGUAGE DefaultSignatures #-}
+
+module Web.Hyperbole.Route where
+
+import Control.Applicative ((<|>))
+import Control.Monad (guard)
+import Data.String (IsString (..))
+import Data.Text (Text, dropWhile, dropWhileEnd, intercalate, pack, splitOn, toLower, unpack)
+import GHC.Generics
+import Text.Read (readMaybe)
+import Web.View.Types (Url (..))
+import Prelude hiding (dropWhile)
+
+
+type IsAbsolute = Bool
+type Segment = Text
+data Path = Path
+  { isAbsolute :: Bool
+  , segments :: [Segment]
+  }
+  deriving (Show)
+
+
+-- what if you want a relative url?
+instance IsString Path where
+  fromString s = Path (isRoot s) [cleanSegment $ pack s]
+   where
+    isRoot ('/' : _) = True
+    isRoot _ = False
+
+
+class Route a where
+  matchRoute :: Path -> Maybe a
+  routePath :: a -> Path
+  defRoute :: a
+
+
+  default matchRoute :: (Generic a, GenRoute (Rep a)) => Path -> Maybe a
+  -- this will match a trailing slash, but not if it is missing
+  matchRoute (Path _ [""]) = pure defRoute
+  matchRoute (Path _ segs) = to <$> genRoute segs
+
+
+  default routePath :: (Generic a, Eq a, GenRoute (Rep a)) => a -> Path
+  routePath p
+    | p == defRoute = Path True [""]
+    | otherwise = Path True $ genPaths $ from p
+
+
+  default defRoute :: (Generic a, GenRoute (Rep a)) => a
+  defRoute = to genFirst
+
+
+pathUrl :: Path -> Url
+pathUrl (Path True ss) = Url $ "/" <> intercalate "/" ss
+pathUrl (Path False ss) = Url $ intercalate "/" ss
+
+
+cleanSegment :: Segment -> Segment
+cleanSegment = dropWhileEnd (== '/') . dropWhile (== '/')
+
+
+pathSegments :: Text -> [Segment]
+pathSegments path = splitOn "/" $ dropWhile (== '/') path
+
+
+class GenRoute f where
+  genRoute :: [Text] -> Maybe (f p)
+  genPaths :: f p -> [Text]
+  genFirst :: f p
+
+
+-- datatype metadata
+instance (GenRoute f) => GenRoute (M1 D c f) where
+  genRoute ps = M1 <$> genRoute ps
+  genPaths (M1 x) = genPaths x
+  genFirst = M1 genFirst
+
+
+-- Constructor names / lines
+instance (Constructor c, GenRoute f) => GenRoute (M1 C c f) where
+  genRoute (n : ps) = do
+    -- take the first path off the list
+    -- check that it matches the constructor name
+    -- check that the rest matches
+    let name = conName (undefined :: M1 C c f x)
+    guard (n == toLower (pack name))
+    M1 <$> genRoute ps
+  genRoute [] = Nothing
+
+
+  genFirst = M1 genFirst
+
+
+  genPaths (M1 x) =
+    let name = conName (undefined :: M1 C c f x)
+     in toLower (pack name) : genPaths x
+
+
+-- Unary constructors
+instance GenRoute U1 where
+  genRoute [] = pure U1
+  genRoute _ = Nothing
+  genPaths _ = []
+  genFirst = U1
+
+
+-- Selectors
+instance (GenRoute f) => GenRoute (M1 S c f) where
+  genRoute ps =
+    M1 <$> genRoute ps
+
+
+  genFirst = M1 genFirst
+
+
+  genPaths (M1 x) = genPaths x
+
+
+-- Sum types
+instance (GenRoute a, GenRoute b) => GenRoute (a :+: b) where
+  genRoute ps = L1 <$> genRoute ps <|> R1 <$> genRoute ps
+  genFirst = L1 genFirst
+  genPaths (L1 a) = genPaths a
+  genPaths (R1 a) = genPaths a
+
+
+-- Product types
+instance (GenRoute a, GenRoute b) => GenRoute (a :*: b) where
+  genRoute (p : ps) = do
+    ga <- genRoute [p]
+    gr <- genRoute ps
+    pure $ ga :*: gr
+  genRoute _ = Nothing
+
+
+  genFirst = genFirst :*: genFirst
+
+
+  genPaths (a :*: b) = genPaths a <> genPaths b
+
+
+instance (Route sub) => GenRoute (K1 R sub) where
+  genRoute ts = K1 <$> matchRoute (Path True ts)
+  genFirst = K1 defRoute
+  genPaths (K1 sub) = (routePath sub).segments
+
+
+genRouteRead :: (Read x) => [Text] -> Maybe (K1 R x a)
+genRouteRead [t] = do
+  K1 <$> readMaybe (unpack t)
+genRouteRead _ = Nothing
+
+
+instance Route Text where
+  matchRoute (Path _ [t]) = pure t
+  matchRoute _ = Nothing
+  routePath t = Path False [t]
+  defRoute = ""
+
+
+instance Route String where
+  matchRoute (Path _ [t]) = pure (unpack t)
+  matchRoute _ = Nothing
+  routePath t = Path False [pack t]
+  defRoute = ""
+
+
+instance Route Integer where
+  matchRoute = matchRouteRead
+  routePath = routePathShow
+  defRoute = 0
+
+
+instance Route Int where
+  matchRoute = matchRouteRead
+  routePath = routePathShow
+  defRoute = 0
+
+
+instance (Route a) => Route (Maybe a) where
+  matchRoute (Path _ []) = pure Nothing
+  matchRoute ps = Just <$> matchRoute ps
+  routePath (Just a) = routePath a
+  routePath Nothing = Path False []
+  defRoute = Nothing
+
+
+matchRouteRead :: (Read a) => Path -> Maybe a
+matchRouteRead (Path _ [t]) = readMaybe (unpack t)
+matchRouteRead _ = Nothing
+
+
+routePathShow :: (Show a) => a -> Path
+routePathShow a = Path False [pack (show a)]
