diff --git a/docs/plans/2026-06-22-book-detail-implementation.md b/docs/plans/2026-06-22-book-detail-implementation.md
new file mode 100644
index 0000000..57c8c60
--- /dev/null
+++ b/docs/plans/2026-06-22-book-detail-implementation.md
@@ -0,0 +1,539 @@
+# Book Detail View Implementation Plan
+
+**Goal:** Add a dedicated `/book?id=X` page showing full book details — header, format selector, reading log, note composer, and recommendation.
+
+**Architecture:** New `spine-book-model` function builds the view model `ht` for one book plist. New `httpd/book` handler serves the journal-style template. Index view gets title links pointing to the book page.
+
+**Tech Stack:** Emacs Lisp, simple-httpd, mustache.el, ht.el, Org-mode
+
+---
+
+### Task 1: Extract shared format definitions
+
+**Files:**
+- Modify: `spine.el`
+
+The format-icons list is currently local inside `spine-index-model`. Extract to a top-level `defconst` so `spine-book-model` can reuse it.
+
+- [ ] **Step 1: Add `spine--format-defs` defconst before `spine-index-model`**
+
+In `spine.el`, before `spine-index-model` (around line 66), add:
+
+```elisp
+(defconst spine--format-defs
+ '(("hardcover" "ti-book" "Hardcover")
+ ("ebook" "ti-device-tablet" "eBook")
+ ("audiobook" "ti-headphones" "Audiobook"))
+ "Alist of (format-key icon label) for book format display.")
+```
+
+- [ ] **Step 2: Update `spine-index-model` to use `spine--format-defs`**
+
+Replace the local `format-icons` let-binding in `spine-index-model`:
+
+```
+OLD (line 70-73):
+ (let* ((format-icons
+ '(("hardcover" . "ti-book")
+ ("ebook" . "ti-device-tablet")
+ ("audiobook" . "ti-headphones")))
+ (grouped (make-hash-table :test 'equal))
+```
+
+NEW:
+```elisp
+ (let* ((grouped (make-hash-table :test 'equal))
+```
+
+And at the format_icon line (~103), change:
+```
+OLD: ("format_icon" (or (cdr (assoc fmt format-icons)) ""))
+NEW: ("format_icon" (or (nth 1 (assoc fmt spine--format-defs)) ""))
+```
+
+- [ ] **Step 3: Add `book_url` to each book model in `spine-index-model`**
+
+In the book model ht (around line 101-135), add a `book_url` field:
+
+```elisp
+("book_url" (format "/book?id=%s" id))
+```
+
+---
+
+### Task 2: Write `spine-book-model` function
+
+**Files:**
+- Create: `test/spine-book-model-test.el`
+- Modify: `spine.el`
+
+- [ ] **Step 1: Write failing test file `test/spine-book-model-test.el`**
+
+```elisp
+;;; spine-book-model-test.el — ERT tests for spine-book-model
+
+(require 'ert)
+(require 'cl-lib)
+
+(setq spine-skip-server-start t)
+(setq spine-org-file (expand-file-name "sample-books.org"
+ (file-name-directory
+ (directory-file-name
+ (file-name-directory load-file-name)))))
+(load-file (expand-file-name "../spine.el"
+ (file-name-directory load-file-name)))
+
+(defconst spine-book-model-test--uow
+ (cl-find "8c1e-uow" (spine-books)
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)
+ "Use of Weapons fixture.")
+
+(defconst spine-book-model-test--aj
+ (cl-find "3a1b-aj" (spine-books)
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)
+ "Ancillary Justice fixture.")
+
+(defconst spine-book-model-test--pir
+ (cl-find "5e8d-pir" (spine-books)
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)
+ "Piranesi fixture.")
+
+(ert-deftest spine-book-model-title-strips-todo ()
+ "Title has TODO prefix stripped."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (equal (ht-get model "title") "Use of Weapons"))))
+
+(ert-deftest spine-book-model-status-reading ()
+ "READING book has status_class reading."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (equal (ht-get model "status_class") "reading"))
+ (should (equal (ht-get model "status_label") "Reading"))))
+
+(ert-deftest spine-book-model-status-read ()
+ "READ book has status_class read."
+ (let ((model (spine-book-model spine-book-model-test--aj)))
+ (should (equal (ht-get model "status_class") "read"))
+ (should (equal (ht-get model "status_label") "Read"))))
+
+(ert-deftest spine-book-model-status-want ()
+ "WANT book has status_class want."
+ (let ((model (spine-book-model spine-book-model-test--pir)))
+ (should (equal (ht-get model "status_class") "want"))
+ (should (equal (ht-get model "status_label") "Want"))))
+
+(ert-deftest spine-book-model-author ()
+ "Author is passed through."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (equal (ht-get model "author") "Iain M. Banks"))))
+
+(ert-deftest spine-book-model-meta-with-format-and-date ()
+ "Meta includes format and date when both present."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (string-match "started.*audiobook" (ht-get model "meta")))))
+
+(ert-deftest spine-book-model-format-active ()
+ "Format list has correct active flag."
+ (let* ((model (spine-book-model spine-book-model-test--uow))
+ (formats (ht-get model "formats")))
+ (should (= (length formats) 3))
+ (should (equal (ht-get (nth 0 formats) "label") "Hardcover"))
+ (should-not (ht-get (nth 0 formats) "active"))
+ (should (equal (ht-get (nth 1 formats) "label") "eBook"))
+ (should-not (ht-get (nth 1 formats) "active"))
+ (should (equal (ht-get (nth 2 formats) "label") "Audiobook"))
+ (should (ht-get (nth 2 formats) "active"))))
+
+(ert-deftest spine-book-model-notes ()
+ "Notes are mapped correctly."
+ (let* ((model (spine-book-model spine-book-model-test--uow))
+ (notes (ht-get model "notes")))
+ (should (= (length notes) 3))
+ (should (equal (ht-get (nth 0 notes) "date") "2026-03-02"))
+ (should (equal (ht-get (nth 0 notes) "text")
+ "The two-track structure is doing something I can't name yet."))))
+
+(ert-deftest spine-book-model-recommendation ()
+ "Recommendation includes initials, by, and note."
+ (let* ((model (spine-book-model spine-book-model-test--uow))
+ (rec (ht-get model "recommendation")))
+ (should rec)
+ (should (equal (ht-get rec "initials") "P"))
+ (should (equal (ht-get rec "by") "Priya"))
+ (should (string-match "Player of Games" (ht-get rec "note")))))
+
+(ert-deftest spine-book-model-no-recommendation ()
+ "No recommendation when rec_by absent."
+ (let ((model (spine-book-model spine-book-model-test--aj)))
+ (should-not (ht-get model "recommendation"))))
+
+(ert-deftest spine-book-model-no-todo-prefix ()
+ "Book with no TODO prefix shows title as-is and empty status."
+ (let* ((books (spine-books))
+ (tcm (cl-find "b2c1-tcm" books
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)))
+ (should tcm)
+ (let ((model (spine-book-model tcm)))
+ (should (equal (ht-get model "title") "The Checklist Manifesto"))
+ (should (equal (ht-get model "status_class") ""))
+ (should (equal (ht-get model "status_label") "")))))
+
+(ert-deftest spine-book-model-no-format-or-date ()
+ "No format and no date → meta is empty."
+ (let* ((books (spine-books))
+ (tcm (cl-find "b2c1-tcm" books
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)))
+ (should tcm)
+ (let ((model (spine-book-model tcm)))
+ (should (equal (ht-get model "meta") "")))))
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
+```
+
+Expected: FAIL — `spine-book-model` undefined.
+
+- [ ] **Step 3: Write `spine-book-model` in `spine.el`**
+
+Add two helper functions and the main model function after `spine-add-form-model` (~line 64), before `spine-index-model`:
+
+```elisp
+(defun spine--format-date (org-date-str)
+ "Format Org date \"[2026-02-20]\" to \"20 Feb\"."
+ (when (and org-date-str (> (length org-date-str) 0))
+ (let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
+ (condition-case nil
+ (format-time-string "%e %b" (date-to-time clean))
+ (error org-date-str)))))
+
+(defun spine--parse-title-status (title)
+ "Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE.
+TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
+ (let ((case-fold-search nil))
+ (if (string-match
+ "\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
+ title)
+ (let ((kw (match-string 1 title))
+ (rest (match-string 2 title)))
+ (list rest
+ (downcase kw)
+ (capitalize (downcase kw))))
+ (list title "" ""))))
+
+(defun spine-book-model (book)
+ "Build view model `ht` for a single BOOK plist, for templates/book.mustache."
+ (let* ((id (plist-get book :id))
+ (title (or (plist-get book :title) ""))
+ (author (or (plist-get book :author) ""))
+ (fmt (plist-get book :format))
+ (added (plist-get book :added))
+ (notes (plist-get book :notes))
+ (rec-by (plist-get book :rec_by))
+ (rec-note (plist-get book :rec_note))
+ (title-info (spine--parse-title-status title))
+ (clean-title (nth 0 title-info))
+ (status-class (nth 1 title-info))
+ (status-label (nth 2 title-info))
+ (meta (cond
+ ((and added fmt)
+ (format "started %s · %s" (spine--format-date added) fmt))
+ (added (format "started %s" (spine--format-date added)))
+ (fmt (format "format: %s" fmt))
+ (t "")))
+ (formats (mapcar
+ (lambda (fd)
+ (ht ("icon" (nth 1 fd))
+ ("label" (nth 2 fd))
+ ("active" (and fmt (equal (car fd) fmt)))))
+ spine--format-defs))
+ (notes-model
+ (mapcar (lambda (n)
+ (ht ("date" (car n))
+ ("text" (cadr n))))
+ notes))
+ (recommendation
+ (when (and rec-by (> (length rec-by) 0))
+ (let ((initials
+ (mapconcat
+ (lambda (s)
+ (when (> (length s) 0)
+ (upcase (substring s 0 1))))
+ (split-string rec-by " +" t) "")))
+ (ht ("initials" initials)
+ ("by" rec-by)
+ ("note" (or rec-note "")))))))
+ (ht ("cover_icon" "ti-book")
+ ("id" id)
+ ("title" clean-title)
+ ("author" author)
+ ("status_class" status-class)
+ ("status_label" status-label)
+ ("meta" meta)
+ ("progress_label" "")
+ ("formats" formats)
+ ("notes" notes-model)
+ ("recommendation" recommendation)
+ ("back_url" (format "/index?id=%s" id)))))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
+```
+
+Expected: PASS for all tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add spine.el test/spine-book-model-test.el
+git commit -m "feat: add spine-book-model for book detail view model"
+```
+
+---
+
+### Task 3: Create `templates/book.mustache`
+
+**Files:**
+- Create: `templates/book.mustache`
+
+- [ ] **Step 1: Write the mustache template**
+
+Based on `spine-mockup-b-journal.mustache` with Pico CSS. Differences from mockup:
+- Back link at top
+- No progress_label in composer placeholder
+- No recommendation date (not stored in data model)
+- Conditional recommendation section
+
+```html
+
+
+
+
+
+
+Spine — {{title}}
+
+
+
+
+
+
+
+ ← Back to shelf
+
+
+
+
+
+ {{title}}
+ {{author}}
+
+ {{#status_class}}
+
{{status_label}}
+ {{/status_class}}
+ {{#meta}}
+
{{meta}}
+ {{/meta}}
+
+
+
+
+ {{#formats}}
+ {{label}}
+ {{/formats}}
+
+
+ {{#notes}}
+ Reading log
+
+ {{#notes}}
+
+ {{/notes}}
+
+ {{/notes}}
+
+
+
+ Log note
+
+
+
+
+ {{#recommendation}}
+ On your radar
+
+
{{initials}}
+
{{by}} "{{note}}"
+
+ {{/recommendation}}
+
+
+
+
+
+
+
+
+
+
+```
+
+Don't write the template yet — this step is informational. The actual template is created in the next step with `write`.
+
+- [ ] **Step 2: Create the template file**
+
+Create `templates/book.mustache` with the content above.
+
+---
+
+### Task 4: Add `httpd/book` handler
+
+**Files:**
+- Modify: `spine.el`
+
+- [ ] **Step 1: Add the handler function after `httpd/edit` (around line 491)**
+
+```elisp
+(defun httpd/book (proc uri-path query request)
+ "Show the journal-style detail page for a single book."
+ (let* ((id (cadr (assoc "id" query)))
+ (books (spine-books))
+ (book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
+ (if book
+ (httpd-with-buffer proc "text/html"
+ (insert (spine-render "book.mustache" (spine-book-model book))))
+ (httpd-redirect proc "/index" 302))))
+```
+
+- [ ] **Step 2: Quick smoke test — load spine.el and check no errors**
+
+```bash
+emacs --batch --eval "(progn (setq spine-skip-server-start t) (load-file \"spine.el\"))"
+```
+
+Expected: loads without errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add templates/book.mustache spine.el
+git commit -m "feat: add httpd/book handler and book detail template"
+```
+
+---
+
+### Task 5: Add title links in index view
+
+**Files:**
+- Modify: `spine.el` (already done in Task 1 Step 3 — `book_url` added)
+- Modify: `templates/index.mustache`
+
+- [ ] **Step 1: Update `templates/index.mustache` to make title a link**
+
+Find line ~81 in `templates/index.mustache`:
+```
+ {{title}} · {{author}}
+```
+
+Replace with:
+```
+ {{title}}
+ · {{author}}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add templates/index.mustache
+git commit -m "feat: link book titles in index to detail page"
+```
+
+---
+
+### Task 6: Run full test suite
+
+- [ ] **Step 1: Run all tests**
+
+```bash
+emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"
+emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"
+emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)"
+emacs --batch -l test/spine-edit-test.el --eval "(ert-run-tests-batch-and-exit)"
+emacs --batch -l test/spine-book-model-test.el --eval "(ert-run-tests-batch-and-exit)"
+```
+
+Expected: all PASS.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add -A
+git commit -m "test: verify book detail view and all existing tests pass"
+```
+
+---
+
+### Task 7: Self-review
+
+- [ ] **Step 1: Verify spec coverage**
+
+Check each spec requirement maps to a task:
+- Model fields (cover_icon, title, author, status_class, status_label, meta, formats, notes, recommendation) → Task 2
+- Status extraction (strip TODO prefix) → Task 2 Step 3
+- Format list with active flag → Task 2 Step 3
+- Recommendation with initials → Task 2 Step 3
+- Handler redirect on missing book → Task 4
+- Template with back link → Task 3
+- No recommendation → handled by mustache `{{#recommendation}}` section
+- Title links in index → Task 5
+- Empty reading log → mustache `{{#notes}}` section handles empty
+
+- [ ] **Step 2: Placeholder scan**
+
+Verify no "TBD", "TODO", "implement later" in plan.
+
+- [ ] **Step 3: Type consistency check**
+
+Verify field names (cover_icon, status_class, etc.) match between model function and template.
diff --git a/docs/plans/2026-06-22-shelves-implementation.md b/docs/plans/2026-06-22-shelves-implementation.md
new file mode 100644
index 0000000..7470b45
--- /dev/null
+++ b/docs/plans/2026-06-22-shelves-implementation.md
@@ -0,0 +1,1047 @@
+# Shelves Data Model Implementation Plan
+
+**Goal:** Restructure spine's data model from TODO-state-based top-level books to shelf-based organization with books nested under shelves.
+
+**Architecture:** Level-1 Org headlines become shelves (arbitrary organizing concepts). Level-2 headlines are books. `spine-books` returns books with a `:shelf` key. New `spine-shelves` returns all shelf names including empties. UI groups by shelf, nav bar lists shelves. TODO states removed; logbook handles history. Add-book requires picking an existing shelf.
+
+**Tech Stack:** Emacs Lisp, Org-mode, mustache templates
+
+---
+
+### Task 1: Restructure sample-books.org
+
+**Files:**
+- Modify: `sample-books.org`
+
+- [ ] **Step 1: Rewrite sample-books.org with shelf hierarchy**
+
+Replace the flat headline list with shelves as level-1 headings and books as level-2. Keep all existing properties, logbooks, notes, and IDs. Remove the `#+TODO:` line. Books retain their TODO keywords for org-mode compatibility but need not — either way the app ignores them.
+
+```org
+#+TITLE: Spine
+
+* Fiction
+** WANT The Left Hand of Darkness
+:PROPERTIES:
+:AUTHOR: Ursula K. Le Guin
+:ISBN: 978-0441478125
+:COVER: covers/left-hand.jpg
+:FORMAT: ebook
+:REC_BY: Alex
+:REC_NOTE: Le Guin at her most human
+:RATING:
+:ADDED: [2026-04-01]
+:LAST_MODIFIED: [2026-04-01]
+:ID: 9a2b-lhd
+:END:
+
+** WANT Dune
+:PROPERTIES:
+:AUTHOR: Frank Herbert
+:ISBN: 978-0441172719
+:COVER: covers/dune.jpg
+:FORMAT: hardcover
+:REC_BY: Everyone
+:REC_NOTE:
+:RATING:
+:ADDED: [2026-05-10]
+:LAST_MODIFIED: [2026-05-10]
+:ID: 7f3c-dun
+:END:
+
+** WANT Piranesi :fiction:
+:PROPERTIES:
+:AUTHOR: Susanna Clarke
+:ISBN:
+:COVER:
+:FORMAT:
+:REC_BY:
+:REC_NOTE:
+:RATING:
+:ADDED: [2026-06-01]
+:LAST_MODIFIED: [2026-06-01]
+:ID: 5e8d-pir
+:END:
+
+** READING Use of Weapons :scifi:literary:
+:PROPERTIES:
+:AUTHOR: Iain M. Banks
+:ISBN: 978-0316029193
+:COVER: covers/use-of-weapons.jpg
+:FORMAT: audiobook
+:REC_BY: Priya
+:REC_NOTE: If you liked Player of Games, this one will wreck you
+:RATING:
+:ADDED: [2026-02-20]
+:LAST_MODIFIED: [2026-03-09]
+:ID: 8c1e-uow
+:END:
+::LOGBOOK:
+- State "READING" from "WANT" [2026-03-01]
+::END:
+- [2026-03-02] The two-track structure is doing something I can't name yet.
+- [2026-03-07] Zakalwe's competence reads as a wound.
+- [2026-03-09] The chapter numbering. Oh.
+
+* Science Fiction
+** READ Ancillary Justice :scifi:
+:PROPERTIES:
+:AUTHOR: Ann Leckie
+:ISBN: 978-0316246620
+:COVER: covers/ancillary.jpg
+:FORMAT: ebook
+:REC_BY:
+:REC_NOTE:
+:RATING: 5
+:ADDED: [2025-11-15]
+:LAST_MODIFIED: [2025-11-15]
+:ID: 3a1b-aj
+:END:
+::LOGBOOK:
+- State "READ" from "READING" [2026-01-10]
+- State "READING" from "WANT" [2025-11-20]
+::END:
+- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
+- [2025-12-05] Justice of Toren's multi-body perspective is unsettling.
+- [2026-01-08] The tea set. Perfect ending.
+
+* Non-Fiction
+** WANT The Checklist Manifesto
+:PROPERTIES:
+:AUTHOR: Atul Gawande
+:ID: b2c1-tcm
+:ADDED: [2026-06-15]
+:LAST_MODIFIED: [2026-06-15]
+:END:
+```
+
+Added a "Non-Fiction" shelf (empty example of a shelf that could be pre-created).
+
+- [ ] **Step 2: Run the test suite to confirm it fails**
+
+Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: failures because `spine-books` still expects flat level-1 headlines.
+
+---
+
+### Task 2: Add `spine-shelves` and adapt `spine-books`
+
+**Files:**
+- Modify: `spine.el`
+- Modify: `test/spine-books-test.el`
+
+- [ ] **Step 1: Write failing test for `spine-shelves`**
+
+Add to `test/spine-books-test.el`:
+
+```elisp
+(ert-deftest spine-shelves-returns-shelf-names ()
+ "spine-shelves returns all level-1 headlines as shelf names."
+ (let ((shelves (spine-shelves)))
+ (should (member "Fiction" shelves))
+ (should (member "Science Fiction" shelves))
+ (should (member "Non-Fiction" shelves))
+ (should (= (length shelves) 3))))
+
+(ert-deftest spine-shelves-includes-empty-shelves ()
+ "spine-shelves includes empty shelves (shelves with no books)."
+ (let ((shelves (spine-shelves)))
+ (should (member "Non-Fiction" shelves))))
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: FAIL — `spine-shelves` undefined.
+
+- [ ] **Step 3: Write `spine-shelves` implementation**
+
+In `spine.el`, before `spine-books`, add:
+
+```elisp
+(defun spine-shelves ()
+ "Return a list of shelf names (strings), one per level-1 headline.
+Includes empty shelves (shelves with no books)."
+ (if (not (file-exists-p spine-org-file))
+ (progn
+ (message "spine-shelves: file not found: %s" spine-org-file)
+ nil)
+ (condition-case err
+ (with-temp-buffer
+ (insert-file-contents spine-org-file)
+ (org-mode)
+ (let ((shelves nil))
+ (org-element-map (org-element-parse-buffer 'headline) 'headline
+ (lambda (hl)
+ (when (= (org-element-property :level hl) 1)
+ (push (org-element-property :raw-value hl) shelves))))
+ (nreverse shelves)))
+ (error
+ (message "spine-shelves: failed to parse %s: %s"
+ spine-org-file (error-message-string err))
+ nil))))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: PASS for the two `spine-shelves` tests.
+
+- [ ] **Step 5: Update `spine-books` to walk nested books under shelves**
+
+Replace the current `spine-books` body. New logic: walk level-1 headlines as shelves. For each, walk level-2 children as books. Each book plist gets a `:shelf` key. No `:status` key.
+
+```elisp
+(defun spine-books ()
+ "Return a list of plists, one per book in `spine-org-file'.
+Books are level-2 headlines nested under level-1 shelf headlines.
+Returns nil if the file is missing or cannot be parsed."
+ (if (not (file-exists-p spine-org-file))
+ (progn
+ (message "spine-books: file not found: %s" spine-org-file)
+ nil)
+ (condition-case err
+ (with-temp-buffer
+ (insert-file-contents spine-org-file)
+ (org-mode)
+ (let ((books nil))
+ (org-element-map (org-element-parse-buffer 'headline) 'headline
+ (lambda (hl)
+ (let ((level (org-element-property :level hl)))
+ (when (= level 2)
+ (let* ((pos (org-element-property :begin hl))
+ (parent (org-element-property :parent hl))
+ (shelf (and parent
+ (= (org-element-property :level parent) 1)
+ (org-element-property :raw-value parent))))
+ (when shelf
+ (push (list :id (spine--prop pos "ID")
+ :title (org-element-property :raw-value hl)
+ :author (spine--prop pos "AUTHOR")
+ :isbn (spine--prop pos "ISBN")
+ :cover (spine--prop pos "COVER")
+ :format (spine--prop pos "FORMAT")
+ :rec_by (spine--prop pos "REC_BY")
+ :rec_note (spine--prop pos "REC_NOTE")
+ :rating (spine--prop pos "RATING")
+ :added (spine--prop pos "ADDED")
+ :last_modified (spine--prop pos "LAST_MODIFIED")
+ :tags (org-element-property :tags hl)
+ :notes (spine--extract-notes
+ (org-element-property :begin hl)
+ (org-element-property :end hl))
+ :shelf shelf)
+ books)))))))
+ (nreverse books)))
+ (error
+ (message "spine-books: failed to parse %s: %s"
+ spine-org-file (error-message-string err))
+ nil))))
+```
+
+Note: `org-element-property :raw-value` for plain text headings (no links within). Safer than `:title` which returns a string or list depending on content.
+
+- [ ] **Step 6: Update `spine-books` tests in spine-books-test.el**
+
+Replace the status assertions and add `:shelf` assertions:
+
+In `spine-books-parses-sample`:
+- Change `(= (length books) 5)` → `(= (length books) 6)` (new book added to Non-Fiction)
+
+In `spine-books-first-book-has-all-fields`:
+- Replace `:status "READING"` check with `:shelf "Fiction"` check
+- Keep all other field assertions
+
+In `spine-books-missing-properties-are-nil`:
+- Add `(should (equal (plist-get pir :shelf) "Fiction"))`
+- Remove `:status "WANT"` check
+
+In `spine-books-read-book-has-rating`:
+- Replace `:status "READ"` check with `:shelf "Science Fiction"` check
+- Keep rating and notes assertions
+
+- [ ] **Step 7: Run tests to verify both pass**
+
+Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: PASS for all spine-books tests.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add sample-books.org spine.el test/spine-books-test.el
+git commit -m "feat: add spine-shelves, adapt spine-books for shelf nesting"
+```
+
+---
+
+### Task 3: Adapt `spine-add-book` for shelves
+
+**Files:**
+- Modify: `spine.el`
+- Modify: `test/spine-add-book-test.el`
+
+- [ ] **Step 1: Update spine-add-book-test.el tests**
+
+Remove status checks. Add `:shelf` argument to all `spine-add-book` calls. Update assertions.
+
+Replace `spine-add-book-creates-headline`:
+```elisp
+(ert-deftest spine-add-book-creates-headline ()
+ "spine-add-book creates a new book under the given shelf."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ ;; Create the file first with a shelf
+ (with-temp-file spine-org-file
+ (insert "* Fiction\n\n"))
+ (spine-add-book :title "Test Book" :author "Author Name" :shelf "Fiction")
+ (let ((books (spine-books)))
+ (should books)
+ (should (= (length books) 1))
+ (let ((book (car books)))
+ (should (equal (plist-get book :title) "Test Book"))
+ (should (equal (plist-get book :author) "Author Name"))
+ (should (equal (plist-get book :shelf) "Fiction"))
+ (should (plist-get book :id)))))
+ (spine-add-book-test--cleanup)))
+```
+
+Update `spine-add-book-sets-all-fields`:
+```elisp
+(ert-deftest spine-add-book-sets-all-fields ()
+ "spine-add-book sets all provided fields."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ (with-temp-file spine-org-file
+ (insert "* Fiction\n\n"))
+ (spine-add-book
+ :title "Full Test"
+ :author "Jane Doe"
+ :format "hardcover"
+ :isbn "978-1234567890"
+ :cover "covers/test.jpg"
+ :rec_by "Friend"
+ :rec_note "You'll love this"
+ :date_added "2026-06-21"
+ :initial_note "Looking forward to this"
+ :shelf "Fiction")
+ (let* ((books (spine-books))
+ (book (car books)))
+ (should (equal (plist-get book :title) "Full Test"))
+ (should (equal (plist-get book :author) "Jane Doe"))
+ (should (equal (plist-get book :format) "hardcover"))
+ (should (equal (plist-get book :isbn) "978-1234567890"))
+ (should (equal (plist-get book :cover) "covers/test.jpg"))
+ (should (equal (plist-get book :rec_by) "Friend"))
+ (should (equal (plist-get book :rec_note) "You'll love this"))
+ (should (equal (plist-get book :added) "[2026-06-21]"))
+ (should (equal (plist-get book :notes)
+ '(("2026-06-21" "Looking forward to this"))))))
+ (spine-add-book-test--cleanup)))
+```
+
+Update `spine-add-book-omits-empty-fields`:
+```elisp
+(ert-deftest spine-add-book-omits-empty-fields ()
+ "spine-add-book does not set empty/omitted fields."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ (with-temp-file spine-org-file
+ (insert "* Fiction\n\n"))
+ (spine-add-book :title "Minimal Book" :shelf "Fiction")
+ (let* ((books (spine-books))
+ (book (car books)))
+ (should (equal (plist-get book :title) "Minimal Book"))
+ (should (equal (plist-get book :shelf) "Fiction"))
+ (should-not (plist-get book :author))
+ (should-not (plist-get book :format))
+ (should-not (plist-get book :isbn))
+ (should-not (plist-get book :tags))))
+ (spine-add-book-test--cleanup)))
+```
+
+Update `spine-add-book-requires-title` — no change needed (still signals error).
+
+Update `spine-add-book-generates-unique-ids`:
+```elisp
+(ert-deftest spine-add-book-generates-unique-ids ()
+ "spine-add-book generates different IDs for each book."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (progn
+ (with-temp-file spine-org-file
+ (insert "* Fiction\n\n"))
+ (spine-add-book :title "Book One" :shelf "Fiction")
+ (spine-add-book :title "Book Two" :shelf "Fiction")
+ (let* ((books (spine-books))
+ (id1 (plist-get (nth 0 books) :id))
+ (id2 (plist-get (nth 1 books) :id)))
+ (should (not (equal id1 id2)))))
+ (spine-add-book-test--cleanup)))
+```
+
+Add a test for missing shelf:
+```elisp
+(ert-deftest spine-add-book-requires-shelf ()
+ "spine-add-book signals error when shelf is missing."
+ (spine-add-book-test--cleanup)
+ (unwind-protect
+ (should-error (spine-add-book :title "No Shelf"))
+ (spine-add-book-test--cleanup)))
+```
+
+- [ ] **Step 2: Run test to verify failures**
+
+Run: `emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: FAIL — spine-add-book doesn't accept `:shelf` yet.
+
+- [ ] **Step 3: Update `spine-add-book` implementation**
+
+Replace the function body. Key changes:
+- Accept `:shelf` keyword arg (required)
+- Remove TODO state setting
+- Remove user tag/category setting (tags can be added back later if needed)
+- Insert under the matching shelf headline instead of at file end
+- Remove `#+TODO:` file initialization
+
+```elisp
+(defun spine-add-book (&rest args)
+ "Add a new book to shelf `:shelf' in `spine-org-file'.
+Keyword arguments: :title :author :shelf :format :isbn :cover
+ :rec_by :rec_note :date_added :initial_note
+:shelf is required. Signals an error if the shelf doesn't exist."
+ (let ((title (plist-get args :title))
+ (shelf (plist-get args :shelf))
+ (author (plist-get args :author))
+ (format (plist-get args :format))
+ (isbn (plist-get args :isbn))
+ (cover (plist-get args :cover))
+ (rec-by (plist-get args :rec_by))
+ (rec-note (plist-get args :rec_note))
+ (date-added (plist-get args :date_added))
+ (initial-note (plist-get args :initial_note)))
+ (unless title
+ (error "spine-add-book: title is required"))
+ (unless shelf
+ (error "spine-add-book: :shelf is required"))
+ (let ((org-file (expand-file-name spine-org-file)))
+ (unless (file-exists-p org-file)
+ (error "spine-add-book: file %s does not exist; create shelves first"
+ org-file))
+ (with-current-buffer (find-file-noselect org-file)
+ (unwind-protect
+ (progn
+ ;; Find the shelf headline
+ (goto-char (point-min))
+ (let ((shelf-found nil))
+ (while (and (not shelf-found)
+ (re-search-forward
+ (format "^\\*+[ \t]+%s[ \t]*$" (regexp-quote shelf))
+ nil t))
+ (when (= (org-current-level) 1)
+ (setq shelf-found t)))
+ (unless shelf-found
+ (error "spine-add-book: shelf \"%s\" not found" shelf)))
+ ;; Go to end of shelf section
+ (org-end-of-subtree)
+ ;; Insert new book headline
+ (org-insert-heading nil t t)
+ (insert title)
+ ;; Set properties (only non-empty)
+ (when (and author (> (length author) 0))
+ (org-set-property "AUTHOR" author))
+ (when (and format (> (length format) 0))
+ (org-set-property "FORMAT" format))
+ (when (and isbn (> (length isbn) 0))
+ (org-set-property "ISBN" isbn))
+ (when (and cover (> (length cover) 0))
+ (org-set-property "COVER" cover))
+ (when (and rec-by (> (length rec-by) 0))
+ (org-set-property "REC_BY" rec-by))
+ (when (and rec-note (> (length rec-note) 0))
+ (org-set-property "REC_NOTE" rec-note))
+ (when (and date-added (> (length date-added) 0))
+ (org-set-property "ADDED" (format "[%s]" date-added)))
+ ;; Set ID
+ (org-set-property "ID"
+ (format "%04x-%s"
+ (random 65535)
+ (substring (sha1 (concat title (number-to-string (random)))) 0 4)))
+ ;; Set last modified
+ (org-set-property "LAST_MODIFIED" (format-time-string "[%Y-%m-%d]"))
+ ;; Add initial note
+ (when (and initial-note (> (length initial-note) 0))
+ (let ((date (if (and date-added (> (length date-added) 0))
+ date-added
+ (format-time-string "%Y-%m-%d"))))
+ (goto-char (org-entry-end-position))
+ (insert (format "\n- [%s] %s\n" date initial-note))))
+ (save-buffer)
+ t)
+ (kill-buffer))))))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `emacs --batch -l test/spine-add-book-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add spine.el test/spine-add-book-test.el
+git commit -m "feat: adapt spine-add-book for shelf-based nesting"
+```
+
+---
+
+### Task 4: Remove `spine-set-status` and update edit tests
+
+**Files:**
+- Modify: `spine.el`
+- Modify: `test/spine-edit-test.el`
+
+- [ ] **Step 1: Remove `spine-set-status` function**
+
+Delete the entire `spine-set-status` function (lines 296-319 approximately). The HTTP handler `httpd/edit` will be updated in a later task.
+
+- [ ] **Step 2: Update spine-edit-test.el**
+
+Remove tests that use `spine-set-status`:
+- `spine-set-status-want-to-reading` — delete
+- `spine-set-status-reading-to-read` — delete
+- `spine-set-status-missing-id` — delete
+- `spine-edit-model-minibuffer-reading` — rewrite for new minibuffer (add note / set rating only)
+- `spine-edit-model-minibuffer-nil-when-no-selection` — keep as `spine-edit-model-no-actions-when-no-selection`
+
+Update `spine-edit-test--setup` to create a shelf structure:
+```elisp
+(defun spine-edit-test--setup ()
+ "Create a test Org file with one shelf and one book."
+ (with-temp-file spine-org-file
+ (insert "* Fiction\n** WANT Edit Test Book\n:PROPERTIES:\n:AUTHOR: Test Author\n:ID: test-0001\n:END:\n"))
+ (spine-books) ; force file load, verify it works
+ t)
+```
+
+Keep note and rating tests (`spine-add-note-appends`, `spine-add-note-default-date`, `spine-set-rating-sets-rating`) — they should still work with the new setup.
+
+Update minibuffer tests:
+```elisp
+(ert-deftest spine-edit-model-actions-include-note-and-rating ()
+ "Selected book shows Add note and Set rating actions."
+ (spine-edit-test--cleanup)
+ (unwind-protect
+ (progn
+ (spine-edit-test--setup)
+ (let* ((books (spine-books))
+ (book (car books))
+ (id (plist-get book :id))
+ (model (spine-index-model books nil id))
+ (minibuffer (ht-get model "minibuffer")))
+ (should minibuffer)
+ (let ((actions (ht-get minibuffer "actions")))
+ (should (= (length actions) 2))
+ (should (equal (ht-get (nth 0 actions) "label") "Add note"))
+ (should (equal (ht-get (nth 1 actions) "label") "Set rating")))))
+ (spine-edit-test--cleanup)))
+
+(ert-deftest spine-edit-model-no-actions-when-no-selection ()
+ "No minibuffer actions when no book is selected."
+ (spine-edit-test--cleanup)
+ (unwind-protect
+ (progn
+ (spine-edit-test--setup)
+ (let* ((books (spine-books))
+ (model (spine-index-model books nil nil)))
+ (should-not (ht-get model "minibuffer"))))
+ (spine-edit-test--cleanup)))
+```
+
+- [ ] **Step 3: Run edit tests**
+
+Run: `emacs --batch -l test/spine-edit-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: PASS for remaining tests.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add spine.el test/spine-edit-test.el
+git commit -m "feat: remove spine-set-status, update edit tests"
+```
+
+---
+
+### Task 5: Update `spine-add-form-model`
+
+**Files:**
+- Modify: `spine.el`
+
+- [ ] **Step 1: Replace category/authors logic with shelf list**
+
+Update `spine-add-form-model` to use `spine-shelves` for the shelf dropdown:
+
+```elisp
+(defun spine-add-form-model (books)
+ "Build view model for templates/add.mustache from current BOOKS."
+ (ht ("app_title" "spine")
+ ("shelves" (or (spine-shelves) '()))))
+```
+
+This replaces the existing implementation that computed `existing_authors` and `existing_categories`.
+
+- [ ] **Step 2: Run books test to verify no regression**
+
+Run: `emacs --batch -l test/spine-books-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: PASS.
+
+---
+
+### Task 6: Rewrite `spine-index-model` for shelf grouping
+
+**Files:**
+- Modify: `spine.el`
+- Modify: `test/spine-index-model-test.el`
+
+- [ ] **Step 1: Rewrite index-model tests**
+
+Replace all tests in `spine-index-model-test.el`:
+
+```elisp
+;;; spine-index-model-test.el — ERT tests for spine-index-model with shelf grouping
+
+(require 'ert)
+(require 'cl-lib)
+
+(setq spine-skip-server-start t)
+(setq spine-org-file (expand-file-name "sample-books.org"
+ (file-name-directory
+ (directory-file-name
+ (file-name-directory load-file-name)))))
+(load-file (expand-file-name "../spine.el"
+ (file-name-directory load-file-name)))
+
+(defconst spine-index-model-test--books (spine-books)
+ "Books fixture loaded once from sample-books.org.")
+
+(ert-deftest spine-index-model-groups-by-shelf ()
+ "Index model groups books by :shelf."
+ (let* ((model (spine-index-model spine-index-model-test--books))
+ (groups (ht-get model "groups")))
+ (should groups)
+ (should (= (length groups) 3)) ;; Fiction, Science Fiction, Non-Fiction
+ (let ((labels (mapcar (lambda (g) (ht-get g "label")) groups)))
+ (should (member "Fiction" labels))
+ (should (member "Science Fiction" labels))
+ (should (member "Non-Fiction" labels))))) ;; even empty shelves appear
+
+(ert-deftest spine-index-model-shelf-counts ()
+ "Each shelf group has the correct number of books."
+ (let* ((model (spine-index-model spine-index-model-test--books))
+ (groups (ht-get model "groups")))
+ (dolist (g groups)
+ (let ((label (ht-get g "label"))
+ (count (ht-get g "count")))
+ (cond
+ ((equal label "Fiction")
+ (should (= count 4))
+ (should (= (length (ht-get g "books")) 4)))
+ ((equal label "Science Fiction")
+ (should (= count 1))
+ (should (= (length (ht-get g "books")) 1)))
+ ((equal label "Non-Fiction")
+ (should (= count 1))
+ (should (= (length (ht-get g "books")) 1))))))))
+
+(ert-deftest spine-index-model-filters-by-shelf ()
+ "filter=shelf-name shows only that shelf's books."
+ (let* ((model (spine-index-model spine-index-model-test--books "Fiction"))
+ (groups (ht-get model "groups")))
+ (should (= (length groups) 1))
+ (should (equal (ht-get (car groups) "label") "Fiction"))))
+
+(ert-deftest spine-index-model-includes-all-shelves-in-nav ()
+ "Shelf nav includes all shelves, including empties."
+ (let* ((model (spine-index-model spine-index-model-test--books))
+ (shelf-nav (ht-get model "shelf_nav")))
+ (should (= (length shelf-nav) 3))
+ (should (member "Non-Fiction" (mapcar (lambda (n) (ht-get n "name")) shelf-nav)))))
+
+(ert-deftest spine-index-model-current-shelf ()
+ "current_shelf is set when filter is active."
+ (let* ((model (spine-index-model spine-index-model-test--books "Science Fiction")))
+ (should (equal (ht-get model "current_shelf") "Science Fiction"))))
+
+(ert-deftest spine-index-model-no-current-shelf-without-filter ()
+ "current_shelf is nil when no filter."
+ (let* ((model (spine-index-model spine-index-model-test--books)))
+ (should-not (ht-get model "current_shelf"))))
+
+(ert-deftest spine-index-model-total-and-shelf-count ()
+ "Titlebar shows correct totals."
+ (let* ((model (spine-index-model spine-index-model-test--books)))
+ (should (= (ht-get model "total_books") 6))
+ (should (= (ht-get model "shelf_count") 3))))
+```
+
+- [ ] **Step 2: Run test to verify failures**
+
+Run: `emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: FAIL — spine-index-model still groups by status.
+
+- [ ] **Step 3: Rewrite `spine-index-model`**
+
+Replace the entire function:
+
+```elisp
+(defun spine-index-model (books &optional shelf-filter selected-id)
+ "Build the view model ht for templates/index.mustache from BOOKS.
+SHELF-Filter limits to one shelf by name (string). nil = all shelves.
+SELECTED-ID expands the matching book's detail section."
+ (let* ((format-icons
+ '(("hardcover" . "ti-book")
+ ("ebook" . "ti-device-tablet")
+ ("audiobook" . "ti-headphones")))
+ (grouped (make-hash-table :test 'equal))
+ (all-shelves (or (spine-shelves) '()))
+ (total (length books)))
+ ;; Group books by shelf
+ (dolist (book books)
+ (let ((shelf (or (plist-get book :shelf) "Uncategorized")))
+ (push book (gethash shelf grouped))))
+ (let ((groups nil)
+ (shelf-nav nil))
+ ;; Build shelf nav from all shelves (including empty)
+ (dolist (shelf all-shelves)
+ (push (ht ("name" shelf)
+ ("href" (format "/index?shelf=%s" (url-encode-url shelf)))
+ ("current" (equal shelf shelf-filter)))
+ shelf-nav))
+ (setq shelf-nav (nreverse shelf-nav))
+ ;; Build shelf groups (only those with books, or empty if filtered)
+ (dolist (shelf all-shelves)
+ (let ((shelf-books (nreverse (gethash shelf grouped))))
+ (when (or (null shelf-filter) (equal shelf shelf-filter))
+ (let ((book-models nil))
+ (dolist (book shelf-books)
+ (let* ((id (plist-get book :id))
+ (selected (equal id selected-id))
+ (fmt (plist-get book :format))
+ (rating (plist-get book :rating))
+ (notes (plist-get book :notes))
+ (model
+ (ht
+ ("format_icon"
+ (or (cdr (assoc fmt format-icons)) ""))
+ ("title" (plist-get book :title))
+ ("author" (or (plist-get book :author) ""))
+ ("tags_inline"
+ (let ((tags (plist-get book :tags)))
+ (when tags
+ (mapconcat (lambda (tag) (concat ":" tag ":"))
+ tags " "))))
+ ("rec_via" (plist-get book :rec_by))
+ ("rating_display"
+ (when (and rating (> (length rating) 0))
+ (make-string (string-to-number rating) ?\u2605)))
+ ("selected" selected)
+ ("detail"
+ (when selected
+ (ht
+ ("meta"
+ (let ((added (plist-get book :added))
+ (fmt (plist-get book :format)))
+ (cond
+ ((and added fmt)
+ (format "started %s · %s" added fmt))
+ (added (format "started %s" added))
+ (fmt (format "format: %s" fmt))
+ (t ""))))
+ ("notes"
+ (when notes
+ (cl-loop for (date text) in notes
+ collect (ht
+ ("date" (format "[%s]" date))
+ ("text" text)))))))))))
+ (push model book-models)))
+ (push (ht ("label" shelf)
+ ("count" (length shelf-books))
+ ("books" (nreverse book-models)))
+ groups)))))
+ (ht ("app_title" "spine")
+ ("total_books" total)
+ ("shelf_count" (length all-shelves))
+ ("current_shelf" shelf-filter)
+ ("shelf_nav" shelf-nav)
+ ("groups" (nreverse groups))
+ ("minibuffer"
+ (when selected-id
+ (let* ((books-by-id (make-hash-table :test 'equal)))
+ (dolist (b books)
+ (puthash (plist-get b :id) b books-by-id))
+ (let ((book (gethash selected-id books-by-id)))
+ (when book
+ (ht ("actions"
+ (list
+ (ht ("command" "note") ("label" "Add note")
+ ("href" (format "/edit?id=%s&action=note" selected-id)))
+ (ht ("command" "rating") ("label" "Set rating")
+ ("href" (format "/edit?id=%s&action=rating" selected-id)))))))))))
+ ))))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `emacs --batch -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: PASS for all index-model tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add spine.el test/spine-index-model-test.el
+git commit -m "feat: rewrite spine-index-model for shelf grouping"
+```
+
+---
+
+### Task 7: Update HTTP handlers
+
+**Files:**
+- Modify: `spine.el`
+
+- [ ] **Step 1: Update `defservlet index`**
+
+Update the index handler to support `?shelf=` filter instead of `?filter=`:
+
+```elisp
+(defservlet index text/html (path query request)
+ (let* ((books (spine-books))
+ (shelf-filter (cadr (assoc "shelf" query))))
+ (if books
+ (insert (spine-render "index.mustache"
+ (spine-index-model books shelf-filter (cadr (assoc "id" query)))))
+ (insert (spine-render-empty-state)))))
+```
+
+- [ ] **Step 2: Update `httpd/add`**
+
+Update to pass `:shelf` from the form:
+
+```elisp
+(defun httpd/add (proc uri-path query request)
+ "Handle /add: GET shows form, POST creates a book."
+ (let ((method (caar request)))
+ (if (equal method "POST")
+ (progn
+ (spine-add-book
+ :title (cadr (assoc "title" query))
+ :author (cadr (assoc "author" query))
+ :shelf (cadr (assoc "shelf" query))
+ :format (cadr (assoc "format" query))
+ :isbn (cadr (assoc "isbn" query))
+ :cover (cadr (assoc "cover" query))
+ :rec_by (cadr (assoc "rec_by" query))
+ :rec_note (cadr (assoc "rec_note" query))
+ :date_added (cadr (assoc "date_added" query))
+ :initial_note (cadr (assoc "initial_note" query)))
+ (httpd-redirect proc "/index" 303))
+ (httpd-with-buffer proc "text/html"
+ (let* ((books (spine-books))
+ (model (if books
+ (spine-add-form-model books)
+ (ht ("app_title" "spine" "shelves" '())))))
+ (insert (spine-render "add.mustache" model)))))))
+```
+
+- [ ] **Step 3: Update `httpd/edit`**
+
+Remove status action cases. Only `note` and `rating` remain:
+
+```elisp
+(defun httpd/edit (proc uri-path query request)
+ "Handle /edit: GET shows prompt page, POST processes actions.
+Query params:
+ id - book ID
+ action - note | rating
+ text - note text (for note action)
+ date - optional date string (for note action)
+ value - rating value (for rating action)"
+ (let ((method (caar request))
+ (id (cadr (assoc "id" query)))
+ (action (cadr (assoc "action" query)))
+ (value (cadr (assoc "value" query)))
+ (text (cadr (assoc "text" query)))
+ (date (cadr (assoc "date" query))))
+ (if (equal method "POST")
+ (progn
+ (cond
+ ((equal action "note")
+ (spine-add-note id text date))
+ ((equal action "rating")
+ (spine-set-rating id value)))
+ (httpd-redirect proc (format "/index?id=%s" id) 303))
+ ;; GET: show prompt page for actions needing input
+ (if (and id (equal action "note"))
+ (let* ((books (spine-books))
+ (book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal))
+ (title (if book (plist-get book :title) "Unknown")))
+ (httpd-with-buffer proc "text/html"
+ (insert (format
+"
+
+
+
+Spine — note
+
+
+
+
+
+
+%s
+
+
+
+
+"
+ title id (format-time-string "%Y-%m-%d"))))))
+ ;; Unknown action or missing id: redirect to index
+ (httpd-redirect proc "/index" 302)))))
+```
+
+- [ ] **Step 4: Run all tests to check for regressions**
+
+Run: `emacs --batch -l test/spine-books-test.el -l test/spine-add-book-test.el -l test/spine-edit-test.el -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: PASS for all.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add spine.el
+git commit -m "feat: update HTTP handlers for shelf model"
+```
+
+---
+
+### Task 8: Update `index.mustache` template
+
+**Files:**
+- Modify: `templates/index.mustache`
+
+- [ ] **Step 1: Replace filter bar with shelf nav**
+
+Replace:
+```mustache
+ {{#current_filter}}
+
+ {{/current_filter}}
+ {{^current_filter}}
+
+ {{/current_filter}}
+```
+
+With:
+```mustache
+
+
All
+ {{#shelf_nav}}
+ ·
+
{{name}}
+ {{/shelf_nav}}
+
+```
+
+- [ ] **Step 2: Update title bar**
+
+Replace `{{total_books}} books · {{reading_count}} reading` with `{{total_books}} books · {{shelf_count}} shelves`
+
+- [ ] **Step 3: Remove status pill from book rows**
+
+In the row div, remove:
+```mustache
+ {{status_label}}
+```
+
+- [ ] **Step 4: Remove `summary_groups` section**
+
+Remove the entire summary_groups block.
+
+- [ ] **Step 5: Run a quick start test to verify no template errors**
+
+Run: `emacs --batch --eval "(setq spine-skip-server-start t)" -l spine.el`
+Expected: loads without error.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add templates/index.mustache
+git commit -m "feat: adapt index template for shelf nav and grouping"
+```
+
+---
+
+### Task 9: Update `add.mustache` template
+
+**Files:**
+- Modify: `templates/add.mustache`
+
+- [ ] **Step 1: Replace category field with shelf dropdown**
+
+Replace:
+```mustache
+ Category
+ {{#existing_categories}}{{/existing_categories}}
+```
+
+With:
+```mustache
+ Shelf *
+ — select —
+ {{#shelves}}{{.}} {{/shelves}}
+
+```
+
+Remove the `authors-list` datalist too — we removed it from the model:
+```mustache
+ {{#existing_authors}}{{/existing_authors}}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add templates/add.mustache
+git commit -m "feat: adapt add template with shelf selector"
+```
+
+---
+
+### Task 10: Final integration check
+
+**Files:**
+- All
+
+- [ ] **Step 1: Run the full test suite**
+
+Run: `emacs --batch -l test/spine-books-test.el -l test/spine-add-book-test.el -l test/spine-edit-test.el -l test/spine-index-model-test.el --eval "(ert-run-tests-batch-and-exit)"`
+Expected: All PASS.
+
+- [ ] **Step 2: Spot-check the full flow loads**
+
+Run: `emacs --batch --eval "(setq spine-skip-server-start nil)" -l spine.el --eval "(sleep-for 1)" --eval "(httpd-stop)"`
+Expected: Spine starts and stops without error.
diff --git a/docs/specs/2026-06-22-book-detail-view-design.md b/docs/specs/2026-06-22-book-detail-view-design.md
new file mode 100644
index 0000000..348c552
--- /dev/null
+++ b/docs/specs/2026-06-22-book-detail-view-design.md
@@ -0,0 +1,109 @@
+# Book Detail View Design
+
+**Date:** 2026-06-22
+**Status:** Draft
+
+## Summary
+
+Add a dedicated book detail page (`/book?id=X`) following the journal-style mockup (Direction B). The page shows full book metadata, format badges, reading log, note composer, and recommendation section. Uses Pico CSS and Tabler icons, matching the add-book template style.
+
+## Route
+
+- **`GET /book?id=`** — renders the full detail page
+- No POST handling (editing flows go through existing `/edit`)
+- Book not found → redirect to `/index`
+
+## Model: `spine-book-model`
+
+Builds an `ht` (hashtable) view model from one book plist.
+
+### Fields
+
+| Field | Type | Source | Notes |
+|---|---|---|---|
+| `cover_icon` | string | hardcoded `"ti-book"` | Placeholder cover icon |
+| `title` | string | `:title` minus TODO prefix | Strip leading `WANT `, `READING `, `READ ` |
+| `author` | string | `:author` | Empty string when nil |
+| `status_class` | string | TODO prefix → lowercase | `"want"` / `"reading"` / `"read"` / `""` |
+| `status_label` | string | TODO prefix → capitalized | `"Want"` / `"Reading"` / `"Read"` / `""` |
+| `meta` | string | format + added date | e.g. `"started 20 Feb · audiobook"`. Omits date if not present, format if not present |
+| `progress_label` | string | — | Empty string; reserved for future progress tracking |
+| `formats` | array | all 3 known formats | `[{icon, label, active}]`. Active matches `:format` property |
+| `notes` | array | `:notes` | `[{date, text}]`. Empty array when no notes |
+| `recommendation` | object or nil | `:rec_by` + `:rec_note` | `{initials, by, note}`. nil when no rec_by |
+
+### Status extraction
+
+Book headlines have TODO keyword prefixes (`WANT`, `READING`, `READ`) as part of their title text. The model function:
+
+1. Strips the prefix from the title for display
+2. Maps recognized prefixes to status classes
+
+### Format list
+
+Hardcoded list of three known formats:
+- `hardcover` → `ti-book` → "Hardcover"
+- `ebook` → `ti-device-tablet` → "eBook"
+- `audiobook` → `ti-headphones` → "Audiobook"
+
+The book's `:format` property determines which is `active: true`; the others are `active: false`.
+
+### Recommendation
+
+Generated from `:rec_by` (name) and `:rec_note` (text). No date stored currently.
+
+`initials` derived from the name: uppercase first letter of each space-separated part. e.g. "Priya" → "P", "John Doe" → "JD".
+
+## Template: `templates/book.mustache`
+
+Based on `spine-mockup-b-journal.mustache` with adaptations:
+- Pico CSS framework
+- Tabler icons for icons
+- Progress-related text omitted from the composer placeholder (says "Add a note…" without percentage)
+- Back link at top to return to index with this book selected (`/index?id=X`)
+- No recommendation date field (not stored in data model)
+- Conditional rendering of recommendation section
+
+## Handler: `httpd/book`
+
+Registered as `httpd/book` matching the existing add/edit handler pattern:
+
+```elisp
+(defun httpd/book (proc uri-path query request)
+ "Show the journal-style detail page for a single book."
+ (let* ((id (cadr (assoc "id" query)))
+ (books (spine-books))
+ (book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
+ (if book
+ (insert (spine-render "book.mustache" (spine-book-model book)))
+ (httpd-redirect proc "/index" 302))))
+```
+| Action | File | Description |
+|---|---|---|
+| New | `templates/book.mustache` | Pico CSS + Tabler detail page |
+| Modify | `spine.el` | Add `spine-book-model`, `httpd/book` handler, title link in index model |
+| New | `test/spine-book-model-test.el` | Tests for `spine-book-model` |
+| Modify | `test/spine-index-model-test.el` | Tests for title link in index model |
+
+## Edge cases
+
+- **No TODO prefix on headline** → title shown as-is, status empty
+- **Unknown TODO prefix** (not WANT/READING/READ) → title shown with prefix, status empty
+- **Book not found** → redirect to `/index`
+- **No format property** → no format active in the selector
+- **No author** → empty string
+- **No notes** → empty reading log section
+- **No recommendation** → section not rendered (optional block)
+
+## Testing
+
+Test file `test/spine-book-model-test.el` covers:
+- Title stripped of TODO prefix
+- Status class derived correctly for WANT/READING/READ
+- Meta string from format + added date
+- Format list with correct active flag
+- Notes mapping
+- Recommendation with initials
+- No recommendation when rec_by absent
+- Book with no TODO prefix (handles gracefully)
+- Book with no format or date (meta is empty)
diff --git a/docs/specs/2026-06-22-shelves-data-model-design.md b/docs/specs/2026-06-22-shelves-data-model-design.md
new file mode 100644
index 0000000..6f4950a
--- /dev/null
+++ b/docs/specs/2026-06-22-shelves-data-model-design.md
@@ -0,0 +1,133 @@
+# Shelves data model
+
+Replace the TODO-state-based book model with shelf-based organization.
+Shelves are top-level Org headings; books are nested underneath them.
+
+## Motivation
+
+Books-as-top-level-headings with TODO states (WANT/READING/READ/ABANDONED)
+made the file's structure reflect lifecycle status rather than organizing
+concepts. The user wants arbitrary grouping ("shelves") as the primary
+organizing axis. TODO states are removed — logbook entries (already present)
+record when reading happened.
+
+## Org data model
+
+```org
+#+TITLE: Spine
+
+* Fiction
+** Piranesi
+:PROPERTIES:
+:AUTHOR: Susanna Clarke
+:FORMAT: ebook
+:REC_BY: Alex
+:REC_NOTE: Le Guin at her most human
+:RATING:
+:ADDED: [2026-04-01]
+:LAST_MODIFIED: [2026-04-01]
+:ID: 5e8d-pir
+:END:
+::LOGBOOK:
+- State "READ" from "WANT" [2026-01-10]
+::END:
+- [2025-11-22] The pronoun game is a genuinely fresh take on identity.
+
+* Non-Fiction
+** The Checklist Manifesto
+...
+```
+
+- Shelves are level-1 headlines (`*`)
+- Books are level-2 headlines (`**`)
+- Books keep their TODO keyword syntax for `org-mode` compatibility,
+ but the app ignores it entirely
+- The `#+TODO:` line is removed — no TODO states
+- Empty shelves (a level-1 headline with no children) are valid and
+ appear in the UI
+- LOGBOOK drawers remain for historical state-change records but are
+ not surfaced by the UI
+
+## Key functions
+
+### `spine-shelves` (new)
+
+Returns an ordered list of shelf names (strings), one per level-1 headline
+that is not a COMMENT or archive target. Empty shelves are included.
+
+### `spine-books` (modified)
+
+Walks level-1 headlines as shelves. For each shelf, walks level-2 children
+as books. Returns flat plist list; each book plist gains a `:shelf` key
+with the parent headline text. No status/TODO filtering.
+
+Places that previously checked Todo state (e.g. `spine-set-status`) are
+removed — the only remaining mutable fields are notes, rating, format.
+
+### `spine-index-model` (modified)
+
+Groups books by `:shelf` instead of `:status`. Accepts an optional
+`shelf` filter (instead of `read`/`want`/`all`) to show one shelf.
+Includes shelf nav items for all shelves (even empty ones, from
+`spine-shelves`).
+
+Removes:
+- Summary groups (READ/ABANDONED links)
+- Concise view truncation
+- Status-related book model fields (status_class, status_label)
+
+### `spine-add-book` (modified)
+
+Takes required `:shelf` argument. Inserts the new book heading under
+the matching level-1 shelf headline instead of at file end. If the
+shelf doesn't exist, signals an error (shelves are created manually).
+
+Removes:
+- `org-todo "WANT"` call
+- `#+TODO:` file initialization
+- Category field logic (replaced by shelf)
+
+### Functions removed
+
+- `spine-set-status` — no status to change. Logbook records history.
+- All HTTP endpoints and UI actions that invoke it.
+
+## UI changes
+
+### Index view (`index.mustache`)
+
+- **Titlebar**: `N books · M shelves`
+- **Filter bar**: replaced with shelf nav — links for each shelf name
+ plus "All" (default). Active shelf highlighted.
+- **Groups**: one section per shelf. Each shelf shows its books in the
+ existing row format, minus the status pill.
+- **Empty shelves**: shown as a group with the shelf name and "(empty)"
+ subtext. No expandable books.
+- **Minibuffer** (when a book is selected): simplified to two actions —
+ **Add note**, **Set rating**. Same for every book.
+
+### Add form (`add.mustache`)
+
+- "Category" free-text field replaced with "Shelf" `` dropdown
+ populated from `spine-shelves`.
+- Required. No datalist — only existing shelves.
+- All other fields unchanged.
+
+## Sample file
+
+`sample-books.org` is restructured to the new shelf layout with
+shelves like "Fiction", "Non-Fiction", "Science Fiction".
+
+## Test changes
+
+- `spine-books-parses-sample`: loads restructured sample, asserts flat
+ book list with `:shelf` keys.
+- `spine-books-first-book-has-all-fields`: adds `:shelf` assertion.
+- `spine-books-missing-properties-are-nil`: adds `:shelf` check.
+- `spine-index-model-*`: rewritten — tests shelf grouping, shelf
+ filtering, empty-shelf inclusion. Status-based tests removed.
+- `spine-add-book-*`: tests require `:shelf` arg. Removes status check.
+ Tests that books land under the correct shelf heading.
+- `spine-edit-*`: keeps note/rating tests. Removes status-change tests.
+ Updates model-assertion tests for new minibuffer shape.
+- New `spine-shelves` tests: parses shelf names, includes empty shelves.
diff --git a/spine.el b/spine.el
index 9d24bf7..82ff5b0 100644
--- a/spine.el
+++ b/spine.el
@@ -63,15 +63,92 @@ Returns the rendered string."
(ht ("app_title" "spine")
("shelves" (or (spine-shelves) '()))))
+(defconst spine--format-defs
+ '(("hardcover" "ti-book" "Hardcover")
+ ("ebook" "ti-device-tablet" "eBook")
+ ("audiobook" "ti-headphones" "Audiobook"))
+ "Alist of (format-key icon label) for book format display.")
+
+(defun spine--format-date (org-date-str)
+ "Format Org date \"[2026-02-20]\" to \"20 Feb\"."
+ (when (and org-date-str (> (length org-date-str) 0))
+ (let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
+ (condition-case nil
+ (format-time-string "%e %b" (date-to-time clean))
+ (error org-date-str)))))
+
+(defun spine--parse-title-status (title)
+ "Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE.
+TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
+ (let ((case-fold-search nil))
+ (if (string-match
+ "\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
+ title)
+ (let ((kw (match-string 1 title))
+ (rest (match-string 2 title)))
+ (list rest (downcase kw) (capitalize (downcase kw))))
+ (list title "" ""))))
+
+(defun spine-book-model (book)
+ "Build view model `ht` for a single BOOK plist, for templates/book.mustache."
+ (let* ((id (plist-get book :id))
+ (title (or (plist-get book :title) ""))
+ (author (or (plist-get book :author) ""))
+ (fmt (plist-get book :format))
+ (added (plist-get book :added))
+ (notes (plist-get book :notes))
+ (rec-by (plist-get book :rec_by))
+ (rec-note (plist-get book :rec_note))
+ (title-info (spine--parse-title-status title))
+ (clean-title (nth 0 title-info))
+ (status-class (nth 1 title-info))
+ (status-label (nth 2 title-info))
+ (meta (cond
+ ((and added fmt)
+ (format "started %s \302\267 %s" (spine--format-date added) fmt))
+ (added (format "started %s" (spine--format-date added)))
+ (fmt (format "format: %s" fmt))
+ (t "")))
+ (formats (mapcar
+ (lambda (fd)
+ (ht ("icon" (nth 1 fd))
+ ("label" (nth 2 fd))
+ ("active" (and fmt (equal (car fd) fmt)))))
+ spine--format-defs))
+ (notes-model
+ (mapcar (lambda (n)
+ (ht ("date" (car n))
+ ("text" (cadr n))))
+ notes))
+ (recommendation
+ (when (and rec-by (> (length rec-by) 0))
+ (let ((initials
+ (mapconcat
+ (lambda (s)
+ (when (> (length s) 0)
+ (upcase (substring s 0 1))))
+ (split-string rec-by " +" t) "")))
+ (ht ("initials" initials)
+ ("by" rec-by)
+ ("note" (or rec-note "")))))))
+ (ht ("cover_icon" "ti-book")
+ ("id" id)
+ ("title" clean-title)
+ ("author" author)
+ ("status_class" status-class)
+ ("status_label" status-label)
+ ("meta" meta)
+ ("progress_label" "")
+ ("formats" formats)
+ ("notes" notes-model)
+ ("recommendation" recommendation)
+ ("back_url" (format "/index?id=%s" id)))))
+
(defun spine-index-model (books &optional shelf-filter selected-id)
"Build the view model ht for templates/index.mustache from BOOKS.
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
SELECTED-ID expands the matching book's detail section."
- (let* ((format-icons
- '(("hardcover" . "ti-book")
- ("ebook" . "ti-device-tablet")
- ("audiobook" . "ti-headphones")))
- (grouped (make-hash-table :test 'equal))
+ (let* ((grouped (make-hash-table :test 'equal))
(all-shelves (or (spine-shelves) '()))
(total (length books)))
;; Group books by shelf
@@ -101,7 +178,7 @@ SELECTED-ID expands the matching book's detail section."
(model
(ht
("format_icon"
- (or (cdr (assoc fmt format-icons)) ""))
+ (or (nth 1 (assoc fmt spine--format-defs)) ""))
("title" (plist-get book :title))
("author" (or (plist-get book :author) ""))
("tags_inline"
@@ -114,6 +191,7 @@ SELECTED-ID expands the matching book's detail section."
(when (and rating (> (length rating) 0))
(make-string (string-to-number rating) ?\u2605)))
("selected" selected)
+ ("book_url" (format "/book?id=%s" id))
("detail"
(when selected
(ht
@@ -489,6 +567,16 @@ Query params:
title id (format-time-string "%Y-%m-%d"))))))
;; Unknown action or missing id: redirect to index
(httpd-redirect proc "/index" 302))))
+
+(defun httpd/book (proc uri-path query request)
+ "Show the journal-style detail page for a single book."
+ (let* ((id (cadr (assoc "id" query)))
+ (books (spine-books))
+ (book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
+ (if book
+ (httpd-with-buffer proc "text/html"
+ (insert (spine-render "book.mustache" (spine-book-model book))))
+ (httpd-redirect proc "/index" 302))))
(defun httpd/ (proc uri-path query request)
"Redirect root to /index."
(httpd-redirect proc "/index" 302))
diff --git a/templates/book.mustache b/templates/book.mustache
new file mode 100644
index 0000000..80a4933
--- /dev/null
+++ b/templates/book.mustache
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+Spine — {{title}}
+
+
+
+
+
+
+
+ ← Back to shelf
+
+
+
+
+
+ {{title}}
+ {{author}}
+
+ {{#status_class}}
+
{{status_label}}
+ {{/status_class}}
+ {{#meta}}
+
{{meta}}
+ {{/meta}}
+
+
+
+
+ {{#formats}}
+ {{label}}
+ {{/formats}}
+
+
+ {{#notes}}
+ Reading log
+
+ {{#notes}}
+
+ {{/notes}}
+
+ {{/notes}}
+
+
+
+ Log note
+
+
+
+
+ {{#recommendation}}
+ On your radar
+
+
{{initials}}
+
{{by}} "{{note}}"
+
+ {{/recommendation}}
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/index.mustache b/templates/index.mustache
index 93b045a..feddca3 100644
--- a/templates/index.mustache
+++ b/templates/index.mustache
@@ -78,7 +78,8 @@
{{#books}}
-
{{title}} · {{author}}
+
{{title}}
+
· {{author}}
{{#tags_inline}}
{{tags_inline}} {{/tags_inline}}
{{#rec_via}}
via {{rec_via}} {{/rec_via}}
{{#rating_display}}
{{rating_display}} {{/rating_display}}
diff --git a/test/spine-book-model-test.el b/test/spine-book-model-test.el
new file mode 100644
index 0000000..8bedb99
--- /dev/null
+++ b/test/spine-book-model-test.el
@@ -0,0 +1,125 @@
+;;; spine-book-model-test.el — ERT tests for spine-book-model
+
+(require 'ert)
+(require 'cl-lib)
+
+(setq spine-skip-server-start t)
+(setq spine-org-file (expand-file-name "sample-books.org"
+ (file-name-directory
+ (directory-file-name
+ (file-name-directory load-file-name)))))
+(load-file (expand-file-name "../spine.el"
+ (file-name-directory load-file-name)))
+
+(defconst spine-book-model-test--uow
+ (cl-find "8c1e-uow" (spine-books)
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)
+ "Use of Weapons fixture.")
+
+(defconst spine-book-model-test--aj
+ (cl-find "3a1b-aj" (spine-books)
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)
+ "Ancillary Justice fixture.")
+
+(defconst spine-book-model-test--pir
+ (cl-find "5e8d-pir" (spine-books)
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)
+ "Piranesi fixture.")
+
+(ert-deftest spine-book-model-title-strips-todo ()
+ "Title has TODO prefix stripped."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (equal (ht-get model "title") "Use of Weapons"))))
+
+(ert-deftest spine-book-model-status-reading ()
+ "READING book has status_class reading."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (equal (ht-get model "status_class") "reading"))
+ (should (equal (ht-get model "status_label") "Reading"))))
+
+(ert-deftest spine-book-model-status-read ()
+ "READ book has status_class read."
+ (let ((model (spine-book-model spine-book-model-test--aj)))
+ (should (equal (ht-get model "status_class") "read"))
+ (should (equal (ht-get model "status_label") "Read"))))
+
+(ert-deftest spine-book-model-status-want ()
+ "WANT book has status_class want."
+ (let ((model (spine-book-model spine-book-model-test--pir)))
+ (should (equal (ht-get model "status_class") "want"))
+ (should (equal (ht-get model "status_label") "Want"))))
+
+(ert-deftest spine-book-model-author ()
+ "Author is passed through."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (equal (ht-get model "author") "Iain M. Banks"))))
+
+(ert-deftest spine-book-model-meta-with-format-and-date ()
+ "Meta includes format and date when both present."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (string-match "started.*audiobook" (ht-get model "meta")))))
+
+(ert-deftest spine-book-model-format-active ()
+ "Format list has correct active flag."
+ (let* ((model (spine-book-model spine-book-model-test--uow))
+ (formats (ht-get model "formats")))
+ (should (= (length formats) 3))
+ (should (equal (ht-get (nth 0 formats) "label") "Hardcover"))
+ (should-not (ht-get (nth 0 formats) "active"))
+ (should (equal (ht-get (nth 1 formats) "label") "eBook"))
+ (should-not (ht-get (nth 1 formats) "active"))
+ (should (equal (ht-get (nth 2 formats) "label") "Audiobook"))
+ (should (ht-get (nth 2 formats) "active"))))
+
+(ert-deftest spine-book-model-notes ()
+ "Notes are mapped correctly."
+ (let* ((model (spine-book-model spine-book-model-test--uow))
+ (notes (ht-get model "notes")))
+ (should (= (length notes) 3))
+ (should (equal (ht-get (nth 0 notes) "date") "2026-03-02"))
+ (should (equal (ht-get (nth 0 notes) "text")
+ "The two-track structure is doing something I can't name yet."))))
+
+(ert-deftest spine-book-model-recommendation ()
+ "Recommendation includes initials, by, and note."
+ (let* ((model (spine-book-model spine-book-model-test--uow))
+ (rec (ht-get model "recommendation")))
+ (should rec)
+ (should (equal (ht-get rec "initials") "P"))
+ (should (equal (ht-get rec "by") "Priya"))
+ (should (string-match "Player of Games" (ht-get rec "note")))))
+
+(ert-deftest spine-book-model-no-recommendation ()
+ "No recommendation when rec_by absent."
+ (let ((model (spine-book-model spine-book-model-test--aj)))
+ (should-not (ht-get model "recommendation"))))
+
+(ert-deftest spine-book-model-no-todo-prefix ()
+ "Book with no TODO prefix shows title as-is and empty status."
+ (let* ((books (spine-books))
+ (tcm (cl-find "b2c1-tcm" books
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)))
+ (should tcm)
+ (let ((model (spine-book-model tcm)))
+ (should (equal (ht-get model "title") "The Checklist Manifesto"))
+ (should (equal (ht-get model "status_class") ""))
+ (should (equal (ht-get model "status_label") "")))))
+
+(ert-deftest spine-book-model-has-date-no-format ()
+ "Has date but no format → meta shows started date only."
+ (let* ((books (spine-books))
+ (tcm (cl-find "b2c1-tcm" books
+ :key (lambda (b) (plist-get b :id))
+ :test #'equal)))
+ (should tcm)
+ (let ((model (spine-book-model tcm)))
+ (should (equal (ht-get model "meta") "started 15 Jun")))))
+
+(ert-deftest spine-book-model-back-url ()
+ "back_url links to index with this book's id."
+ (let ((model (spine-book-model spine-book-model-test--uow)))
+ (should (equal (ht-get model "back_url") "/index?id=8c1e-uow"))))