diff --git a/.engram/chunks/5f70b832.jsonl.gz b/.engram/chunks/5f70b832.jsonl.gz
new file mode 100644
index 00000000..5278eaa6
Binary files /dev/null and b/.engram/chunks/5f70b832.jsonl.gz differ
diff --git a/.engram/manifest.json b/.engram/manifest.json
index 25feb9f8..6dfff5cc 100644
--- a/.engram/manifest.json
+++ b/.engram/manifest.json
@@ -72,6 +72,14 @@
"sessions": 0,
"memories": 5,
"prompts": 1
+ },
+ {
+ "id": "5f70b832",
+ "created_by": "andres",
+ "created_at": "2026-04-22T19:29:00Z",
+ "sessions": 194,
+ "memories": 618,
+ "prompts": 264
}
]
}
diff --git a/apps/loom-example-app/index.html b/apps/loom-example-app/index.html
new file mode 100644
index 00000000..5e0c32c0
--- /dev/null
+++ b/apps/loom-example-app/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Loom Example App
+
+
+
+
+
diff --git a/apps/loom-example-app/package.json b/apps/loom-example-app/package.json
new file mode 100644
index 00000000..56dcf734
--- /dev/null
+++ b/apps/loom-example-app/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@effectify/loom-example-app",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@effectify/loom": "workspace:*",
+ "@effectify/loom-nitro": "workspace:*",
+ "@effectify/loom-router": "workspace:*",
+ "@effectify/loom-vite": "workspace:*",
+ "@picocss/pico": "catalog:",
+ "effect": "catalog:"
+ },
+ "devDependencies": {
+ "@nx/vite": "catalog:",
+ "jsdom": "catalog:",
+ "typescript": "catalog:",
+ "vite": "catalog:",
+ "vitest": "catalog:"
+ }
+}
diff --git a/apps/loom-example-app/project.json b/apps/loom-example-app/project.json
new file mode 100644
index 00000000..c2089da1
--- /dev/null
+++ b/apps/loom-example-app/project.json
@@ -0,0 +1,30 @@
+{
+ "name": "@effectify/loom-example-app",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "apps/loom-example-app/src",
+ "projectType": "application",
+ "tags": ["loom", "example", "app"],
+ "targets": {
+ "lint": {
+ "executor": "nx-oxlint:lint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": ["apps/loom-example-app/**/*.{ts,tsx,js,jsx,mts,cts}"]
+ }
+ },
+ "test": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "../../node_modules/.bin/vitest run --config vite.config.mts",
+ "cwd": "apps/loom-example-app"
+ }
+ },
+ "typecheck": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "../../node_modules/.bin/tsc --noEmit -p tsconfig.app.json && ../../node_modules/.bin/tsc --noEmit -p tsconfig.spec.json",
+ "cwd": "apps/loom-example-app"
+ }
+ }
+ }
+}
diff --git a/apps/loom-example-app/src/app-config.ts b/apps/loom-example-app/src/app-config.ts
new file mode 100644
index 00000000..e0001df3
--- /dev/null
+++ b/apps/loom-example-app/src/app-config.ts
@@ -0,0 +1,4 @@
+export const appRootId = "loom-root"
+export const appBuildId = "loom-example-app"
+export const appPayloadElementId = "__loom_payload__"
+export const counterInitialCount = 2
diff --git a/apps/loom-example-app/src/app.css b/apps/loom-example-app/src/app.css
new file mode 100644
index 00000000..145588b4
--- /dev/null
+++ b/apps/loom-example-app/src/app.css
@@ -0,0 +1,302 @@
+@import "@picocss/pico/css/pico.min.css";
+
+:root {
+ --loom-example-surface: rgba(255, 255, 255, 0.82);
+ --loom-example-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
+}
+
+body {
+ min-height: 100vh;
+ background:
+ radial-gradient(circle at top, rgba(16, 185, 129, 0.12), transparent 30%),
+ linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
+}
+
+[data-app-shell="loom-example-app"] {
+ padding-block: clamp(2rem, 4vw, 4rem);
+}
+
+.loom-example-layout {
+ display: grid;
+ gap: 1.5rem;
+}
+
+.loom-example-hero {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.loom-example-eyebrow {
+ margin: 0;
+ color: var(--pico-primary);
+ font-size: 0.85rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.counter-title,
+.todo-title,
+.loom-example-not-found-title {
+ margin: 0;
+}
+
+.counter-copy,
+.todo-copy,
+.loom-example-copy,
+.counter-debug-note,
+.dev-mode-note,
+.compat-seam-note {
+ margin: 0;
+ color: var(--pico-muted-color);
+}
+
+.loom-example-card,
+.loom-example-note-stack {
+ display: grid;
+ gap: 1rem;
+ padding: clamp(1.25rem, 3vw, 2rem);
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 1.25rem;
+ background: var(--loom-example-surface);
+ box-shadow: var(--loom-example-shadow);
+ backdrop-filter: blur(10px);
+}
+
+.counter-value-card {
+ display: inline-grid;
+ gap: 0.35rem;
+}
+
+.counter-cue-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.counter-cue-label {
+ color: var(--pico-muted-color);
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.counter-value-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--pico-muted-color);
+}
+
+.counter-value {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 2rem;
+ font-weight: 700;
+}
+
+.counter-value-prefix {
+ color: var(--pico-muted-color);
+ font-size: 1rem;
+ font-weight: 500;
+}
+
+.counter-dynamic-value {
+ display: inline-flex;
+ min-width: 2.5ch;
+ justify-content: center;
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ background-color: rgba(15, 23, 42, 0.05);
+}
+
+.counter-reactive-cue {
+ display: inline-flex;
+ align-items: center;
+ min-height: 2rem;
+ padding: 0.3rem 0.7rem;
+ border-radius: 999px;
+ color: #0f172a;
+ font-size: 0.85rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ transition: background-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
+}
+
+.counter-reactive-cue--baseline {
+ color: #1d4ed8;
+}
+
+.counter-reactive-cue--rising {
+ color: #047857;
+}
+
+.counter-reactive-cue--falling {
+ color: #c2410c;
+}
+
+.counter-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin: 0;
+}
+
+.counter-actions > button {
+ margin: 0;
+}
+
+.todo-top-row {
+ display: grid;
+ gap: 1rem;
+}
+
+@media (min-width: 900px) {
+ .todo-top-row {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
+ align-items: start;
+ }
+}
+
+.todo-kpi-row,
+.todo-stat-grid,
+.todo-composer-row,
+.todo-composer-meta,
+.todo-list-header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.9rem;
+ align-items: center;
+}
+
+.todo-kpi,
+.todo-stat {
+ display: grid;
+ gap: 0.2rem;
+ min-width: 9rem;
+ padding: 0.9rem 1rem;
+ border-radius: 1rem;
+ background: rgba(15, 23, 42, 0.04);
+}
+
+.todo-kpi-label,
+.todo-stat-label,
+.todo-section-title,
+.todo-item-status,
+.todo-session-count {
+ margin: 0;
+}
+
+.todo-kpi-label,
+.todo-stat-label,
+.todo-item-status {
+ color: var(--pico-muted-color);
+ font-size: 0.85rem;
+}
+
+.todo-kpi-value,
+.todo-stat-value,
+.todo-title,
+.todo-section-title,
+.todo-item-title {
+ margin: 0;
+}
+
+.todo-kpi-value,
+.todo-stat-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+.todo-panel {
+ gap: 1rem;
+}
+
+.todo-section-title {
+ font-size: 1.25rem;
+}
+
+.todo-input {
+ flex: 1 1 18rem;
+ min-width: 15rem;
+ margin: 0;
+}
+
+.todo-composer-row > button,
+.todo-list-header > button,
+.todo-item > button,
+.todo-link {
+ margin: 0;
+}
+
+.todo-composer-meta {
+ justify-content: space-between;
+}
+
+.todo-session-count {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.65rem;
+ border-radius: 999px;
+ background: rgba(37, 99, 235, 0.1);
+ color: #1d4ed8;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.todo-item-list {
+ display: grid;
+ gap: 0.85rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 0.85rem;
+ align-items: center;
+ padding: 0.95rem 1rem;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.72);
+}
+
+.todo-item--completed {
+ background: rgba(16, 185, 129, 0.08);
+}
+
+.todo-item-copy {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.todo-item-title {
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.todo-item-title--completed {
+ text-decoration: line-through;
+ color: var(--pico-muted-color);
+}
+
+.todo-empty-state {
+ margin: 0;
+ padding: 1rem;
+ border-radius: 1rem;
+ background: rgba(15, 23, 42, 0.04);
+ color: var(--pico-muted-color);
+}
+
+.loom-example-note-stack {
+ gap: 0.85rem;
+}
+
+.loom-example-not-found {
+ display: grid;
+ gap: 1rem;
+}
diff --git a/apps/loom-example-app/src/document.ts b/apps/loom-example-app/src/document.ts
new file mode 100644
index 00000000..55851e0e
--- /dev/null
+++ b/apps/loom-example-app/src/document.ts
@@ -0,0 +1,32 @@
+import type * as Loom from "@effectify/loom"
+import { Html } from "@effectify/loom"
+import { appPayloadElementId, appRootId } from "./app-config.js"
+
+export interface DocumentOptions {
+ readonly title: string
+ readonly body: Loom.View.Child
+}
+
+export const createDocument = (options: DocumentOptions): Loom.View.Child =>
+ Html.el(
+ "html",
+ Html.attr("lang", "en"),
+ Html.children(
+ Html.el(
+ "head",
+ Html.children(
+ Html.el("meta", Html.attr("charset", "utf-8")),
+ Html.el("meta", Html.attr("name", "viewport"), Html.attr("content", "width=device-width, initial-scale=1")),
+ Html.el("title", Html.children(`Loom Example App · ${options.title}`)),
+ ),
+ ),
+ Html.el(
+ "body",
+ Html.children(
+ Html.el("div", Html.attr("id", appRootId), Html.children(options.body)),
+ Html.el("script", Html.attr("type", "application/json"), Html.attr("id", appPayloadElementId)),
+ Html.el("script", Html.attr("type", "module"), Html.attr("src", "/src/entry-browser.ts")),
+ ),
+ ),
+ ),
+ )
diff --git a/apps/loom-example-app/src/entry-browser.ts b/apps/loom-example-app/src/entry-browser.ts
new file mode 100644
index 00000000..ab333806
--- /dev/null
+++ b/apps/loom-example-app/src/entry-browser.ts
@@ -0,0 +1,8 @@
+import "./app.css"
+import { startClientApp } from "./entry-client.js"
+
+export const bootClientDocument = (document: Document) => startClientApp(document)
+
+if (typeof window !== "undefined" && typeof document !== "undefined") {
+ void bootClientDocument(document)
+}
diff --git a/apps/loom-example-app/src/entry-client.ts b/apps/loom-example-app/src/entry-client.ts
new file mode 100644
index 00000000..828f2187
--- /dev/null
+++ b/apps/loom-example-app/src/entry-client.ts
@@ -0,0 +1,73 @@
+import { Html, mount } from "@effectify/loom"
+import { LoomVite } from "@effectify/loom-vite"
+import { appBuildId, appPayloadElementId, appRootId } from "./app-config.js"
+import { prepareRouteRuntime } from "./router-runtime.js"
+import { bodyForResult, resolveAppRequest, titleForResult, todoRoutePath } from "./router.js"
+import * as counterRouteModule from "./routes/counter-route.js"
+import * as todoRouteModule from "./routes/todo-route.js"
+
+export const bootstrapClient = (
+ document: Document,
+ options?: LoomVite.LoomBootstrapOptions,
+): Promise =>
+ LoomVite.bootstrap(document, {
+ ...options,
+ expectedBuildId: options?.expectedBuildId ?? appBuildId,
+ payloadElementId: options?.payloadElementId ?? appPayloadElementId,
+ })
+
+const defaultClientUrl = "https://effectify.dev/"
+
+const mountClientRoute = (pathname: string, root: HTMLElement): boolean => {
+ if (pathname === "/") {
+ mount({ counterRoute: counterRouteModule.component }, { root })
+ return true
+ }
+
+ if (pathname === todoRoutePath) {
+ mount({ todoRoute: todoRouteModule.component }, { root })
+ return true
+ }
+
+ return false
+}
+
+const renderClientFallback = async (document: Document): Promise => {
+ const root = document.getElementById(appRootId)
+
+ if (!(root instanceof HTMLElement) || root.innerHTML.trim() !== "") {
+ return false
+ }
+
+ const requestUrl = new URL(document.location?.href ?? defaultClientUrl, defaultClientUrl)
+ await prepareRouteRuntime(requestUrl)
+ const result = resolveAppRequest(requestUrl)
+
+ if (requestUrl.pathname === "/" || requestUrl.pathname === todoRoutePath) {
+ root.innerHTML = ''
+ const shell = root.querySelector('[data-app-shell="loom-example-app"]')
+
+ if (shell instanceof HTMLElement) {
+ mountClientRoute(requestUrl.pathname, shell)
+ }
+ } else {
+ root.innerHTML = Html.renderToString(bodyForResult(result))
+ }
+
+ document.title = `Loom Example App · ${titleForResult(result)}`
+
+ return true
+}
+
+export const startClientApp = async (
+ document: Document,
+ options?: LoomVite.LoomBootstrapOptions,
+): Promise => {
+ const result = await bootstrapClient(document, options)
+
+ if (result.status !== "resumed") {
+ await renderClientFallback(document)
+ }
+
+ return result
+}
diff --git a/apps/loom-example-app/src/entry-server.ts b/apps/loom-example-app/src/entry-server.ts
new file mode 100644
index 00000000..b367e414
--- /dev/null
+++ b/apps/loom-example-app/src/entry-server.ts
@@ -0,0 +1,69 @@
+import { LoomNitro } from "@effectify/loom-nitro"
+import { Resumability } from "@effectify/loom"
+import { appBuildId, appPayloadElementId, appRootId } from "./app-config.js"
+import { prepareRouteRuntime } from "./router-runtime.js"
+import { bodyForResult, resolveAppRequest, statusForResult, titleForResult } from "./router.js"
+import { createDocument } from "./document.js"
+
+const applicationBaseUrl = "https://effectify.dev"
+
+const normalizeRequestUrl = (input: string): URL => new URL(input, applicationBaseUrl)
+
+const payloadPlaceholder = ``
+
+const injectResumabilityPayload = (
+ html: string,
+ payload: LoomNitro.LoomResumabilityPayload | undefined,
+): string => {
+ if (payload === undefined) {
+ return html
+ }
+
+ const encodedPayload = Resumability.encodeContract(payload)
+ .replace(/${encodedPayload}`,
+ )
+}
+
+export const createServerRenderer = (): LoomNitro.LoomNitroRenderer => {
+ const renderer = LoomNitro.renderer({
+ buildId: appBuildId,
+ rootId: appRootId,
+ render: (request) => {
+ const requestUrl = normalizeRequestUrl(request.url)
+
+ return prepareRouteRuntime(requestUrl).then(() => {
+ const result = resolveAppRequest(requestUrl)
+
+ return createDocument({
+ title: titleForResult(result),
+ body: bodyForResult(result),
+ })
+ })
+ },
+ response: (request) => {
+ const result = resolveAppRequest(normalizeRequestUrl(request.url))
+
+ return {
+ status: statusForResult(result),
+ }
+ },
+ })
+
+ return {
+ name: renderer.name,
+ render: async (request) => {
+ const result = await renderer.render(request)
+
+ return {
+ ...result,
+ html: injectResumabilityPayload(result.html, result.resumability),
+ }
+ },
+ }
+}
diff --git a/apps/loom-example-app/src/router-runtime.ts b/apps/loom-example-app/src/router-runtime.ts
new file mode 100644
index 00000000..5c985fbb
--- /dev/null
+++ b/apps/loom-example-app/src/router-runtime.ts
@@ -0,0 +1,204 @@
+import * as Effect from "effect/Effect"
+import { Router, Runtime } from "@effectify/loom-router"
+import { todoPageRoute, todoRouteId, todoRoutePath } from "./router.js"
+import {
+ resetTodoRouteViewState,
+ setTodoActionStatus,
+ setTodoFeedback,
+ setTodoItems,
+ setTodoLoaderStatus,
+} from "./routes/todo-route-state.js"
+import { makeTodoService, type TodoItem, type TodoServiceApi } from "./todo-service.js"
+
+type TodoRouteServices = Readonly<{
+ todoService: TodoServiceApi
+}>
+
+type TodoLoaderState = Runtime.LoaderState
+type TodoActionState = Runtime.ActionState
+
+export interface TodoRouteRuntime {
+ readonly load: (input?: string | URL) => Promise
+ readonly reset: () => void
+ readonly submit: (
+ options: { readonly input?: string | URL; readonly submission: Runtime.Submission },
+ ) => Promise<{
+ readonly action: TodoActionState
+ readonly loader?: TodoLoaderState
+ }>
+}
+
+const todoServices = (): TodoRouteServices => ({
+ todoService: makeTodoService(),
+})
+
+const todoRuntimeRouter = Router.make({
+ routes: [todoPageRoute],
+})
+
+type TodoResolvedRoute =
+ & Router.ResolveSuccess
+ & {
+ readonly route: typeof todoPageRoute
+ }
+
+const isTodoResolvedRoute = (value: Router.ResolveResult): value is TodoResolvedRoute =>
+ Router.isResolveSuccess(value) && value.route.identifier === todoRouteId
+
+const resolveTodoRoute = (input: string | URL = todoRoutePath): TodoResolvedRoute => {
+ const resolved = Router.resolve(todoRuntimeRouter, input)
+
+ if (!isTodoResolvedRoute(resolved)) {
+ throw new Error(`Expected '${todoRoutePath}' to resolve successfully for the Loom todo runtime`)
+ }
+
+ return resolved
+}
+
+const staleTodoData = (state: TodoLoaderState | undefined): ReadonlyArray | undefined => {
+ if (state === undefined) {
+ return undefined
+ }
+
+ switch (state._tag) {
+ case "success":
+ case "revalidating":
+ return state.data
+ case "failure":
+ return state.data
+ default:
+ return undefined
+ }
+}
+
+const syncTodoLoaderState = (state: TodoLoaderState): void => {
+ switch (state._tag) {
+ case "success":
+ setTodoItems(state.data)
+ setTodoLoaderStatus("loaded")
+ setTodoFeedback(undefined)
+ return
+ case "revalidating":
+ setTodoItems(state.data)
+ setTodoLoaderStatus("revalidating")
+ return
+ case "failure":
+ setTodoItems(state.data ?? [])
+ setTodoLoaderStatus("failure")
+ setTodoFeedback(state.error.message)
+ return
+ case "loading":
+ setTodoLoaderStatus("loading")
+ return
+ case "idle":
+ setTodoLoaderStatus("idle")
+ }
+}
+
+const syncTodoActionState = (state: TodoActionState): void => {
+ switch (state._tag) {
+ case "success":
+ setTodoActionStatus("success")
+ setTodoFeedback(`Action '${state.result.intent}' completed and revalidated.`)
+ return
+ case "failure":
+ setTodoActionStatus("failure")
+ setTodoFeedback(state.error.message)
+ return
+ case "invalid-input":
+ setTodoActionStatus("invalid-input")
+ setTodoFeedback(state.issues[0].message)
+ return
+ case "submitting":
+ setTodoActionStatus("submitting")
+ return
+ case "idle":
+ setTodoActionStatus("idle")
+ setTodoFeedback(undefined)
+ }
+}
+
+export const createTodoRouteRuntime = (services: TodoRouteServices = todoServices()): TodoRouteRuntime => {
+ let latestLoaderState: TodoLoaderState | undefined
+
+ return {
+ load: async (input = todoRoutePath) => {
+ const loaded = await Runtime.load({
+ resolved: resolveTodoRoute(input),
+ services,
+ })
+
+ latestLoaderState = loaded
+
+ return loaded
+ },
+ reset: () => {
+ Effect.runSync(services.todoService.reset())
+ latestLoaderState = undefined
+ },
+ submit: async ({ input = todoRoutePath, submission }) => {
+ const resolved = resolveTodoRoute(input)
+ const action = await Runtime.submit({
+ resolved,
+ services,
+ submission,
+ })
+
+ if (action._tag !== "success") {
+ return { action }
+ }
+
+ const previous = staleTodoData(latestLoaderState)
+ const loader = previous === undefined
+ ? await Runtime.load({ resolved, services })
+ : await Runtime.revalidate({ previous, resolved, services })
+
+ latestLoaderState = loader
+
+ return {
+ action,
+ loader,
+ }
+ },
+ }
+}
+
+export const todoRouteRuntime = createTodoRouteRuntime()
+
+export const prepareRouteRuntime = async (input: URL): Promise => {
+ if (input.pathname === todoRoutePath) {
+ await loadTodoRouteState(input)
+ }
+}
+
+export const loadTodoRouteState = async (input: string | URL = todoRoutePath): Promise => {
+ setTodoLoaderStatus("loading")
+ const loaded = await todoRouteRuntime.load(input)
+
+ syncTodoLoaderState(loaded)
+ return loaded
+}
+
+export const submitTodoRuntimeAction = async (
+ submission: Runtime.Submission,
+): Promise<{
+ readonly action: TodoActionState
+ readonly loader?: TodoLoaderState
+}> => {
+ setTodoActionStatus("submitting")
+
+ const result = await todoRouteRuntime.submit({ submission })
+
+ syncTodoActionState(result.action)
+
+ if (result.loader !== undefined) {
+ syncTodoLoaderState(result.loader)
+ }
+
+ return result
+}
+
+export const resetTodoExampleState = (): void => {
+ todoRouteRuntime.reset()
+ resetTodoRouteViewState()
+}
diff --git a/apps/loom-example-app/src/router.ts b/apps/loom-example-app/src/router.ts
new file mode 100644
index 00000000..2a11b0cd
--- /dev/null
+++ b/apps/loom-example-app/src/router.ts
@@ -0,0 +1,83 @@
+import type * as Loom from "@effectify/loom"
+import { Component, View, Web } from "@effectify/loom"
+import { Fallback, Layout, RouteModule, Router, type Router as RouterTypes } from "@effectify/loom-router"
+import { counterRouteId, counterRoutePath, counterRouteTitle } from "./routes/counter-route.js"
+import * as counterRouteModule from "./routes/counter-route.js"
+import * as todoRouteModule from "./routes/todo-route.js"
+
+export const todoRouteId = "todo"
+export const todoRoutePath = "/todos"
+export const todoRouteTitle = "Todo app"
+
+export const counterPageRoute = RouteModule.compile({
+ identifier: counterRouteId,
+ module: counterRouteModule,
+ path: counterRoutePath,
+})
+
+export const todoPageRoute = RouteModule.compile({
+ identifier: todoRouteId,
+ module: {
+ ...todoRouteModule,
+ component: () => Component.use(todoRouteModule.component),
+ },
+ path: todoRoutePath,
+})
+
+const AppShell = Component.make("AppShell").pipe(
+ Component.view(({ children }) =>
+ View.main(children).pipe(Web.className("container"), Web.data("app-shell", "loom-example-app"))
+ ),
+)
+
+const notFoundView = (context: RouterTypes.Context): Loom.View.ViewChild =>
+ View.vstack(
+ View.vstack(View.text("Route not found")).pipe(
+ Web.className("loom-example-not-found-title"),
+ Web.attr("role", "heading"),
+ Web.aria("level", 1),
+ ),
+ View.vstack(View.text(`The Loom example serves ${counterRoutePath} and ${todoRoutePath}.`)).pipe(
+ Web.className("loom-example-copy"),
+ ),
+ View.vstack(View.text(`Requested path: ${context.pathname}`)).pipe(Web.className("loom-example-copy")),
+ ).pipe(Web.className("loom-example-card loom-example-not-found"), Web.data("route-view", "not-found"))
+
+export const appRouter = Router.make({
+ layout: Layout.make(({ child }) => Component.use(AppShell, child)),
+ routes: [counterPageRoute, todoPageRoute] as const,
+ fallback: {
+ notFound: Fallback.make(notFoundView),
+ },
+})
+
+const matchedRouteTitle = (result: Router.ResolveSuccess): string => {
+ switch (result.route.identifier) {
+ case counterPageRoute.identifier:
+ return counterRouteTitle
+ case todoRouteId:
+ return todoRouteTitle
+ default:
+ return "Not Found"
+ }
+}
+
+export const resolveAppRequest = (input: string | URL): Router.ResolveResult => Router.resolve(appRouter, input)
+
+export const titleForResult = (result: Router.ResolveResult): string =>
+ Router.isResolveSuccess(result) ? matchedRouteTitle(result) : "Not Found"
+
+export const statusForResult = (result: Router.ResolveResult): number => {
+ if (Router.isResolveNotFound(result)) {
+ return 404
+ }
+
+ if (Router.isResolveInvalidInput(result)) {
+ return 400
+ }
+
+ return 200
+}
+
+export const bodyForResult = (result: Router.ResolveResult): Loom.View.ViewChild =>
+ result.output ?? View.vstack(View.text("Loom example route output is unavailable."))
diff --git a/apps/loom-example-app/src/routes/counter-route.ts b/apps/loom-example-app/src/routes/counter-route.ts
new file mode 100644
index 00000000..8fdddcf7
--- /dev/null
+++ b/apps/loom-example-app/src/routes/counter-route.ts
@@ -0,0 +1,123 @@
+import { Atom } from "effect/unstable/reactivity"
+import { Component, View, Web } from "@effectify/loom"
+import { counterInitialCount } from "../app-config.js"
+
+export const counterRouteId = "counter"
+export const counterRoutePath = "/"
+export const counterRouteTitle = "Counter"
+
+const counterCueTone = (count: number): "baseline" | "rising" | "falling" => {
+ if (count > counterInitialCount) {
+ return "rising"
+ }
+
+ if (count < counterInitialCount) {
+ return "falling"
+ }
+
+ return "baseline"
+}
+
+const counterCueStyle = (count: number): Web.StyleRecord => {
+ const delta = count - counterInitialCount
+ const tone = counterCueTone(count)
+ const lift = Math.min(Math.abs(delta), 4)
+
+ if (tone === "rising") {
+ return {
+ backgroundColor: `rgba(16, 185, 129, ${0.14 + lift * 0.06})`,
+ boxShadow: `0 0 0 1px rgba(5, 150, 105, ${0.35 + lift * 0.08})`,
+ transform: `translateY(${-lift}px)`,
+ }
+ }
+
+ if (tone === "falling") {
+ return {
+ backgroundColor: `rgba(249, 115, 22, ${0.14 + lift * 0.06})`,
+ boxShadow: `0 0 0 1px rgba(234, 88, 12, ${0.35 + lift * 0.08})`,
+ transform: `translateY(${lift}px)`,
+ }
+ }
+
+ return {
+ backgroundColor: "rgba(59, 130, 246, 0.12)",
+ boxShadow: "0 0 0 1px rgba(37, 99, 235, 0.18)",
+ transform: "translateY(0px)",
+ }
+}
+
+export const CounterRoute = Component.make("CounterRoute").pipe(
+ Component.state({
+ count: () => Atom.make(counterInitialCount).pipe(Atom.keepAlive),
+ }),
+ Component.actions({
+ decrement: ({ count }) => count.update((value) => value - 1),
+ increment: ({ count }) => count.update((value) => value + 1),
+ reset: ({ count }) => count.set(counterInitialCount),
+ }),
+ Component.view(({ state, actions }) =>
+ View.vstack(
+ View.vstack(
+ View.text("Example app only").pipe(Web.className("loom-example-eyebrow")),
+ View.text("Loom vNext counter").pipe(
+ Web.as("h1"),
+ Web.className("counter-title"),
+ ),
+ View.text(
+ "This example keeps a single route and teaches the current Loom happy path first: Component.state/actions/view with View.text + Web modifiers.",
+ ).pipe(Web.className("counter-copy")),
+ View.link("Open the todo app example", "/todos").pipe(Web.className("outline secondary todo-link")),
+ ).pipe(Web.className("loom-example-hero")),
+ View.vstack(
+ View.vstack(
+ View.text("Counter state").pipe(Web.className("counter-value-label")),
+ View.hstack(
+ View.text("Count: ").pipe(Web.className("counter-value-prefix")),
+ View.text(() => `${state.count()}`).pipe(
+ Web.className("counter-dynamic-value"),
+ Web.data("counter-dynamic-value", "true"),
+ ),
+ ).pipe(Web.className("counter-value"), Web.data("counter-value", "true")),
+ View.hstack(
+ View.text("Reactive cue").pipe(Web.className("counter-cue-label")),
+ View.text("Loom attr/class/style in place").pipe(
+ Web.className("counter-reactive-cue"),
+ Web.data("counter-reactive-cue", "true"),
+ Web.attr("title", () => `Reactive cue tone: ${counterCueTone(state.count())} (${state.count()})`),
+ Web.data("counter-tone", () => counterCueTone(state.count())),
+ Web.className(() => `counter-reactive-cue--${counterCueTone(state.count())}`),
+ Web.style(() => counterCueStyle(state.count())),
+ ),
+ ).pipe(Web.className("counter-cue-row")),
+ ).pipe(Web.className("counter-value-card")),
+ View.hstack(
+ View.button(View.fragment("-", 1), actions.decrement).pipe(
+ Web.className("secondary"),
+ Web.data("counter-action", "decrement"),
+ ),
+ View.button(View.fragment("+", 1), actions.increment).pipe(
+ Web.className("contrast"),
+ Web.data("counter-action", "increment"),
+ ),
+ View.button("Reset", actions.reset).pipe(
+ Web.className("outline secondary"),
+ Web.data("counter-action", "reset"),
+ ),
+ ).pipe(Web.className("counter-actions"), Web.data("counter-controls", "true")),
+ ).pipe(Web.className("loom-example-card")),
+ View.vstack(
+ View.text(
+ "Html stays at the document/resume boundary only, not in the main authoring path developers copy from this example.",
+ ).pipe(Web.className("compat-seam-note"), Web.data("compat-seam-note", "true")),
+ View.text(
+ "Reactive cue: the badge now uses Loom-native attr/class/style bindings, while the numeric text stays on the span-backed dynamic-text seam.",
+ ).pipe(Web.className("counter-debug-note"), Web.data("counter-debug-note", "true")),
+ View.text(
+ "Dev caveat: in plain Vite dev the browser uses mount(...) to fill the empty root when no payload is present. That fallback is honest DX, not fake full SSR.",
+ ).pipe(Web.className("dev-mode-note"), Web.data("dev-mode-note", "true")),
+ ).pipe(Web.className("loom-example-note-stack")),
+ ).pipe(Web.className("loom-example-layout"), Web.data("route-view", "counter"))
+ ),
+)
+
+export const component = CounterRoute
diff --git a/apps/loom-example-app/src/routes/todo-route-state.ts b/apps/loom-example-app/src/routes/todo-route-state.ts
new file mode 100644
index 00000000..084aa427
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route-state.ts
@@ -0,0 +1,38 @@
+import { Atom, AtomRegistry } from "effect/unstable/reactivity"
+import { cloneInitialTodos, type TodoItem } from "../todo-service.js"
+
+export type TodoLoaderStatus = "idle" | "loading" | "loaded" | "revalidating" | "failure"
+
+export type TodoActionStatus = "idle" | "submitting" | "success" | "invalid-input" | "failure"
+
+export const todoRegistry = AtomRegistry.make()
+
+export const todoDraftAtom = Atom.make("")
+export const todoItemsAtom = Atom.make>([])
+export const todoLoaderStatusAtom = Atom.make("idle")
+export const todoActionStatusAtom = Atom.make("idle")
+export const todoFeedbackAtom = Atom.make(undefined)
+
+export const setTodoItems = (items: ReadonlyArray): void => {
+ todoRegistry.set(todoItemsAtom, [...items])
+}
+
+export const setTodoLoaderStatus = (status: TodoLoaderStatus): void => {
+ todoRegistry.set(todoLoaderStatusAtom, status)
+}
+
+export const setTodoActionStatus = (status: TodoActionStatus): void => {
+ todoRegistry.set(todoActionStatusAtom, status)
+}
+
+export const setTodoFeedback = (feedback: string | undefined): void => {
+ todoRegistry.set(todoFeedbackAtom, feedback)
+}
+
+export const resetTodoRouteViewState = (): void => {
+ todoRegistry.set(todoDraftAtom, "")
+ setTodoItems(cloneInitialTodos())
+ setTodoLoaderStatus("idle")
+ setTodoActionStatus("idle")
+ setTodoFeedback(undefined)
+}
diff --git a/apps/loom-example-app/src/routes/todo-route-submission.ts b/apps/loom-example-app/src/routes/todo-route-submission.ts
new file mode 100644
index 00000000..7135930f
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route-submission.ts
@@ -0,0 +1,16 @@
+import type { Runtime } from "@effectify/loom-router"
+
+export interface TodoRouteSubmissionResult {
+ readonly action: {
+ readonly _tag: string
+ }
+ readonly loader?: unknown
+}
+
+export const submitTodoRouteSubmission = async (
+ submission: Runtime.Submission,
+): Promise => {
+ const { submitTodoRuntimeAction } = await import("../router-runtime.js")
+
+ return submitTodoRuntimeAction(submission)
+}
diff --git a/apps/loom-example-app/src/routes/todo-route.ts b/apps/loom-example-app/src/routes/todo-route.ts
new file mode 100644
index 00000000..91e636d0
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route.ts
@@ -0,0 +1,37 @@
+import * as Effect from "effect/Effect"
+import { pipe } from "effect/Function"
+import { Route } from "@effectify/loom-router"
+import { TodoRoute } from "./todo-route/todo-route-component.js"
+import { todoRegistry } from "./todo-route-state.js"
+import {
+ TodoCommandResultSchema,
+ TodoCommandSchema,
+ TodoItemsSchema,
+ TodoRouteErrorSchema,
+ type TodoRouteServices,
+} from "./todo-route/todo-route-shared.js"
+
+export const component = Object.assign(TodoRoute, { registry: todoRegistry })
+
+export const loader = pipe(
+ Effect.fn(function*({ services }: { readonly services: TodoRouteServices }) {
+ return yield* services.todoService.list()
+ }),
+ Route.loader({
+ output: TodoItemsSchema,
+ }),
+)
+
+export const action = pipe(
+ Effect.fn(function*({
+ input,
+ services,
+ }: { readonly input: typeof TodoCommandSchema.Type; readonly services: TodoRouteServices }) {
+ return yield* services.todoService.dispatch(input)
+ }),
+ Route.action({
+ input: TodoCommandSchema,
+ output: TodoCommandResultSchema,
+ error: TodoRouteErrorSchema,
+ }),
+)
diff --git a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts
new file mode 100644
index 00000000..ee133794
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts
@@ -0,0 +1,75 @@
+import { Atom } from "effect/unstable/reactivity"
+import { Component, View, Web } from "@effectify/loom"
+import { todoActionStatusAtom, todoDraftAtom } from "../todo-route-state.js"
+import { submitTodoRouteSubmission } from "../todo-route-submission.js"
+import { TodoPanel } from "./todo-route-shared.js"
+
+export const TodoComposer = Component.make("TodoComposer").pipe(
+ Component.state({
+ actionStatus: todoActionStatusAtom,
+ draft: todoDraftAtom,
+ additions: () => Atom.make(0),
+ }),
+ Component.actions(({ model }) => ({
+ submitDraft: async (titleInput?: string): Promise => {
+ const title = titleInput ?? model.draft.get()
+ const result = await submitTodoRouteSubmission({
+ intent: "create",
+ title,
+ })
+
+ if (result.action._tag === "success") {
+ model.draft.set("")
+ model.additions.update((value: number) => value + 1)
+ }
+ },
+ syncDraft: (value: string): void => {
+ model.draft.set(value)
+ },
+ })),
+ Component.view(({ state, actions }) =>
+ Component.use(TodoPanel, [
+ View.text("Composer").pipe(Web.as("h2"), Web.className("todo-section-title")),
+ View.text(
+ "The Add button now submits normalized action input through the Loom runtime before the loader revalidates the list.",
+ ).pipe(Web.className("todo-copy")),
+ View.hstack(
+ View.input().pipe(
+ Web.className("todo-input"),
+ Web.attr("placeholder", "What should we ship next?"),
+ Web.attr("aria-label", "Todo title"),
+ Web.data("todo-input", "true"),
+ Web.value(() => state.draft()),
+ Web.on("input", ({ currentTarget }) => {
+ if (currentTarget instanceof HTMLInputElement) {
+ actions.syncDraft(currentTarget.value)
+ }
+ }),
+ Web.on("keydown", ({ event, currentTarget }) => {
+ if (event instanceof KeyboardEvent && event.key === "Enter") {
+ event.preventDefault()
+
+ if (currentTarget instanceof HTMLInputElement) {
+ void actions.submitDraft(currentTarget.value)
+ }
+ }
+ }),
+ ),
+ View.button("Add todo", () => actions.submitDraft()).pipe(
+ Web.className("contrast"),
+ Web.attr("disabled", () => state.actionStatus() === "submitting"),
+ Web.data("todo-add-action", "true"),
+ ),
+ ).pipe(Web.className("todo-composer-row")),
+ View.hstack(
+ View.text("Every successful submit triggers a self-revalidation through the route loader.").pipe(
+ Web.className("todo-copy"),
+ ),
+ View.text(() => `Added from this mounted composer: ${state.additions()}`).pipe(
+ Web.className("todo-session-count"),
+ Web.data("todo-session-count", "true"),
+ ),
+ ).pipe(Web.className("todo-composer-meta")),
+ ])
+ ),
+)
diff --git a/apps/loom-example-app/src/routes/todo-route/todo-hero.ts b/apps/loom-example-app/src/routes/todo-route/todo-hero.ts
new file mode 100644
index 00000000..8388f8af
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route/todo-hero.ts
@@ -0,0 +1,42 @@
+import { Component, View, Web } from "@effectify/loom"
+import { counterRoutePath } from "../counter-route.js"
+import { todoDraftAtom, todoItemsAtom, todoLoaderStatusAtom } from "../todo-route-state.js"
+
+export const TodoHero = Component.make("TodoHero").pipe(
+ Component.state({
+ draft: todoDraftAtom,
+ todos: todoItemsAtom,
+ loaderStatus: todoLoaderStatusAtom,
+ }),
+ Component.view(({ state }) =>
+ View.vstack(
+ View.vstack(
+ View.text("Example app only").pipe(Web.className("loom-example-eyebrow")),
+ View.text("Loom vNext todo app").pipe(Web.as("h1"), Web.className("todo-title")),
+ View.text(
+ "This route now reads through a loader and writes through an action runtime so the example shows an honest data flow instead of mutating module state directly.",
+ ).pipe(Web.className("todo-copy")),
+ ).pipe(Web.className("loom-example-hero")),
+ View.hstack(
+ View.vstack(
+ View.text("Loaded todos").pipe(Web.className("todo-kpi-label")),
+ View.text(() => `${state.todos().length} tracked todos`).pipe(Web.className("todo-kpi-value")),
+ ).pipe(Web.className("todo-kpi")),
+ View.vstack(
+ View.text("Loader status").pipe(Web.className("todo-kpi-label")),
+ View.text(() => state.loaderStatus()).pipe(
+ Web.className("todo-kpi-value"),
+ Web.data("todo-runtime-status", "true"),
+ ),
+ ).pipe(Web.className("todo-kpi")),
+ View.vstack(
+ View.text("Draft sync").pipe(Web.className("todo-kpi-label")),
+ View.text(() => state.draft().trim().length > 0 ? "Composer is holding input" : "Composer is empty").pipe(
+ Web.className("todo-kpi-value"),
+ ),
+ ).pipe(Web.className("todo-kpi")),
+ View.link("Back to counter", counterRoutePath).pipe(Web.className("outline secondary todo-link")),
+ ).pipe(Web.className("todo-kpi-row")),
+ ).pipe(Web.data("todo-hero", "true"))
+ ),
+)
diff --git a/apps/loom-example-app/src/routes/todo-route/todo-insights.ts b/apps/loom-example-app/src/routes/todo-route/todo-insights.ts
new file mode 100644
index 00000000..e2402cc3
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route/todo-insights.ts
@@ -0,0 +1,50 @@
+import { Component, View, Web } from "@effectify/loom"
+import { todoActionStatusAtom, todoFeedbackAtom, todoItemsAtom } from "../todo-route-state.js"
+import { completedTodoCount, remainingTodoCount, TodoPanel } from "./todo-route-shared.js"
+
+export const TodoInsights = Component.make("TodoInsights").pipe(
+ Component.state({
+ actionStatus: todoActionStatusAtom,
+ feedback: todoFeedbackAtom,
+ todos: todoItemsAtom,
+ }),
+ Component.view(({ state }) =>
+ Component.use(TodoPanel, [
+ View.text("Runtime snapshot").pipe(Web.as("h2"), Web.className("todo-section-title")),
+ View.text(
+ "The route loader owns the initial list, and every button funnels through the route action plus self-revalidation.",
+ ).pipe(Web.className("todo-copy")),
+ View.hstack(
+ View.vstack(
+ View.text("Open").pipe(Web.className("todo-stat-label")),
+ View.text(() => `${remainingTodoCount(state.todos())}`).pipe(
+ Web.className("todo-stat-value"),
+ Web.data("todo-open-count", "true"),
+ ),
+ ).pipe(Web.className("todo-stat")),
+ View.vstack(
+ View.text("Completed").pipe(Web.className("todo-stat-label")),
+ View.text(() => `${completedTodoCount(state.todos())}`).pipe(
+ Web.className("todo-stat-value"),
+ Web.data("todo-completed-count", "true"),
+ ),
+ ).pipe(Web.className("todo-stat")),
+ View.vstack(
+ View.text("Action status").pipe(Web.className("todo-stat-label")),
+ View.text(() => state.actionStatus()).pipe(
+ Web.className("todo-stat-value"),
+ Web.data("todo-action-status", "true"),
+ ),
+ ).pipe(Web.className("todo-stat")),
+ ).pipe(Web.className("todo-stat-grid")),
+ View.if(
+ () => state.feedback() !== undefined,
+ View.text(() => state.feedback() ?? "").pipe(
+ Web.className("todo-copy"),
+ Web.data("todo-feedback", "true"),
+ ),
+ View.fragment(),
+ ),
+ ])
+ ),
+)
diff --git a/apps/loom-example-app/src/routes/todo-route/todo-list.ts b/apps/loom-example-app/src/routes/todo-route/todo-list.ts
new file mode 100644
index 00000000..26eacd79
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route/todo-list.ts
@@ -0,0 +1,76 @@
+import { Component, View, Web } from "@effectify/loom"
+import { todoActionStatusAtom, todoItemsAtom } from "../todo-route-state.js"
+import { submitTodoRouteSubmission } from "../todo-route-submission.js"
+import { hasCompletedTodos, TodoPanel } from "./todo-route-shared.js"
+
+export const TodoList = Component.make("TodoList").pipe(
+ Component.state({
+ actionStatus: todoActionStatusAtom,
+ todos: todoItemsAtom,
+ }),
+ Component.actions(() => ({
+ clearCompleted: async (): Promise => {
+ await submitTodoRouteSubmission({ intent: "clear-completed" })
+ },
+ removeTodo: async (id: number): Promise => {
+ await submitTodoRouteSubmission({ intent: "remove", id: String(id) })
+ },
+ toggleTodo: async (id: number): Promise => {
+ await submitTodoRouteSubmission({ intent: "toggle", id: String(id) })
+ },
+ })),
+ Component.view(({ state, actions }) =>
+ Component.use(TodoPanel, [
+ View.hstack(
+ View.vstack(
+ View.text("Todo list").pipe(Web.as("h2"), Web.className("todo-section-title")),
+ View.text(
+ "Secondary buttons also dispatch through the same route action instead of mutating atoms in place.",
+ ).pipe(
+ Web.className("todo-copy"),
+ ),
+ ),
+ View.button("Clear completed", () => actions.clearCompleted()).pipe(
+ Web.className("outline secondary"),
+ Web.attr("disabled", () => !hasCompletedTodos(state.todos()) || state.actionStatus() === "submitting"),
+ Web.data("todo-clear-completed", "true"),
+ ),
+ ).pipe(Web.className("todo-list-header")),
+ View.if(
+ () => state.todos().length === 0,
+ View.text("No todos left. Add another one from the composer above.").pipe(
+ Web.className("todo-empty-state"),
+ Web.data("todo-empty-state", "true"),
+ ),
+ View.vstack(
+ View.for(() => state.todos(), {
+ key: (todo) => todo.id,
+ render: (todo) =>
+ View.hstack(
+ View.button(todo.completed ? "Re-open" : "Done", () => actions.toggleTodo(todo.id)).pipe(
+ Web.className(todo.completed ? "outline secondary" : "secondary"),
+ Web.attr("disabled", () => state.actionStatus() === "submitting"),
+ Web.data("todo-toggle-id", `${todo.id}`),
+ ),
+ View.vstack(
+ View.text(todo.title).pipe(
+ Web.className(todo.completed ? "todo-item-title todo-item-title--completed" : "todo-item-title"),
+ ),
+ View.text(todo.completed ? "Completed task" : "Open task").pipe(Web.className("todo-item-status")),
+ ).pipe(Web.className("todo-item-copy")),
+ View.button("Remove", () => actions.removeTodo(todo.id)).pipe(
+ Web.className("outline contrast"),
+ Web.attr("disabled", () => state.actionStatus() === "submitting"),
+ Web.data("todo-remove-id", `${todo.id}`),
+ ),
+ ).pipe(
+ Web.as("li"),
+ Web.className(todo.completed ? "todo-item todo-item--completed" : "todo-item"),
+ Web.data("todo-item-id", `${todo.id}`),
+ ),
+ }),
+ ).pipe(Web.as("ul"), Web.className("todo-item-list"), Web.data("todo-list", "true")),
+ ),
+ ])
+ ),
+)
diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts
new file mode 100644
index 00000000..f729f728
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts
@@ -0,0 +1,20 @@
+import { Component, View, Web } from "@effectify/loom"
+import { TodoComposer } from "./todo-composer.js"
+import { TodoHero } from "./todo-hero.js"
+import { TodoInsights } from "./todo-insights.js"
+import { TodoList } from "./todo-list.js"
+import { TodoNotes, TodoPageShell } from "./todo-route-shared.js"
+
+export const TodoRoute = Component.make("TodoRoute").pipe(
+ Component.view(() =>
+ Component.use(TodoPageShell, [
+ Component.use(TodoHero),
+ View.hstack(
+ Component.use(TodoInsights),
+ Component.use(TodoComposer),
+ ).pipe(Web.className("todo-top-row")),
+ Component.use(TodoList),
+ Component.use(TodoNotes),
+ ])
+ ),
+)
diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts
new file mode 100644
index 00000000..aec4ed2e
--- /dev/null
+++ b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts
@@ -0,0 +1,70 @@
+import * as Schema from "effect/Schema"
+import { Component, View, Web } from "@effectify/loom"
+import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../../todo-service.js"
+
+export type TodoRouteServices = Readonly<{
+ todoService: TodoServiceApi
+}>
+
+const TodoIntentSchema = Schema.Union([
+ Schema.Literal("create"),
+ Schema.Literal("toggle"),
+ Schema.Literal("remove"),
+ Schema.Literal("clear-completed"),
+])
+
+const TodoIdSchema = Schema.NumberFromString.check(Schema.isInt())
+
+const TodoTitleSchema = Schema.Trim.check(Schema.isNonEmpty())
+
+export const TodoItemSchema = Schema.Struct({
+ completed: Schema.Boolean,
+ id: Schema.Number,
+ title: Schema.String,
+})
+
+export const TodoItemsSchema = Schema.Array(TodoItemSchema)
+
+export const TodoCommandSchema = Schema.Union([
+ Schema.Struct({ intent: Schema.Literal("create"), title: TodoTitleSchema }),
+ Schema.Struct({ id: TodoIdSchema, intent: Schema.Literal("toggle") }),
+ Schema.Struct({ id: TodoIdSchema, intent: Schema.Literal("remove") }),
+ Schema.Struct({ intent: Schema.Literal("clear-completed") }),
+])
+
+export const TodoCommandResultSchema = Schema.Struct({
+ intent: TodoIntentSchema,
+})
+
+export const TodoRouteErrorSchema = Schema.instanceOf(TodoNotFoundError)
+
+export const remainingTodoCount = (todos: ReadonlyArray): number =>
+ todos.filter((todo) => !todo.completed).length
+
+export const completedTodoCount = (todos: ReadonlyArray): number =>
+ todos.filter((todo) => todo.completed).length
+
+export const hasCompletedTodos = (todos: ReadonlyArray): boolean => todos.some((todo) => todo.completed)
+
+export const TodoPanel = Component.make("TodoPanel").pipe(
+ Component.view(({ children }) => View.vstack(children).pipe(Web.className("loom-example-card todo-panel"))),
+)
+
+export const TodoNotes = Component.make("TodoNotes").pipe(
+ Component.view(() =>
+ View.vstack(
+ View.text(
+ "View.input() + Web.value() still cover the composer, but the durable todo state now comes from loader/action runtime boundaries.",
+ ).pipe(Web.className("compat-seam-note"), Web.data("todo-dx-caveat", "true")),
+ View.text(
+ "The point of this example is architecture: Effect-backed service first, Loom route runtime second, and UI atoms as the projected view state.",
+ ).pipe(Web.className("dev-mode-note")),
+ ).pipe(Web.className("loom-example-note-stack"))
+ ),
+)
+
+export const TodoPageShell = Component.make("TodoPageShell").pipe(
+ Component.view(({ children }) =>
+ View.vstack(children).pipe(Web.className("loom-example-layout"), Web.data("route-view", "todo"))
+ ),
+)
diff --git a/apps/loom-example-app/src/todo-service.ts b/apps/loom-example-app/src/todo-service.ts
new file mode 100644
index 00000000..1a617c20
--- /dev/null
+++ b/apps/loom-example-app/src/todo-service.ts
@@ -0,0 +1,109 @@
+import * as Data from "effect/Data"
+import * as Effect from "effect/Effect"
+import * as Layer from "effect/Layer"
+import * as Ref from "effect/Ref"
+import * as ServiceMap from "effect/ServiceMap"
+
+export interface TodoItem {
+ readonly id: number
+ readonly title: string
+ readonly completed: boolean
+}
+
+export type TodoCommand =
+ | { readonly intent: "create"; readonly title: string }
+ | { readonly intent: "toggle"; readonly id: number }
+ | { readonly intent: "remove"; readonly id: number }
+ | { readonly intent: "clear-completed" }
+
+export type TodoCommandResult = Readonly<{
+ intent: TodoCommand["intent"]
+}>
+
+export class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
+ readonly id: number
+}> {}
+
+export interface TodoServiceApi {
+ readonly list: () => Effect.Effect>
+ readonly dispatch: (command: TodoCommand) => Effect.Effect
+ readonly reset: () => Effect.Effect
+}
+
+export const initialTodoItems: ReadonlyArray = [
+ { id: 1, title: "Sketch the shared Atom shape", completed: true },
+ { id: 2, title: "Wire the composer to shared state", completed: false },
+ { id: 3, title: "Show composition through child components", completed: false },
+]
+
+export const cloneInitialTodos = (): Array => initialTodoItems.map((todo) => ({ ...todo }))
+
+export class TodoService extends ServiceMap.Service()("LoomExampleTodoService", {
+ make: Effect.gen(function*() {
+ const todosRef = yield* Ref.make(cloneInitialTodos())
+ const nextIdRef = yield* Ref.make(initialTodoItems.length + 1)
+
+ return {
+ list: () => Ref.get(todosRef),
+ dispatch: (command) =>
+ Effect.gen(function*() {
+ switch (command.intent) {
+ case "create": {
+ const nextId = yield* Ref.get(nextIdRef)
+ const nextTodo: TodoItem = {
+ id: nextId,
+ title: command.title,
+ completed: false,
+ }
+
+ yield* Ref.update(todosRef, (current) => [...current, nextTodo])
+ yield* Ref.set(nextIdRef, nextId + 1)
+
+ return { intent: command.intent }
+ }
+ case "toggle": {
+ const current = yield* Ref.get(todosRef)
+
+ if (!current.some((todo) => todo.id === command.id)) {
+ return yield* Effect.fail(new TodoNotFoundError({ id: command.id }))
+ }
+
+ yield* Ref.update(todosRef, (todos) =>
+ todos.map((todo) => todo.id === command.id ? { ...todo, completed: !todo.completed } : todo))
+
+ return { intent: command.intent }
+ }
+ case "remove": {
+ const current = yield* Ref.get(todosRef)
+
+ if (
+ !current.some((todo) =>
+ todo.id === command.id
+ )
+ ) {
+ return yield* Effect.fail(new TodoNotFoundError({ id: command.id }))
+ }
+
+ yield* Ref.update(todosRef, (todos) => todos.filter((todo) => todo.id !== command.id))
+
+ return { intent: command.intent }
+ }
+ case "clear-completed": {
+ yield* Ref.update(todosRef, (todos) => todos.filter((todo) => !todo.completed))
+
+ return { intent: command.intent }
+ }
+ }
+ }),
+ reset: () =>
+ Effect.gen(function*() {
+ yield* Ref.set(todosRef, cloneInitialTodos())
+ yield* Ref.set(nextIdRef, initialTodoItems.length + 1)
+ }),
+ }
+ }),
+}) {
+ static readonly layer = Layer.effect(TodoService, this.make)
+}
+
+export const makeTodoService = (): TodoServiceApi => Effect.runSync(TodoService.make)
diff --git a/apps/loom-example-app/tests/entry-client.test.ts b/apps/loom-example-app/tests/entry-client.test.ts
new file mode 100644
index 00000000..94b91249
--- /dev/null
+++ b/apps/loom-example-app/tests/entry-client.test.ts
@@ -0,0 +1,288 @@
+// @vitest-environment jsdom
+
+import { beforeEach, describe, expect, it } from "vitest"
+import { bootstrapClient, startClientApp } from "../src/entry-client.js"
+import { createServerRenderer } from "../src/entry-server.js"
+import { resetTodoExampleState } from "../src/router-runtime.js"
+
+const yieldToEventLoop = async (): Promise => {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+const expectElement = (value: Element | null, name: string): HTMLElement => {
+ if (!(value instanceof HTMLElement)) {
+ throw new Error(`expected ${name}`)
+ }
+
+ return value
+}
+
+const expectInputElement = (value: Element | null, name: string): HTMLInputElement => {
+ if (!(value instanceof HTMLInputElement)) {
+ throw new Error(`expected ${name}`)
+ }
+
+ return value
+}
+
+describe("loom example app client entry", () => {
+ beforeEach(() => {
+ resetTodoExampleState()
+ })
+
+ it("reports a missing payload while leaving the SSR shell untouched", async () => {
+ document.body.innerHTML = 'server shell
'
+ const before = document.body.innerHTML
+
+ const result = await bootstrapClient(document)
+
+ expect(result.status).toBe("missing-payload")
+ expect(document.body.innerHTML).toBe(before)
+ })
+
+ it("leaves the server-rendered shell alone when the page already contains SSR html", async () => {
+ const renderer = createServerRenderer()
+ const result = await renderer.render({
+ method: "GET",
+ url: "/",
+ headers: {},
+ })
+
+ document.documentElement.innerHTML = result.html
+ const before = document.body.innerHTML
+
+ const bootstrap = await startClientApp(document)
+
+ expect(bootstrap.status).toBe("missing-payload")
+ expect(document.body.innerHTML).toBe(before)
+ })
+
+ it("accepts explicit bootstrap options for missing payload diagnostics", async () => {
+ document.body.innerHTML = 'server shell
'
+
+ const result = await bootstrapClient(document, { payloadElementId: "loom-demo-payload" })
+
+ expect(result.status).toBe("missing-payload")
+ expect(result.diagnostics[0]?.issues[0]?.subject).toBe("loom-demo-payload")
+ })
+
+ it("mounts the counter route into an empty dev root and keeps the buttons interactive", async () => {
+ document.documentElement.innerHTML = `
+ Loom Example App
+
+
+
+
+ `
+ window.history.replaceState({}, "", "/")
+
+ const result = await startClientApp(document)
+ const count = () => document.querySelector('[data-counter-value="true"]')?.textContent
+ const normalizedCount = () => count()?.replace(/\s+/g, " ").trim()
+ const dynamicValue = () => document.querySelector('[data-counter-dynamic-value="true"]')
+ const reactiveCue = () => document.querySelector('[data-counter-reactive-cue="true"]')
+ const click = (actionName: "decrement" | "increment" | "reset") => {
+ const button = document.querySelector(`[data-counter-action="${actionName}"]`)
+
+ if (!(button instanceof HTMLButtonElement)) {
+ throw new Error(`expected ${actionName} button`)
+ }
+
+ button.click()
+ }
+
+ expect(result.status).toBe("missing-payload")
+ expect(document.querySelectorAll('[data-app-shell="loom-example-app"]')).toHaveLength(1)
+ expect(normalizedCount()).toBe("Count: 2")
+ expect(document.body.textContent).toContain("Loom-native attr/class/style bindings")
+
+ const cueBefore = expectElement(reactiveCue(), "reactive cue")
+ const dynamicValueBefore = expectElement(dynamicValue(), "dynamic counter value")
+
+ expect(cueBefore.dataset.counterTone).toBe("baseline")
+ expect(cueBefore.className).toContain("counter-reactive-cue--baseline")
+ expect(cueBefore.getAttribute("title")).toBe("Reactive cue tone: baseline (2)")
+ expect(cueBefore.style.backgroundColor).toBe("rgba(59, 130, 246, 0.12)")
+ expect(cueBefore.style.transform).toBe("translateY(0px)")
+
+ click("increment")
+ await yieldToEventLoop()
+ expect(normalizedCount()).toBe("Count: 3")
+
+ const cueAfterIncrement = expectElement(reactiveCue(), "reactive cue after increment")
+ const dynamicValueAfterIncrement = expectElement(dynamicValue(), "dynamic counter value after increment")
+
+ expect(cueAfterIncrement).toBe(cueBefore)
+ expect(dynamicValueAfterIncrement).toBe(dynamicValueBefore)
+ expect(cueAfterIncrement.getAttribute("data-counter-tone")).toBe("rising")
+ expect(cueAfterIncrement.className).toContain("counter-reactive-cue--rising")
+ expect(cueAfterIncrement.getAttribute("title")).toBe("Reactive cue tone: rising (3)")
+ expect(cueAfterIncrement.style.transform).toBe("translateY(-1px)")
+
+ click("increment")
+ await yieldToEventLoop()
+ expect(normalizedCount()).toBe("Count: 4")
+ expect(expectElement(reactiveCue(), "reactive cue after second increment").style.transform).toBe("translateY(-2px)")
+
+ click("decrement")
+ await yieldToEventLoop()
+ expect(normalizedCount()).toBe("Count: 3")
+
+ click("decrement")
+ await yieldToEventLoop()
+ expect(normalizedCount()).toBe("Count: 2")
+ expect(reactiveCue()?.getAttribute("data-counter-tone")).toBe("baseline")
+ expect(expectElement(reactiveCue(), "reactive cue after decrement").style.transform).toBe("translateY(0px)")
+
+ click("reset")
+ await yieldToEventLoop()
+ expect(normalizedCount()).toBe("Count: 2")
+ expect(document.body.textContent).toContain("mount(...)")
+ expect(document.title).toBe("Loom Example App · Counter")
+ })
+
+ it("handles delegated clicks that originate from button text nodes in the dev fallback", async () => {
+ document.documentElement.innerHTML = `
+ Loom Example App
+
+
+
+
+ `
+ window.history.replaceState({}, "", "/")
+
+ await startClientApp(document)
+
+ const incrementButton = document.querySelector('[data-counter-action="increment"]')
+
+ if (!(incrementButton instanceof HTMLButtonElement)) {
+ throw new Error("expected increment button")
+ }
+
+ const labelNode = incrementButton.firstChild
+
+ if (!(labelNode instanceof Text)) {
+ throw new Error("expected increment button text node")
+ }
+
+ labelNode.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+
+ expect(document.querySelector('[data-counter-value="true"]')?.textContent?.replace(/\s+/g, " ").trim()).toBe(
+ "Count: 3",
+ )
+ })
+
+ it("renders a minimal not-found message for non-root dev fallback paths", async () => {
+ document.documentElement.innerHTML = `
+ Loom Example App
+
+
+
+
+ `
+ window.history.replaceState({}, "", "/missing")
+
+ const result = await startClientApp(document)
+
+ expect(result.status).toBe("missing-payload")
+ expect(document.body.textContent).toContain("Route not found")
+ expect(document.body.textContent).toContain("Requested path: /missing")
+ })
+
+ it("mounts the todo route into the dev fallback and keeps shared atoms in sync across sections", async () => {
+ document.documentElement.innerHTML = `
+ Loom Example App
+
+
+
+
+ `
+ window.history.replaceState({}, "", "/todos")
+
+ const result = await startClientApp(document)
+ const todoInput = () => document.querySelector('[data-todo-input="true"]')
+ const addTodoButton = () => document.querySelector('[data-todo-add-action="true"]')
+ const openCount = () => document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim()
+ const completedCount = () => document.querySelector('[data-todo-completed-count="true"]')?.textContent?.trim()
+ const sessionCount = () => document.querySelector('[data-todo-session-count="true"]')?.textContent?.trim()
+ const clickButton = (selector: string) => {
+ const button = document.querySelector(selector)
+
+ if (!(button instanceof HTMLButtonElement)) {
+ throw new Error(`expected button for ${selector}`)
+ }
+
+ button.click()
+ }
+
+ expect(result.status).toBe("missing-payload")
+ expect(document.querySelector('[data-route-view="todo"]')).not.toBeNull()
+ expect(openCount()).toBe("2")
+ expect(completedCount()).toBe("1")
+ expect(sessionCount()).toBe("Added from this mounted composer: 0")
+
+ const input = expectInputElement(todoInput(), "todo input")
+ input.focus()
+ input.value = "Document the slot tradeoffs"
+ input.dispatchEvent(new Event("input", { bubbles: true }))
+ await yieldToEventLoop()
+
+ expect(expectInputElement(todoInput(), "todo input after typing")).toBe(input)
+ expect(document.activeElement).toBe(input)
+ expect(input.value).toBe("Document the slot tradeoffs")
+
+ const addButton = expectElement(addTodoButton(), "add todo button")
+
+ addButton.click()
+ await yieldToEventLoop()
+
+ expect(openCount()).toBe("3")
+ expect(sessionCount()).toBe("Added from this mounted composer: 1")
+ expect(expectInputElement(todoInput(), "todo input after add").value).toBe("")
+ expect(document.querySelector('[data-todo-item-id="4"]')?.textContent).toContain("Document the slot tradeoffs")
+
+ clickButton('[data-todo-toggle-id="2"]')
+ await yieldToEventLoop()
+ expect(openCount()).toBe("2")
+ expect(completedCount()).toBe("2")
+
+ clickButton('[data-todo-remove-id="1"]')
+ await yieldToEventLoop()
+ expect(openCount()).toBe("2")
+ expect(completedCount()).toBe("1")
+ expect(document.querySelector('[data-todo-item-id="1"]')).toBeNull()
+
+ clickButton('[data-todo-clear-completed="true"]')
+ await yieldToEventLoop()
+ expect(openCount()).toBe("2")
+ expect(completedCount()).toBe("0")
+ expect(document.querySelector('[data-todo-item-id="2"]')).toBeNull()
+ expect(document.title).toBe("Loom Example App · Todo app")
+ })
+
+ it("shows invalid action feedback when the todo action input fails validation", async () => {
+ document.documentElement.innerHTML = `
+ Loom Example App
+
+
+
+
+ `
+ window.history.replaceState({}, "", "/todos")
+
+ await startClientApp(document)
+
+ const addButton = document.querySelector('[data-todo-add-action="true"]')
+
+ if (!(addButton instanceof HTMLButtonElement)) {
+ throw new Error("expected add todo button")
+ }
+
+ addButton.click()
+ await yieldToEventLoop()
+
+ expect(document.querySelector('[data-todo-feedback="true"]')?.textContent).toContain("length of at least 1")
+ expect(document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim()).toBe("2")
+ expect(document.querySelector('[data-todo-action-status="true"]')?.textContent?.trim()).toBe("invalid-input")
+ })
+})
diff --git a/apps/loom-example-app/tests/entry-server.test.ts b/apps/loom-example-app/tests/entry-server.test.ts
new file mode 100644
index 00000000..6c2adee1
--- /dev/null
+++ b/apps/loom-example-app/tests/entry-server.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import { createServerRenderer } from "../src/entry-server.js"
+import { resetTodoExampleState } from "../src/router-runtime.js"
+
+describe("loom example app server entry", () => {
+ beforeEach(() => {
+ resetTodoExampleState()
+ })
+
+ it("renders the single counter route inside the shared document shell", async () => {
+ const renderer = createServerRenderer()
+ const result = await renderer.render({
+ method: "GET",
+ url: "/",
+ headers: {},
+ })
+
+ expect(result.status).toBe(200)
+ expect(result.html).toContain("Loom vNext counter")
+ expect(result.html).toContain('id="loom-root"')
+ expect(result.html).toContain('data-route-view="counter"')
+ expect(result.html).toContain('id="__loom_payload__"')
+ expect(result.html).toContain('data-counter-action="increment"')
+ expect(result.html).toContain('data-counter-value="true"')
+ expect(result.html).toContain('data-counter-dynamic-value="true"')
+ expect(result.html).toContain('data-counter-reactive-cue="true"')
+ expect(result.html).toContain("Loom-native attr/class/style bindings")
+ expect(result.html).toContain("mount(...) to fill the empty root")
+ })
+
+ it("renders the todo route with shared-state sections and interactive controls", async () => {
+ const renderer = createServerRenderer()
+ const result = await renderer.render({
+ method: "GET",
+ url: "/todos",
+ headers: {},
+ })
+
+ expect(result.status).toBe(200)
+ expect(result.html).toContain("Loom vNext todo app")
+ expect(result.html).toContain('data-route-view="todo"')
+ expect(result.html).toContain('data-todo-input="true"')
+ expect(result.html).toContain('data-todo-add-action="true"')
+ expect(result.html).toContain('data-todo-list="true"')
+ expect(result.html).toContain('data-todo-session-count="true"')
+ expect(result.html).toContain('value=""')
+ expect(result.html).toContain("Sketch the shared Atom shape")
+ expect(result.html).toContain("durable todo state now comes from loader/action runtime boundaries")
+ })
+
+ it("returns a minimal not-found document for unknown paths", async () => {
+ const renderer = createServerRenderer()
+ const result = await renderer.render({
+ method: "GET",
+ url: "/missing-route",
+ headers: {},
+ })
+
+ expect(result.status).toBe(404)
+ expect(result.html).toContain("Route not found")
+ expect(result.html).toContain('data-route-view="not-found"')
+ expect(result.html).toContain("/missing-route")
+ })
+})
diff --git a/apps/loom-example-app/tests/project-shape.test.ts b/apps/loom-example-app/tests/project-shape.test.ts
new file mode 100644
index 00000000..31117125
--- /dev/null
+++ b/apps/loom-example-app/tests/project-shape.test.ts
@@ -0,0 +1,172 @@
+import { readFileSync } from "node:fs"
+import { pathToFileURL } from "node:url"
+import { describe, expect, it } from "vitest"
+
+const readJson = (relativePath: string): unknown =>
+ JSON.parse(readFileSync(new URL(relativePath, import.meta.url), "utf8"))
+
+describe("loom example app project shape", () => {
+ it("declares explicit Nx lint, test, and typecheck targets plus tsconfig references", () => {
+ const project = readJson("../project.json") as {
+ name: string
+ targets: Record
+ }
+ const tsconfig = readJson("../tsconfig.json") as {
+ references: ReadonlyArray<{ path: string }>
+ }
+
+ expect(project.name).toBe("@effectify/loom-example-app")
+ expect(Object.keys(project.targets)).toEqual(expect.arrayContaining(["lint", "test", "typecheck"]))
+ expect(tsconfig.references).toEqual([
+ { path: "./tsconfig.app.json" },
+ { path: "./tsconfig.spec.json" },
+ ])
+ })
+
+ it("uses only the public Loom packages and registers the Loom Vite plugin", async () => {
+ const packageJson = readJson("../package.json") as {
+ dependencies: Record
+ }
+ const viteModuleUrl = pathToFileURL(new URL("../vite.config.mts", import.meta.url).pathname).href
+ const viteConfigModule = await import(viteModuleUrl)
+ const viteConfig = "default" in viteConfigModule ? viteConfigModule.default : viteConfigModule
+ const config = typeof viteConfig === "function" ? await viteConfig() : viteConfig
+
+ expect(Object.keys(packageJson.dependencies)).toEqual(
+ expect.arrayContaining([
+ "@effectify/loom",
+ "@effectify/loom-nitro",
+ "@effectify/loom-router",
+ "@effectify/loom-vite",
+ ]),
+ )
+ expect(config.plugins.map((plugin: { name?: string }) => plugin.name)).toEqual(
+ expect.arrayContaining(["effectify:loom-vite"]),
+ )
+ })
+
+ it("authors the example through the vNext root surface first and uses mount for the dev fallback", () => {
+ const counterRouteSource = readFileSync(new URL("../src/routes/counter-route.ts", import.meta.url), "utf8")
+ const todoRouteSource = readFileSync(new URL("../src/routes/todo-route.ts", import.meta.url), "utf8")
+ const todoRouteComponentSource = readFileSync(
+ new URL("../src/routes/todo-route/todo-route-component.ts", import.meta.url),
+ "utf8",
+ )
+ const todoHeroSource = readFileSync(new URL("../src/routes/todo-route/todo-hero.ts", import.meta.url), "utf8")
+ const todoComposerSource = readFileSync(
+ new URL("../src/routes/todo-route/todo-composer.ts", import.meta.url),
+ "utf8",
+ )
+ const todoListSource = readFileSync(new URL("../src/routes/todo-route/todo-list.ts", import.meta.url), "utf8")
+ const todoRouteStateSource = readFileSync(new URL("../src/routes/todo-route-state.ts", import.meta.url), "utf8")
+ const routerSource = readFileSync(new URL("../src/router.ts", import.meta.url), "utf8")
+ const entryClientSource = readFileSync(new URL("../src/entry-client.ts", import.meta.url), "utf8")
+ const loomReadmeSource = readFileSync(new URL("../../../packages/loom/README.md", import.meta.url), "utf8")
+ const loomPrdSource = readFileSync(new URL("../../../docs/loom-prd.md", import.meta.url), "utf8")
+ const loomRfcSource = readFileSync(new URL("../../../docs/loom-rfc.md", import.meta.url), "utf8")
+
+ expect(counterRouteSource).toContain("Component.state")
+ expect(counterRouteSource).toContain('export const CounterRoute = Component.make("CounterRoute").pipe(')
+ expect(counterRouteSource).toContain("export const component = CounterRoute")
+ expect(counterRouteSource).not.toContain("export const counterRoute =")
+ expect(counterRouteSource).not.toContain("Component.stateFactory")
+ expect(counterRouteSource).toContain("Component.actions")
+ expect(counterRouteSource).toContain("Component.view")
+ expect(counterRouteSource).toContain("Web.as")
+ expect(counterRouteSource).toContain("View.vstack")
+ expect(counterRouteSource).toContain("View.hstack")
+ expect(counterRouteSource).toContain("Web.data")
+ expect(counterRouteSource).not.toContain("Html.el(")
+ expect(counterRouteSource).not.toContain("Route.make({")
+ expect(counterRouteSource).not.toContain("counterPageRoute")
+ expect(todoRouteStateSource).toContain("export const todoItemsAtom = Atom.make")
+ expect(todoRouteSource).toContain("export const component = Object.assign(TodoRoute, { registry: todoRegistry })")
+ expect(todoRouteSource).toContain("export const loader = pipe(")
+ expect(todoRouteSource).toContain("Route.loader({")
+ expect(todoRouteSource).toContain("Effect.fn(function*({ services }: { readonly services: TodoRouteServices }) {")
+ expect(todoRouteSource).toContain("export const action = pipe(")
+ expect(todoRouteSource).toContain("Route.action({")
+ expect(todoRouteSource).toContain(
+ "}: { readonly input: typeof TodoCommandSchema.Type; readonly services: TodoRouteServices }) {",
+ )
+ expect(todoRouteSource).toContain("input: TodoCommandSchema,")
+ expect(todoRouteSource).toContain("output: TodoCommandResultSchema,")
+ expect(todoRouteSource).toContain("error: TodoRouteErrorSchema,")
+ expect(todoRouteSource).not.toContain("const todoLoaderOptions = {")
+ expect(todoRouteSource).not.toContain("const todoActionOptions = {")
+ expect(todoRouteSource).not.toContain("Route.ModuleLoader<")
+ expect(todoRouteSource).not.toContain("Route.ModuleAction<")
+ expect(todoRouteSource).not.toContain("Route.ModuleLoaderContext<")
+ expect(todoRouteSource).not.toContain("Route.ModuleActionContext<")
+ expect(todoRouteSource).not.toContain("todoActionDecoder")
+ expect(todoRouteSource).not.toContain("toTodoRouteFailure")
+ expect(todoRouteComponentSource).toContain('Component.make("TodoRoute").pipe(')
+ expect(todoRouteComponentSource).toContain("Component.use(TodoHero")
+ expect(todoRouteComponentSource).toContain("Component.use(TodoList")
+ expect(todoRouteComponentSource).not.toContain("attachTodoRegistry")
+ expect(todoRouteComponentSource).not.toContain("View.fragment()")
+ expect(todoRouteComponentSource).not.toContain("todoRegistry")
+ expect(todoRouteComponentSource).not.toContain("Object.assign(")
+ expect(todoHeroSource).toContain('export const TodoHero = Component.make("TodoHero").pipe(')
+ expect(todoHeroSource).toContain('Component.make("TodoHero").pipe(')
+ expect(todoHeroSource).toContain("Component.state({")
+ expect(todoHeroSource).not.toContain("Component.children()")
+ expect(todoHeroSource).not.toContain("attachTodoRegistry")
+ expect(todoHeroSource).not.toContain("withTodoRouteRegistry")
+ expect(todoHeroSource).toContain("View.link")
+ expect(todoHeroSource).not.toContain("registry: todoRegistry")
+ expect(todoComposerSource).toContain('export const TodoComposer = Component.make("TodoComposer").pipe(')
+ expect(todoComposerSource).toContain('Component.make("TodoComposer").pipe(')
+ expect(todoComposerSource).not.toContain("Component.stateFactory")
+ expect(todoComposerSource).not.toContain("attachTodoRegistry")
+ expect(todoComposerSource).toContain("View.button")
+ expect(todoComposerSource).toContain("View.input()")
+ expect(todoComposerSource).toContain("Web.value(")
+ expect(todoComposerSource).not.toContain("registry: todoRegistry")
+ expect(todoListSource).toContain('export const TodoList = Component.make("TodoList").pipe(')
+ expect(todoListSource).toContain("View.button")
+ expect(todoListSource).not.toContain("attachTodoRegistry")
+ expect(todoListSource).not.toContain("withTodoRouteRegistry")
+ expect(todoListSource).not.toContain("registry: todoRegistry")
+ expect(todoRouteSource).not.toContain('Web.as("input")')
+ expect(todoRouteSource).not.toContain('Web.attr("value"')
+ expect(todoRouteSource).not.toContain("syncTodoInputValue")
+ expect(todoRouteSource).not.toContain("Html.el(")
+ expect(todoRouteSource).not.toContain("todoPageRoute")
+ expect(todoRouteSource).not.toContain("Route.make({")
+ expect(routerSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"')
+ expect(routerSource).toContain("module: counterRouteModule,")
+ expect(routerSource).not.toContain("Component.children()")
+ expect(routerSource).toContain('Component.make("AppShell")')
+ expect(routerSource).toContain("Component.use(AppShell")
+ expect(routerSource).toContain("Fallback.make(notFoundView)")
+ expect(routerSource).toContain("RouteModule.compile({")
+ expect(routerSource).toContain("component: () => Component.use(todoRouteModule.component)")
+ expect(routerSource).not.toContain("internal/route-modules")
+ expect(routerSource).not.toContain("Html.el(")
+ expect(routerSource).not.toContain("registry: todoRegistry")
+ expect(entryClientSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"')
+ expect(entryClientSource).toContain('import * as todoRouteModule from "./routes/todo-route.js"')
+ expect(entryClientSource).toContain("mount({ counterRoute: counterRouteModule.component }, { root })")
+ expect(entryClientSource).toContain("mount({ todoRoute: todoRouteModule.component }, { root })")
+
+ for (const docSource of [loomReadmeSource, loomPrdSource, loomRfcSource]) {
+ expect(docSource).not.toContain("Component.stateFactory")
+ expect(docSource).not.toContain("Component.children()")
+ }
+
+ expect(loomReadmeSource).toContain('Component.make("CounterRoute")')
+ expect(loomReadmeSource).toContain("RouteModule.compile({")
+ expect(loomReadmeSource).toContain("module: { component: CounterRoute, loader },")
+ expect(loomReadmeSource).toContain("Route.loader({")
+ expect(loomReadmeSource).toContain("children }) =>")
+ expect(loomPrdSource).toContain('Component.make("CounterRoute")')
+ expect(loomPrdSource).toContain("RouteModule.compile({")
+ expect(loomPrdSource).toContain("module: { component: CounterRoute, loader },")
+ expect(loomPrdSource).toContain("Component.view(({ children }) =>")
+ expect(loomRfcSource).toContain('Component.make("CounterRoute")')
+ expect(loomRfcSource).toContain("RouteModule.compile({")
+ expect(loomRfcSource).toContain("module: { component: CounterRoute, loader },")
+ expect(loomRfcSource).toContain("Component.view(({ children }) =>")
+ })
+})
diff --git a/apps/loom-example-app/tests/public-api.types.ts b/apps/loom-example-app/tests/public-api.types.ts
new file mode 100644
index 00000000..49d69685
--- /dev/null
+++ b/apps/loom-example-app/tests/public-api.types.ts
@@ -0,0 +1,37 @@
+import type * as Loom from "@effectify/loom"
+import { Router } from "@effectify/loom-router"
+import { CounterRoute } from "../src/routes/counter-route.js"
+import { appRouter, bodyForResult, resolveAppRequest, todoPageRoute, todoRouteId } from "../src/router.js"
+import { counterRouteId } from "../src/routes/counter-route.js"
+
+type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2
+ ? true
+ : false
+type Expect = Value
+
+const homeHref = Router.href(appRouter, counterRouteId)
+const todoHref = Router.href(appRouter, todoPageRoute)
+const homeBody: Loom.View.Child = bodyForResult(resolveAppRequest("https://effectify.dev/"))
+const todoBody: Loom.View.Child = bodyForResult(resolveAppRequest("https://effectify.dev/todos"))
+const counterComponent: Loom.Component.Component = CounterRoute
+
+type HomeHrefContract = Expect>
+type TodoHrefContract = Expect>
+type HomeBodyContract = Expect>
+type TodoBodyContract = Expect>
+
+// @ts-expect-error unknown route identifiers must fail before runtime
+Router.href(appRouter, "settings")
+
+export const typecheckSmoke = {
+ appRouter,
+ counterComponent,
+ counterRouteId,
+ homeBody,
+ homeHref,
+ todoBody,
+ todoHref,
+ todoRouteId,
+}
+
+export type { HomeBodyContract, HomeHrefContract, TodoBodyContract, TodoHrefContract }
diff --git a/apps/loom-example-app/tests/router-runtime.test.ts b/apps/loom-example-app/tests/router-runtime.test.ts
new file mode 100644
index 00000000..9c8373f7
--- /dev/null
+++ b/apps/loom-example-app/tests/router-runtime.test.ts
@@ -0,0 +1,199 @@
+import * as Effect from "effect/Effect"
+import { readFileSync } from "node:fs"
+import { describe, expect, it } from "vitest"
+import { createTodoRouteRuntime } from "../src/router-runtime.js"
+import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../src/todo-service.js"
+
+const makeTestTodoService = (seed: ReadonlyArray) => {
+ let todos = [...seed]
+ let nextId = seed.length + 1
+ const calls = {
+ dispatch: 0,
+ list: 0,
+ }
+
+ const todoService: TodoServiceApi = {
+ dispatch: (command) =>
+ Effect.gen(function*() {
+ calls.dispatch += 1
+
+ switch (command.intent) {
+ case "create":
+ todos = [...todos, { completed: false, id: nextId++, title: command.title }]
+ return { intent: command.intent }
+ case "toggle":
+ if (!todos.some((todo) => todo.id === command.id)) {
+ return yield* Effect.fail(new TodoNotFoundError({ id: command.id }))
+ }
+
+ todos = todos.map((todo) => todo.id === command.id ? { ...todo, completed: !todo.completed } : todo)
+ return { intent: command.intent }
+ case "remove":
+ if (!todos.some((todo) => todo.id === command.id)) {
+ return yield* Effect.fail(new TodoNotFoundError({ id: command.id }))
+ }
+
+ todos = todos.filter((todo) => todo.id !== command.id)
+ return { intent: command.intent }
+ case "clear-completed":
+ todos = todos.filter((todo) => !todo.completed)
+ return { intent: command.intent }
+ }
+ }),
+ list: () =>
+ Effect.sync(() => {
+ calls.list += 1
+ return todos
+ }),
+ reset: () =>
+ Effect.sync(() => {
+ todos = [...seed]
+ nextId = seed.length + 1
+ }),
+ }
+
+ return {
+ calls,
+ todoService,
+ }
+}
+
+describe("loom example app todo router runtime", () => {
+ it("authors the /todos route as a route module and compiles it at the router boundary", () => {
+ const counterRouteSource = readFileSync(new URL("../src/routes/counter-route.ts", import.meta.url), "utf8")
+ const todoRouteSource = readFileSync(new URL("../src/routes/todo-route.ts", import.meta.url), "utf8")
+ const todoRouteComponentSource = readFileSync(
+ new URL("../src/routes/todo-route/todo-route-component.ts", import.meta.url),
+ "utf8",
+ )
+ const routerSource = readFileSync(new URL("../src/router.ts", import.meta.url), "utf8")
+
+ expect(counterRouteSource).toContain('export const CounterRoute = Component.make("CounterRoute").pipe(')
+ expect(counterRouteSource).toContain("export const component = CounterRoute")
+ expect(counterRouteSource).not.toContain("Route.make({")
+ expect(counterRouteSource).not.toContain("counterPageRoute")
+ expect(todoRouteSource).toContain("export const component = Object.assign(TodoRoute, { registry: todoRegistry })")
+ expect(todoRouteSource).toContain("export const loader = pipe(")
+ expect(todoRouteSource).toContain("Route.loader({")
+ expect(todoRouteSource).toContain("Effect.fn(function*({ services }: { readonly services: TodoRouteServices }) {")
+ expect(todoRouteSource).toContain("export const action = pipe(")
+ expect(todoRouteSource).toContain("Route.action({")
+ expect(todoRouteSource).toContain(
+ "}: { readonly input: typeof TodoCommandSchema.Type; readonly services: TodoRouteServices }) {",
+ )
+ expect(todoRouteSource).toContain("input: TodoCommandSchema,")
+ expect(todoRouteSource).toContain("output: TodoCommandResultSchema,")
+ expect(todoRouteSource).toContain("error: TodoRouteErrorSchema,")
+ expect(todoRouteSource).not.toContain("const todoLoaderOptions = {")
+ expect(todoRouteSource).not.toContain("const todoActionOptions = {")
+ expect(todoRouteSource).not.toContain("Route.ModuleLoader<")
+ expect(todoRouteSource).not.toContain("Route.ModuleAction<")
+ expect(todoRouteSource).not.toContain("todoActionDecoder")
+ expect(todoRouteSource).not.toContain("todoPageRoute")
+ expect(todoRouteSource).not.toContain("Route.make({")
+ expect(todoRouteComponentSource).toContain('Component.make("TodoRoute").pipe(')
+ expect(todoRouteComponentSource).toContain("Component.use(TodoHero")
+ expect(todoRouteComponentSource).toContain("Component.use(TodoList")
+ expect(todoRouteComponentSource).not.toContain("attachTodoRegistry")
+ expect(todoRouteComponentSource).not.toContain("withTodoRouteRegistry")
+ expect(todoRouteComponentSource).not.toContain("todoRegistry")
+ expect(routerSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"')
+ expect(routerSource).toContain("module: counterRouteModule,")
+ expect(routerSource).toContain("RouteModule.compile({")
+ expect(routerSource).toContain("component: () => Component.use(todoRouteModule.component)")
+ expect(routerSource).not.toContain("internal/route-modules")
+ })
+
+ it("executes the initial loader through the route runtime", async () => {
+ const { calls, todoService } = makeTestTodoService([
+ { completed: false, id: 1, title: "Ship the loader demo" },
+ ])
+ const runtime = createTodoRouteRuntime({ todoService })
+
+ const loaded = await runtime.load()
+
+ expect(loaded).toEqual({
+ _tag: "success",
+ data: [{ completed: false, id: 1, title: "Ship the loader demo" }],
+ route: loaded.route,
+ })
+ expect(calls).toEqual({ dispatch: 0, list: 1 })
+ })
+
+ it("revalidates the loader after a successful action", async () => {
+ const { calls, todoService } = makeTestTodoService([
+ { completed: false, id: 1, title: "Ship the loader demo" },
+ ])
+ const runtime = createTodoRouteRuntime({ todoService })
+
+ await runtime.load()
+ const result = await runtime.submit({
+ submission: { intent: "create", title: "Close the runtime loop" },
+ })
+
+ expect(result.action).toEqual({
+ _tag: "success",
+ result: { intent: "create" },
+ revalidated: false,
+ route: result.action.route,
+ })
+ expect(result.loader).toEqual({
+ _tag: "success",
+ data: [
+ { completed: false, id: 1, title: "Ship the loader demo" },
+ { completed: false, id: 2, title: "Close the runtime loop" },
+ ],
+ route: result.loader?.route,
+ })
+ expect(calls).toEqual({ dispatch: 1, list: 2 })
+ })
+
+ it("returns invalid-input results without dispatching the action", async () => {
+ const { calls, todoService } = makeTestTodoService([
+ { completed: false, id: 1, title: "Ship the loader demo" },
+ ])
+ const runtime = createTodoRouteRuntime({ todoService })
+
+ await runtime.load()
+ const result = await runtime.submit({
+ submission: { intent: "create", title: " " },
+ })
+
+ expect(result).toEqual({
+ action: {
+ _tag: "invalid-input",
+ issues: [{
+ _tag: "LoomRouterActionInputFailure",
+ input: { intent: "create", title: " " },
+ message: expect.stringContaining("length of at least 1"),
+ }],
+ route: result.action.route,
+ submission: { intent: "create", title: " " },
+ },
+ })
+ expect(calls).toEqual({ dispatch: 0, list: 1 })
+ })
+
+ it("surfaces typed route action failures without revalidating the loader", async () => {
+ const { calls, todoService } = makeTestTodoService([
+ { completed: false, id: 1, title: "Ship the loader demo" },
+ ])
+ const runtime = createTodoRouteRuntime({ todoService })
+
+ await runtime.load()
+ const result = await runtime.submit({
+ submission: { id: "99", intent: "remove" },
+ })
+
+ expect(result).toEqual({
+ action: {
+ _tag: "failure",
+ error: expect.objectContaining({
+ error: expect.objectContaining({ _tag: "TodoNotFoundError", id: 99 }),
+ }),
+ route: result.action.route,
+ },
+ })
+ expect(calls).toEqual({ dispatch: 1, list: 1 })
+ })
+})
diff --git a/apps/loom-example-app/tsconfig.app.json b/apps/loom-example-app/tsconfig.app.json
new file mode 100644
index 00000000..efe69266
--- /dev/null
+++ b/apps/loom-example-app/tsconfig.app.json
@@ -0,0 +1,22 @@
+{
+ "extends": "./tsconfig.json",
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.mts"
+ ],
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node", "vite/client"],
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ }
+}
diff --git a/apps/loom-example-app/tsconfig.json b/apps/loom-example-app/tsconfig.json
new file mode 100644
index 00000000..c8ba5651
--- /dev/null
+++ b/apps/loom-example-app/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.spec.json" }
+ ],
+ "compilerOptions": {
+ "strict": true
+ }
+}
diff --git a/apps/loom-example-app/tsconfig.spec.json b/apps/loom-example-app/tsconfig.spec.json
new file mode 100644
index 00000000..bf7cec5c
--- /dev/null
+++ b/apps/loom-example-app/tsconfig.spec.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.app.json",
+ "include": [
+ "vite.config.mts",
+ "tests/**/*.ts"
+ ],
+ "compilerOptions": {
+ "types": [
+ "node",
+ "vite/client",
+ "vitest/globals",
+ "vitest/importMeta",
+ "vitest"
+ ]
+ }
+}
diff --git a/apps/loom-example-app/vite.config.mts b/apps/loom-example-app/vite.config.mts
new file mode 100644
index 00000000..66d5a1f3
--- /dev/null
+++ b/apps/loom-example-app/vite.config.mts
@@ -0,0 +1,40 @@
+import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"
+import type { Plugin } from "vite"
+import { defineConfig } from "vitest/config"
+import { appPayloadElementId } from "./src/app-config.js"
+
+const injectBeforeClosingBody = (html: string, fragment: string): string => {
+ const closingBodyTagIndex = html.indexOf("