Files
spine/docs/plans/2026-06-20-pico-css-styling.md
T

12 KiB

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.elspine-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:

(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
emacs --quick --batch --eval "(setq spine-skip-server-start t)" --load spine.el

Expected: exits cleanly, no errors.

  • Step 3: Commit
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.elspine-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:

(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
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
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
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

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
curl -s http://localhost:8080/index | grep -c "picocss"

Expected: 1 (the CDN <link> is present).

  • Step 3: Verify no Tabler Icons reference
curl -s http://localhost:8080/index | grep -c "tabler"

Expected: 0.

  • Step 4: Verify groups in correct order
curl -s http://localhost:8080/index | grep -oP '<h2>[^<]+'

Expected:

WANT
READING
READ

No ABANDONED.

  • Step 5: Verify emoji glyphs and status pills
curl -s http://localhost:8080/index | grep -E "📖|📱|🎧" | wc -l

Expected: 3 (three format glyphs present — Piranesi has no format).

curl -s http://localhost:8080/index | grep -oP '<mark class="[^"]+">[^<]+'

Expected: want, reading, read pills visible.

  • Step 6: Verify expanded detail
curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -oP 'spine-detail'

Expected: spine-detail present (2 occurrences — opening and closing).

curl -s "http://localhost:8080/index?id=8c1e-uow" | grep -c "logline\|spine-detail"

Expected: detail section renders.

  • Step 7: Verify recommendation trail
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
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
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

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
git status --short

Expected: no modified tracked files (only pre-existing untracked files).

  • Step 3: Review commit log
git log --oneline -4

Expected: 2-3 new commits on top of previous work.