Compare commits

...

16 Commits

Author SHA1 Message Date
jbrechtel 7faf0cd8ac feat: render index via Mustache template with spine-index-model 2026-06-21 07:38:32 -04:00
jbrechtel 67e41ad110 docs: add mustache templating implementation plan 2026-06-21 07:37:29 -04:00
jbrechtel c7971eb297 docs: add mustache templating design spec 2026-06-21 07:31:18 -04:00
jbrechtel a6e2c99b62 feat: restyle index page with Pico CSS and emoji glyphs 2026-06-20 17:55:33 -04:00
jbrechtel 112ea4df07 feat: restyle empty-state page with Pico CSS 2026-06-20 17:55:04 -04:00
jbrechtel bbb40efd1a docs: add Pico CSS styling implementation plan 2026-06-20 17:53:47 -04:00
jbrechtel 1ad0631f5b docs: add Pico CSS styling design spec 2026-06-20 17:51:23 -04:00
jbrechtel 24f24620d3 fix: route / to /index, use cadr for query alist, rename lambda t param 2026-06-20 17:02:57 -04:00
jbrechtel 794ef3ffc9 feat: wire up / index handler replacing /hello 2026-06-20 16:59:19 -04:00
jbrechtel 94c310a0b3 feat: add spine-render-index and empty-state view functions 2026-06-20 16:59:06 -04:00
jbrechtel cc74ca5cfa test: add ERT tests for spine-books, fix cl-return-from issue 2026-06-20 16:58:45 -04:00
jbrechtel f25dffa183 feat: add spine-books model function with empty-prop nil handling 2026-06-20 16:57:57 -04:00
jbrechtel a93efd3617 feat: add spine-skip-server-start guard for testing 2026-06-20 16:56:46 -04:00
jbrechtel 362a83ca42 test: add sample-books.org test data 2026-06-20 16:56:25 -04:00
jbrechtel d476a4eacc docs: add data model and index page implementation plan 2026-06-20 16:54:53 -04:00
jbrechtel de64d71936 docs: add data model and index page design spec 2026-06-20 16:52:31 -04:00
15 changed files with 2854 additions and 9 deletions
+9
View File
@@ -0,0 +1,9 @@
# Overview
Spine is a tool for managing your personal library. Spine keeps track of books you want to read, are reading and have read. Spine lets you track notes about the books and why you were interested in them in the first place.
# Design
Spine is primarily a web application where you view and enter details about books. To ensure your data is fully portable and independent of Spine itself, your book data is backed by a single Org file ( http://orgmode.org/ )
The application itself is written in Elisp and runs inside of Emacs but serves up its interface as HTML derived from your `spine.org` file.
@@ -0,0 +1,238 @@
# Mustache Template Rendering — Implementation Plan
**Goal:** Render the index page via Mustache (`spine-render`) using the mockup-A template, replacing inline HTML generation.
**Architecture:** Move `spine-mockup-a-agenda.mustache``templates/index.mustache`. Add `spine-index-model` function to build the view model `ht`. Update the index handler. Remove old `spine-render-index`.
**Tech Stack:** Emacs Lisp, `mustache`, `ht`.
---
## File structure
| File | Responsibility |
|------|---------------|
| `templates/index.mustache` | Index page Mustache template (moved from root) |
| `spine.el` | Add `spine-index-model`, update handler, remove `spine-render-index` |
---
### Task 1: Move template and add spine-index-model
**Files:**
- Create: `templates/index.mustache`
- Modify: `spine.el`
- Delete: `spine-mockup-a-agenda.mustache`
- [ ] **Step 1: Move the template**
```bash
mv spine-mockup-a-agenda.mustache templates/index.mustache
```
- [ ] **Step 2: Add `spine-index-model` function to `spine.el`**
Insert after `spine--h` (after the HTML escaping helper), before `spine-render-index`:
```elisp
(defun spine-index-model (books &optional selected-id)
"Build the view model ht for templates/index.mustache from BOOKS.
SELECTED-ID expands the matching book's detail section."
(let* ((status-labels '(("WANT" . "on deck")
("READING" . "reading")
("READ" . "read")
("ABANDONED" . "abandoned")))
(format-icons '(("hardcover" . "ti-book")
("ebook" . "ti-device-tablet")
("audiobook" . "ti-headphones")))
(order '("WANT" "READING" "READ" "ABANDONED"))
(grouped (make-hash-table :test 'equal))
(total (length books))
(reading-count 0))
;; Count reading books
(dolist (book books)
(when (equal "READING" (plist-get book :status))
(cl-incf reading-count)))
;; Group books by status
(dolist (book books)
(let ((status (or (plist-get book :status) "WANT")))
(push book (gethash status grouped))))
;; Build groups list for template
(let ((groups nil))
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(let ((book-models nil))
(dolist (book group-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))
(status (or (plist-get book :status) "WANT"))
(model
(ht
("format_icon"
(or (cdr (assoc fmt format-icons)) ""))
("status_class" (downcase status))
("status_label" (downcase status))
("title" (plist-get book :title))
("author" (or (plist-get book :author) ""))
("tags_inline"
(let ((tags (plist-get book :tags)))
(when tags
(mapconcat (lambda (t) (concat ":" t ":"))
tags " "))))
("rec_via" (plist-get book :rec_by))
("rating_display"
(when (and rating (> (length rating) 0))
(let ((n (string-to-number rating)))
(make-string n ?★))))
("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" (cdr (assoc status status-labels)))
("count" (length group-books))
("books" (nreverse book-models)))
groups)))))
(ht ("app_title" "spine")
("total_books" total)
("reading_count" reading-count)
("groups" (nreverse groups))))))
```
- [ ] **Step 3: Remove old `spine-render-index` function**
Delete the `spine-render-index` function entirely.
- [ ] **Step 4: Update the index handler**
Replace the handler's call from `spine-render-index` to `spine-render` + `spine-index-model`:
```elisp
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
```
- [ ] **Step 5: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly.
- [ ] **Step 6: Verify template renders**
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el \
--eval "(progn (message \"HTML length: %d\" (length (spine-render \"index.mustache\" (spine-index-model (spine-books) nil)))))"
```
Expected: non-zero length, no errors.
- [ ] **Step 7: Commit**
```bash
git add templates/index.mustache spine.el
git rm spine-mockup-a-agenda.mustache
git commit -m "feat: render index via Mustache template with spine-index-model"
```
---
### Task 2: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Start server with sample data**
```bash
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
pkill -f "emacs --quick" 2>/dev/null
sleep 1
emacs --quick --eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" --load spine.el &
sleep 5
```
- [ ] **Step 2: Verify Pico + Tabler CDN links**
```bash
curl -s http://localhost:8080/index | grep -c "picocss"
curl -s http://localhost:8080/index | grep -c "tabler"
```
Expected: `1` and `1`.
- [ ] **Step 3: Verify groups and content**
```bash
curl -s http://localhost:8080/index | grep -oP 'on deck|reading|read'
```
Expected: `on deck`, `reading`, `read` (no `abandoned`).
- [ ] **Step 4: Verify Tabler icon glyphs**
```bash
curl -s http://localhost:8080/index | grep -oP 'ti-book|ti-headphones|ti-device-tablet'
```
- [ ] **Step 5: Verify expanded detail**
```bash
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline"
```
Expected: `3` (three reading notes).
- [ ] **Step 6: Kill server**
```bash
kill %1 2>/dev/null
```
---
### Task 3: ERT tests and final verification
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run ERT tests**
```bash
emacs --quick --batch --directory "$(pwd)" --load test/spine-books-test.el -f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 2: Git status**
```bash
git status --short
git log --oneline -3
```
+365
View File
@@ -0,0 +1,365 @@
# Pico CSS Styling — Implementation Plan
**Goal:** Rewrite `spine-render-index` and `spine-render-empty-state` to emit Pico CSS-styled HTML via CDN, replacing the mockup-A `<div>` structure with Pico-native semantic elements plus a small inline `<style>` block.
**Architecture:** View-layer only change. No model or handler changes. The two view functions in `spine.el` are rewritten. Pico CSS v2 loads from `jsdelivr.net` CDN. Custom styles live in a `<style>` block in `<head>`.
**Tech Stack:** Emacs Lisp (view functions only), Pico CSS v2 (CDN).
---
## File structure
| File | Responsibility |
|------|---------------|
| `spine.el` | Rewrite `spine-render-index` and `spine-render-empty-state` |
No new files. No package changes.
---
### Task 1: Rewrite spine-render-empty-state
**Files:**
- Modify: `spine.el``spine-render-empty-state`
- [ ] **Step 1: Replace `spine-render-empty-state` with Pico-styled version**
The function `spine-render-empty-state` is currently at approximately line 220 in `spine.el`. Replace the existing definition with:
```elisp
(defun spine-render-empty-state ()
"Render a page indicating no books file was found."
(concat "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
"<title>Spine</title>\n"
"<link rel=\"stylesheet\""
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n"
"</head>\n<body>\n"
"<main class=\"container\">\n"
"<article>\n"
"<header><strong>spine</strong></header>\n"
"<p>No books yet.</p>\n"
"</article>\n"
"</main>\n</body>\n</html>"))
```
- [ ] **Step 2: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly, no errors.
- [ ] **Step 3: Commit**
```bash
git add spine.el
git commit -m "feat: restyle empty-state page with Pico CSS"
```
---
### Task 2: Rewrite spine-render-index with Pico HTML
**Files:**
- Modify: `spine.el``spine-render-index`
- [ ] **Step 1: Replace `spine-render-index` with Pico-styled version**
The function `spine-render-index` is currently at approximately line 123 in `spine.el`. Replace the entire function definition with:
```elisp
(defun spine-render-index (books &optional selected-id)
"Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID."
(let ((order '("WANT" "READING" "READ" "ABANDONED"))
(grouped (make-hash-table :test 'equal))
(total 0)
(reading-count 0))
;; Count totals
(dolist (book books)
(cl-incf total)
(when (equal "READING" (plist-get book :status))
(cl-incf reading-count)))
;; Group books by status
(dolist (book books)
(let* ((status (or (plist-get book :status) "WANT")))
(push book (gethash status grouped))))
;; Render
(with-temp-buffer
(insert "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
(insert "<meta charset=\"utf-8\">\n")
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
(insert "<title>Spine</title>\n")
(insert "<link rel=\"stylesheet\""
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n")
(insert "<style>\n")
(insert ".spine-row{display:flex;align-items:center;gap:0.75rem;")
(insert "padding:0.4rem 0;border-bottom:1px solid var(--pico-muted-border-color)}\n")
(insert ".spine-row:last-child{border-bottom:none}\n")
(insert ".spine-selected{background:var(--pico-secondary-background)}\n")
(insert ".spine-glyph{width:1.5rem;text-align:center;flex-shrink:0}\n")
(insert ".spine-title{flex:1;min-width:0}\n")
(insert ".spine-trail{font-size:0.85em;color:var(--pico-muted-color)}\n")
(insert ".spine-tags{font-size:0.85em;color:var(--pico-muted-color)}\n")
(insert ".spine-detail{margin:0 0 0 2rem;border-left:2px solid var(--pico-primary)}\n")
(insert ".spine-detail p{margin-bottom:0.3rem}\n")
(insert ".spine-detail footer{margin-top:0.5rem}\n")
(insert "mark.want{background:var(--pico-mark-background);"
"color:var(--pico-mark-color)}\n")
(insert "mark.reading{background:#faeeda;color:#854f0b}\n")
(insert "mark.read{background:#e1f5ee;color:#085041}\n")
(insert "mark.abandoned{background:var(--pico-muted-color);"
"color:var(--pico-background-color)}\n")
(insert "article{margin-bottom:0}\n")
(insert "article header{padding-bottom:0.5rem;margin-bottom:0.5rem}\n")
(insert "</style>\n")
(insert "</head>\n<body>\n")
(insert "<main class=\"container\">\n")
(insert "<article>\n")
(insert (format "<header><strong>spine</strong>"
"<small>%d books · %d reading</small></header>\n"
total reading-count))
;; Each group in order
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(insert (format "<hgroup><h2>%s</h2><small>%d</small></hgroup>\n"
status (length group-books)))
(dolist (book group-books)
(let ((selected (equal (plist-get book :id) selected-id)))
(insert (format "<div class=\"spine-row%s\">\n"
(if selected " spine-selected" "")))
;; Format glyph (emoji)
(let ((fmt (plist-get book :format)))
(insert (format "<span class=\"spine-glyph\">%s</span>\n"
(cond ((equal fmt "hardcover") "📖")
((equal fmt "ebook") "📱")
((equal fmt "audiobook") "🎧")
(t "")))))
;; Title + author
(insert "<span class=\"spine-title\">")
(insert (spine--h (plist-get book :title)))
(let ((author (plist-get book :author)))
(when author
(insert (format " <small>%s</small>"
(spine--h author)))))
(insert "</span>\n")
;; Tags
(let ((tags (plist-get book :tags)))
(when tags
(insert (format "<small class=\"spine-tags\">%s</small>\n"
(mapconcat (lambda (tag) (concat ":" tag ":"))
tags " ")))))
;; Recommendation trail
(let ((rec-by (plist-get book :rec_by))
(rec-note (plist-get book :rec_note)))
(when (and rec-by (> (length rec-by) 0))
(insert
(format "<span class=\"spine-trail\">%s%s</span>\n"
(spine--h rec-by)
(if (and rec-note (> (length rec-note) 0))
(concat ": " (spine--h rec-note))
"")))))
;; Status pill
(let ((status (or (plist-get book :status) "WANT")))
(insert (format "<mark class=\"%s\">%s</mark>\n"
(downcase status) (downcase status))))
;; Expanded detail
(when selected
(insert "<blockquote class=\"spine-detail\">\n")
(dolist (note (plist-get book :notes))
(insert (format "<p><small>[%s]</small> %s</p>\n"
(spine--h (car note))
(spine--h (cadr note)))))
(let ((parts nil))
(let ((rating (plist-get book :rating)))
(when (and rating (> (length rating) 0))
(push (format "Rating: %s/5" (spine--h rating)) parts)))
(let ((isbn (plist-get book :isbn)))
(when (and isbn (> (length isbn) 0))
(push (format "ISBN: %s" (spine--h isbn)) parts)))
(let ((fmt (plist-get book :format)))
(when (and fmt (> (length fmt) 0))
(push (format "Format: %s" (spine--h fmt)) parts)))
(let ((added (plist-get book :added)))
(when (and added (> (length added) 0))
(push (format "Added: %s" (spine--h added)) parts)))
(when parts
(insert (format "<footer>%s</footer>\n"
(mapconcat #'identity (nreverse parts) " · ")))))
(insert "</blockquote>\n"))
(insert "</div>\n"))))))
(insert "</article>\n")
(insert "</main>\n</body>\n</html>\n")
(buffer-string))))
```
- [ ] **Step 2: Verify batch load**
```bash
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el
```
Expected: exits cleanly, no errors.
- [ ] **Step 3: Verify rendering against sample data**
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el \
--eval "(progn (message \"render-index length: %d\" (length (spine-render-index (spine-books) nil))))"
```
Expected: non-zero length, no errors.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: restyle index page with Pico CSS and emoji glyphs"
```
---
### Task 3: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Kill existing server and start fresh**
```bash
emacsclient --socket-name=spine --eval '(kill-emacs)' 2>/dev/null
pkill -f "emacs --quick" 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
```
- [ ] **Step 2: Verify Pico CSS is linked**
```bash
curl -s http://localhost:8080/index | grep -c "picocss"
```
Expected: `1` (the CDN `<link>` is present).
- [ ] **Step 3: Verify no Tabler Icons reference**
```bash
curl -s http://localhost:8080/index | grep -c "tabler"
```
Expected: `0`.
- [ ] **Step 4: Verify groups in correct order**
```bash
curl -s http://localhost:8080/index | grep -oP '<h2>[^<]+'
```
Expected:
```
WANT
READING
READ
```
No ABANDONED.
- [ ] **Step 5: Verify emoji glyphs and status pills**
```bash
curl -s http://localhost:8080/index | grep -E "📖|📱|🎧" | wc -l
```
Expected: `3` (three format glyphs present — Piranesi has no format).
```bash
curl -s http://localhost:8080/index | grep -oP '<mark class="[^"]+">[^<]+'
```
Expected: `want`, `reading`, `read` pills visible.
- [ ] **Step 6: Verify expanded detail**
```bash
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -oP 'spine-detail'
```
Expected: `spine-detail` present (2 occurrences — opening and closing).
```bash
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline\|spine-detail"
```
Expected: detail section renders.
- [ ] **Step 7: Verify recommendation trail**
```bash
curl -s http://localhost:8080/index | grep "Priya"
```
Expected: output contains `Priya: If you liked Player of Games, this one will wreck you`.
- [ ] **Step 8: Verify empty state**
```bash
kill $EMACS_PID 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"/tmp/nonexistent-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
curl -s http://localhost:8080/index | grep -c "No books yet"
```
Expected: `1`.
- [ ] **Step 9: Stop server**
```bash
kill $EMACS_PID 2>/dev/null
```
---
### Task 4: Run ERT tests and final verification
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run full ERT test suite**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 2: Verify git status**
```bash
git status --short
```
Expected: no modified tracked files (only pre-existing untracked files).
- [ ] **Step 3: Review commit log**
```bash
git log --oneline -4
```
Expected: 2-3 new commits on top of previous work.
@@ -0,0 +1,737 @@
# Spine Data Model & Index Page — Implementation Plan
**Goal:** Build the model layer (`spine-books`) and index page view so the app reads a real `spine.org` file and displays books grouped by status.
**Architecture:** Model/view split. `spine-books` parses `spine.org` into plists via `org-element` + `org-entry-get`. `spine-render-index` takes those plists and an optional selected ID, returns HTML matching mockup A's class structure. A new `/` handler replaces the `/hello` smoke test. A `spine-skip-server-start` guard lets tests load `spine.el` without starting the HTTP server.
**Tech Stack:** Emacs Lisp, `org-element` (built-in), `org-entry-get` (built-in), `ert` (built-in testing).
---
## File structure
| File | Responsibility |
|------|---------------|
| `sample-books.org` | Test data: 5 books across all statuses |
| `spine.el` | Modified: new functions, handler, skip-server-start guard |
| `test/spine-books-test.el` | ERT tests for `spine-books` |
---
### Task 1: Create sample test data
**Files:**
- Create: `sample-books.org`
- [ ] **Step 1: Write `sample-books.org`**
Five books covering all four statuses, plus an entry with minimal properties.
```org
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
* 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]
: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]
:ID: 7f3c-dun
:END:
* WANT Piranesi
:PROPERTIES:
:AUTHOR: Susanna Clarke
:ISBN:
:COVER:
:FORMAT:
:REC_BY:
:REC_NOTE:
:RATING:
:ADDED: [2026-06-01]
:ID: 5e8d-pir
:END:
* READING Use of Weapons
: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]
: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.
* READ Ancillary Justice
:PROPERTIES:
:AUTHOR: Ann Leckie
:ISBN: 978-0316246620
:COVER: covers/ancillary.jpg
:FORMAT: ebook
:REC_BY:
:REC_NOTE:
:RATING: 5
:ADDED: [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.
```
This file exercises:
- **WANT books**: 3 (Left Hand, Dune, Piranesi) — tests group header, count, different property densities
- **READING books**: 1 (Use of Weapons) — tests notes extraction, recommendation trail
- **READ books**: 1 (Ancillary Justice) — tests rating, LOGBOOK
- **ABANDONED books**: 0 — tests empty group omission
- **Minimal properties**: Piranesi has no ISBN, cover, format, recommendation, or notes
- [ ] **Step 2: Verify file exists**
Run: `cat sample-books.org | head -3`
Expected: `#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)`
- [ ] **Step 3: Commit**
```bash
git add sample-books.org
git commit -m "test: add sample-books.org test data"
```
---
### Task 2: Add server-start guard to spine.el
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `spine-skip-server-start` variable and wrap server start**
Insert after the template rendering section (after line 44, before `;; --- handlers`):
```elisp
(defvar spine-skip-server-start nil
"When non-nil, do not start the HTTP server.
Set by test harnesses that only need the model functions.")
```
Change the server start block (lines 5358) to:
```elisp
;; --- start ------------------------------------------------------------
(unless spine-skip-server-start
(setq httpd-host spine-host)
(setq httpd-port spine-port)
(httpd-start)
(message "Spine listening on http://%s:%d"
(or spine-host "localhost") spine-port))
```
- [ ] **Step 2: Verify batch load with guard**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly, no "Spine listening" message, no errors.
- [ ] **Step 3: Verify server still works normally without guard**
Run the normal `scripts/run` flow and curl `/hello``/hello` still exists at this point since the handler hasn't been replaced yet.
```bash
scripts/run 8080
sleep 3
curl -s http://localhost:8080/hello
scripts/stop
```
Expected: HTML containing `<h1>Hello from Emacs.</h1>`.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-skip-server-start guard for testing"
```
---
### Task 3: Implement spine-books model function
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `cl-lib` require**
At the top of `spine.el`, after `(require 'ht)`, add:
```elisp
(require 'cl-lib)
```
`cl-lib` is built into Emacs and provides `cl-return-from`.
- [ ] **Step 2: Add `spine-org-file` config variable**
After `spine-port` and `spine-host` defvars (after line 27), add:
```elisp
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
```
- [ ] **Step 3: Add `spine--h` HTML escaping helper**
After `spine-render`, add:
```elisp
(defun spine--h (text)
"HTML-escape TEXT."
(when text
(let ((s text))
(setq s (replace-regexp-in-string "&" "&amp;" s))
(setq s (replace-regexp-in-string "<" "&lt;" s))
(setq s (replace-regexp-in-string ">" "&gt;" s))
(setq s (replace-regexp-in-string "\"" "&quot;" s))
s)))
```
- [ ] **Step 4: Add `spine--extract-notes` helper**
After `spine--h`, add:
```elisp
(defun spine--extract-notes (hl-begin hl-end)
"Extract reading notes from headline body between HL-BEGIN and HL-END.
Returns list of (date-string text-string) pairs."
(let ((notes nil))
(save-excursion
(goto-char hl-begin)
;; Skip past PROPERTIES and LOGBOOK drawers
(while (re-search-forward
"^[ \t]*:\\(PROPERTIES\\|LOGBOOK\\):" hl-end t)
(re-search-forward "^[ \t]*:END:" hl-end t))
;; Collect note lines: "- [YYYY-MM-DD] text"
(while (re-search-forward
"^- \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\] \\(.*\\)"
hl-end t)
(push (list (match-string 1) (match-string 2)) notes)))
(nreverse notes)))
```
- [ ] **Step 5: Add `spine-books` function**
After `spine--extract-notes`, add:
```elisp
(defun spine-books ()
"Return a list of plists, one per top-level headline in `spine-org-file'.
Returns nil if the file is missing or cannot be parsed."
(unless (file-exists-p spine-org-file)
(message "spine-books: file not found: %s" spine-org-file)
(cl-return-from spine-books 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)
(when (= (org-element-property :level hl) 1)
(let ((pos (org-element-property :begin hl)))
(push (list :id (save-excursion
(goto-char pos)
(org-entry-get nil "ID"))
:title (org-element-property :title hl)
:status (org-element-property :todo-keyword hl)
:author (save-excursion
(goto-char pos)
(org-entry-get nil "AUTHOR"))
:isbn (save-excursion
(goto-char pos)
(org-entry-get nil "ISBN"))
:cover (save-excursion
(goto-char pos)
(org-entry-get nil "COVER"))
:format (save-excursion
(goto-char pos)
(org-entry-get nil "FORMAT"))
:rec_by (save-excursion
(goto-char pos)
(org-entry-get nil "REC_BY"))
:rec_note (save-excursion
(goto-char pos)
(org-entry-get nil "REC_NOTE"))
:rating (save-excursion
(goto-char pos)
(org-entry-get nil "RATING"))
:added (save-excursion
(goto-char pos)
(org-entry-get nil "ADDED"))
:tags (org-element-property :tags hl)
:notes (spine--extract-notes
(org-element-property :begin hl)
(org-element-property :end hl)))
books)))))
(nreverse books)))
(error
(message "spine-books: failed to parse %s: %s"
spine-org-file (error-message-string err))
nil)))
```
- [ ] **Step 6: Verify batch load still works**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly.
- [ ] **Step 7: Verify spine-books returns data from sample file**
Run:
```bash
emacs --quick --batch \
--eval "(setq spine-skip-server-start t spine-org-file \"sample-books.org\")" \
--load spine.el \
--eval "(progn (let ((books (spine-books))) (message \"Found %d books\" (length books))))"
```
Expected: `Found 5 books` in output.
- [ ] **Step 8: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-books model function"
```
---
### Task 4: Test spine-books with ERT
**Files:**
- Create: `test/spine-books-test.el`
- [ ] **Step 1: Create `test/` directory**
```bash
mkdir -p test
```
- [ ] **Step 2: Write `test/spine-books-test.el`**
```elisp
;;; spine-books-test.el — ERT tests for spine-books
(require 'ert)
(require 'cl-lib)
;; Load spine.el without starting the server
(setq spine-skip-server-start t)
(setq spine-org-file (expand-file-name "sample-books.org"
(file-name-directory load-file-name)))
(load-file (expand-file-name "spine.el"
(file-name-directory load-file-name)))
(ert-deftest spine-books-parses-sample ()
"spine-books returns correct plists for sample-books.org."
(let ((books (spine-books)))
(should books)
(should (= (length books) 5))))
(ert-deftest spine-books-first-book-has-all-fields ()
"Use of Weapons entry has all expected fields."
(let* ((books (spine-books))
(uow (cl-find "8c1e-uow" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should uow)
(should (equal (plist-get uow :title) "Use of Weapons"))
(should (equal (plist-get uow :status) "READING"))
(should (equal (plist-get uow :author) "Iain M. Banks"))
(should (equal (plist-get uow :isbn) "978-0316029193"))
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
(should (equal (plist-get uow :format) "audiobook"))
(should (equal (plist-get uow :rec_by) "Priya"))
(should (equal (plist-get uow :rec_note) "If you liked Player of Games, this one will wreck you"))
(should (equal (plist-get uow :tags) '("scifi" "literary")))
(should (equal (plist-get uow :notes)
'(("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."))))))
(ert-deftest spine-books-missing-properties-are-nil ()
"Piranesi entry has nil for non-present properties."
(let* ((books (spine-books))
(pir (cl-find "5e8d-pir" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should pir)
(should (equal (plist-get pir :title) "Piranesi"))
(should (equal (plist-get pir :status) "WANT"))
(should (equal (plist-get pir :author) "Susanna Clarke"))
;; ISBN, cover, format, rec_by, rec_note should all be nil or ""
(should-not (plist-get pir :isbn))
(should-not (plist-get pir :cover))
(should-not (plist-get pir :format))
(should-not (plist-get pir :rec_by))
(should-not (plist-get pir :rec_note))
;; Notes should be empty
(should-not (plist-get pir :notes))))
(ert-deftest spine-books-read-book-has-rating ()
"Ancillary Justice has rating 5."
(let* ((books (spine-books))
(aj (cl-find "3a1b-aj" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should aj)
(should (equal (plist-get aj :status) "READ"))
(should (equal (plist-get aj :rating) "5"))
(should (= (length (plist-get aj :notes)) 3))))
(ert-deftest spine-books-empty-file-returns-empty-list ()
"Empty Org file returns empty list, not nil."
(let* ((tmp-file (make-temp-file "spine-test-" nil ".org"))
(spine-org-file tmp-file))
(unwind-protect
(let ((books (spine-books)))
(should (listp books))
(should (= (length books) 0)))
(delete-file tmp-file))))
(ert-deftest spine-books-missing-file-returns-nil ()
"Non-existent file returns nil."
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
(should-not (spine-books))))
```
- [ ] **Step 3: Run the tests**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 4: Commit**
```bash
git add test/spine-books-test.el
git commit -m "test: add ERT tests for spine-books"
```
---
### Task 5: Implement view functions
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Add `spine-render-index` function**
After `spine-books`, add:
```elisp
(defun spine-render-index (books &optional selected-id)
"Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID."
(let ((order '("WANT" "READING" "READ" "ABANDONED"))
(grouped (make-hash-table :test 'equal)))
;; Group books by status
(dolist (book books)
(let* ((status (or (plist-get book :status) "WANT")))
(push book (gethash status grouped))))
;; Render
(with-temp-buffer
(insert "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
(insert "<meta charset=\"utf-8\">\n")
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
(insert "<title>Spine</title>\n</head>\n<body>\n")
(insert "<div class=\"frame\">\n")
(insert "<div class=\"titlebar\">Spine</div>\n")
;; Each group in order
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(insert (format "<div class=\"group\">%s (%d)</div>\n"
status (length group-books)))
(dolist (book group-books)
(let ((selected (equal (plist-get book :id) selected-id)))
(insert (format "<div class=\"row%s\">\n"
(if selected " selected" "")))
;; Format glyph
(let ((fmt (plist-get book :format)))
(insert (format "<span class=\"glyph\">%s</span>\n"
(cond ((equal fmt "hardcover") "[H]")
((equal fmt "ebook") "[E]")
((equal fmt "audiobook") "[A]")
(t "")))))
;; Title + author
(insert "<span class=\"title\">")
(insert (spine--h (plist-get book :title)))
(let ((author (plist-get book :author)))
(when author
(insert (format " <span class=\"muted\">%s</span>"
(spine--h author)))))
(insert "</span>\n")
;; Tags
(let ((tags (plist-get book :tags)))
(when tags
(insert (format "<span class=\"tags\">%s</span>\n"
(mapconcat (lambda (t) (concat ":" t ":"))
tags " ")))))
;; Recommendation trail
(let ((rec-by (plist-get book :rec_by))
(rec-note (plist-get book :rec_note)))
(when (and rec-by (> (length rec-by) 0))
(insert
(format "<span class=\"trail\">%s%s</span>\n"
(spine--h rec-by)
(if (and rec-note (> (length rec-note) 0))
(concat ": " (spine--h rec-note))
"")))))
;; Status pill
(let ((status (or (plist-get book :status) "WANT")))
(insert (format "<span class=\"pill %s\">%s</span>\n"
(downcase status) status)))
;; Expanded detail
(when selected
(insert "<div class=\"detail\">\n")
(dolist (note (plist-get book :notes))
(insert (format
"<div class=\"logline\"><span class=\"logdate\">[%s]</span> %s</div>\n"
(spine--h (car note))
(spine--h (cadr note)))))
(let ((rating (plist-get book :rating)))
(when (and rating (> (length rating) 0))
(insert (format "<div>Rating: %s/5</div>\n"
(spine--h rating)))))
(let ((isbn (plist-get book :isbn)))
(when (and isbn (> (length isbn) 0))
(insert (format "<div>ISBN: %s</div>\n"
(spine--h isbn)))))
(let ((fmt (plist-get book :format)))
(when (and fmt (> (length fmt) 0))
(insert (format "<div>Format: %s</div>\n"
(spine--h fmt)))))
(let ((added (plist-get book :added)))
(when (and added (> (length added) 0))
(insert (format "<div>Added: %s</div>\n"
(spine--h added)))))
(insert "</div>\n"))
(insert "</div>\n"))))))
(insert "</div>\n</body>\n</html>\n")
(buffer-string))))
```
- [ ] **Step 2: Add `spine-render-empty-state` function**
After `spine-render-index`, add:
```elisp
(defun spine-render-empty-state ()
"Render a page indicating no books file was found."
(concat "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
"<title>Spine</title>\n</head>\n<body>\n"
"<div class=\"frame\">\n"
"<div class=\"titlebar\">Spine</div>\n"
"<div class=\"row\">No books yet.</div>\n"
"</div>\n</body>\n</html>\n"))
```
- [ ] **Step 3: Verify batch load**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly, no errors.
- [ ] **Step 4: Commit**
```bash
git add spine.el
git commit -m "feat: add spine-render-index and empty-state view functions"
```
---
### Task 6: Wire up the index handler
**Files:**
- Modify: `spine.el`
- [ ] **Step 1: Replace `/hello` handler with `/` index handler**
Replace the `hello` handler (lines 4851) with:
```elisp
(defservlet index text/html ()
(let ((books (spine-books)))
(if books
(insert (spine-render-index books
(ht-get (ht-from-query-string (httpd-query-string)) "id")))
(insert (spine-render-empty-state)))))
```
- [ ] **Step 2: Verify batch load**
Run: `emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el`
Expected: exits cleanly.
- [ ] **Step 3: Commit**
```bash
git add spine.el
git commit -m "feat: wire up / index handler replacing /hello"
```
---
### Task 7: Smoke test end-to-end
**Files:**
- No changes — verification only.
- [ ] **Step 1: Start server with sample data**
```bash
emacs --quick \
--eval "(setq spine-org-file \"$(pwd)/sample-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
```
- [ ] **Step 2: Verify index page returns HTML with real data**
```bash
curl -s http://localhost:8080/ | head -20
```
Expected: HTML document with:
- `<title>Spine</title>`
- `<div class="group">WANT (3)</div>`
- `<div class="group">READING (1)</div>`
- `<div class="group">READ (1)</div>`
- No `ABANDONED` group (zero books)
- "Use of Weapons" visible in output
- Recommendation trail "Priya: If you liked..."
- [ ] **Step 3: Verify expanded detail via ?id=**
```bash
curl -s "http://localhost:8080/?id=8c1e-uow" | grep -A5 "detail"
```
Expected: expanded detail section with reading notes for Use of Weapons:
- `[2026-03-02] The two-track structure...`
- `[2026-03-07] Zakalwe's competence...`
- `[2026-03-09] The chapter numbering. Oh.`
- [ ] **Step 4: Verify bogus ?id= does not crash**
```bash
curl -s "http://localhost:8080/?id=nonexistent"
```
Expected: normal index page, no expanded detail, HTTP 200.
- [ ] **Step 5: Verify empty state when file is missing**
```bash
kill $EMACS_PID 2>/dev/null
sleep 1
emacs --quick \
--eval "(setq spine-org-file \"/tmp/nonexistent-books.org\")" \
--load spine.el &
EMACS_PID=$!
sleep 5
curl -s http://localhost:8080/
```
Expected: HTML containing `No books yet.`
- [ ] **Step 6: Stop server**
```bash
kill $EMACS_PID 2>/dev/null
```
- [ ] **Step 7: Verify with scripts/run**
```bash
# Override spine-org-file via the run script (pass it as an extra eval)
SPINE_ORG="$(pwd)/sample-books.org"
emacs --quick --daemon=spine \
--eval "(setq spine-org-file \"$SPINE_ORG\")" \
--load spine.el
sleep 3
curl -s http://localhost:8080/ | grep -c "WANT"
# Expected: 1 (the group header "WANT (3)")
emacsclient --socket-name=spine --eval '(kill-emacs)'
```
The output should contain `1`.
---
### Task 8: Final verification and cleanup
**Files:**
- No changes — verification only.
- [ ] **Step 1: Run full ERT test suite**
```bash
emacs --quick --batch \
--directory "$(pwd)" \
--load test/spine-books-test.el \
-f ert-run-tests-batch-and-exit
```
Expected: all 6 tests pass.
- [ ] **Step 2: Verify git status is clean**
```bash
git status
```
Expected: `nothing to commit, working tree clean`
- [ ] **Step 3: Review commit log**
```bash
git log --oneline
```
Expected: ~7 commits covering sample data, server guard, model, tests, view, handler.
@@ -0,0 +1,137 @@
# Spine — Mustache Template Rendering
Replaces inline HTML generation with Mustache template rendering via
`spine-render`. The mockup-A mustache template becomes the canonical
index page template. A view-model builder function transforms `spine-books`
plists into the template's expected context shape.
## Architecture
```
spine.org ──→ [spine-books] ──→ book plists
[spine-index-model] ──→ view model (ht)
templates/index.mustache ───────────┼──→ [spine-render] ──→ HTML
```
## Template
`spine-mockup-a-agenda.mustache` moves to `templates/index.mustache`.
The template already contains:
- Pico CSS v2.1.1 CDN link
- Tabler Icons CDN link
- All custom CSS in `<style>` block (agenda layout, pills, detail, dark mode)
- Mustache sections: `{{#groups}}`, `{{#books}}`, `{{#detail}}`, `{{#notes}}`, `{{#minibuffer}}`
- `minibuffer` section kept in the template but not populated (empty context → section omitted)
## View model
A new function `spine-index-model` takes the book plists and an optional
selected-id, returns an `ht` hash table shaped for the template:
```elisp
(spine-index-model books &optional selected-id) ht
```
```json
{
"app_title": "spine",
"total_books": 5,
"reading_count": 1,
"groups": [
{
"label": "on deck",
"count": 3,
"books": [
{
"format_icon": "ti-book",
"status_class": "want",
"status_label": "want",
"title": "The Left Hand of Darkness",
"author": "Ursula K. Le Guin",
"tags_inline": null,
"rec_via": "Alex",
"rating_display": null,
"selected": false,
"detail": null
}
]
}
]
}
```
### Field mappings
| Plist key | Template field | Transform |
|---|---|---|
| `:status` | `groups[].label` | WANT→"on deck", READING→"reading", READ→"read", ABANDONED→"abandoned" |
| `:status` | `status_class` / `status_label` | lowercased |
| `:format` | `format_icon` | hardcover→`ti-book`, ebook→`ti-device-tablet`, audiobook→`ti-headphones`, nil→`""` |
| `:tags` | `tags_inline` | `("scifi" "literary")``":scifi:literary:"` (string), nil→nil |
| `:rec_by` | `rec_via` | direct string, nil→nil |
| `:rating` | `rating_display` | "5"→"★★★★★", nil→nil |
| `:id` = selected-id | `selected` | boolean |
| `:notes` | `detail.notes` | list of `{date, text}` |
| `:added` + `:format` | `detail.meta` | composite string |
### Detail meta format
```
"started [2026-02-20] · hardcover" (WANT/READING)
"finished [2026-01-10] · ebook" (READ, use most recent LOGBOOK date)
"" (no dates)
```
### Group ordering
`on deck` (WANT) → `reading` (READING) → `read` (READ) → `abandoned` (ABANDONED).
Zero-book groups omitted.
## Handler
```elisp
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
```
`spine-render-empty-state` keeps its current Pico-styled inline HTML
(no Mustache needed for a single static message).
## Templates directory
`spine-mockup-a-agenda.mustache``templates/index.mustache`
`spine-mockup-b-journal.mustache` stays in root as a reference/mockup
for the future detail view.
## Files changed
| File | Change |
|---|---|
| `templates/index.mustache` | New — moved from root mockup |
| `spine.el` | Add `spine-index-model`, update handler, remove old `spine-render-index` |
| `spine-mockup-a-agenda.mustache` | Deleted (moved) |
## Out of scope
- Populating the `minibuffer` section (capture UI)
- Mockup B / journal detail view
- Cover image display
## Acceptance
- [ ] Index page renders via Mustache, not inline HTML
- [ ] Pico CSS + Tabler Icons CDN links present
- [ ] Groups: on deck, reading, read — correct order, zero-book groups omitted
- [ ] Format shown as Tabler icon glyph
- [ ] Status pills have distinct colors per state
- [ ] `?id=...` expands matching book detail
- [ ] Recommendation appears as "via {name}"
- [ ] ERT tests still pass (model layer unchanged)
@@ -0,0 +1,163 @@
# Spine — Pico CSS styling
Styles the index page using [Pico CSS v2](https://picocss.com/) via CDN,
restructuring HTML to use Pico-native semantic elements and adding a small
custom `<style>` block for the few things Pico doesn't cover.
## CSS strategy
- **Pico CSS v2** loaded from CDN:
`https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css`
- No build step. No local CSS file. No Sass/PostCSS.
- Light/dark theme auto-switches via `prefers-color-scheme` (Pico default).
- Custom styles in a single inline `<style>` block inside `<head>`.
- **No Tabler Icons.** Format glyphs use emoji instead.
## HTML restructure
Pico styles semantic elements. The current `<div>`-heavy output is rewritten
to use Pico-native elements. Only `spine-` prefixed classes remain for
layout pieces Pico doesn't provide.
| Current element | New element | Purpose |
|---|---|---|
| `<div class="frame">` | `<article>` | Card container (Pico styles borders/padding) |
| `<div class="titlebar">` | `<header>` inside `<article>` | Title bar with book count |
| `<div class="group">WANT (3)</div>` | `<hgroup><h2>WANT</h2><small>3</small></hgroup>` | Group header with count |
| `<div class="row">` | `<div class="spine-row">` | Book row (flex, Pico has no horizontal list) |
| `<div class="detail">` | `<blockquote class="spine-detail">` | Expanded notes section |
| `<span class="pill want">` | `<mark class="want">want</mark>` | Status badge (colored per status) |
| `<span class="glyph">` | `<span class="spine-glyph">` | Format emoji |
| `<span class="title">` | `<span class="spine-title">` | Book title + author |
| `<span class="muted">` | `<small>` | Author name (Pico styles `<small>` as muted) |
| `<span class="tags">` | `<small>` with tag text | Category tags |
| `<span class="trail">` | `<span class="spine-trail">` | Recommendation trail |
## Custom CSS
In an inline `<style>` block in `<head>`:
```css
.spine-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.spine-row:last-child { border-bottom: none; }
.spine-selected { background: var(--pico-secondary-background); }
.spine-glyph { width: 1.5rem; text-align: center; flex-shrink: 0; }
.spine-title { flex: 1; min-width: 0; }
.spine-trail { font-size: 0.85em; color: var(--pico-muted-color); }
.spine-detail {
margin: 0 0 0 2rem;
border-left: 2px solid var(--pico-primary);
}
.spine-detail p { margin-bottom: 0.3rem; }
.spine-detail footer { margin-top: 0.5rem; }
/* Status pill colors */
mark.want { background: var(--pico-mark-background); color: var(--pico-mark-color); }
mark.reading { background: #faeeda; color: #854f0b; }
mark.read { background: #e1f5ee; color: #085041; }
mark.abandoned { background: var(--pico-muted-color); color: var(--pico-background-color); }
/* Pico-specific overrides */
article { margin-bottom: 0; }
article header { padding-bottom: 0.5rem; margin-bottom: 0.5rem; }
```
## Format glyphs
Emoji per format, empty string if unset:
| Format | Glyph |
|---|---|
| `hardcover` | 📖 |
| `ebook` | 📱 |
| `audiobook` | 🎧 |
| `nil` / unset | (empty) |
## Full page shell
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spine</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>custom styles</style>
</head>
<body>
<main class="container">
<article>
<header>
<strong>spine</strong>
<small>N books · M reading</small>
</header>
…groups and rows…
</article>
</main>
</body>
</html>
```
## Example output (Use of Weapons, expanded)
```html
<hgroup><h2>READING</h2><small>1</small></hgroup>
<div class="spine-row spine-selected">
<span class="spine-glyph">🎧</span>
<span class="spine-title">Use of Weapons <small>Iain M. Banks</small></span>
<small>:scifi: :literary:</small>
<span class="spine-trail">Priya: If you liked Player of Games, this one will wreck you</span>
<mark class="reading">reading</mark>
<blockquote class="spine-detail">
<p><small>[2026-03-02]</small> The two-track structure is doing something I can't name yet.</p>
<p><small>[2026-03-07]</small> Zakalwe's competence reads as a wound.</p>
<p><small>[2026-03-09]</small> The chapter numbering. Oh.</p>
<footer>Format: audiobook · ISBN: 978-0316029193 · Added: [2026-02-20]</footer>
</blockquote>
</div>
```
## Empty state
```html
<article>
<header><strong>spine</strong></header>
<p>No books yet.</p>
</article>
```
## Files changed
- `spine.el``spine-render-index` and `spine-render-empty-state` rewritten
to emit Pico-structured HTML with inline CSS
No new files. No new dependencies beyond the CDN `<link>`.
## Out of scope
- JavaScript interactivity
- Cover image display
- Write endpoints
- Per-note format
- `?id=` selection without page reload (full page reload only)
## Acceptance
- [ ] Page renders with Pico CSS applied (fonts, spacing, colors match Pico defaults)
- [ ] Light/dark mode switches with OS preference
- [ ] Groups appear in order: WANT, READING, READ — zero-book groups omitted
- [ ] `?id=...` expands the matching book's detail with reading notes
- [ ] Status pills have distinct colors per state
- [ ] Format shown as emoji glyph
- [ ] Recommendation trail shown when `REC_BY` present
- [ ] Empty state shows "No books yet."
- [ ] No console 404s (Pico CDN resolves)
- [ ] No Tabler Icons reference in output
@@ -0,0 +1,218 @@
# Spine — data model and index page
Extends [`spine-spec.md`](../../spine-spec.md) with a concrete Org data structure
and defines the first real view: an index page that reads from `spine.org` and
displays books grouped by lifecycle status.
## Data model (`spine.org`)
Each book is a top-level headline. The TODO keyword is the lifecycle state.
Properties hold structured metadata. The headline body holds reading notes
as a plain Org list with inactive timestamps.
### TODO states
```org
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
```
### Properties
| Property | Required | Value |
|---|---|---|
| `:AUTHOR:` | yes | freeform string |
| `:ISBN:` | no | freeform string |
| `:COVER:` | no | path relative to `spine.org` |
| `:FORMAT:` | no | `hardcover` \| `ebook` \| `audiobook` |
| `:REC_BY:` | no | who recommended it |
| `:REC_NOTE:` | no | why it's on your radar |
| `:RATING:` | no | 15, set when moved to `READ` |
| `:ADDED:` | no | `[YYYY-MM-DD]` date added to spine |
| `:ID:` | yes | stable unique identifier (e.g. `8c1e-uow`) |
Missing or empty properties → `nil` at read time. The view decides how to
render nils.
### Tags
Org tags on the headline = categories (e.g. `:scifi:literary:`).
Tags filter and inherit natively in Org.
### Body: reading notes
A plain Org list in the headline body. Each item is prefixed with an inactive
timestamp `[YYYY-MM-DD]`. Appending a note = inserting one list item.
```org
- [2026-03-02] The two-track structure is doing something I can't name yet.
```
### LOGBOOK
State-change history is captured automatically by `org-log-into-drawer`.
This gives us the "when read" timeline for free — each state transition gets
a timestamped entry.
```org
:LOGBOOK:
- State "READING" from "WANT" [2026-02-20]
:END:
```
### Full example entry
```org
* READING Use of Weapons
: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]
:ID: 8c1e-uow
:END:
:LOGBOOK:
- State "READING" from "WANT" [2026-02-20]
: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.
```
## Architecture
Model/view split. Data extraction from Org is isolated from HTML rendering.
```
spine.org ──→ [spine-books] ──→ book plists ──→ [spine-render-index] ──→ HTML
```
### Model layer: `spine-books`
Opens the Org file, walks top-level headlines with `org-element`, returns a
list of plists — one per book.
```elisp
(spine-books) list of plists
```
Per-book plist shape:
```elisp
(:id "8c1e-uow"
:title "Use of Weapons"
:status "READING"
: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…"
:rating nil
:added "[2026-02-20]"
:tags ("scifi" "literary")
:notes (("[2026-03-02]" "The two-track structure…")
("[2026-03-07]" "Zakalwe's competence…")))
```
**Org file not found or invalid:** `spine-books` returns `nil` and messages a
warning. The `condition-case` around the parse catches malformed Org.
**Reading strategy:** visit `spine.org` in a temp buffer, parse with
`org-element-parse-buffer`, walk top-level headlines. Full scan — adequate
for ~tens of books. No `org-ql` yet; add it later when live filtering is needed.
### View layer: `spine-render-index`
Takes the book list and an optional selected ID, returns an HTML string.
No CSS, no JavaScript — structural HTML with CSS class names matching
mockup A so styles drop in later.
**Grouping:** books partitioned by status in this order:
`WANT``READING``READ``ABANDONED`.
Each group gets a header with count.
**Collapsed row per book:**
- Format glyph: `[H]` hardcover, `[E]` ebook, `[A]` audiobook (nothing if unset)
- Title + author
- Tags
- Recommendation trail (`REC_BY: REC_NOTE`) when present
- Status pill
**Expanded detail** (triggered by `?id=` query param):
- Reading log: each note as date + text
- Properties: format, ISBN, rating, added date
**Section structure:**
```html
<div class="frame">
<div class="titlebar">Spine</div>
<div class="group">WANT (3)</div>
<div class="row"></div>
<div class="row"></div>
<div class="row"></div>
<div class="group">READING (1)</div>
<div class="row selected">
<div class="detail">
<!-- expanded notes for selected book -->
</div>
</div>
</div>
```
**Empty state:** when `spine-books` returns `nil` → page with "No books yet."
message.
### Handler
```elisp
(defservlet index text/html ()
(let ((books (spine-books)))
(if books
(insert (spine-render-index books
(ht-get (ht-from-query-string (httpd-query-string)) "id")))
(insert (spine-render-empty-state)))))
```
- `/` → index, all books collapsed
- `/?id=8c1e-uow` → index with that book's detail expanded
- Bogus `?id=` → no expansion, normal collapsed view
### Configuration
```elisp
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
```
Overridable before load.
## Error handling
| Case | Behavior |
|---|---|
| `spine.org` missing | `spine-books` returns `nil` → empty-state page |
| `spine.org` invalid Org | `condition-case` around parse, message error, return `nil` |
| Bogus `?id=` param | no expansion, normal collapsed view |
| Package/bootstrap failures | surfaced at startup (existing scaffold behavior) |
## Out of scope
- Write endpoints (add book, add note, set status/rating/format)
- CSS styling
- JavaScript interactivity
- Cover image display
- `org-ql` integration
- Search or filtering beyond status grouping
## Acceptance
- [ ] `spine-books` parses a sample `spine.org` and returns correct plists
- [ ] Missing properties → `nil`, not errors
- [ ] `/?id=...` expands the matching book's detail
- [ ] Empty `spine.org` (no headlines) → empty list, not error
- [ ] Missing `spine.org` → empty-state page, no crash
- [ ] Groups appear in order: WANT, READING, READ, ABANDONED
- [ ] Groups with zero books are omitted from output
+79
View File
@@ -0,0 +1,79 @@
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
* 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]
: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]
:ID: 7f3c-dun
:END:
* WANT Piranesi
:PROPERTIES:
:AUTHOR: Susanna Clarke
:ISBN:
:COVER:
:FORMAT:
:REC_BY:
:REC_NOTE:
:RATING:
:ADDED: [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]
: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.
* READ Ancillary Justice
:PROPERTIES:
:AUTHOR: Ann Leckie
:ISBN: 978-0316246620
:COVER: covers/ancillary.jpg
:FORMAT: ebook
:REC_BY:
:REC_NOTE:
:RATING: 5
:ADDED: [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.
+124
View File
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spine — Direction A (agenda)</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
:root {
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
--bg-warning: #faeeda; --text-warning: #854f0b;
--bg-success: #e1f5ee; --text-success: #085041;
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
--radius-md: 8px; --radius-lg: 12px;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
--bg-warning: #3a2c12; --text-warning: #fac775;
--bg-success: #103a30; --text-success: #9fe1cb;
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
font-family: var(--sans); padding: 28px 16px; }
.wrap { max-width: 720px; margin: 0 auto; }
.frame { font-family: var(--mono); border: 0.5px solid var(--border-secondary);
border-radius: var(--radius-lg); overflow: hidden; background: var(--bg-primary); }
.titlebar { display:flex; align-items:center; justify-content:space-between;
padding:10px 14px; border-bottom:0.5px solid var(--border-tertiary);
background: var(--bg-secondary); font-size:13px; }
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
.pill { padding:1px 7px; border-radius:var(--radius-md); font-size:11px; }
.pill.want { background:var(--bg-info); color:var(--text-info); }
.pill.reading { background:var(--bg-warning); color:var(--text-warning); }
.pill.read { background:var(--bg-success); color:var(--text-success); }
.title { flex:1; min-width:0; }
.muted { color:var(--text-tertiary); }
.tags { color:var(--text-info); font-size:12px; }
.trail { color:var(--text-secondary); font-size:12px; }
.selected { background:var(--bg-secondary); border-left:2px solid var(--border-info); }
.detail { padding:8px 14px 12px 32px; background:var(--bg-secondary);
border-left:2px solid var(--border-info); font-size:12.5px; }
.logline { display:flex; gap:8px; margin-bottom:5px; }
.logdate { color:var(--text-tertiary); white-space:nowrap; }
.minibuffer { border-top:0.5px solid var(--border-tertiary); padding:9px 14px;
background:var(--bg-secondary); font-size:13px; display:flex; gap:8px; align-items:center; }
.caret { opacity:0.5; }
</style>
</head>
<body>
<div class="wrap">
<div class="frame">
<div class="titlebar">
<span style="font-weight:500;">spine · agenda</span>
<span class="muted">6 books · 1 reading</span>
</div>
<div class="group">on deck · 3</div>
<div class="row">
<i class="ti ti-book glyph" aria-hidden="true"></i>
<span class="pill want">want</span>
<span class="title">Tomorrow, and Tomorrow, and Tomorrow <span class="muted">· Zevin</span></span>
<span class="trail">via Priya</span>
</div>
<div class="row">
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
<span class="pill want">want</span>
<span class="title">Children of Time <span class="muted">· Tchaikovsky</span></span>
<span class="tags">:scifi:</span>
</div>
<div class="row">
<i class="ti ti-device-tablet glyph" aria-hidden="true"></i>
<span class="pill want">want</span>
<span class="title">The Spear Cuts Through Water <span class="muted">· Jimenez</span></span>
<span class="trail">via book club</span>
</div>
<div class="group">reading · 1</div>
<div class="row selected">
<i class="ti ti-book glyph" style="color:var(--text-secondary);" aria-hidden="true"></i>
<span class="pill reading">reading</span>
<span class="title" style="font-weight:500;">Use of Weapons <span class="muted" style="font-weight:400;">· Banks</span></span>
<span class="tags">:scifi:literary:</span>
</div>
<div class="detail">
<div class="muted" style="margin-bottom:8px;">started [2026-02-20] · hardcover · 62% · rating —</div>
<div class="logline"><span class="logdate">[03-02]</span><span>The two-track structure is doing something I can't name yet. Suspicious of the chair.</span></div>
<div class="logline"><span class="logdate">[03-07]</span><span>Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</span></div>
<div class="logline" style="margin-bottom:0;"><span class="logdate">[03-09]</span><span>The chapter numbering. Oh.</span></div>
</div>
<div class="group">read · 2</div>
<div class="row">
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
<span class="pill read">read</span>
<span class="title">A Memory Called Empire <span class="muted">· Martine</span></span>
<span class="trail">★★★★★</span>
</div>
<div class="row" style="padding-bottom:8px;">
<i class="ti ti-book glyph" aria-hidden="true"></i>
<span class="pill read">read</span>
<span class="title">Piranesi <span class="muted">· Clarke</span></span>
<span class="trail">★★★★☆</span>
</div>
<div class="minibuffer">
<span class="muted">M-x</span>
<span>spine-note</span>
<span style="border-left:2px solid var(--text-info); padding-left:6px;">Banks lets the structure detonate the whole book<span class="caret"></span></span>
</div>
</div>
</div>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Spine — Direction B (journal)</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
:root {
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
--bg-warning: #faeeda; --text-warning: #854f0b;
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
--radius-md: 8px; --radius-lg: 12px;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
--bg-warning: #3a2c12; --text-warning: #fac775;
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
font-family: var(--sans); padding: 28px 16px; }
.wrap { max-width: 560px; margin: 0 auto; }
.surface { background: var(--bg-secondary); border-radius: var(--radius-lg); padding: 1.25rem; }
.card { background: var(--bg-primary); border:0.5px solid var(--border-tertiary);
border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; }
.cover { width:44px; height:60px; border-radius:var(--radius-md); background:var(--bg-info);
display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.pill { background:var(--bg-warning); color:var(--text-warning); padding:2px 9px;
border-radius:var(--radius-md); font-size:12px; }
.seg { flex:1; text-align:center; padding:7px 0; font-size:13px; border:0.5px solid var(--border-tertiary);
border-radius:var(--radius-md); color:var(--text-secondary); }
.seg.active { border:2px solid var(--border-info); color:var(--text-info); background:var(--bg-info); }
.seclabel { font-size:12px; letter-spacing:0.03em; color:var(--text-tertiary); margin-bottom:10px; }
.thread { border-left:2px solid var(--border-tertiary); padding-left:14px; margin-bottom:14px; }
.entry { margin-bottom:12px; }
.entry:last-child { margin-bottom:0; }
.edate { font-size:12px; color:var(--text-tertiary); margin-bottom:2px; }
.etext { font-size:14px; line-height:1.5; }
input, button { font-family: var(--sans); font-size:14px; }
input { height:36px; padding:0 10px; border:0.5px solid var(--border-secondary);
border-radius:var(--radius-md); background:var(--bg-primary); color:var(--text-primary); }
input::placeholder { color:var(--text-tertiary); }
button { height:36px; padding:0 12px; border:0.5px solid var(--border-secondary);
border-radius:var(--radius-md); background:transparent; color:var(--text-primary); cursor:pointer; }
button:hover { background:var(--bg-secondary); }
.avatar { width:30px; height:30px; border-radius:50%; background:var(--bg-info);
display:flex; align-items:center; justify-content:center; font-size:12px;
color:var(--text-info); flex-shrink:0; }
</style>
</head>
<body>
<div class="wrap">
<div class="surface">
<div class="card">
<div style="display:flex; gap:14px; align-items:flex-start; margin-bottom:16px;">
<div class="cover"><i class="ti ti-book" style="font-size:22px; color:var(--text-info);" aria-hidden="true"></i></div>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:16px;">Use of Weapons</div>
<div style="font-size:14px; color:var(--text-secondary); margin-bottom:6px;">Iain M. Banks</div>
<span class="pill">Reading</span>
<span style="color:var(--text-tertiary); font-size:13px; margin-left:8px;">started 20 Feb · 62%</span>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:18px;">
<div class="seg"><i class="ti ti-book" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Hardcover</div>
<div class="seg"><i class="ti ti-device-tablet" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>eBook</div>
<div class="seg active"><i class="ti ti-headphones" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Audiobook</div>
</div>
<div class="seclabel">Reading log</div>
<div class="thread">
<div class="entry">
<div class="edate">2 Mar</div>
<div class="etext">The two-track structure is doing something I can't name yet. Suspicious of the chair.</div>
</div>
<div class="entry">
<div class="edate">7 Mar</div>
<div class="etext">Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</div>
</div>
<div class="entry">
<div class="edate">9 Mar</div>
<div class="etext">The chapter numbering. Oh.</div>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:20px;">
<input type="text" placeholder="Add a note at 62%…" style="flex:1;" />
<button><i class="ti ti-plus" style="font-size:15px; vertical-align:-2px; margin-right:4px;" aria-hidden="true"></i>Log note</button>
</div>
<div style="border-top:0.5px solid var(--border-tertiary); padding-top:14px;">
<div class="seclabel">On your radar</div>
<div style="display:flex; align-items:flex-start; gap:10px; margin-bottom:14px;">
<div class="avatar">PK</div>
<div style="font-size:14px; line-height:1.5;"><span style="font-weight:500;">Priya</span> <span style="color:var(--text-tertiary);">· 12 Jan</span><br>"If you liked Player of Games, this one will wreck you in a better way."</div>
</div>
<div style="display:flex; gap:8px;">
<input type="text" placeholder="Recommended by…" style="width:34%;" />
<input type="text" placeholder="Why it's on your radar…" style="flex:1;" />
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size:16px;" aria-hidden="true"></i></button>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Spine — Direction B (journal)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
.book { max-width: 560px; margin-inline: auto; }
.book hgroup { margin-bottom: .4rem; }
.book hgroup h3 { margin-bottom: .1rem; }
.book hgroup p { color: var(--pico-muted-color); margin: 0; }
.cover { width: 44px; height: 60px; border-radius: var(--pico-border-radius);
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.pill { padding: .1rem .5rem; border-radius: var(--pico-border-radius); font-size: .75rem; font-weight: 500; }
.pill.want { background: #e6f1fb; color: #0c447c; }
.pill.reading { background: #faeeda; color: #854f0b; }
.pill.read { background: #e1f5ee; color: #085041; }
.formats { margin-bottom: 1.1rem; }
.formats button { --pico-font-size: .85rem; }
.seclabel { font-size: .72rem; letter-spacing: .03em; color: var(--pico-muted-color);
text-transform: none; margin-bottom: .6rem; }
.thread { border-left: 2px solid var(--pico-muted-border-color); padding-left: .9rem; margin-bottom: .9rem; }
.entry { margin-bottom: .8rem; }
.entry:last-child { margin-bottom: 0; }
.edate { font-size: .75rem; color: var(--pico-muted-color); margin-bottom: .15rem; }
.etext { font-size: .9rem; line-height: 1.5; }
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
display: flex; align-items: center; justify-content: center; font-size: .75rem; }
.compose, .recform { margin-bottom: 0; }
@media (prefers-color-scheme: dark) {
.pill.want { background: #14304a; color: #b5d4f4; }
.pill.reading { background: #3a2c12; color: #fac775; }
.pill.read { background: #103a30; color: #9fe1cb; }
}
</style>
</head>
<body>
{{!
View model for this template:
{
cover_icon: string, Tabler class for the cover placeholder, e.g. "ti-book"
title: string,
author: string,
status_class: string, "want" | "reading" | "read"
status_label: string,
meta: string, e.g. "started 20 Feb · 62%"
progress_label: string, e.g. "62%" (used in the note composer placeholder)
formats: [ { icon: string, label: string, active: boolean } ], the format selector segments
notes: [ { date: string, text: string } ], reading log
recommendation: { optional
initials: string,
by: string,
date: string,
note: string
}?
}
}}
<main class="container">
<article class="book">
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 1rem;">
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 22px;" aria-hidden="true"></i></div>
<div style="flex: 1; min-width: 0;">
<hgroup>
<h3>{{title}}</h3>
<p>{{author}}</p>
</hgroup>
<span class="pill {{status_class}}">{{status_label}}</span>
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
</div>
</div>
<div class="formats" role="group">
{{#formats}}
<button class="{{^active}}secondary outline{{/active}}"><i class="ti {{icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px;" aria-hidden="true"></i>{{label}}</button>
{{/formats}}
</div>
<p class="seclabel">Reading log</p>
<div class="thread">
{{#notes}}
<div class="entry">
<div class="edate">{{date}}</div>
<div class="etext">{{text}}</div>
</div>
{{/notes}}
</div>
<div class="compose" role="group">
<input type="text" placeholder="Add a note at {{progress_label}}…" />
<button><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
</div>
<hr />
<p class="seclabel">On your radar</p>
{{#recommendation}}
<div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 1rem;">
<div class="avatar">{{initials}}</div>
<div style="font-size: .9rem; line-height: 1.5;"><strong>{{by}}</strong> <span class="muted" style="color: var(--pico-muted-color);">· {{date}}</span><br>"{{note}}"</div>
</div>
{{/recommendation}}
<div class="recform" role="group">
<input type="text" placeholder="Recommended by…" style="flex: 0 0 34%;" />
<input type="text" placeholder="Why it's on your radar…" />
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size: 16px;" aria-hidden="true"></i></button>
</div>
</article>
</main>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
# Spine — design brief
A personal library and reading tracker. Tracks books I want to read, am reading,
have read, and what I think about them; captures notes repeatedly through a read;
and records who recommended a book and why.
This document is a starting point for a coding agent. Several decisions are
deliberately **left open** (see "Open decisions"). Do not invent answers for
those — scaffold around them or ask.
## Core features
- Track each book through a reading lifecycle: want → reading → read (or abandoned).
- Capture **multiple timestamped notes** across a single read, not one review at the end.
- Record **recommendations**: who recommended a book and a short "why it's on my radar"
note. This is context shown inline, not a search/analytics concern.
- Flag the **format** consumed: hardcover, ebook, or audiobook.
- Rate finished books.
- Browse "on deck" (want-to-read) and optionally filter by category.
## Architecture
- **Source of truth is a single Org file.** No database.
- **Emacs owns the file.** All reads and writes go through a running Emacs via
`emacsclient`, so `org-element` does the parsing and serialization canonically.
This avoids third-party Org parser fidelity problems on write.
- **No read/search index initially.** Queries are simple status + tag filters
answered live by `org-ql`. A derived SQLite index can be added later if the
recommendation data ever needs real querying; it is explicitly out of scope now.
- The **web layer is a thin client** over emacsclient. Its language and framework
are undecided (see Open decisions).
## Data model (Org)
Each book is a top-level headline whose TODO state is its lifecycle status.
```org
#+TITLE: Spine
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
#+STARTUP: logdrawer
* READING Use of Weapons
:PROPERTIES:
:AUTHOR: Iain M. Banks
:FORMAT: audiobook
:REC_BY: Priya
:REC_NOTE: If you liked Player of Games, this one will wreck you in a better way.
:RATING:
:ADDED: [2026-02-20]
:ID: 8c1e-uow
:END:
:LOGBOOK:
- State "READING" from "WANT" [2026-02-20]
: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. Banks never lets a skill be free.
- [2026-03-09] The chapter numbering. Oh.
```
Field notes:
- **Status**: the TODO keyword. `WANT READING | READ ABANDONED`.
- **Status history**: comes free from the LOGBOOK drawer when
`org-log-into-drawer` is enabled. Distinct from reading notes.
- **Reading notes**: a plain Org list in the headline body, each item prefixed
with an inactive timestamp `[YYYY-MM-DD]`. Appending a note is inserting one
list item. Notes are not individually queried.
- **Format**: single `:FORMAT:` property — `hardcover` | `ebook` | `audiobook`.
(See Open decisions for the multi-format case.)
- **Recommendation**: `:REC_BY:` plus optional `:REC_NOTE:`. Surfaced inline as
context, never ranked or searched.
- **Category**: Org **tags** on the headline (`:scifi:literary:`), not a property —
tags filter natively and inherit.
- **Rating**: `:RATING:` 15, set when moved to READ.
## emacsclient integration
### Read: on-deck list
```elisp
(require 'org-ql)
(require 'json)
(defun spine-ondeck (&optional tag)
"JSON of WANT books, optionally filtered by TAG."
(json-serialize
(vconcat
(org-ql-select "~/spine/books.org"
(if tag `(and (todo "WANT") (tags ,tag)) '(todo "WANT"))
:action
(lambda ()
(list :title (org-get-heading t t t t)
:author (org-entry-get nil "AUTHOR")
:format (org-entry-get nil "FORMAT")
:rec_by (org-entry-get nil "REC_BY")
:tags (vconcat (org-get-tags))))))))
(defun spine-ondeck-file (path &optional tag)
"Write `spine-ondeck' output to PATH (avoids emacsclient quoting)."
(with-temp-file path (insert (spine-ondeck tag))))
```
Invoke: `emacsclient --eval '(spine-ondeck-file "/tmp/spine.json")'`, then read
the file from the web layer. (`emacsclient --eval` prints values with `prin1`, so
returning a JSON string directly comes back quoted/escaped — the temp-file hop
sidesteps that. A small `simple-httpd` endpoint is the alternative; undecided.)
### Write: to implement
- `spine-add-note` — append a `[today] text` list item under a book's headline.
- `spine-add-book` — insert a new WANT headline with initial properties.
- `spine-set-status` / `spine-set-rating` / `spine-set-format`.
These set state through `org-element` / standard Org commands, not text munging.
Not yet written.
## UI
Two directions are mocked as standalone HTML (`spine-mockup-a-agenda.html`,
`spine-mockup-b-journal.html`). They render the same Org record through different
front doors.
- **Direction A — agenda (TUI).** An org-agenda-style surface grouped by status.
Format shows as a leading glyph, the recommendation rides on the row as "why
it's here," the selected book expands inline to show its reading log, and
capture happens in a minibuffer rather than a form. This is the home surface.
- **Direction B — journal card.** A book detail card. Format is a segmented
control, the reading log is a thread you append to with an inline composer, and
the recommendation has an explicit "who / why" capture form. This is the
drill-in view.
**Recommended composition:** hybrid. Direction A as the home/browse surface,
Direction B as the detail view when a book is selected. Agenda answers "what now,"
card answers "where am I with this one." Not locked in — building one alone is
fine.
## Open decisions
- **Glue language + web framework — undecided.** Possibly elisp end-to-end
(Emacs serves the UI too), possibly an external thin client shelling out to
emacsclient. Leave this unbound; don't hardwire a stack.
- **Read transport:** temp-file hop vs `simple-httpd` endpoint in Emacs.
- **Per-note format:** book-level `:FORMAT:` is the default. If a book is regularly
consumed across media (hardcover at home, audio on a commute), format may need
to move to per-note. Single-value for now.
- **Hybrid vs single UI direction** — see UI section.
- **Search index:** deferred. Not now.
## Out of scope (for now)
- Recommender ranking / "hit rate" analytics.
- Full-text search across notes.
- Any external book-metadata API (covers, ISBN lookup) — placeholder cover blocks
in the mockups are decoration only.
+194 -9
View File
@@ -16,6 +16,7 @@
(require 'simple-httpd)
(require 'mustache)
(require 'ht)
(require 'cl-lib)
;; --- configuration ----------------------------------------------------
@@ -26,6 +27,9 @@
nil means localhost only. Set to a string like \"0.0.0.0\" to listen on
all interfaces.")
(defvar spine-org-file "~/spine/books.org"
"Path to the spine.org data file.")
(defvar spine-template-dir
(expand-file-name "templates/"
(file-name-directory load-file-name))
@@ -43,19 +47,200 @@ Returns the rendered string."
(buffer-string))
context))
(defun spine--h (text)
"HTML-escape TEXT."
(when text
(let ((s text))
(setq s (replace-regexp-in-string "&" "&amp;" s))
(setq s (replace-regexp-in-string "<" "&lt;" s))
(setq s (replace-regexp-in-string ">" "&gt;" s))
(setq s (replace-regexp-in-string "\"" "&quot;" s))
s)))
(defun spine-index-model (books &optional selected-id)
"Build the view model ht for templates/index.mustache from BOOKS.
SELECTED-ID expands the matching book's detail section."
(let* ((status-labels
'(("WANT" . "on deck")
("READING" . "reading")
("READ" . "read")
("ABANDONED" . "abandoned")))
(format-icons
'(("hardcover" . "ti-book")
("ebook" . "ti-device-tablet")
("audiobook" . "ti-headphones")))
(order '("WANT" "READING" "READ" "ABANDONED"))
(grouped (make-hash-table :test 'equal))
(total (length books))
(reading-count 0))
(dolist (book books)
(when (equal "READING" (plist-get book :status))
(cl-incf reading-count)))
(dolist (book books)
(let ((status (or (plist-get book :status) "WANT")))
(push book (gethash status grouped))))
(let ((groups nil))
(dolist (status order)
(let ((group-books (nreverse (gethash status grouped))))
(when group-books
(let ((book-models nil))
(dolist (book group-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))
(status (or (plist-get book :status) "WANT"))
(model
(ht
("format_icon"
(or (cdr (assoc fmt format-icons)) ""))
("status_class" (downcase status))
("status_label" (downcase status))
("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" (cdr (assoc status status-labels)))
("count" (length group-books))
("books" (nreverse book-models)))
groups)))))
(ht ("app_title" "spine")
("total_books" total)
("reading_count" reading-count)
("groups" (nreverse groups))))))
(defun spine--extract-notes (hl-begin hl-end)
"Extract reading notes from headline body between HL-BEGIN and HL-END.
Returns list of (date-string text-string) pairs."
(let ((notes nil))
(save-excursion
(goto-char hl-begin)
;; Skip past PROPERTIES and LOGBOOK drawers
(while (re-search-forward
"^[ \t]*:\\(PROPERTIES\\|LOGBOOK\\):" hl-end t)
(re-search-forward "^[ \t]*:END:" hl-end t))
;; Collect note lines: "- [YYYY-MM-DD] text"
(while (re-search-forward
"^- \\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\] \\(.*\\)"
hl-end t)
(push (list (match-string 1) (match-string 2)) notes)))
(nreverse notes)))
(defun spine--prop (pos prop)
"Get Org property PROP at headline position POS.
Returns nil for empty or missing properties."
(let ((val (save-excursion
(goto-char pos)
(org-entry-get nil prop))))
(and val (> (length val) 0) val)))
(defun spine-books ()
"Return a list of plists, one per top-level headline in `spine-org-file'.
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)
(when (= (org-element-property :level hl) 1)
(let ((pos (org-element-property :begin hl)))
(push (list :id (spine--prop pos "ID")
:title (org-element-property :title hl)
:status (org-element-property :todo-keyword 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")
:tags (org-element-property :tags hl)
:notes (spine--extract-notes
(org-element-property :begin hl)
(org-element-property :end hl)))
books)))))
(nreverse books)))
(error
(message "spine-books: failed to parse %s: %s"
spine-org-file (error-message-string err))
nil))))
(defun spine-render-empty-state ()
"Render a page indicating no books file was found."
(concat "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
"<title>Spine</title>\n"
"<link rel=\"stylesheet\""
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n"
"</head>\n<body>\n"
"<main class=\"container\">\n"
"<article>\n"
"<header><strong>spine</strong></header>\n"
"<p>No books yet.</p>\n"
"</article>\n"
"</main>\n</body>\n</html>"))
(defvar spine-skip-server-start nil
"When non-nil, do not start the HTTP server.
Set by test harnesses that only need the model functions.")
;; --- handlers ---------------------------------------------------------
(defservlet hello text/html ()
(insert (spine-render "hello.mustache"
(ht ("title" "Spine")
("message" "Hello from Emacs.")))))
(defservlet index text/html (path query request)
(let ((books (spine-books)))
(if books
(insert (spine-render "index.mustache"
(spine-index-model books (cadr (assoc "id" query)))))
(insert (spine-render-empty-state)))))
(defun httpd/ (proc uri-path query request)
"Redirect root to /index."
(httpd-redirect proc "/index" 302))
;; --- start ------------------------------------------------------------
(setq httpd-host spine-host)
(setq httpd-port spine-port)
(httpd-start)
(message "Spine listening on http://%s:%d"
(or spine-host "localhost") spine-port)
(unless spine-skip-server-start
(setq httpd-host spine-host)
(setq httpd-port spine-port)
(httpd-start)
(message "Spine listening on http://%s:%d"
(or spine-host "localhost") spine-port))
(provide 'spine)
;;; spine.el ends here
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Spine — Direction A (agenda)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
<style>
.agenda { font-family: var(--pico-font-family-monospace); padding: 0; overflow: hidden; }
.agenda > header, .agenda > footer { margin: 0; padding: .6rem .9rem; font-size: .85rem; }
.agenda > header { display: flex; justify-content: space-between; align-items: center; }
.group { padding: .65rem .9rem .2rem; font-size: .7rem; letter-spacing: .04em; color: var(--pico-muted-color); }
.row { display: flex; align-items: center; gap: .6rem; padding: .3rem .9rem; font-size: .85rem; }
.row.selected { background: var(--pico-card-sectioning-background-color); border-left: 2px solid var(--pico-primary); }
.row.selected .title { font-weight: 500; }
.row.selected .glyph { color: var(--pico-color); }
.glyph { width: 1rem; color: var(--pico-muted-color); flex-shrink: 0; }
.title { flex: 1; min-width: 0; }
.muted { color: var(--pico-muted-color); }
.tags { color: var(--pico-primary); font-size: .8rem; }
.trail { color: var(--pico-muted-color); font-size: .8rem; }
.pill { padding: .05rem .45rem; border-radius: var(--pico-border-radius); font-size: .7rem; font-weight: 500; }
.pill.want { background: #e6f1fb; color: #0c447c; }
.pill.reading { background: #faeeda; color: #854f0b; }
.pill.read { background: #e1f5ee; color: #085041; }
.detail { padding: .5rem .9rem .7rem 2rem; background: var(--pico-card-sectioning-background-color);
border-left: 2px solid var(--pico-primary); font-size: .8rem; }
.logline { display: flex; gap: .5rem; margin-bottom: .3rem; }
.logline:last-child { margin-bottom: 0; }
.logdate { color: var(--pico-muted-color); white-space: nowrap; }
.endpad { height: .4rem; }
.minibuffer { display: flex; gap: .5rem; align-items: center; }
.minibuffer .text { border-left: 2px solid var(--pico-primary); padding-left: .4rem; }
.caret { opacity: .5; }
@media (prefers-color-scheme: dark) {
.pill.want { background: #14304a; color: #b5d4f4; }
.pill.reading { background: #3a2c12; color: #fac775; }
.pill.read { background: #103a30; color: #9fe1cb; }
}
</style>
</head>
<body>
{{!
View model for this template:
{
app_title: string,
total_books: number,
reading_count: number,
groups: [
{
label: string, e.g. "on deck"
count: number,
books: [
{
format_icon: string, Tabler class, e.g. "ti-headphones" (data layer maps FORMAT -> icon)
status_class: string, "want" | "reading" | "read" (maps Org TODO state -> pill style)
status_label: string, text shown in the pill
title: string,
author: string,
tags_inline: string?, optional, e.g. ":scifi:literary:"
rec_via: string?, optional, REC_BY -> rendered as "via {name}"
rating_display: string?, optional, e.g. "★★★★☆"
selected: boolean?, marks the in-focus row (expands detail)
detail: { optional; render when a row is expanded
meta: string, e.g. "started [2026-02-20] · hardcover · 62% · rating —"
notes: [ { date: string, text: string } ] reading log
}?
}
]
}
],
minibuffer: { command: string, text: string }? optional capture line
}
}}
<main class="container">
<article class="agenda" style="max-width: 720px; margin-inline: auto;">
<header>
<strong>{{app_title}}</strong>
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
</header>
{{#groups}}
<div class="group">{{label}} · {{count}}</div>
{{#books}}
<div class="row{{#selected}} selected{{/selected}}">
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
<span class="pill {{status_class}}">{{status_label}}</span>
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
{{#rating_display}}<span class="trail">{{rating_display}}</span>{{/rating_display}}
</div>
{{#detail}}
<div class="detail">
<div class="muted" style="margin-bottom: .5rem;">{{meta}}</div>
{{#notes}}
<div class="logline"><span class="logdate">{{date}}</span><span>{{text}}</span></div>
{{/notes}}
</div>
{{/detail}}
{{/books}}
{{/groups}}
<div class="endpad"></div>
{{#minibuffer}}
<footer class="minibuffer">
<span class="muted">M-x</span>
<span>{{command}}</span>
<span class="text">{{text}}<span class="caret">▏</span></span>
</footer>
{{/minibuffer}}
</article>
</main>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
;;; spine-books-test.el — ERT tests for spine-books
(require 'ert)
(require 'cl-lib)
;; Load spine.el without starting the server
(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)))
(ert-deftest spine-books-parses-sample ()
"spine-books returns correct plists for sample-books.org."
(let ((books (spine-books)))
(should books)
(should (= (length books) 5))))
(ert-deftest spine-books-first-book-has-all-fields ()
"Use of Weapons entry has all expected fields."
(let* ((books (spine-books))
(uow (cl-find "8c1e-uow" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should uow)
(should (equal (plist-get uow :title) "Use of Weapons"))
(should (equal (plist-get uow :status) "READING"))
(should (equal (plist-get uow :author) "Iain M. Banks"))
(should (equal (plist-get uow :isbn) "978-0316029193"))
(should (equal (plist-get uow :cover) "covers/use-of-weapons.jpg"))
(should (equal (plist-get uow :format) "audiobook"))
(should (equal (plist-get uow :rec_by) "Priya"))
(should (equal (plist-get uow :rec_note) "If you liked Player of Games, this one will wreck you"))
(should (equal (plist-get uow :tags) '("scifi" "literary")))
(should (equal (plist-get uow :notes)
'(("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."))))))
(ert-deftest spine-books-missing-properties-are-nil ()
"Piranesi entry has nil for non-present properties."
(let* ((books (spine-books))
(pir (cl-find "5e8d-pir" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should pir)
(should (equal (plist-get pir :title) "Piranesi"))
(should (equal (plist-get pir :status) "WANT"))
(should (equal (plist-get pir :author) "Susanna Clarke"))
(should-not (plist-get pir :isbn))
(should-not (plist-get pir :cover))
(should-not (plist-get pir :format))
(should-not (plist-get pir :rec_by))
(should-not (plist-get pir :rec_note))
(should-not (plist-get pir :notes))))
(ert-deftest spine-books-read-book-has-rating ()
"Ancillary Justice has rating 5."
(let* ((books (spine-books))
(aj (cl-find "3a1b-aj" books
:key (lambda (b) (plist-get b :id))
:test #'equal)))
(should aj)
(should (equal (plist-get aj :status) "READ"))
(should (equal (plist-get aj :rating) "5"))
(should (= (length (plist-get aj :notes)) 3))))
(ert-deftest spine-books-empty-file-returns-empty-list ()
"Empty Org file returns empty list, not nil."
(let* ((tmp-file (make-temp-file "spine-test-" nil ".org"))
(spine-org-file tmp-file))
(unwind-protect
(let ((books (spine-books)))
(should (listp books))
(should (= (length books) 0)))
(delete-file tmp-file))))
(ert-deftest spine-books-missing-file-returns-nil ()
"Non-existent file returns nil."
(let ((spine-org-file "/tmp/spine-nonexistent-12345.org"))
(should-not (spine-books))))