diff --git a/local-live-view/README.md b/local-live-view/README.md
index cdc61492..e9d08f74 100644
--- a/local-live-view/README.md
+++ b/local-live-view/README.md
@@ -1,90 +1,17 @@
-# First steps
+# LocalLiveView
-## Usage
-To use LocalLiveView you need to:
-1. Add `:local_live_view` to your mix.exs file:
-```elixir
-defmodule LocalThermostat.MixProject do
- use Mix.Project
- ...
- defp deps do
- [
- {:local_live_view, github: "software-mansion/popcorn", sparse: "local-live-view"}
- ]
- end
-end
-```
-2. Attach local_live_view.js script in your .html file:
-```html
-
-
-
-
-
-```
-3. Define your LocalLiveView in the `lib` directory:
-```elixir
-defmodule ThermostatLive do
- use LocalLiveView
+**LocalLiveView is a library for running Phoenix LiveView state in the browser, powered by [Popcorn](https://hexdocs.pm/popcorn).**
- def render(assigns) do
- ~H"""
-
Current temperature: {@temperature}°F
-
-
Country: {@country}
- """
- end
+LocalLiveView compiles your Elixir modules to WebAssembly and runs them via Popcorn directly in the browser. You get zero-latency UI and offline capability while using the same Phoenix LiveView API you already know.
- def mount(_params, _session, socket) do
- temperature = 65
- {:ok, assign(socket, :temperature, temperature)}
- end
+## Documentation
- def handle_event("inc_temperature", _params, socket) do
- {:noreply, update(socket, :temperature, &(&1 + 1))}
- end
-end
-```
-4. Add html tag to render defined view, by using data-pop-view:
-```html
-
-
-
-
-
-
-```
-
-## Build
-
-To build the project after defining the above, run:
-
-```bash
-mix deps.get
-mix build
-```
-
-This will generate the necessary scripts and popcorn files into the output directory.
-
-## Serve
-
-To serve the project locally:
-
-```bash
-mix dev
-```
-
-and visit [localhost:4000](http://localhost:4000).
-
-You can also use `mix popcorn.server` directly, or any HTTP server that sets the required headers:
-
-```
-Cross-Origin-Opener-Policy: "same-origin"
-Cross-Origin-Embedder-Policy: "require-corp"
-```
+The API documentation and guides are available at
## Examples
-- `examples/local-lv-thermostat` - Simple thermostat app demonstrating basic LocalLiveView state management and events.
-- `examples/local-lv-forms` - Form handling with LocalLiveView, including validation and submission.
-- `examples/local-lv-compare` - Side-by-side comparison of Phoenix LiveView and LocalLiveView.
+The source code for all examples is in the `examples/` directory of this repository:
+
+- `local-lv-thermostat` — Basic state management and events
+- `local-lv-forms` — Form handling with validation and Mirror Sync
+- `local-lv-compare` — Side-by-side comparison of Phoenix LiveView vs LocalLiveView
diff --git a/local-live-view/lib/server/mirror.ex b/local-live-view/lib/server/mirror.ex
index d9d4b693..cbfed972 100644
--- a/local-live-view/lib/server/mirror.ex
+++ b/local-live-view/lib/server/mirror.ex
@@ -6,12 +6,12 @@ defmodule LocalLiveView.Mirror do
It receives synced payloads from the local runtime via the `handle_sync/2` callback.
```
- defmodule Mirror.MyLive do
+ defmodule Mirror.MyLocal do
use LocalLiveView.Mirror
@impl true
def handle_sync(local_assigns, _mirror_assigns) do
- Phoenix.PubSub.broadcast(MyApp.PubSub, "llv_mirror:MyLive", {:llv_attrs, local_assigns})
+ Phoenix.PubSub.broadcast(MyApp.PubSub, "llv_mirror:MyLocal", {:llv_attrs, local_assigns})
{:ok, local_assigns}
end
end
diff --git a/local-live-view/mix.exs b/local-live-view/mix.exs
index b217d295..92d39e60 100644
--- a/local-live-view/mix.exs
+++ b/local-live-view/mix.exs
@@ -47,11 +47,14 @@ defmodule LocalLiveView.MixProject do
filter_modules: ~r/^(?!Elixir.Phoenix\.).*/,
extras: [
"pages/introduction/welcome.md",
- "README.md"
+ "pages/getting-started/installation.md",
+ "pages/guides/first-view.md",
+ "pages/guides/mirror-sync.md"
],
groups_for_extras: [
Introduction: ~r"/introduction/",
- "Getting started": "README.md"
+ "Getting started": ~r"/getting-started/",
+ Guides: ~r"/guides/"
]
]
end
diff --git a/local-live-view/pages/getting-started/installation.md b/local-live-view/pages/getting-started/installation.md
new file mode 100644
index 00000000..9f473a7d
--- /dev/null
+++ b/local-live-view/pages/getting-started/installation.md
@@ -0,0 +1,108 @@
+# Installation
+
+LocalLiveView is installed into an existing Phoenix project using the `mix llv.install` generator.
+
+## Prerequisites
+
+- A Phoenix project generated with `mix phx.new`
+- `:local_live_view` added as a dependency
+- `pnpm` or `npm` for JS package management
+
+## Step 1 — Add the dependency
+
+Add `:local_live_view` to your `mix.exs`:
+
+```elixir
+defp deps do
+ [
+ # ...existing deps...
+ {:local_live_view, github: "software-mansion/popcorn", sparse: "local-live-view"}
+ ]
+end
+```
+
+Then fetch it:
+
+```bash
+mix deps.get
+```
+
+## Step 2 — Run the installer
+
+```bash
+mix llv.install
+```
+
+The installer configures your project automatically:
+
+| What | Where |
+|---|---|
+| Adds `LocalLiveView.Socket` | `lib/*_web/endpoint.ex` |
+| Adds COOP/COEP security headers (required for WASM) | `lib/*_web/endpoint.ex` |
+| Registers `LocalLiveView.ChannelRegistry` | `lib//application.ex` |
+| Imports `LocalLiveView.Component` | `lib/*_web.ex` (html_helpers) |
+| Changes app.js script tag to `type="module"` | `lib/*_web/components/layouts/root.html.heex` |
+| Adds `setup` call for the JS bridge | `assets/js/app.js` |
+| Adds `local_live_view` JS package | `assets/package.json` |
+| Replaces esbuild watcher with `build.mjs` | `mix.exs`, `config/dev.exs` |
+| Generates the `local/` WASM project | `local/` |
+
+> **Manual fallback:** If the installer can't find a file (e.g. your project has a non-standard structure), it prints the exact snippet to add manually.
+
+## Step 3 — Install JS dependencies
+
+```bash
+pnpm install
+# or: npm install --prefix assets
+```
+
+## Step 4 — Build the WASM bundle
+
+```bash
+mix llv.build
+```
+
+This compiles your `local/` project to a WASM bundle at `priv/static/assets/js/wasm/bundle.avm`.
+
+To build automatically as part of `mix setup`, the installer already adds `llv.build` to the `setup` alias in `mix.exs`.
+
+## Step 5 — Start the server
+
+```bash
+mix phx.server
+```
+
+Visit [localhost:4000](http://localhost:4000). The installer generated a sample `HelloLocal` view — add it to any template to confirm everything works:
+
+```heex
+<.local_live_view view="HelloLocal" />
+```
+
+## What was generated
+
+The installer creates a `local/` directory — a separate Mix project for your client-side Elixir code:
+
+```
+local/
+├── config/
+│ └── config.exs # Popcorn output path config
+├── lib/
+│ ├── local/
+│ │ └── application.ex # OTP application
+│ └── hello_local.ex # Sample LocalLiveView
+├── .formatter.exs
+└── mix.exs # Compiles to WASM via popcorn.cook
+```
+
+Add your LocalLiveView modules to `local/lib/`. They will be compiled into the WASM bundle when you run `mix llv.build`.
+
+## Security headers
+
+LocalLiveView requires [SharedArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer), which browsers only allow with the following HTTP headers set:
+
+```
+Cross-Origin-Opener-Policy: same-origin
+Cross-Origin-Embedder-Policy: require-corp
+```
+
+The installer adds a `put_wasm_security_headers/2` plug to your endpoint automatically. If you use a CDN or reverse proxy, make sure these headers are forwarded.
diff --git a/local-live-view/pages/guides/first-view.md b/local-live-view/pages/guides/first-view.md
new file mode 100644
index 00000000..fbb0a8b8
--- /dev/null
+++ b/local-live-view/pages/guides/first-view.md
@@ -0,0 +1,142 @@
+# Your first LocalLiveView
+
+This guide walks through building a simple counter view to introduce the LocalLiveView programming model.
+
+## Creating a view module
+
+LocalLiveView modules live in the `local/lib/` directory of your project. Create `local/lib/counter_local.ex`:
+
+```elixir
+defmodule CounterLocal do
+ use LocalLiveView
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, count: 0)}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
Count: {@count}
+
+
+
+ """
+ end
+
+ def handle_event("increment", _params, socket) do
+ {:noreply, update(socket, :count, &(&1 + 1))}
+ end
+
+ def handle_event("decrement", _params, socket) do
+ {:noreply, update(socket, :count, &(&1 - 1))}
+ end
+end
+```
+
+This should look familiar if you've used Phoenix LiveView. The differences are:
+
+- `use LocalLiveView` instead of `use Phoenix.LiveView`
+
+## Mounting the view
+
+Use the `<.local_live_view>` component in any Phoenix template:
+
+```heex
+<.local_live_view view="CounterLocal" />
+```
+
+The `view` attribute is the module name as a string. The component renders a `
` that becomes the mount point for the WASM view.
+
+## Building and running
+
+After adding the module, rebuild the WASM bundle:
+
+```bash
+mix llv.build
+```
+
+Then start or reload the server:
+
+```bash
+mix phx.server
+```
+
+The counter is now fully local — clicks are handled in the browser with no server round-trips.
+
+## Callbacks
+
+### `mount/3`
+
+Called once when the view is initialized. Use it to set up initial assigns.
+
+```elixir
+def mount(_params, _session, socket) do
+ {:ok, assign(socket, count: 0, label: "Counter")}
+end
+```
+
+### `render/1`
+
+Returns the HEEx template for the current state. Called automatically after every state change.
+
+```elixir
+def render(assigns) do
+ ~H"""
+
{@label}: {@count}
+ """
+end
+```
+
+### `handle_event/3`
+
+Handles events triggered from the template. Returns `{:noreply, socket}` with updated assigns.
+
+```elixir
+def handle_event("reset", _params, socket) do
+ {:noreply, assign(socket, count: 0)}
+end
+```
+
+## Assigning state
+
+LocalLiveView uses the same assign functions as Phoenix LiveView:
+
+```elixir
+# Assign a single key
+assign(socket, :count, 0)
+
+# Assign multiple keys at once
+assign(socket, count: 0, label: "Counter")
+
+# Update a key using the current value
+update(socket, :count, &(&1 + 1))
+```
+
+## Timers and periodic updates
+
+You can schedule recurring messages using `Process.send_after/3`, just like in Phoenix LiveView:
+
+```elixir
+def mount(_params, _session, socket) do
+ Process.send_after(self(), :tick, 1000)
+ {:ok, assign(socket, time: Time.utc_now())}
+end
+
+def handle_info(:tick, socket) do
+ Process.send_after(self(), :tick, 1000)
+ {:noreply, assign(socket, time: Time.utc_now())}
+end
+```
+
+## Multiple views on one page
+
+Each `<.local_live_view>` on the page runs as an independent process in the WASM runtime. You can mount as many as you need:
+
+```heex
+<.local_live_view view="CounterLocal" />
+<.local_live_view view="CounterLocal" id="second-counter" />
+<.local_live_view view="ThermostatLocal" />
+```
+
+When mounting the same view multiple times, use the `id` attribute to give each instance a unique identifier.
diff --git a/local-live-view/pages/guides/mirror-sync.md b/local-live-view/pages/guides/mirror-sync.md
new file mode 100644
index 00000000..a7401160
--- /dev/null
+++ b/local-live-view/pages/guides/mirror-sync.md
@@ -0,0 +1,148 @@
+# Mirror Sync
+
+By default, a LocalLiveView is fully self-contained in the browser — the server knows nothing about its state. Mirror Sync is the mechanism for bridging that gap: it lets a LocalLiveView push selected assigns to the server, where a server-side module can react to them.
+
+## When to use it
+
+Use Mirror Sync when you need the server to be aware of local state, for example:
+
+- Broadcasting local state changes to other connected users via PubSub
+- Persisting user input to a database
+- Letting a server-side Phoenix LiveView display or react to local state
+
+## How it works
+
+```
+Browser Server
+┌─────────────────────┐ ┌───────────────────────────┐
+│ MyLocal │ │ Mirror.MyLocal │
+│ handle_event(...) │ │ handle_sync( │
+│ mirror_sync( │──sync───▶│ local_assigns, │
+│ socket, │ │ mirror_assigns │
+│ [:count] │ │ ) │
+│ ) │ └───────────────────────────┘
+└─────────────────────┘
+```
+
+1. Your LocalLiveView calls `mirror_sync/2` with the socket and a list of assign keys.
+2. The JS bridge sends those assigns to the server over a Phoenix Channel.
+3. The server finds `Mirror.` and calls its `handle_sync/2` callback.
+
+## Setting up mirror sync
+
+### 1. Declare the mirror keys
+
+Pass the keys you want to sync when calling `mirror_sync/2`:
+
+```elixir
+defmodule MyLocal do
+ use LocalLiveView
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, count: 0)}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+
+ def handle_event("increment", _params, socket) do
+ socket = update(socket, :count, &(&1 + 1))
+ mirror_sync(socket, [:count])
+ {:noreply, socket}
+ end
+end
+```
+
+`mirror_sync/2` takes the socket and a list of assign keys to send. It returns the socket unchanged, so you can pipe it or ignore the return value.
+
+### 2. Create the Mirror module
+
+On the server side, create `lib/mirror/my_live.ex`:
+
+```elixir
+defmodule Mirror.MyLocal do
+ use LocalLiveView.Mirror
+
+ @impl true
+ def handle_sync(local_assigns, _mirror_assigns) do
+ {:ok, local_assigns}
+ end
+end
+```
+
+The module must be named `Mirror.` — the view name is the last part of your LocalLiveView module name. `LocalLiveView.Component` auto-detects the mirror module and enables the sync channel when rendering the mount point.
+
+`handle_sync/2` receives:
+- `local_assigns` — a map of the synced assigns (keys are strings)
+- `mirror_assigns` — the mirror's current state (what was returned from the previous `handle_sync` call)
+
+It must return `{:ok, new_mirror_assigns}`.
+
+## Broadcasting via PubSub
+
+The most common use case is broadcasting local state to other LiveViews:
+
+```elixir
+defmodule Mirror.MyLocal do
+ use LocalLiveView.Mirror
+
+ @impl true
+ def handle_sync(local_assigns, _mirror_assigns) do
+ Phoenix.PubSub.broadcast(
+ MyApp.PubSub,
+ "llv_mirror:MyLocal",
+ {:llv_attrs, local_assigns}
+ )
+
+ {:ok, local_assigns}
+ end
+end
+```
+
+Then subscribe and handle in a server-side LiveView:
+
+```elixir
+defmodule MyAppWeb.DashboardLive do
+ use MyAppWeb, :live_view
+
+ def mount(_params, _session, socket) do
+ Phoenix.PubSub.subscribe(MyApp.PubSub, "llv_mirror:MyLocal")
+ {:ok, assign(socket, count: 0)}
+ end
+
+ def handle_info({:llv_attrs, %{"count" => count}}, socket) do
+ {:noreply, assign(socket, count: count)}
+ end
+end
+```
+
+## Mirror assigns and conflict resolution
+
+`handle_sync/2` can return different assigns than it received — this is useful when you want the mirror to store derived or enriched state:
+
+```elixir
+def handle_sync(%{"count" => count} = local_assigns, mirror_assigns) do
+ enriched = Map.put(local_assigns, "total_increments", Map.get(mirror_assigns, "total_increments", 0) + 1)
+ {:ok, enriched}
+end
+```
+
+The returned value becomes `mirror_assigns` in the next call, but it is **not** sent back to the browser — it only lives on the server.
+
+## Sync frequency
+
+`mirror_sync/2` sends over the wire every time it's called. Call it only when state has changed and the server needs to know — typically at the end of `handle_event/3` when relevant assigns were updated.
+
+## Serialization
+
+Assigns are serialized as a JSON-compatible map before being sent. The following types are supported:
+
+- Primitives: strings, numbers, booleans, nil
+- Lists
+- Maps (keys are converted to strings)
+- Structs (converted to maps via `Map.from_struct/1`)
+
+Atoms, tuples, and PIDs are not serializable and will cause a runtime error if included.
diff --git a/local-live-view/pages/introduction/welcome.md b/local-live-view/pages/introduction/welcome.md
index 2c889d14..97116436 100644
--- a/local-live-view/pages/introduction/welcome.md
+++ b/local-live-view/pages/introduction/welcome.md
@@ -1,5 +1,60 @@
# Welcome
-LocalLiveView delivers local-first functionality by merging the powers of [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/welcome.html) with [Popcorn](https://hexdocs.pm/popcorn/readme.html).
-## What is a LocalLiveView?
-LocalLiveView is a project that aims to store the LiveView state in the browser in a virtual machine provided by Popcorn.
-It allows to store, update and render the assigns similarly to Phoenix LiveView.
+
+LocalLiveView brings local-first interactivity to Phoenix applications by running your Elixir code directly in the browser — using the same [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/welcome.html) API you already know.
+
+## What is LocalLiveView?
+
+In a standard Phoenix LiveView app, UI state lives on the server. Every interaction travels over the network: user clicks button → server processes event → server sends diff → browser updates DOM. This works great, but it means latency is always in the loop.
+
+LocalLiveView moves that state into the browser itself. Your Elixir modules are compiled to WebAssembly and executed via [Popcorn](https://hexdocs.pm/popcorn/introduction.html), which runs AtomVM — a tiny Erlang virtual machine — directly in the browser. Events are handled locally, renders happen instantly, and the server is only involved when you explicitly want it to be.
+
+The result: **zero-latency UI**, offline capability, and a familiar Elixir/LiveView programming model.
+
+## How it works
+
+```
+Browser Server
+┌──────────────────────────────────────┐ ┌──────────────────┐
+│ Phoenix LiveView page │ │ Phoenix app │
+│ │ │ │
+│ <.local_live_view view="MyLocal"> │ │ Mirror.MyLocal │
+│ │ │ │ (optional) │
+│ ▼ │ │ ▲ │
+│ Popcorn (AtomVM WASM) │─────▶│ mirror_sync │
+│ MyLocal (your Elixir code) │ │ │
+│ ├── mount/3 │ └──────────────────┘
+│ ├── render/1 │
+│ └── handle_event/3 │
+└──────────────────────────────────────┘
+```
+
+1. Your `local/` project is compiled to a `.avm` WASM bundle at build time.
+2. When the page loads, Popcorn starts the WASM runtime and mounts your LocalLiveViews.
+3. User interactions are handled in the browser — no round-trip to the server.
+4. Optionally, you can sync selected assigns to the server via `mirror_sync/2`, letting server-side LiveViews react to local state changes.
+
+## Key concepts
+
+**LocalLiveView module** — An Elixir module that uses `use LocalLiveView` and implements `mount/3`, `render/1`, and optionally `handle_event/3`. Lives in the `local/` project (compiled to WASM).
+
+**`local/` project** — A separate Mix project inside your Phoenix app, built via `mix llv.build`. The `local/` project depends on `:local_live_view` and contains all your client-side Elixir code.
+
+**`<.local_live_view>`** — A Phoenix component that renders the mount point for a LocalLiveView. Generated by `mix llv.install` and available as `import LocalLiveView.Component` in your web module.
+
+**Mirror** — An optional server-side module (`Mirror.MyLocal`) that receives synced assigns from a LocalLiveView. Useful for broadcasting state changes to other users or persisting data.
+
+## Relationship to Phoenix LiveView
+
+LocalLiveView deliberately mirrors the Phoenix LiveView API. If you know LiveView, you already know most of LocalLiveView:
+
+| Phoenix LiveView | LocalLiveView |
+|---|---|
+| `use Phoenix.LiveView` | `use LocalLiveView` |
+| `mount/3` | `mount/3` |
+| `render/1` with `~H` | `render/1` with `~H` |
+| `handle_event/3` | `handle_event/3` |
+| `assign/2`, `update/3` | `assign/2`, `update/3` |
+| `phx-click` | `phx-click` |
+| Runs on server | Runs in browser (WASM) |
+
+The API is intentionally identical — LocalLiveView intercepts Phoenix LiveView's standard JavaScript layer, so event attributes work exactly the same way.