diff --git a/README.md b/README.md
index 40825bc..ac56675 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,39 @@
-# Nolimit Cache — release feed
+# Nolimit Cache
-Built zips only. The source lives in `kw/nolimit-cache`, which is private.
+
-This repository is **public on purpose**: every WordPress site running the
-plugin asks it for updates, and it never sends credentials. A private feed
-answers 404 and the update simply never appears — no error on the site, no
-entry in the plugins list.
+A page cache and front-end optimiser for WordPress and WooCommerce. It answers
+a visitor's request from a file on disk before WordPress boots, and it knows
+enough about WooCommerce not to do that to a customer holding a basket. WebP
+and AVIF copies of your images, Redis object cache, preloading and a set of
+health checks are in the same plugin — with **nothing leaving the site**. No
+account, no licence check, no telemetry.
-Nothing here is edited by hand. Each release is published by the tag job in
-the source repository.
\ No newline at end of file
+## Install
+
+1. Download `nolimit-cache.zip` from the [latest release](https://gitea.nolimit.dk/kw/nolimit-cache-dist/releases/latest).
+2. **Plugins → Add New → Upload Plugin**, then activate.
+3. Open **Nolimit → Cache** and let the setup walk you through it — [the setup
+ guide](docs/SETUP.md) explains what it changes and how to tell it is working.
+
+Later versions arrive in **Dashboard → Updates** like any other plugin.
+
+## Documentation
+
+- [Overview](docs/README.md) — what it does, and what it deliberately does not
+- [Installing it, and telling whether it works](docs/SETUP.md) — the settings
+ that need a decision, images in WebP and AVIF, and how to check the cache is
+ really serving
+- [Hooks and integration](docs/CONTRACT.md) — for developers: what the plugin
+ offers to other plugins and to your own code
+
+## More of it
+
+| | |
+|---|---|
+|  | **Page cache.** How long a page lives, what is never cached whatever you set, and separate copies for phones or signed-in visitors only if you ask for them. |
+|  | **Images.** WebP and AVIF copies written beside your JPEG and PNG files and offered to the browsers that understand them. Originals are never changed. |
+|  | **WooCommerce.** The basket, checkout and account pages are never cached, and a product page is cleared when its stock or an order changes. |
+|  | **Tools.** What it has installed in `wp-content`, written out plainly, and a button to write it again. |
+
+Requires WordPress 6.0+ and PHP 7.4+. WooCommerce is optional.
diff --git a/assets/hero.png b/assets/hero.png
new file mode 100644
index 0000000..69ada4e
Binary files /dev/null and b/assets/hero.png differ
diff --git a/assets/screenshots/images.png b/assets/screenshots/images.png
new file mode 100644
index 0000000..440948d
Binary files /dev/null and b/assets/screenshots/images.png differ
diff --git a/assets/screenshots/page-cache.png b/assets/screenshots/page-cache.png
new file mode 100644
index 0000000..947a4dd
Binary files /dev/null and b/assets/screenshots/page-cache.png differ
diff --git a/assets/screenshots/tools.png b/assets/screenshots/tools.png
new file mode 100644
index 0000000..37cf8dd
Binary files /dev/null and b/assets/screenshots/tools.png differ
diff --git a/assets/screenshots/woocommerce.png b/assets/screenshots/woocommerce.png
new file mode 100644
index 0000000..5961214
Binary files /dev/null and b/assets/screenshots/woocommerce.png differ
diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md
new file mode 100644
index 0000000..b948b54
--- /dev/null
+++ b/docs/CONTRACT.md
@@ -0,0 +1,91 @@
+# Hooks and integration
+
+What Nolimit Cache offers to other code, and what it listens to. Written for
+developers who want to extend the plugin or sit next to it; nothing here is
+needed to use it.
+
+Hooks prefixed `nlcac_` are this plugin's own and are stable: a rename is a
+breaking change and will be called out in the release notes. Hooks prefixed
+`wpmgr_` are shared by name with the other Nolimit plugins (see section 2).
+
+A note on the words: for a filter, `add_filter()` is how you **answer** it, by
+supplying a value. The headings below describe the mechanism, who calls
+`apply_filters()` and who calls `add_filter()`, because that cannot be misread.
+
+---
+
+## 1. Extension points this plugin OFFERS — `nlcac_*`
+
+`apply_filters()` / `do_action()` in our code: we run everyone's callbacks.
+
+| Hook | Type | Signature | Fired from |
+|---|---|---|---|
+| `nlcac_reason_not_to_cache` | filter | `( string $reason, … )` — return a non-empty string to refuse to cache this request | page cache |
+| `nlcac_page_cached` | action | `( string $path )` — a page was just written to disk | page cache |
+| `nlcac_purge_urls_for_post` | filter | `( array $urls, int $post_id )` — the URLs about to be purged for a post | purging |
+| `nlcac_preload_urls` | filter | `( array $urls )` — the queue the preloader will walk | preloader |
+| `nlcac_exclude_script` | filter | `( bool $exclude, string $handle )` — keep a script out of defer | optimiser |
+| `nlcac_ai_payload` | filter | `( array $payload )` — what the assistant is about to send | assistant |
+| `nlcac_images_skip` | filter | `( bool $skip, string $img_tag )` — return true to keep one `
` out of the WebP/AVIF rewrite | images |
+
+**If this plugin is absent**, none of these fire. A consumer must not assume
+its callback ever runs; `has_filter()` is not a substitute for the plugin
+being active.
+
+**A consumer must not** do slow or networked work in
+`nlcac_reason_not_to_cache`. It runs on every request, before WordPress, in
+the drop-in — which is the whole reason the cache is fast.
+
+## 2. The family's shared hooks — `wpmgr_*`
+
+Shared by name across the Nolimit plugins, so **load order decides who wins**
+for anything that declares itself once.
+
+### Offered here — we call `apply_filters()` / `do_action()`
+
+| Hook | Type | Notes |
+|---|---|---|
+| `wpmgr_cache_cleared` | action | `( int $removed )`. Fired when the page cache is emptied, so a sibling holding derived state can drop it in the same click. Our own object cache listens to it and only calls `wp_cache_flush()`, so there is no path back into purge. |
+| `wpmgr_dashboard_cards` | filter | Provided by the shared menu file, which every Nolimit plugin carries identically. |
+| `wpmgr_menu_title` | filter | Same. |
+| `wpmgr_menu_glyph` | filter | Same. It exists so the glyph can be overridden. |
+
+### Answered here — we register a callback with `add_filter()` / `add_action()`
+
+| Hook | Type | What we do | If nobody offers it |
+|---|---|---|---|
+| `wpmgr_dashboard_cards` | filter | Add this plugin's card | The card is not drawn; nothing breaks |
+| `wpmgr_menu_title` | filter | Answer the shared menu's title | Shared file's default |
+| `wpmgr_cache_cleared` | action | Flush our object cache | — (we also fire it) |
+| `wpmgr_signals` | filter | Supply this plugin's health signals to a family-wide collection. We answer it; we never read it | Only Nolimit Assistant applies this filter. With it absent or inactive, our signals are contributed to nothing and no error is raised. |
+| `wpmgr_setup_recommendations` | filter | Offer three setup items to the Assistant's Setup screen: turn the page cache on, warm it in the background, keep the basket rule on. Every `done` is read from the stored setting, never measured. | Nothing is offered and nothing breaks. Only Nolimit Assistant applies this filter, so without it these items are shown nowhere |
+
+## 3. WordPress and WooCommerce core
+
+The plugin also attaches to WordPress and WooCommerce core hooks. They are
+listed by area rather than one row each, because none of them is a contract
+anyone else needs to honour: they are how the plugin attaches to WordPress.
+
+| Area | Hooks |
+|---|---|
+| Boot | `init`, `plugins_loaded` |
+| Admin | `admin_init`, `admin_menu`, `admin_notices`, `admin_head`, `admin_footer`, `admin_bar_menu`, `admin_enqueue_scripts`, `plugin_action_links_{file}` |
+| Purging | `transition_post_status`, `post_updated`, `deleted_post`, `edit_term`, `delete_term`, `comment_post`, `transition_comment_status`, `update_option_sidebars_widgets`, `shutdown` |
+| Front end | `template_redirect`, `wp_head`, `wp_enqueue_scripts`, `script_loader_tag`, `script_loader_src`, `style_loader_src`, `the_content`, `wp_get_attachment_url`, `wp_calculate_image_srcset`, `embed_oembed_discover`, `tiny_mce_plugins` |
+| Updates | `site_transient_update_plugins`, `plugins_api`, `upgrader_process_complete` |
+| Media | `wp_update_attachment_metadata`, `wp_delete_file` |
+| Settings | `update_option_nlcac_settings` |
+| Scheduling | `cron_schedules` |
+| REST | `rest_api_init` |
+| WooCommerce | `woocommerce_new_product`, `woocommerce_update_product`, `woocommerce_order_status_changed`, `woocommerce_product_set_stock`, `woocommerce_product_set_stock_status`, `woocommerce_variation_set_stock`, `woocommerce_variation_set_stock_status`, `wc_after_products_starting_sales`, `wc_after_products_ending_sales` |
+
+🔴 **`site_transient_update_plugins` is the dangerous one.** The filter name
+is built from the update host, so every plugin updating from
+gitea.nolimit.dk lands on the same one and WordPress runs every callback for
+every plugin. Answering unconditionally offers our zip to a sibling. The
+guard is two halves and both are required — register with **3** arguments,
+and return `$update` untouched unless `$plugin_file` is ours. Registering
+with 2 leaves the argument unset and the guard silently does nothing, which
+is the same shape of bug it exists to fix.
+
+If you write a plugin that updates from the same host, apply the same guard.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..c71efb5
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,54 @@
+# Nolimit Cache
+
+A page cache and front-end optimiser for WordPress and WooCommerce. It answers
+a visitor's request from a file on disk before WordPress boots, and it knows
+enough about WooCommerce not to do that to a customer holding a basket.
+
+It is one of a family of Nolimit plugins that share an admin menu and a
+dashboard. [Hooks and integration](CONTRACT.md) lists what it offers to the
+others, and to your own code.
+
+## What it does
+
+- **Page cache.** An `advanced-cache.php` drop-in serves a stored copy of a
+ page. This runs before the theme, the plugins and most of WordPress, which
+ is where the speed comes from and also why it has to be careful.
+- **Object cache.** An optional `object-cache.php` drop-in backed by Redis.
+- **Front-end optimisation.** Defers scripts, drops WordPress's emoji and
+ oEmbed machinery, and can rewrite asset URLs to a CDN.
+- **Images.** Writes WebP (and optionally AVIF) copies beside JPEG and PNG
+ files and offers them with ``, so the page cache stays correct for
+ every browser. Originals are never touched.
+- **Preloading.** Walks a queue of URLs on a five-minute cron so the first
+ real visitor does not pay for the first render.
+- **Purging.** Clears the right pages when a post, a term, a comment or a
+ WooCommerce product or order changes.
+- **Health checks and an assistant.** A set of signals about whether the cache
+ is actually working, and an ask-box on the admin screens.
+
+## What it deliberately does not do
+
+- **No minification or concatenation.** Those break themes in ways that are
+ expensive to debug and are better handled by a build step.
+- **No CSS "critical path" extraction.** Same reason.
+- **No telemetry of any kind.** Nothing is reported anywhere. The only
+ outbound requests are the update check against gitea.nolimit.dk and an AI
+ provider if, and only if, one has been configured with a key.
+- **No account, no licence check, no phone-home.**
+
+## Where things are
+
+| | |
+|---|---|
+| Admin screen | **Nolimit → Cache** |
+| Settings | one option, `nlcac_settings` |
+| Engine config | `wp-content/cache/nolimit/config.php`, written from the settings |
+| Drop-ins | `wp-content/advanced-cache.php`, `wp-content/object-cache.php` |
+| REST namespace | `nlcac/v1` |
+| Requires | WordPress 6.0, PHP 7.4. WooCommerce optional |
+| Version | 0.3.1 |
+
+## Reading order
+
+1. [Installing it, and telling whether it works](SETUP.md)
+2. [Hooks and integration](CONTRACT.md), for developers
diff --git a/docs/SETUP.md b/docs/SETUP.md
new file mode 100644
index 0000000..9b7a055
--- /dev/null
+++ b/docs/SETUP.md
@@ -0,0 +1,91 @@
+# Installing it, and telling whether it works
+
+## Install
+
+The plugin updates itself from its release repository, but the **first**
+install is manual, and on a copy older than 0.2.3 it has to be.
+
+1. Download `nolimit-cache.zip` from the latest release at
+ `https://gitea.nolimit.dk/kw/nolimit-cache-dist/releases`.
+2. Plugins → Add New → Upload Plugin → Activate.
+3. After that, updates appear in the normal update row.
+
+> **Below 0.2.3, do not trust the update row.** Versions before 0.2.3 could
+> offer another Nolimit plugin's zip as their own update. Upgrading *to*
+> 0.2.3 or later has to be done by hand for that reason; after that the update
+> row is safe.
+
+## Activation writes two things outside the plugin folder
+
+- `wp-content/advanced-cache.php` — the drop-in. Without it nothing is served
+ from cache, however the settings look.
+- `WP_CACHE` in `wp-config.php` — WordPress only loads the drop-in when this
+ constant is true.
+
+If `wp-config.php` is not writable, the plugin says so on its own screen
+rather than failing quietly. That is the single most common reason for "it is
+switched on and nothing is cached".
+
+## The settings that are a decision
+
+Everything else has a defensible default. These four do not:
+
+| Setting | Why it needs a decision |
+|---|---|
+| **Cache lifetime** | How stale a page may be. A shop with frequent price changes wants a short one; a mostly-static site can leave it long, because a change purges the affected pages anyway. |
+| **Excluded URIs / cookies** | Anything that must never be served from a shared copy. The WooCommerce basket, checkout and account pages are already excluded and cannot be un-excluded. |
+| **Object cache (Redis)** | Off unless there is a Redis to point it at. It replaces `object-cache.php`, so it interacts with anything else that wants that file. |
+| **CDN rewriting** | Only if assets are genuinely served from the other host. Wrong here means missing images rather than slow ones. |
+
+## Images: WebP and AVIF
+
+**Nolimit → Cache → Images** writes WebP (and, if the server can, AVIF) copies
+next to your JPEG and PNG files and offers them to browsers that understand
+them. The originals are never touched, so switching it off, or deleting the
+copies, leaves the site exactly as it was.
+
+- Conversion runs in the background; the screen shows how many of the library
+ are done and how much space was saved.
+- If an image changes size or position after switching on, pick the second
+ delivery option on the same screen. A few themes style images with selectors
+ that the extra `` element breaks.
+
+## How to tell it is working
+
+Do not read the settings page for this. Read these, in order.
+
+### 1. The hit ratio, on **Nolimit → Cache**
+
+The dashboard shows a hit ratio over the last 7 days and a count of stored
+pages. **Two numbers produced by different routes**: stored pages are counted
+by walking the disk, the ratio comes from a counter the engine writes before
+WordPress boots. If pages are stored and the ratio says nothing has been
+measured, those two disagree and the dashboard will say so explicitly rather
+than showing a bare zero.
+
+That distinction exists because a zero can mean two things, a quiet site or a
+broken counter, and the dashboard should never leave you guessing which.
+
+### 2. The response headers
+
+```
+curl -sI https://example.test/ | grep -i x-nolimit
+```
+
+A served-from-cache response carries the plugin's header. A logged-in request
+or one with a basket cookie should **not**.
+
+### 3. The health report
+
+**Nolimit → Cache → Health** runs each signal and says which failed and why —
+the drop-in missing, the cache directory not writable, `WP_CACHE` unset, the
+drop-in pointing at a different copy of the plugin. Each is measured by doing
+the thing (writing a file, reading the config the engine reads), not by
+calling `is_writable()` and believing it.
+
+## Turning it off
+
+Deactivating removes the drop-in. If a drop-in is ever left behind — a
+half-finished delete, a file permission problem — a site can end up serving
+from a cache no plugin is managing. `wp-content/advanced-cache.php` is safe to
+delete by hand; WordPress treats its absence as "no page cache".