Scaffold app: Roda + Phlex + Sequel (in-memory SQLite)
Mirrors ketchup architecture. Routes for creating lists, viewing
items/axes, recording matchup results. Win-count placeholder for
ratings until openskill is added.

Assisted-by: Claude Opus 4.6 via pi
change unynxmxxzwswvtztqzouzpyuxvmprrkz
commit 052d1a1f7dacb299c2a334ba76926124b6547af4
author Alpha Chen <alpha@kejadlen.dev>
date
parent rkuzmwzo
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..860a5d8
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+gem "phlex"
+gem "puma"
+gem "rackup"
+gem "roda"
+gem "sequel"
+gem "sqlite3"
+
+group :development do
+  gem "minitest"
+  gem "rack-test"
+  gem "rake"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..f4f5711
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,92 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    bigdecimal (4.0.1)
+    drb (2.2.3)
+    minitest (6.0.2)
+      drb (~> 2.0)
+      prism (~> 1.5)
+    nio4r (2.7.5)
+    phlex (2.4.1)
+      refract (~> 1.0)
+      zeitwerk (~> 2.7)
+    prism (1.9.0)
+    puma (7.2.0)
+      nio4r (~> 2.0)
+    rack (3.2.5)
+    rack-test (2.2.0)
+      rack (>= 1.3)
+    rackup (2.3.1)
+      rack (>= 3)
+    rake (13.3.1)
+    refract (1.1.0)
+      prism
+      zeitwerk
+    roda (3.101.0)
+      rack
+    sequel (5.102.0)
+      bigdecimal
+    sqlite3 (2.9.1-aarch64-linux-gnu)
+    sqlite3 (2.9.1-aarch64-linux-musl)
+    sqlite3 (2.9.1-arm-linux-gnu)
+    sqlite3 (2.9.1-arm-linux-musl)
+    sqlite3 (2.9.1-arm64-darwin)
+    sqlite3 (2.9.1-x86-linux-gnu)
+    sqlite3 (2.9.1-x86-linux-musl)
+    sqlite3 (2.9.1-x86_64-darwin)
+    sqlite3 (2.9.1-x86_64-linux-gnu)
+    sqlite3 (2.9.1-x86_64-linux-musl)
+    zeitwerk (2.7.5)
+
+PLATFORMS
+  aarch64-linux-gnu
+  aarch64-linux-musl
+  arm-linux-gnu
+  arm-linux-musl
+  arm64-darwin
+  x86-linux-gnu
+  x86-linux-musl
+  x86_64-darwin
+  x86_64-linux-gnu
+  x86_64-linux-musl
+
+DEPENDENCIES
+  minitest
+  phlex
+  puma
+  rack-test
+  rackup
+  rake
+  roda
+  sequel
+  sqlite3
+
+CHECKSUMS
+  bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
+  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
+  minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d
+  nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+  phlex (2.4.1) sha256=e596717fbfe38b5271840266758779ebe75092e02629f0c170287e6290a70b12
+  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+  puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
+  rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
+  rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
+  rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
+  rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+  refract (1.1.0) sha256=ee3b9627e39f7692831101e2fedd73e0d09a592ff5d5c05f171d14211fc7a9c7
+  roda (3.101.0) sha256=07b13bb5fbb8f606c17f8c21ae4897f629e59cb6f398665da6666920efd39843
+  sequel (5.102.0) sha256=c25bb85a44ae59b3ed41ca7c32c33316cf81a6fcaeabedb4036ba141dde70919
+  sqlite3 (2.9.1-aarch64-linux-gnu) sha256=85535ddf1c37f116ebebe0330bbbffc2ccb55d09f69717a565f8cfb35142f136
+  sqlite3 (2.9.1-aarch64-linux-musl) sha256=646a28a655fc0298ff4266de0af89b66477a2d9ad65cebb5abad190bb64ed092
+  sqlite3 (2.9.1-arm-linux-gnu) sha256=ed25696b0fb4694ca4f47287eaaa9e0d46a0a0c92990c453743d6ab6b4f51fa0
+  sqlite3 (2.9.1-arm-linux-musl) sha256=82ca90eefe50935c827ab0c8dffff5219f57b5da0c92039e3e27f7dbccc9e992
+  sqlite3 (2.9.1-arm64-darwin) sha256=e0cc5521aa03361e2da56635f3745242510b0b98c4608a3824b7e31ab2e7ffb9
+  sqlite3 (2.9.1-x86-linux-gnu) sha256=c3517e6f0df1f3a99fb73c5b0e6f02fc93b3b6b2ca0de72cb6d3956153310603
+  sqlite3 (2.9.1-x86-linux-musl) sha256=2216ab52dbff54bbc5ea87789e02c60f1c749f7ce052cef36da542ecc9aadc25
+  sqlite3 (2.9.1-x86_64-darwin) sha256=5ce2c05eed8dc7c6debd560e2c5960e36521652b9a43bc3e42bc431db600c36f
+  sqlite3 (2.9.1-x86_64-linux-gnu) sha256=1cbb644204ed143e5c96f6d59b5c571ba6f18b18a9dc5aa11c101187ff227afd
+  sqlite3 (2.9.1-x86_64-linux-musl) sha256=bbd50dd1caca78b6c069701d9009ef714461495985d4c374ea1a1def061ba67c
+  zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
+
+BUNDLED WITH
+  4.0.3
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..cdd1c50
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,12 @@
+$LOAD_PATH.unshift File.expand_path("lib", __dir__)
+
+require "minitest/test_task"
+
+Minitest::TestTask.create
+
+desc "Start dev server with auto-restart"
+task :dev do
+  sh "fd | entr -r rackup"
+end
+
+task default: :test
diff --git a/config.ru b/config.ru
new file mode 100644
index 0000000..e7331d6
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+
+require_relative "lib/rank_king/web"
+
+$stderr.puts RankKing::CONFIG
+
+run RankKing::Web.freeze.app
diff --git a/db/migrate/001_create_lists.rb b/db/migrate/001_create_lists.rb
new file mode 100644
index 0000000..1317ac3
--- /dev/null
+++ b/db/migrate/001_create_lists.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+  change do
+    create_table(:lists) do
+      primary_key :id
+      String :name, null: false
+      DateTime :created_at
+      DateTime :updated_at
+    end
+
+    create_table(:items) do
+      primary_key :id
+      foreign_key :list_id, :lists, null: false
+      String :name, null: false
+      DateTime :created_at
+      DateTime :updated_at
+    end
+
+    create_table(:axes) do
+      primary_key :id
+      foreign_key :list_id, :lists, null: false
+      String :name, null: false
+      DateTime :created_at
+      DateTime :updated_at
+    end
+
+    create_table(:results) do
+      primary_key :id
+      foreign_key :axis_id, :axes, null: false
+      foreign_key :winner_id, :items, null: false
+      foreign_key :loser_id, :items, null: false
+      DateTime :created_at
+    end
+  end
+end
diff --git a/lib/rank_king/config.rb b/lib/rank_king/config.rb
new file mode 100644
index 0000000..712f3b7
--- /dev/null
+++ b/lib/rank_king/config.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "securerandom"
+
+module RankKing
+  Config = Data.define(
+    :database_url,
+    :session_secret,
+  )
+
+  class Config
+    def to_s
+      "Config(database=#{database_url})"
+    end
+
+    def self.from_env(env = ENV)
+      new(
+        database_url: env.fetch("DATABASE_URL", ":memory:"),
+        session_secret: env.fetch("SESSION_SECRET") { SecureRandom.hex(64) },
+      )
+    end
+  end
+
+  CONFIG = Config.from_env
+end
diff --git a/lib/rank_king/db.rb b/lib/rank_king/db.rb
new file mode 100644
index 0000000..8b9fc1c
--- /dev/null
+++ b/lib/rank_king/db.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require "sequel"
+
+require_relative "config"
+
+module RankKing
+  DB = Sequel.sqlite(CONFIG.database_url)
+  Sequel.extension :migration
+  Sequel::Migrator.run(DB, File.expand_path("../../db/migrate", __dir__))
+end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
new file mode 100644
index 0000000..6bc49b6
--- /dev/null
+++ b/lib/rank_king/models.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require_relative "db"
+
+Sequel::Model.plugin :timestamps, update_on_create: true
+
+module RankKing
+  class List < Sequel::Model(DB[:lists])
+    one_to_many :items, class: "RankKing::Item"
+    one_to_many :axes, class: "RankKing::Axis"
+  end
+
+  class Item < Sequel::Model(DB[:items])
+    many_to_one :list, class: "RankKing::List"
+  end
+
+  class Axis < Sequel::Model(DB[:axes])
+    one_to_many :results, class: "RankKing::Result"
+  end
+
+  class Result < Sequel::Model(DB[:results])
+    many_to_one :axis, class: "RankKing::Axis"
+    many_to_one :winner, class: "RankKing::Item"
+    many_to_one :loser, class: "RankKing::Item"
+  end
+end
diff --git a/lib/rank_king/views/home.rb b/lib/rank_king/views/home.rb
new file mode 100644
index 0000000..0b4af4e
--- /dev/null
+++ b/lib/rank_king/views/home.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "layout"
+
+module RankKing
+  module Views
+    class Home < Phlex::HTML
+      def initialize(lists:, csrf:)
+        @lists = lists
+        @csrf = csrf
+      end
+
+      def view_template
+        render Layout.new do
+          div(class: "home") do
+            render_lists
+            render_new_list_form
+          end
+        end
+      end
+
+      private
+
+      def render_lists
+        section(class: "section") do
+          h2 { "Lists" }
+          if @lists.empty?
+            p(class: "empty") { "No lists yet." }
+          else
+            ul(class: "list-list") do
+              @lists.each do |list|
+                li do
+                  a(href: "/lists/#{list.id}") { list.name }
+                end
+              end
+            end
+          end
+        end
+      end
+
+      def render_new_list_form
+        section(class: "section") do
+          h2 { "New List" }
+          form(method: "post", action: "/lists", class: "form") do
+            input(type: "hidden", name: "_csrf", value: @csrf.call("/lists"))
+
+            div(class: "field") do
+              label(for: "name") { "Name" }
+              input(type: "text", name: "name", id: "name", required: true)
+            end
+
+            div(class: "field") do
+              label(for: "items") { "Items (one per line)" }
+              textarea(name: "items", id: "items", rows: 6, required: true)
+            end
+
+            div(class: "field") do
+              label(for: "axes") { "Axes (one per line)" }
+              textarea(name: "axes", id: "axes", rows: 3, placeholder: "e.g. Taste\nValue\nConvenience")
+            end
+
+            button(type: "submit", class: "btn-primary") { "Create List" }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/layout.rb b/lib/rank_king/views/layout.rb
new file mode 100644
index 0000000..b4b6cf8
--- /dev/null
+++ b/lib/rank_king/views/layout.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require "digest"
+require "phlex"
+
+module RankKing
+  module Views
+    class Layout < Phlex::HTML
+      ASSET_VERSIONS = begin
+        root = File.expand_path("../../../public", __dir__)
+        %w[
+          /css/reset.css
+          /css/utopia.css
+          /css/app.css
+        ].to_h {|path|
+          file = File.join(root, path)
+          [path, File.exist?(file) ? Digest::MD5.file(file).hexdigest[0, 10] : "0"]
+        }.freeze
+      end
+
+      def initialize(title: "Rank King")
+        @title = title
+      end
+
+      def view_template(&)
+        doctype
+        html(lang: "en") do
+          head do
+            meta(charset: "utf-8")
+            meta(name: "viewport", content: "width=device-width, initial-scale=1")
+            title { @title }
+            link(rel: "stylesheet", href: asset_path("/css/reset.css"))
+            link(rel: "stylesheet", href: asset_path("/css/utopia.css"))
+            link(rel: "stylesheet", href: asset_path("/css/app.css"))
+          end
+          body do
+            header(class: "site-header") do
+              a(href: "/", class: "site-name") { "Rank King" }
+            end
+            main { yield }
+          end
+        end
+      end
+
+      private
+
+      def asset_path(path)
+        version = ASSET_VERSIONS[path]
+        version ? "#{path}?v=#{version}" : path
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
new file mode 100644
index 0000000..2629d5c
--- /dev/null
+++ b/lib/rank_king/views/list/show.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "../layout"
+
+module RankKing
+  module Views
+    module ListViews
+      class Show < Phlex::HTML
+        def initialize(list:, csrf:)
+          @list = list
+          @csrf = csrf
+        end
+
+        def view_template
+          render Layout.new(title: "#{@list.name} — Rank King") do
+            div(class: "list-show") do
+              h1 { @list.name }
+              render_items
+              render_axes
+              render_matchup
+              render_ratings
+            end
+          end
+        end
+
+        private
+
+        def render_items
+          section(class: "section") do
+            h2 { "Items" }
+            ul(class: "item-list") do
+              @list.items.each do |item|
+                li { item.name }
+              end
+            end
+          end
+        end
+
+        def render_axes
+          section(class: "section") do
+            h2 { "Axes" }
+            if @list.axes.empty?
+              p(class: "empty") { "No axes defined." }
+            else
+              ul(class: "axis-list") do
+                @list.axes.each do |axis|
+                  li { axis.name }
+                end
+              end
+            end
+          end
+        end
+
+        def render_matchup
+          return if @list.axes.empty? || @list.items.size < 2
+
+          items = @list.items.to_a
+          pair = items.sample(2)
+          axis = @list.axes.to_a.sample
+
+          section(class: "section") do
+            h2 { "Matchup" }
+            p(class: "matchup-axis") { "On: #{axis.name}" }
+            div(class: "matchup") do
+              form(method: "post", action: "/lists/#{@list.id}/results", class: "matchup-choice") do
+                input(type: "hidden", name: "_csrf", value: @csrf.call("/lists/#{@list.id}/results"))
+                input(type: "hidden", name: "axis_id", value: axis.id)
+                input(type: "hidden", name: "winner_id", value: pair[0].id)
+                input(type: "hidden", name: "loser_id", value: pair[1].id)
+                button(type: "submit", class: "btn-choice") { pair[0].name }
+              end
+
+              span(class: "matchup-vs") { "vs" }
+
+              form(method: "post", action: "/lists/#{@list.id}/results", class: "matchup-choice") do
+                input(type: "hidden", name: "_csrf", value: @csrf.call("/lists/#{@list.id}/results"))
+                input(type: "hidden", name: "axis_id", value: axis.id)
+                input(type: "hidden", name: "winner_id", value: pair[1].id)
+                input(type: "hidden", name: "loser_id", value: pair[0].id)
+                button(type: "submit", class: "btn-choice") { pair[1].name }
+              end
+            end
+          end
+        end
+
+        def render_ratings
+          return if @list.axes.empty?
+
+          section(class: "section") do
+            h2 { "Ratings" }
+            @list.axes.each do |axis|
+              render_axis_ratings(axis)
+            end
+          end
+        end
+
+        def render_axis_ratings(axis)
+          results = axis.results
+          return if results.empty?
+
+          ratings = compute_ratings(axis)
+
+          div(class: "axis-ratings") do
+            h3 { axis.name }
+            ol(class: "rating-list") do
+              ratings.each do |item, rating|
+                li { "#{item.name} — #{format("%.0f", rating)}" }
+              end
+            end
+          end
+        end
+
+        def compute_ratings(axis)
+          # Placeholder: simple win count until openskill is added
+          wins = Hash.new(0)
+          axis.results.each { |r| wins[r.winner_id] += 1 }
+
+          items_by_id = @list.items.to_h { |i| [i.id, i] }
+          wins
+            .sort_by { |_, count| -count }
+            .map { |id, count| [items_by_id[id], count] }
+            .select { |item, _| item }
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
new file mode 100644
index 0000000..5a539ac
--- /dev/null
+++ b/lib/rank_king/web.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require "roda"
+
+require_relative "models"
+require_relative "views/home"
+require_relative "views/list/show"
+
+module RankKing
+  class Web < Roda
+    plugin :halt
+    plugin :static, %w[ /css /favicon.svg ]
+    plugin :sessions, secret: CONFIG.session_secret
+    plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
+
+    route do |r|
+      check_csrf!
+
+      r.root do
+        lists = List.all
+        Views::Home.new(lists: lists, csrf: method(:csrf_token)).call
+      end
+
+      r.on "lists" do
+        r.is do
+          r.post do
+            name = r.params["name"].to_s.strip
+            r.halt 422 if name.empty?
+
+            items_text = r.params["items"].to_s.strip
+            r.halt 422 if items_text.empty?
+
+            item_names = items_text.lines.map(&:strip).reject(&:empty?)
+            r.halt 422 if item_names.size < 2
+
+            axes_text = r.params["axes"].to_s.strip
+            axis_names = axes_text.lines.map(&:strip).reject(&:empty?)
+
+            list = DB.transaction do
+              l = List.create(name: name)
+              item_names.each { |n| Item.create(list_id: l.id, name: n) }
+              axis_names.each { |n| Axis.create(list_id: l.id, name: n) }
+              l
+            end
+
+            r.redirect "/lists/#{list.id}"
+          end
+        end
+
+        r.on Integer do |list_id|
+          @list = List[list_id]
+          r.halt 404 unless @list
+
+          r.is do
+            r.get do
+              Views::ListViews::Show.new(list: @list, csrf: method(:csrf_token)).call
+            end
+          end
+
+          r.on "results" do
+            r.post do
+              axis_id = r.params["axis_id"].to_i
+              winner_id = r.params["winner_id"].to_i
+              loser_id = r.params["loser_id"].to_i
+
+              axis = @list.axes_dataset.where(id: axis_id).first
+              r.halt 422 unless axis
+
+              winner = @list.items_dataset.where(id: winner_id).first
+              loser = @list.items_dataset.where(id: loser_id).first
+              r.halt 422 unless winner && loser
+              r.halt 422 if winner.id == loser.id
+
+              Result.create(axis_id: axis.id, winner_id: winner.id, loser_id: loser.id)
+
+              r.redirect "/lists/#{@list.id}"
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/public/css/app.css b/public/css/app.css
new file mode 100644
index 0000000..b101399
--- /dev/null
+++ b/public/css/app.css
@@ -0,0 +1,174 @@
+/* -------------------- */
+/* Global styles */
+/* -------------------- */
+
+body {
+  font-family: system-ui, -apple-system, sans-serif;
+  font-size: var(--step--1);
+  color: var(--color-text, #1a1a1a);
+  background: var(--color-surface, #fff);
+  min-height: 100dvh;
+  display: flex;
+  flex-direction: column;
+}
+
+a {
+  color: inherit;
+}
+
+h1 { font-size: var(--step-2); font-weight: 600; }
+h2 { font-size: var(--step-0); font-weight: 600; }
+h3 { font-size: var(--step--1); font-weight: 600; }
+
+/* -------------------- */
+/* Site header */
+/* -------------------- */
+
+.site-header {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-s);
+  padding: var(--space-xs) var(--space-l);
+  border-block-end: 1px solid var(--color-border, #e0e0e0);
+}
+
+.site-name {
+  font-weight: 600;
+  letter-spacing: 0.03em;
+  text-decoration: none;
+}
+
+/* -------------------- */
+/* Layout */
+/* -------------------- */
+
+main {
+  flex: 1;
+  max-width: 40rem;
+  margin-inline: auto;
+  padding: var(--space-m) var(--space-l);
+  width: 100%;
+}
+
+/* -------------------- */
+/* Sections */
+/* -------------------- */
+
+.section + .section {
+  margin-block-start: var(--space-l);
+}
+
+.empty {
+  color: var(--color-text-secondary, #666);
+  font-size: var(--step--1);
+}
+
+/* -------------------- */
+/* Forms */
+/* -------------------- */
+
+.form {
+  display: grid;
+  gap: var(--space-s);
+}
+
+.field {
+  display: grid;
+  gap: var(--space-3xs);
+}
+
+label {
+  font-size: var(--step--1);
+  font-weight: 600;
+}
+
+input,
+textarea,
+select {
+  padding: var(--space-3xs) var(--space-2xs);
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  font: inherit;
+  background: #fff;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+  outline: 2px solid transparent;
+  border-color: var(--color-text-secondary, #666);
+  box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.15);
+}
+
+textarea {
+  resize: vertical;
+}
+
+.btn-primary {
+  padding: var(--space-3xs) var(--space-s);
+  border: 1px solid #1a1a1a;
+  border-radius: 4px;
+  background: #1a1a1a;
+  color: #fff;
+  font: inherit;
+  font-weight: 600;
+  cursor: pointer;
+}
+
+.btn-primary:hover {
+  background: #333;
+}
+
+/* -------------------- */
+/* Matchup */
+/* -------------------- */
+
+.matchup {
+  display: flex;
+  align-items: center;
+  gap: var(--space-s);
+}
+
+.matchup-axis {
+  font-size: var(--step--1);
+  color: var(--color-text-secondary, #666);
+  margin-block-end: var(--space-xs);
+}
+
+.matchup-vs {
+  font-size: var(--step--1);
+  color: var(--color-text-secondary, #666);
+  font-weight: 600;
+}
+
+.btn-choice {
+  padding: var(--space-xs) var(--space-m);
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  background: #fff;
+  font: inherit;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.1s;
+}
+
+.btn-choice:hover {
+  border-color: #1a1a1a;
+  background: #f5f5f5;
+}
+
+/* -------------------- */
+/* Ratings */
+/* -------------------- */
+
+.axis-ratings {
+  margin-block-end: var(--space-s);
+}
+
+.rating-list {
+  padding-inline-start: var(--space-m);
+}
+
+.rating-list li {
+  padding-block: var(--space-3xs);
+}
diff --git a/public/css/reset.css b/public/css/reset.css
new file mode 100644
index 0000000..1959e7c
--- /dev/null
+++ b/public/css/reset.css
@@ -0,0 +1,79 @@
+/* -------------------- */
+/* A (more) Modern Reset */
+/* @link https://piccalil.li/blog/a-more-modern-css-reset/ */
+/* -------------------- */
+
+/* Box sizing rules */
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+/* Prevent font size inflation */
+html {
+  -moz-text-size-adjust: none;
+  -webkit-text-size-adjust: none;
+  text-size-adjust: none;
+}
+
+/* Remove default margin in favour of better control in authored CSS */
+body, h1, h2, h3, h4, p,
+figure, blockquote, dl, dd {
+  margin-block-end: 0;
+}
+
+/* Remove list styles on ul, ol elements with a list role,
+   which suggests default styling will be removed */
+ul[role='list'],
+ol[role='list'] {
+  list-style: none;
+}
+
+/* Set core body defaults */
+body {
+  min-height: 100vh;
+  line-height: 1.5;
+}
+
+/* Set shorter line heights on headings and interactive elements */
+h1, h2, h3, h4,
+button, input, label {
+  line-height: 1.1;
+}
+
+/* Balance text wrapping on headings */
+h1, h2,
+h3, h4 {
+  text-wrap: balance;
+}
+
+/* A elements that don't have a class get default styles */
+a:not([class]) {
+  text-decoration-skip-ink: auto;
+  color: currentColor;
+}
+
+/* Make images easier to work with */
+img,
+picture {
+  max-width: 100%;
+  display: block;
+}
+
+/* Inherit fonts for inputs and buttons */
+input, button,
+textarea, select {
+  font-family: inherit;
+  font-size: inherit;
+}
+
+/* Make sure textareas without a rows attribute are not tiny */
+textarea:not([rows]) {
+  min-height: 10em;
+}
+
+/* Anything that has been anchored to should have extra scroll margin */
+:target {
+  scroll-margin-block: 5ex;
+}
diff --git a/public/css/utopia.css b/public/css/utopia.css
new file mode 100644
index 0000000..f5d9f67
--- /dev/null
+++ b/public/css/utopia.css
@@ -0,0 +1,83 @@
+/* -------------------- */
+/* Utopia fluid type scale */
+/* @link https://utopia.fyi/type/calculator?c=360,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
+/* -------------------- */
+
+:root {
+  --step--2: clamp(0.7813rem, 0.7736rem + 0.0341vw, 0.8rem);
+  --step--1: clamp(0.9375rem, 0.9119rem + 0.1136vw, 1rem);
+  --step-0: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
+  --step-1: clamp(1.35rem, 1.2631rem + 0.3864vw, 1.5625rem);
+  --step-2: clamp(1.62rem, 1.4837rem + 0.6057vw, 1.9531rem);
+  --step-3: clamp(1.944rem, 1.7405rem + 0.9044vw, 2.4414rem);
+  --step-4: clamp(2.3328rem, 2.0387rem + 1.3072vw, 3.0518rem);
+  --step-5: clamp(2.7994rem, 2.384rem + 1.8461vw, 3.8147rem);
+}
+
+/* -------------------- */
+/* Utopia fluid space scale */
+/* @link https://utopia.fyi/space/calculator?c=360,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
+/* -------------------- */
+
+:root {
+  --space-3xs: clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem);
+  --space-2xs: clamp(0.5625rem, 0.5369rem + 0.1136vw, 0.625rem);
+  --space-xs: clamp(0.875rem, 0.8494rem + 0.1136vw, 0.9375rem);
+  --space-s: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
+  --space-m: clamp(1.6875rem, 1.6108rem + 0.3409vw, 1.875rem);
+  --space-l: clamp(2.25rem, 2.1477rem + 0.4545vw, 2.5rem);
+  --space-xl: clamp(3.375rem, 3.2216rem + 0.6818vw, 3.75rem);
+  --space-2xl: clamp(4.5rem, 4.2955rem + 0.9091vw, 5rem);
+  --space-3xl: clamp(6.75rem, 6.4432rem + 1.3636vw, 7.5rem);
+
+  /* One-up pairs */
+  --space-3xs-2xs: clamp(0.3125rem, 0.1847rem + 0.5682vw, 0.625rem);
+  --space-2xs-xs: clamp(0.5625rem, 0.4091rem + 0.6818vw, 0.9375rem);
+  --space-xs-s: clamp(0.875rem, 0.7216rem + 0.6818vw, 1.25rem);
+  --space-s-m: clamp(1.125rem, 0.8182rem + 1.3636vw, 1.875rem);
+  --space-m-l: clamp(1.6875rem, 1.3551rem + 1.4773vw, 2.5rem);
+  --space-l-xl: clamp(2.25rem, 1.6364rem + 2.7273vw, 3.75rem);
+  --space-xl-2xl: clamp(3.375rem, 2.7102rem + 2.9545vw, 5rem);
+  --space-2xl-3xl: clamp(4.5rem, 3.2727rem + 5.4545vw, 7.5rem);
+
+  /* Custom pairs */
+  --space-s-l: clamp(1.125rem, 0.5625rem + 2.5vw, 2.5rem);
+}
+
+/* -------------------- */
+/* Utopia fluid grid */
+/* @link https://utopia.fyi/grid/calculator?c=360,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */
+/* -------------------- */
+
+:root {
+  --grid-max-width: 77.50rem;
+  --grid-gutter: var(--space-s-l, clamp(1.125rem, 0.5625rem + 2.5vw, 2.5rem));
+  --grid-columns: 12;
+}
+
+/* -------------------- */
+/* Utopia custom clamp */
+/* @link https://utopia.fyi/clamp/calculator?a=360,1240,16—48 */
+/* -------------------- */
+
+:root {
+  --fluid-16-48: clamp(1rem, 0.1818rem + 3.6364vw, 3rem);
+}
+
+/* -------------------- */
+/* Semantic color tokens */
+/* -------------------- */
+
+:root {
+  --color-text: #222;
+  --color-text-secondary: #555;
+  --color-text-tertiary: #767676;
+  --color-border: #ddd;
+  --color-border-light: #eee;
+  --color-surface: #fcfcfc;
+  --color-surface-hover: #f0f0f0;
+  --color-accent: #c00;
+  --color-success: #27ae60;
+  --color-success-bg: #f0fff0;
+  --color-accent-bg: #ffe5e5;
+}
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..29efc4c
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+ENV["DATABASE_URL"] = ":memory:"
diff --git a/test/test_web.rb b/test/test_web.rb
new file mode 100644
index 0000000..85d47f9
--- /dev/null
+++ b/test/test_web.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+
+require "minitest/autorun"
+require "rack/test"
+
+require_relative "../lib/rank_king/web"
+
+class TestWeb < Minitest::Test
+  include Rack::Test::Methods
+
+  def app = RankKing::Web.app
+
+  def setup
+    RankKing::DB[:results].delete
+    RankKing::DB[:axes].delete
+    RankKing::DB[:items].delete
+    RankKing::DB[:lists].delete
+  end
+
+  def test_root_shows_empty_state
+    get "/"
+    assert last_response.ok?
+    assert_includes last_response.body, "No lists yet."
+  end
+
+  def test_create_list
+    create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: "Taste\nValue")
+    assert last_response.redirect?
+
+    list = RankKing::DB[:lists].first
+    assert_equal "Foods", list[:name]
+
+    items = RankKing::DB[:items].where(list_id: list[:id]).map { |i| i[:name] }
+    assert_equal %w[Pizza Sushi Tacos], items
+
+    axes = RankKing::DB[:axes].where(list_id: list[:id]).map { |a| a[:name] }
+    assert_equal %w[Taste Value], axes
+  end
+
+  def test_create_list_rejects_empty_name
+    create_list(name: "  ", items: "A\nB")
+    assert_equal 422, last_response.status
+  end
+
+  def test_create_list_rejects_fewer_than_two_items
+    create_list(name: "Solo", items: "OnlyOne")
+    assert_equal 422, last_response.status
+  end
+
+  def test_create_list_without_axes
+    create_list(name: "Simple", items: "A\nB", axes: "")
+    assert last_response.redirect?
+
+    list = RankKing::DB[:lists].first
+    assert_equal 0, RankKing::DB[:axes].where(list_id: list[:id]).count
+  end
+
+  def test_show_list
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+    list = RankKing::DB[:lists].first
+
+    get "/lists/#{list[:id]}"
+    assert last_response.ok?
+    assert_includes last_response.body, "Foods"
+    assert_includes last_response.body, "Pizza"
+    assert_includes last_response.body, "Sushi"
+    assert_includes last_response.body, "Taste"
+  end
+
+  def test_show_list_404_for_nonexistent
+    get "/lists/999999"
+    assert_equal 404, last_response.status
+  end
+
+  def test_record_result
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+    list = RankKing::DB[:lists].first
+    items = RankKing::DB[:items].where(list_id: list[:id]).all
+    axis = RankKing::DB[:axes].first(list_id: list[:id])
+
+    csrf_post "/lists/#{list[:id]}/results", {
+      axis_id: axis[:id],
+      winner_id: items[0][:id],
+      loser_id: items[1][:id],
+    }
+    assert last_response.redirect?
+
+    result = RankKing::DB[:results].first
+    assert_equal axis[:id], result[:axis_id]
+    assert_equal items[0][:id], result[:winner_id]
+    assert_equal items[1][:id], result[:loser_id]
+  end
+
+  def test_record_result_rejects_same_item
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+    list = RankKing::DB[:lists].first
+    item = RankKing::DB[:items].first(list_id: list[:id])
+    axis = RankKing::DB[:axes].first(list_id: list[:id])
+
+    csrf_post "/lists/#{list[:id]}/results", {
+      axis_id: axis[:id],
+      winner_id: item[:id],
+      loser_id: item[:id],
+    }
+    assert_equal 422, last_response.status
+  end
+
+  def test_root_links_to_list
+    create_list(name: "Foods", items: "Pizza\nSushi")
+    list = RankKing::DB[:lists].first
+
+    get "/"
+    assert_includes last_response.body, "/lists/#{list[:id]}"
+    assert_includes last_response.body, "Foods"
+  end
+
+  def test_csrf_rejects_post_without_token
+    post "/lists", { name: "Nope", items: "A\nB" }
+    assert_equal 403, last_response.status
+  end
+
+  private
+
+  def csrf_post(path, params = {})
+    get "/"
+    token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
+    # The home page CSRF token is for /lists, we need one for the target path
+    get "/lists/#{path.split("/")[2]}" if path.include?("/results")
+    token = last_response.body[/action="#{Regexp.escape(path)}".*?name="_csrf" value="([^"]+)"/m, 1] || token
+    post path, params.merge("_csrf" => token)
+  end
+
+  def create_list(name:, items:, axes: "")
+    get "/"
+    token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
+    post "/lists", {
+      _csrf: token,
+      name: name,
+      items: items,
+      axes: axes,
+    }
+  end
+end