diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md
index 123abe5..c04084f 100644
--- a/docs/TECHNICAL.md
+++ b/docs/TECHNICAL.md
@@ -78,6 +78,7 @@ All files are stored under `library/` (relative to the app working directory, ma
`GET /api/library` runs in fast-path mode by default (DB-only, no full disk rescan).
For a forced sync: `GET /api/library?rescan=true` or `POST /library/rescan`.
`include_file_info=true` is optional for file size/mtime enrichment.
+ETag caching: response includes `ETag: "{count}-{max_updated_at_unix}"` and `Cache-Control: no-cache`. Client sends `If-None-Match`; server returns `304 Not Modified` when nothing changed.
`/api/home` returns:
- `continue_reading`
@@ -164,6 +165,18 @@ Home read sections are ordered oldest-first:
Publish flow: all chapters are run through `normalize_wysiwyg_html()`, then `build_epub()` produces an EPUB 2.0 ZIP. The file path is computed via `make_rel_path(media_type="epub", …)`. The book is inserted into the library with `needs_review=True`. The draft is deleted on success.
+### `routers/following.py`
+- `GET /following` — Following page (author URL management)
+- `GET /api/following` — all distinct library authors with URL (if set), book count, and last-added date
+- `POST /api/following/{author_name}` — set or clear URL for an author (empty `url` removes the record)
+
+`GET /api/following` returns one entry per non-archived author:
+```json
+{ "name": "Author Name", "book_count": 5, "last_added": "2026-03-27T…", "url": "https://…" }
+```
+
+URL is stored in the `authors` table (`name` unique, `url`, `created_at`, `updated_at`).
+
### `routers/backup.py`
- `GET /backup` — backup page
- `GET /api/backup/credentials` — Dropbox settings (includes `app_key_configured` flag)
@@ -256,6 +269,8 @@ Dropbox settings are managed via the web UI on `/backup`.
- Bookmarks: saved per book via `POST /library/bookmarks/{filename}`; shown in Library sidebar section; navigated via `?bm_ch=N&bm_scroll=F` URL params on reader page.
- Convert page: after loading metadata, if a book with the same title+author already exists in the library, a warning banner is shown (with a link to the existing book); user can still proceed with conversion. Check is done server-side in `/preload` response (`already_exists`, `existing_books`).
- Duplicates view (`#duplicates`): groups non-archived books by `(title, author)` (case-insensitive); shows only groups with ≥ 2 copies; counter in sidebar shows total number of duplicate books. Detection is entirely client-side from the existing library data.
+- Incomplete view (`#incomplete`): shows all non-archived books where `publication_status` is not `Complete` (Ongoing, Hiatus, or blank); sidebar counter included.
+- Following page (`/following`): dedicated page in its own sidebar section between Library and Tools; shows all library authors with their external URL; two tabs — Following (authors with URL set) and All Authors; inline URL editing with keyboard support (Enter = save, Escape = cancel); clicking Visit opens the external URL in a new tab. Author URLs are stored in the `authors` table. Sidebar counter shows number of followed authors.
- Book Builder (`/builder`): create EPUB books from scratch; drafts stored in `builder_drafts` (JSONB chapters); contenteditable editor with toolbar (bold/italic/underline/blockquote/author-note/scene-break/normalize); autosave every 30 s + Ctrl+S; publish normalizes HTML via `normalize_wysiwyg_html()` and builds EPUB via `build_epub()`.
---
@@ -277,9 +292,13 @@ Dropbox settings are managed via the web UI on `/backup`.
---
## Performance Notes
-- Library load is optimized for large datasets:
- - `list_library_json()` uses pre-aggregation for `reading_sessions`.
+- Library load is optimized for large datasets (1000+ books):
+ - `list_library_json()` uses `json_agg` in the main query to inline tags per book — eliminates a separate `SELECT * FROM book_tags` query and Python merge loop.
- `has_cached_cover` is provided directly via SQL join instead of full cache fetch.
+ - `reading_sessions` is pre-aggregated in a subquery.
+ - ETag on `/api/library`: cheap `COUNT + MAX(updated_at)` query before full load; `304 Not Modified` on cache hit.
+- Front-end rendering uses `IntersectionObserver` to defer both cover image loading and placeholder canvas drawing until cards enter the viewport — prevents hundreds of simultaneous HTTP requests and canvas operations on initial render.
+- `renderBooksGrid`, `renderDuplicatesView`, `renderSeriesDetail` all use a single DOM pass: cover `
` and `