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