diff --git a/README.md b/README.md
index 87fc9c2..75a7436 100644
--- a/README.md
+++ b/README.md
@@ -1,93 +1,39 @@
# sphinx-searchlite
-Client-side documentation search for any Sphinx theme. No search service, no
-runtime dependency, no build step beyond Sphinx itself.
+Client-side documentation search for any Sphinx theme. It needs no search
+service, no runtime dependency, and no build step beyond Sphinx itself.
-```python
-extensions = ["sphinx_searchlite"]
-```
-
-That is the whole setup. A `⌘K` / `/` dialog appears on every page, backed by a
-JSON index emitted next to the build.
-
-## Why not Sphinx's own search?
-
-Sphinx ships `searchindex.js`, but it is coupled to `searchtools.js` and to the
-dedicated `search.html` page. There is no supported way to query it from your
-own UI. `sphinx-searchlite` emits plain records instead, so a theme can render
-results however it likes.
-
-## Ranking
-
-Results are scored with BM25 over two fields:
-
-- the record's own heading, boosted heavily
-- the title of the page it belongs to, boosted lightly
-
-The distinction matters. Boosting both together makes every section of a page
-inherit that page's relevance, so searching for a page title surfaces its
-subsections above the page itself.
-
-The word you are still typing is matched as a prefix *in addition to* an exact
-match, so `install` also reaches `installation`. All query words must match, so
-extra words narrow the result set.
-
-## Configuration
+[](https://github.com/python-project-templates/sphinx-searchlite/actions/workflows/build.yaml)
+[](https://codecov.io/gh/python-project-templates/sphinx-searchlite)
+[](https://github.com/python-project-templates/sphinx-searchlite)
+[](https://pypi.python.org/pypi/sphinx-searchlite)
-| Option | Default | Description |
-| ------------------------------- | ----------------------- | ------------------------------------------------------------------------------- |
-| `searchlite_index_filename` | `searchlite-index.json` | Written to `_static/`. |
-| `searchlite_max_text` | `1200` | Characters of body text kept per record. |
-| `searchlite_ui` | `True` | Ship the bundled dialog. Set `False` to supply your own. |
-| `searchlite_adopt_theme_search` | `True` | Rebind the theme's own search box to the dialog. Set `False` to leave it alone. |
+## Quick start
-## Working with an existing theme
+Install the package:
-Most themes already render a search box wired to Sphinx's `search.html`. Left
-alone that gives a page two different searches, and the dialog has no visible
-trigger of its own. By default searchlite takes that box over: it is made
-read-only, and focusing, clicking, or submitting it opens the dialog instead.
-
-The dialog also adopts the page's own colours, so it follows a theme's
-light/dark toggle rather than the `prefers-color-scheme` media query — themes
-typically switch on a class or attribute, which that query does not track.
-
-## Driving it yourself
-
-Set `searchlite_ui = False` and use the engine directly:
-
-```js
-const engine = SearchLite.create({ url: SearchLite.indexUrl });
-
-await engine.load();
-const { items, terms } = engine.search("query");
+```bash
+pip install sphinx-searchlite
```
-Each item is `{ u, t, s, x }` — url, page title, section heading, text.
-`terms` includes prefix expansions, so it can be passed to the bundled
-`SearchLite.highlight(text, terms)` and `SearchLite.excerpt(record, terms)`
-helpers.
-
-Add `data-searchlite-open` to any element to make it a trigger.
+Add it to `conf.py`:
-## Styling
-
-The bundled dialog reads `--searchlite-*` custom properties, so a theme can
-recolour it without overriding rules:
-
-```css
-:root {
- --searchlite-background: #111;
- --searchlite-border: #333;
-}
+```python
+extensions = ["sphinx_searchlite"]
```
-`--searchlite-muted`, `--searchlite-border`, and `--searchlite-accent` are
-derived from `--searchlite-foreground`, so setting the background and
-foreground pair is usually enough. Note that the adopted page colours are set
-on the dialog element itself and so win over `:root`; style
-`#searchlite-dialog` directly to override them.
+Build your docs and open any page. Press `/` or `⌘K` (`Ctrl+K` on Linux and
+Windows) to open the search dialog. If your theme has its own search box,
+clicking it opens the same dialog.
+
+## Documentation
-## Licence
+- [How-to guides](docs/src/how-to.md): keep the theme's search box, write your
+ own UI, add triggers, and restyle the dialog.
+- [Reference](docs/src/reference.md): configuration options, the JavaScript
+ API, the index format, and CSS custom properties.
+- [Explanation](docs/src/explanation.md): why searchlite exists, how ranking
+ works, and how the dialog picks its colors.
-Apache-2.0.
+> [!NOTE]
+> This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).
diff --git a/docs/src/explanation.md b/docs/src/explanation.md
new file mode 100644
index 0000000..5f705f0
--- /dev/null
+++ b/docs/src/explanation.md
@@ -0,0 +1,42 @@
+# Explanation
+
+## Why not Sphinx's own search?
+
+Sphinx ships `searchindex.js`, but it is tied to `searchtools.js` and the
+dedicated `search.html` page, and there is no supported way to query it from
+your own UI. searchlite writes plain JSON records instead, so a theme can score
+and render results however it likes.
+
+## How ranking works
+
+Results are scored with BM25 over the record text, with extra weight for two
+fields:
+
+- the record's own heading, weighted heavily
+- the title of the page it belongs to, weighted lightly
+
+These are kept separate because every section of a page shares that page's
+title. If both were weighted heavily, searching for a page title would rank
+its subsections alongside the page itself, often above it.
+
+The last word of the query is matched as a prefix as well as exactly, since
+you are probably still typing it. That way `install` also finds
+`installation`. Results must match every query word that appears in the index,
+so adding words narrows the results. Words that appear nowhere in the index are
+ignored.
+
+## Why the dialog takes over the theme's search box
+
+Most themes render a search box that submits to `search.html`. Left alone, a
+page would have two different searches, and the dialog would have no visible
+way to open it. searchlite makes that box read-only and opens the dialog when
+it is clicked, activated with `Enter` or `Space`, or its form is submitted.
+It does not open on focus, because closing the dialog returns focus to the box,
+which would open it again.
+
+## How the dialog picks its colors
+
+The dialog reads the page's background and text colors and copies them onto
+itself. It watches for changes, so it follows the theme's light and dark
+toggle. Most themes switch modes with a class or attribute, which
+`prefers-color-scheme` does not detect.
diff --git a/docs/src/how-to.md b/docs/src/how-to.md
new file mode 100644
index 0000000..a46d01c
--- /dev/null
+++ b/docs/src/how-to.md
@@ -0,0 +1,64 @@
+# How-to guides
+
+## Keep the theme's own search box
+
+By default searchlite takes over the theme's search box so that it opens the
+dialog. To leave the box wired to Sphinx's `search.html`, set:
+
+```python
+searchlite_adopt_theme_search = False
+```
+
+The dialog still opens with `/` and `⌘K`, and from any element with
+`data-searchlite-open`.
+
+## Add a button that opens the dialog
+
+Add the `data-searchlite-open` attribute to any element in your templates:
+
+```html
+
+```
+
+## Use your own search UI
+
+Turn off the bundled dialog so only the engine is shipped:
+
+```python
+searchlite_ui = False
+```
+
+Then query the engine from your own script:
+
+```js
+const engine = SearchLite.create({ url: SearchLite.indexUrl });
+
+await engine.load();
+const { items, terms } = engine.search("query");
+
+for (const item of items) {
+ const snippet = SearchLite.highlight(SearchLite.excerpt(item, terms), terms);
+ // render item.u, item.t, item.s, and snippet
+}
+```
+
+See the [JavaScript API](reference.md#javascript-api) for what each call
+returns.
+
+## Restyle the dialog
+
+Set `--searchlite-*` custom properties on `#searchlite-dialog`:
+
+```css
+#searchlite-dialog {
+ --searchlite-background: #111;
+ --searchlite-foreground: #eee;
+ --searchlite-radius: 0.25rem;
+}
+```
+
+Target the dialog element rather than `:root`. The dialog copies the page's
+colors onto itself at runtime, and those inline values override anything
+inherited from `:root`. Setting the background and foreground is usually
+enough, because the muted, border, and accent colors are mixed from the
+foreground.
diff --git a/docs/src/reference.md b/docs/src/reference.md
new file mode 100644
index 0000000..ba4fab6
--- /dev/null
+++ b/docs/src/reference.md
@@ -0,0 +1,92 @@
+# Reference
+
+## Configuration
+
+Set these in `conf.py`.
+
+| Option | Default | Description |
+| ------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------- |
+| `searchlite_index_filename` | `searchlite-index.json` | Name of the index file written to `_static/`. |
+| `searchlite_max_text` | `1200` | Maximum characters of body text kept per record. |
+| `searchlite_ui` | `True` | Ship the bundled dialog and its CSS. When `False`, only the engine is shipped. |
+| `searchlite_adopt_theme_search` | `True` | Make the theme's search box open the dialog. Has no effect when `searchlite_ui` is `False`. |
+
+## Index format
+
+The index is a JSON array of records. Each page produces one record for the
+page and one for each section that has an anchor.
+
+| Key | Description |
+| --- | --------------------------------------------------------------------------- |
+| `u` | URL relative to the documentation root, with `#anchor` for sections. |
+| `t` | Title of the page. |
+| `s` | Section heading. Absent on page records. |
+| `x` | Text, truncated to `searchlite_max_text`. Sections exclude nested sections. |
+
+`engine.load()` rewrites `u` to an absolute URL.
+
+## JavaScript API
+
+The engine defines a global `SearchLite` object.
+
+### `SearchLite.create({ url })`
+
+Returns an engine for the index at `url`. Nothing is fetched until `load()` is
+called.
+
+### `engine.load()`
+
+Fetches and indexes the records. Returns a promise. Repeated calls return the
+same promise. A failed fetch produces an empty index.
+
+### `engine.ready()`
+
+Returns `true` once the index is built.
+
+### `engine.search(query)`
+
+Returns `{ items, terms }`. `items` is an array of up to 20 records, best match
+first. `terms` lists the index terms that matched, including prefix expansions.
+Returns empty arrays if the index is not loaded or the query has no words.
+
+### `SearchLite.indexUrl`
+
+Absolute URL of the index for the current page, or `null` if it cannot be
+resolved.
+
+### `SearchLite.tokenize(text)`
+
+Returns the lowercase words in `text`, split on anything other than `a-z`,
+`0-9`, and `_`.
+
+### `SearchLite.excerpt(record, terms, length = 120)`
+
+Returns about `length` characters of `record.x`, starting just before the first
+matching term. Prefixed with `…` when it does not start at the beginning.
+
+### `SearchLite.highlight(text, terms)`
+
+Returns a `DocumentFragment` of `text` with each match wrapped in ``.
+
+## Dialog
+
+| Key | Action |
+| ------------------- | -------------------------- |
+| `/`, `⌘K`, `Ctrl+K` | Open the dialog. |
+| `↑`, `↓` | Move between results. |
+| `Enter` | Go to the selected result. |
+| `Escape` | Close the dialog. |
+
+Any element with the `data-searchlite-open` attribute opens the dialog when
+clicked.
+
+## CSS custom properties
+
+| Property | Default | Set on |
+| ------------------------- | --------------------------------- | ------------------------ |
+| `--searchlite-background` | `#fff`, or the page background | `:root`, then the dialog |
+| `--searchlite-foreground` | `#18181b`, or the page text color | `:root`, then the dialog |
+| `--searchlite-radius` | `0.75rem` | `:root` |
+| `--searchlite-muted` | foreground at 60% | `#searchlite-dialog` |
+| `--searchlite-border` | foreground at 20% | `#searchlite-dialog` |
+| `--searchlite-accent` | foreground at 10% | `#searchlite-dialog` |
diff --git a/pyproject.toml b/pyproject.toml
index 171c4cc..5e8d9c3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -146,6 +146,6 @@ section-order = [
[tool.yardang]
title = "sphinx-searchlite"
root = "README.md"
-pages = []
+pages = ["docs/src/how-to.md", "docs/src/reference.md", "docs/src/explanation.md"]
use-autoapi = true