Redesign the UI on Gloss, after the round 6 wireframes
Vendors Gloss (CSS plus system.js for tabs) under public/vendor/gloss
with a rake gloss:vendor task, the way pro-tacts does, and rebuilds
every screen on its components:

- Lists: names only, with New list in the header.
- New list: name, a source dropdown (Sources::KINDS, so Text for now),
  the source's own fields, and axes as tags.
- List: back, title, and a ⋯ menu holding list actions: the source's
  sync dialog and settings. Axis tabs over the ranking, with unranked
  items last and Compare on the axis.
- Compare: its own page, a pair to pick from, Skip, and recent results.
- Settings: name, the list's fixed source, axis tags, Delete and Save.

The Text source's sync dialog is laid out as a Gloss dialog. Deleting
a list or an axis now deletes what hangs off it, archived items
included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTqTY5gmGmtR4d3kBWGZTX
change
commit 8c7c46d7539b38c31890d21cac14d6b7c2dab5bd
author Claude <noreply@anthropic.com>
date
parent ad52f070
diff --git a/AGENTS.md b/AGENTS.md
index fefc29e..22245d6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,7 @@ Rank King is a webapp for ranking items along multiple axes through pairwise com
 
 - Ruby 4, Roda (routing), Sequel (ORM), SQLite
 - Phlex for views (pure Ruby HTML)
+- Gloss (https://github.com/kejadlen/gloss) for the design system, vendored
 - Puma web server, Rack
 - Minitest for tests
 - Steep for type checking, against inline RBS comments
@@ -36,6 +37,9 @@ test/              — Minitest tests
 sig/               — RBS for what inline comments cannot express
 Steepfile          — type checking config
 public/            — static assets
+  vendor/gloss/    — vendored Gloss CSS and JS (refresh with rake gloss:vendor)
+  css/app.css      — Rank King's layout over Gloss
+  js/app.js        — axis tags and delete confirmation
 .github/workflows/ — CI, PR previews, preview reaper (GitHub Actions)
 Dockerfile         — production image
 fly.toml           — Fly config for the main demo and PR previews
@@ -58,6 +62,7 @@ fly.toml           — Fly config for the main demo and PR previews
 | Run tests | `bundle exec rake test` |
 | Type check | `bundle exec rake steep` |
 | Run dev server | `bundle exec rake dev` (uses `fd \| entr -r rackup demo.ru`) |
+| Refresh Gloss | `bundle exec rake gloss:vendor GLOSS=/path/to/gloss` |
 | Start server | `bundle exec rackup` (behind the proxy) or `bundle exec rackup demo.ru` (stub login) |
 
 ## CI and deployment
@@ -87,6 +92,17 @@ Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
 - Models use `Sequel::Model` with `timestamps` plugin
 - Views are Phlex components under `lib/rank_king/views/`
 - CSRF protection is on for all POST routes
+- Markup follows Gloss' contracts (`_components/<name>.md` in the Gloss
+  repo): bare elements, ARIA roles, and `data-*` variants, with no class
+  beyond Gloss' own. `app.css` reads only `--gl-*` tokens and lays
+  components out rather than restyling them.
+- Every screen opens with one header row (back, title, then the
+  screen's own actions). List actions go in the list's ⋯ menu and list
+  options in its settings form, so a new feature adds an entry there
+  rather than a new button or screen.
+- A list's source is fixed when it is made. `Sources::KINDS` lists the
+  kinds the new-list form offers, and each kind's `title` names it there
+  and in settings. Its sync dialog opens from the list's ⋯ menu.
 - Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
 - Lists are private to their owner (404 for anyone else)
 - Config is a frozen `Data.define` value object
diff --git a/Rakefile b/Rakefile
index 7cb2df0..c3cfe1b 100644
--- a/Rakefile
+++ b/Rakefile
@@ -17,3 +17,55 @@ task :steep do
 end
 
 task default: %i[ steep test ]
+
+# Rank King inherits Gloss (https://github.com/kejadlen/gloss) as its
+# design system. Gloss ships as plain CSS meant to be copied into a
+# consuming project rather than pulled in as a dependency (see its
+# install.md), so public/vendor/gloss is a vendored snapshot, and this
+# task is how it gets refreshed.
+namespace :gloss do
+  desc "Refresh the vendored Gloss CSS and JS from a local checkout (GLOSS=path)"
+  task :vendor do
+    require "fileutils"
+    require "pathname"
+
+    checkout = Pathname.new(ENV.fetch("GLOSS") { abort "usage: rake gloss:vendor GLOSS=/path/to/gloss/checkout" })
+    commit = `git -C #{checkout} rev-parse HEAD`.strip
+    abort "#{checkout} is not a git checkout" unless $?.success?
+
+    out = Pathname.new(__dir__) / "public" / "vendor" / "gloss"
+    FileUtils.mkdir_p(out)
+
+    header = <<~CSS
+      /* Vendored from kejadlen/gloss @ #{commit}, #{Time.now.utc.strftime("%Y-%m-%d")}.
+         Refresh with `rake gloss:vendor GLOSS=/path/to/gloss/checkout`. */
+
+    CSS
+
+    # Mirrors CSS_BUNDLES in Gloss' lib/build.rb.
+    bundles = {
+      "tokens.css" => %w[assets/css/tokens.css],
+      "base.css" => %w[_sass/base/reset.css _sass/base/typography.css],
+      "components.css" => %w[
+        _sass/components/icon.css
+        _sass/components/button.css
+        _sass/components/field.css
+        _sass/components/badge.css
+        _sass/components/card.css
+        _sass/components/feedback.css
+        _sass/components/tabs.css
+      ],
+    }
+
+    bundles.each do |name, sources|
+      body = sources.map { |source| (checkout / source).read }.join("\n")
+      (out / name).write(header + body)
+      puts "wrote #{out / name}"
+    end
+
+    # The tab keyboard wiring; the theme toggle in it is unused here.
+    js = out / "system.js"
+    js.write(header + (checkout / "assets/js/system.js").read)
+    puts "wrote #{js}"
+  end
+end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 6a23561..0e2dca8 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -20,6 +20,14 @@ module RankKing
     one_to_many :axes, class: "RankKing::Axis"
 
     def source = Sources.fetch(source_kind).for(self)
+
+    # Archived items too, which the items association leaves out.
+    def before_destroy
+      Result.where(axis_id: axes_dataset.select(:id)).delete
+      axes_dataset.delete
+      Item.where(list_id: id).delete
+      super
+    end
   end
 
   # @rbs skip
@@ -29,7 +37,27 @@ module RankKing
 
   # @rbs skip
   class Axis < Sequel::Model(DB[:axes])
+    many_to_one :list, class: "RankKing::List"
     one_to_many :results, class: "RankKing::Result"
+
+    # The list's items in order on this axis, then the ones with no
+    # results on it yet. Ordered by win count until OpenSkill is wired in.
+    def ranking
+      wins = Hash.new(0)
+      compared = Set.new
+      results.each do |result|
+        wins[result.winner_id] += 1
+        compared << result.winner_id << result.loser_id
+      end
+
+      ranked, unranked = list.items.partition { |item| compared.include?(item.id) }
+      [ranked.sort_by { |item| [-wins[item.id], item.id] }, unranked]
+    end
+
+    def before_destroy
+      results_dataset.delete
+      super
+    end
   end
 
   # @rbs skip
diff --git a/lib/rank_king/sources.rb b/lib/rank_king/sources.rb
index 9909ef2..fcf89e1 100644
--- a/lib/rank_king/sources.rb
+++ b/lib/rank_king/sources.rb
@@ -15,6 +15,10 @@ require_relative "sources/text"
 
 module RankKing
   module Sources
+    # Every kind a list can be made from, in the order the new-list form
+    # offers them. A list keeps its kind for good.
+    KINDS = %w[Text].freeze #: Array[String]
+
     # A list's source_kind names a class in this module.
     #: (String kind) -> _SourceClass
     def self.fetch(kind) = const_get(kind, false)
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 7d85b84..e4a5257 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -11,6 +11,9 @@ module RankKing
       #: (List list) -> Text
       def self.for(list) = new(list)
 
+      #: () -> String
+      def self.title = "Typed list"
+
       #: (List list) -> void
       def initialize(list)
         @list = list
diff --git a/lib/rank_king/views/axis_tags.rb b/lib/rank_king/views/axis_tags.rb
new file mode 100644
index 0000000..b3d8133
--- /dev/null
+++ b/lib/rank_king/views/axis_tags.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module RankKing
+  module Views
+    # Axes as removable tags. Saved axes post their ids as `axis_ids[]`,
+    # new ones post their names as `axes[]`. The trailing input is itself
+    # an `axes[]` field, so a name typed there is kept even without
+    # public/js/app.js, which turns each Enter into a tag.
+    class AxisTags < Phlex::HTML
+      def initialize(axes: [], hint: nil)
+        @axes = axes
+        @hint = hint
+      end
+
+      def view_template
+        fieldset(data_tags: true) do
+          legend { "Axes" }
+          div(class: "tag-set") do
+            @axes.each do |axis|
+              span(class: "tag") do
+                plain axis.name
+                input(type: "hidden", name: "axis_ids[]", value: axis.id)
+                button(type: "button", aria_label: "Remove #{axis.name}") { "×" }
+              end
+            end
+            span(class: "tag", data_dashed: true) do
+              input(type: "text", name: "axes[]", placeholder: "+ Axis", aria_label: "New axis")
+            end
+          end
+          small { @hint } if @hint
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/home.rb b/lib/rank_king/views/home.rb
index 0b4af4e..1a9d8eb 100644
--- a/lib/rank_king/views/home.rb
+++ b/lib/rank_king/views/home.rb
@@ -7,64 +7,31 @@ require_relative "layout"
 module RankKing
   module Views
     class Home < Phlex::HTML
-      def initialize(lists:, csrf:)
+      def initialize(lists:)
         @lists = lists
-        @csrf = csrf
       end
 
       def view_template
         render Layout.new do
-          div(class: "home") do
-            render_lists
-            render_new_list_form
+          render PageHeader.new(title: "Rank King") do
+            a(href: "/lists/new", class: "btn", data_variant: "primary", data_size: "sm",
+              style: "--gl-icon: var(--gl-icon-plus)") { "New list" }
           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 }
+          section do
+            h2(class: "type-label") { "Lists" }
+            if @lists.empty?
+              p { "No lists yet." }
+            else
+              ul(class: "card") do
+                @lists.each do |list|
+                  li { 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
index f1321c3..4084aea 100644
--- a/lib/rank_king/views/layout.rb
+++ b/lib/rank_king/views/layout.rb
@@ -7,13 +7,21 @@ require "phlex"
 module RankKing
   module Views
     class Layout < Phlex::HTML
+      STYLESHEETS = %w[
+        /vendor/gloss/tokens.css
+        /vendor/gloss/base.css
+        /vendor/gloss/components.css
+        /css/app.css
+      ].freeze
+
+      SCRIPTS = %w[
+        /vendor/gloss/system.js
+        /js/app.js
+      ].freeze
+
       ASSET_VERSIONS = begin
         root = Pathname(__dir__).join("../../../public").expand_path
-        %w[
-          /css/reset.css
-          /css/utopia.css
-          /css/app.css
-        ].to_h {|path|
+        (STYLESHEETS + SCRIPTS).to_h {|path|
           # Pathname#join would treat the leading slash as a new root.
           file = root.join(path.delete_prefix("/"))
           [path, file.exist? ? Digest::MD5.file(file).hexdigest[0, 10] : "0"]
@@ -31,14 +39,10 @@ module RankKing
             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"))
+            STYLESHEETS.each { |path| link(rel: "stylesheet", href: asset_path(path)) }
+            SCRIPTS.each { |path| script(src: asset_path(path), defer: true) }
           end
           body do
-            header(class: "site-header") do
-              a(href: "/", class: "site-name") { "Rank King" }
-            end
             main { yield }
           end
         end
@@ -51,5 +55,27 @@ module RankKing
         version ? "#{path}?v=#{version}" : path
       end
     end
+
+    # The one row at the top of every screen: an optional way back, the
+    # title, and whatever the screen puts at the far end.
+    class PageHeader < Phlex::HTML
+      def initialize(title:, back: nil, back_label: nil, size: "type-h3")
+        @title = title
+        @back = back
+        @back_label = back_label
+        @size = size
+      end
+
+      def view_template(&block)
+        header do
+          if @back
+            a(href: @back, class: "icon-button", aria_label: @back_label,
+              style: "--gl-icon: var(--gl-icon-chevron-left)")
+          end
+          h1(class: @size) { @title }
+          yield if block
+        end
+      end
+    end
   end
 end
diff --git a/lib/rank_king/views/list/compare.rb b/lib/rank_king/views/list/compare.rb
new file mode 100644
index 0000000..9bc6579
--- /dev/null
+++ b/lib/rank_king/views/list/compare.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "../layout"
+
+module RankKing
+  module Views
+    module ListViews
+      class Compare < Phlex::HTML
+        RECENT = 5
+
+        def initialize(list:, axis:, csrf:)
+          @list = list
+          @axis = axis
+          @csrf = csrf
+        end
+
+        def view_template
+          render Layout.new(title: "#{@axis.name} · #{@list.name} — Rank King") do
+            render PageHeader.new(title: @list.name, back: "/lists/#{@list.id}?axis=#{@axis.id}", back_label: @list.name)
+
+            section do
+              h2(class: "type-label") { "Which wins on #{@axis.name}?" }
+              render_matchup
+            end
+
+            render_recent
+          end
+        end
+
+        private
+
+        def render_matchup
+          first, second = @list.items.sample(2)
+
+          div(data_matchup: true) do
+            render_choice(first, second)
+            render_choice(second, first)
+          end
+
+          a(href: "/lists/#{@list.id}/axes/#{@axis.id}/compare", class: "btn", data_variant: "ghost") { "Skip" }
+        end
+
+        def render_choice(winner, loser)
+          action = "/lists/#{@list.id}/results"
+          form(method: "post", action: action) do
+            input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+            input(type: "hidden", name: "axis_id", value: @axis.id)
+            input(type: "hidden", name: "winner_id", value: winner.id)
+            input(type: "hidden", name: "loser_id", value: loser.id)
+            button(type: "submit") { winner.name }
+          end
+        end
+
+        def render_recent
+          results = @axis.results_dataset.reverse(:id).limit(RECENT).all
+          return if results.empty?
+
+          section do
+            h2(class: "type-label") { "Recent" }
+            ul(class: "card") do
+              results.each do |result|
+                li do
+                  span do
+                    strong { result.winner.name }
+                    plain " over #{result.loser.name}"
+                  end
+                end
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/list/new.rb b/lib/rank_king/views/list/new.rb
new file mode 100644
index 0000000..8ee0ee5
--- /dev/null
+++ b/lib/rank_king/views/list/new.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "../layout"
+require_relative "../axis_tags"
+
+module RankKing
+  module Views
+    module ListViews
+      class New < Phlex::HTML
+        def initialize(sources:, csrf:)
+          @sources = sources
+          @csrf = csrf
+        end
+
+        def view_template
+          render Layout.new(title: "New list — Rank King") do
+            render PageHeader.new(title: "New list", back: "/", back_label: "Lists")
+
+            form(method: "post", action: "/lists") do
+              input(type: "hidden", name: "_csrf", value: @csrf.call("/lists"))
+
+              label(class: "field") do
+                plain "Name"
+                input(type: "text", name: "name", required: true, autofocus: true)
+              end
+
+              label(class: "field") do
+                plain "Source"
+                select(name: "source") do
+                  @sources.each { |value, name| option(value: value) { name } }
+                end
+                small { "A list keeps its source." }
+              end
+
+              # The chosen source's own fields. Each source gets one of these.
+              fieldset(data_source: "Text") do
+                label(class: "field") do
+                  plain "Items"
+                  textarea(name: "items", rows: 6, required: true)
+                  small { "One per line, at least two." }
+                end
+              end
+
+              render AxisTags.new
+
+              footer do
+                button(type: "submit", data_variant: "primary") { "Create list" }
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/list/settings.rb b/lib/rank_king/views/list/settings.rb
new file mode 100644
index 0000000..bffdeee
--- /dev/null
+++ b/lib/rank_king/views/list/settings.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "../layout"
+require_relative "../axis_tags"
+
+module RankKing
+  module Views
+    module ListViews
+      # A future setting is a new section of this form, not a new screen.
+      class Settings < Phlex::HTML
+        def initialize(list:, source:, csrf:)
+          @list = list
+          @source = source
+          @csrf = csrf
+        end
+
+        def view_template
+          settings = "/lists/#{@list.id}/settings"
+          delete = "/lists/#{@list.id}/delete"
+
+          render Layout.new(title: "Settings · #{@list.name} — Rank King") do
+            render PageHeader.new(title: "Settings", back: "/lists/#{@list.id}", back_label: @list.name)
+
+            form(id: "settings", method: "post", action: settings) do
+              input(type: "hidden", name: "_csrf", value: @csrf.call(settings))
+
+              label(class: "field") do
+                plain "Name"
+                input(type: "text", name: "name", value: @list.name, required: true)
+              end
+
+              # A list keeps the source it was made from, so this is a
+              # fact about the list, not a control.
+              div(class: "field") do
+                plain "Source"
+                p { @source }
+              end
+
+              render AxisTags.new(axes: @list.axes, hint: "Removing an axis deletes its results.")
+            end
+
+            form(method: "post", action: delete, data_confirm: "Delete #{@list.name} and all its results?") do
+              input(type: "hidden", name: "_csrf", value: @csrf.call(delete))
+              footer do
+                button(type: "submit", data_variant: "danger", data_size: "sm") { "Delete list" }
+                button(type: "submit", form: "settings", data_variant: "primary") { "Save" }
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
index e93f41c..6485532 100644
--- a/lib/rank_king/views/list/show.rb
+++ b/lib/rank_king/views/list/show.rb
@@ -8,133 +8,94 @@ module RankKing
   module Views
     module ListViews
       class Show < Phlex::HTML
-        def initialize(list:, csrf:)
+        def initialize(list:, csrf:, axis: nil)
           @list = list
           @csrf = csrf
+          @axes = list.axes
+          @selected = axis || @axes.first
         end
 
         def view_template
           render Layout.new(title: "#{@list.name} — Rank King") do
-            div(class: "list-show") do
-              h1 { @list.name }
+            render PageHeader.new(title: @list.name, back: "/", back_label: "Lists", size: "type-h2") do
+              render_menu
+            end
+
+            if @axes.empty?
               render_items
+            else
               render_axes
-              render_matchup
-              render_ratings
             end
           end
         end
 
         private
 
-        def render_items
-          section(class: "section") do
-            div(class: "section-header") do
-              h2 { "Items" }
-              render_sync
-            end
-            ul(class: "item-list") do
-              @list.items.each do |item|
-                li { item.name }
-              end
+        # Every list action lives here, so a new one is a new entry
+        # rather than a new button on the screen. The source decides what
+        # syncing looks like: its entry opens a dialog holding its own form.
+        def render_menu
+          source = @list.source
+
+          button(class: "icon-button", popovertarget: "list-actions", aria_label: "List actions",
+                 style: "--gl-icon: var(--rk-icon-more)")
+          nav(id: "list-actions", popover: true, aria_label: "List actions") do
+            ul(class: "card") do
+              li { button(type: "button", commandfor: "sync-dialog", command: "show-modal") { source.label } }
+              li { a(href: "/lists/#{@list.id}/settings") { "Settings" } }
             end
           end
+          dialog(id: "sync-dialog") { render source.form(csrf: @csrf) }
         end
 
-        # The source decides what syncing looks like: the button opens a
-        # dialog holding the source's own form.
-        def render_sync
-          source = @list.source
-          button(type: "button", class: "btn-link", commandfor: "sync-dialog", command: "show-modal") { source.label }
-          dialog(id: "sync-dialog", class: "dialog") do
-            render source.form(csrf: @csrf)
+        def render_items
+          p { "No axes yet. Add one in settings to start comparing." }
+          ul(class: "card") do
+            @list.items.each { |item| li { item.name } }
           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
+          div(data_tabs: true) do
+            div(role: "tablist", aria_label: "Axes") do
+              @axes.each do |axis|
+                selected = axis.id == @selected.id
+                button(role: "tab", id: "axis-#{axis.id}-tab", aria_controls: "axis-#{axis.id}",
+                       aria_selected: selected.to_s, tabindex: selected ? 0 : -1) { axis.name }
               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 }
+            @axes.each do |axis|
+              div(role: "tabpanel", id: "axis-#{axis.id}", aria_labelledby: "axis-#{axis.id}-tab",
+                  hidden: axis.id != @selected.id) do
+                render_ranking(axis)
               end
             end
           end
         end
 
-        def render_ratings
-          return if @list.axes.empty?
+        def render_ranking(axis)
+          ranked, unranked = axis.ranking
 
-          section(class: "section") do
-            h2 { "Ratings" }
-            @list.axes.each do |axis|
-              render_axis_ratings(axis)
+          ol(class: "card") do
+            ranked.each.with_index(1) do |item, rank|
+              li do
+                span(class: "type-label") { format("%02d", rank) }
+                plain item.name
+              end
             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)}" }
+            unranked.each do |item|
+              li do
+                span(class: "type-label") { "—" }
+                plain item.name
+                span(class: "type-label") { "Unranked" }
               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 }
+          a(href: "/lists/#{@list.id}/axes/#{axis.id}/compare", class: "btn", data_variant: "primary") do
+            "Compare on #{axis.name}"
+          end
         end
       end
     end
diff --git a/lib/rank_king/views/sources/text.rb b/lib/rank_king/views/sources/text.rb
index 4cf6eee..a19df91 100644
--- a/lib/rank_king/views/sources/text.rb
+++ b/lib/rank_king/views/sources/text.rb
@@ -6,7 +6,9 @@ module RankKing
   module Views
     module Sources
       # The Text source's sync dialog: every item's name, editable in place,
-      # and a box for new ones.
+      # and a box for new ones. It fills a <dialog> as Gloss lays one out:
+      # header, body, footer, so the form is the body and the footer's
+      # Save reaches it by id.
       class Text < Phlex::HTML
         def initialize(list:, csrf:)
           @list = list
@@ -15,22 +17,24 @@ module RankKing
 
         def view_template
           action = "/lists/#{@list.id}/sync"
-          form(method: "post", action:, class: "form") do
+
+          header { "Edit items" }
+          form(id: "sync-form", method: "post", action:) do
             input(type: "hidden", name: "_csrf", value: @csrf.call(action))
-            h2 { "Edit items" }
-            p(class: "empty") { "Clear a name to remove that item." }
+            p { "Clear a name to remove that item." }
             @list.items_dataset.order(:id).each do |item|
               input(type: "text", name: "items[#{item.id}]", value: item.name, aria_label: "Item name")
             end
-            div(class: "field") do
-              label(for: "new-items") { "Add items, one per line" }
-              textarea(name: "new_items", id: "new-items", rows: 3)
-            end
-            div(class: "dialog-actions") do
-              button(type: "button", class: "btn-link", commandfor: "sync-dialog", command: "close") { "Cancel" }
-              button(type: "submit", class: "btn-primary") { "Save" }
+            label(class: "field") do
+              plain "Add items"
+              textarea(name: "new_items", rows: 3)
+              small { "One per line." }
             end
           end
+          footer do
+            button(type: "button", commandfor: "sync-dialog", command: "close") { "Cancel" }
+            button(type: "submit", form: "sync-form", data_variant: "primary") { "Save" }
+          end
         end
       end
     end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 28b47b9..82cdc63 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -5,12 +5,15 @@ require "roda"
 require_relative "models"
 require_relative "proxy_auth"
 require_relative "views/home"
+require_relative "views/list/new"
 require_relative "views/list/show"
+require_relative "views/list/compare"
+require_relative "views/list/settings"
 
 module RankKing
   class Web < Roda
     plugin :halt
-    plugin :static, %w[ /css /favicon.svg ]
+    plugin :static, %w[ /css /js /vendor ]
     plugin :sessions, secret: CONFIG.session_secret
     plugin :route_csrf, csrf_failure: :empty_403, check_request_methods: %w[POST]
     plugin :error_handler, classes: [ProxyAuth::MissingLogin] do |e|
@@ -25,7 +28,7 @@ module RankKing
 
       r.root do
         lists = user.lists_dataset.order(:id).all
-        Views::Home.new(lists: lists, csrf: method(:csrf_token)).call
+        Views::Home.new(lists: lists).call
       end
 
       r.on "lists" do
@@ -34,14 +37,19 @@ module RankKing
             name = r.params["name"].to_s.strip
             r.halt 422 if name.empty?
 
-            item_names = r.params["items"].to_s.lines.map(&:strip).reject(&:empty?)
+            source_kind = r.params["source"] || "Text"
+            r.halt 422 unless Sources::KINDS.include?(source_kind)
+
+            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?)
+            axis_names = names_param(r.params["axes"])
 
             list = DB.transaction do
-              l = List.create(name: name, user_id: user.id, source_kind: "Text")
+              l = List.create(name: name, user_id: user.id, source_kind: source_kind)
               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
@@ -51,6 +59,13 @@ module RankKing
           end
         end
 
+        r.get "new" do
+          kinds = Sources::KINDS.to_h do |kind|
+            [kind, Sources.fetch(kind).title] #: [String, String]
+          end
+          Views::ListViews::New.new(sources: kinds, csrf: method(:csrf_token)).call
+        end
+
         r.on Integer do |list_id|
           # Lists are private to their owner.
           list = user.lists_dataset.where(id: list_id).first
@@ -58,18 +73,53 @@ module RankKing
 
           r.is do
             r.get do
-              Views::ListViews::Show.new(list: list, csrf: method(:csrf_token)).call
+              axis = list.axes_dataset.where(id: r.params["axis"].to_i).first
+              Views::ListViews::Show.new(list: list, axis: axis, csrf: method(:csrf_token)).call
             end
           end
 
-          # Submitted from the source's own sync dialog.
-          r.on "sync" do
+          r.is "settings" do
+            r.get do
+              source = Sources.fetch(list.source_kind).title
+              Views::ListViews::Settings.new(list: list, source: source, csrf: method(:csrf_token)).call
+            end
+
             r.post do
-              list.source.sync(r.params)
+              name = r.params["name"].to_s.strip
+              r.halt 422 if name.empty?
+
+              kept = Array(r.params["axis_ids"]) #: Array[untyped]
+              kept_ids = kept.map { |id| id.to_i }
+              new_names = names_param(r.params["axes"])
+
+              DB.transaction do
+                list.update(name: name)
+                list.axes_dataset.exclude(id: kept_ids).each { |axis| axis.destroy }
+                new_names.each { |n| Axis.create(list_id: list.id, name: n) }
+              end
+
               r.redirect "/lists/#{list.id}"
             end
           end
 
+          # Submitted from the source's own sync dialog.
+          r.post "sync" do
+            list.source.sync(r.params)
+            r.redirect "/lists/#{list.id}"
+          end
+
+          r.post "delete" do
+            DB.transaction { list.destroy }
+            r.redirect "/"
+          end
+
+          r.get "axes", Integer, "compare" do |axis_id|
+            axis = list.axes_dataset.where(id: axis_id).first
+            r.halt 404 unless axis
+
+            Views::ListViews::Compare.new(list: list, axis: axis, csrf: method(:csrf_token)).call
+          end
+
           r.on "results" do
             r.post do
               axis_id = r.params["axis_id"].to_i
@@ -86,11 +136,20 @@ module RankKing
 
               Result.create(axis_id: axis.id, winner_id: winner.id, loser_id: loser.id)
 
-              r.redirect "/lists/#{list.id}"
+              r.redirect "/lists/#{list.id}/axes/#{axis.id}/compare"
             end
           end
         end
       end
     end
+
+    private
+
+    # Names from a repeated field (`axes[]`), one per tag.
+    #: (untyped) -> Array[String]
+    def names_param(value)
+      names = Array(value) #: Array[untyped]
+      names.map { |n| n.to_s.strip }.reject(&:empty?).uniq
+    end
   end
 end
diff --git a/public/css/app.css b/public/css/app.css
index 63ddc03..3899300 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -1,209 +1,155 @@
-/* -------------------- */
-/* 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);
-}
+/* Rank King's own layer over Gloss (public/vendor/gloss). Every value
+   reads a --gl-* token; nothing here restyles a Gloss component, it only
+   lays the components out. */
 
-.site-name {
-  font-weight: 600;
-  letter-spacing: 0.03em;
-  text-decoration: none;
+:root {
+  /* Lucide "ellipsis", drawn the way Gloss draws its own icon tokens.
+     Gloss has no "more" icon yet; this belongs upstream when it does. */
+  --rk-icon-more: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='1'/%3E%3Ccircle cx='19' cy='12' r='1'/%3E%3Ccircle cx='5' cy='12' r='1'/%3E%3C/svg%3E");
 }
 
 /* -------------------- */
-/* Layout */
+/* Page */
 /* -------------------- */
 
 main {
-  flex: 1;
-  max-width: 40rem;
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-m);
+  max-width: 34rem;
   margin-inline: auto;
-  padding: var(--space-m) var(--space-l);
-  width: 100%;
+  padding: var(--gl-space-m) var(--gl-space-s);
 }
 
-/* -------------------- */
-/* Sections */
-/* -------------------- */
+main form { max-width: none; }
 
-.section + .section {
-  margin-block-start: var(--space-l);
-}
+main p { margin: 0; }
 
-.empty {
-  color: var(--color-text-secondary, #666);
-  font-size: var(--step--1);
+/* One row: back, title, then whatever the screen puts at the end. */
+main > header {
+  display: flex;
+  align-items: center;
+  gap: var(--gl-space-2xs);
 }
 
-/* -------------------- */
-/* Forms */
-/* -------------------- */
+main > header h1 { flex: 1; min-width: 0; }
 
-.form {
-  display: grid;
-  gap: var(--space-s);
-}
-
-.field {
-  display: grid;
-  gap: var(--space-3xs);
+section {
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-2xs);
 }
 
-label {
-  font-size: var(--step--1);
-  font-weight: 600;
+/* Actions at the foot of a form: one on the right, or two pushed apart. */
+form footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: var(--gl-space-2xs);
 }
 
-input,
-textarea,
-select {
-  padding: var(--space-3xs) var(--space-2xs);
-  border: 1px solid #ccc;
-  border-radius: 4px;
-  font: inherit;
-  background: #fff;
-}
+form footer:has(> :nth-child(2)) { justify-content: space-between; }
 
-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);
-}
+/* -------------------- */
+/* Fields */
+/* -------------------- */
 
+/* Gloss styles <input> and <select> but not <textarea>; this matches them. */
 textarea {
+  width: 100%;
+  padding: var(--gl-space-xs) calc(var(--gl-space-xs) - 1px);
+  background: var(--gl-color-surface-card);
+  border: 1px solid var(--gl-color-border-default);
+  border-radius: var(--gl-radius-sm);
+  color: var(--gl-color-text-primary);
+  font: 400 var(--gl-step--1)/1.5 var(--gl-font-sans);
+  text-transform: none;
+  letter-spacing: normal;
   resize: vertical;
+  outline: none;
 }
 
-.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;
+/* A value shown in a field's place when it can't be changed. */
+.field > p {
+  font: 400 var(--gl-step--1)/1.5 var(--gl-font-sans);
+  text-transform: none;
+  letter-spacing: normal;
+  color: var(--gl-color-text-primary);
 }
 
-.btn-primary:hover {
-  background: #333;
+[data-tags] > small {
+  font-size: var(--gl-step--2);
+  color: var(--gl-color-text-tertiary);
 }
 
-.btn-link {
+/* The "+ Axis" slot is a dashed tag holding a bare input. */
+.tag[data-dashed] input:not([type="checkbox"]):not([type="radio"]) {
+  width: 8ch;
   padding: 0;
-  border: 0;
-  background: none;
-  color: #666;
+  border: none;
+  background: transparent;
   font: inherit;
-  font-size: 0.875em;
-  text-decoration: underline;
-  cursor: pointer;
 }
 
-.section-header {
-  display: flex;
-  align-items: baseline;
-  gap: var(--space-s);
-}
+/* -------------------- */
+/* List actions menu */
+/* -------------------- */
 
-.dialog {
-  width: min(32rem, 100% - 2 * var(--space-s));
-  padding: var(--space-m);
-  border: 1px solid #ccc;
-  border-radius: 8px;
-}
+main > header [popovertarget] { anchor-name: --rk-list-actions; }
 
-.dialog::backdrop {
-  background: rgba(0, 0, 0, 0.3);
+[popover] {
+  position-anchor: --rk-list-actions;
+  position-area: bottom span-left;
+  min-width: 12rem;
+  margin: var(--gl-space-3xs) 0 0;
+  padding: 0;
+  border: none;
+  background: transparent;
+  overflow: visible;
 }
 
-.dialog-actions {
-  display: flex;
-  justify-content: flex-end;
-  align-items: baseline;
-  gap: var(--space-s);
-}
+[popover] > .card { box-shadow: var(--gl-shadow-float); }
 
 /* -------------------- */
-/* Matchup */
+/* Ranking */
 /* -------------------- */
 
-.matchup {
+[role="tabpanel"]:not([hidden]) {
   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);
+  flex-direction: column;
+  gap: var(--gl-space-s);
 }
 
-.matchup-vs {
-  font-size: var(--step--1);
-  color: var(--color-text-secondary, #666);
-  font-weight: 600;
-}
+/* The rank sits in a fixed gutter; "Unranked" sits at the far end. */
+.card > li:has(> .type-label:first-child) { align-items: baseline; }
 
-.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;
+.card > li > .type-label:first-child {
+  min-width: 2.5ch;
+  color: var(--gl-color-text-tertiary);
 }
 
-.btn-choice:hover {
-  border-color: #1a1a1a;
-  background: #f5f5f5;
+.card > li > .type-label:last-child:not(:first-child) {
+  margin-inline-start: auto;
+  color: var(--gl-color-text-tertiary);
 }
 
 /* -------------------- */
-/* Ratings */
+/* Compare */
 /* -------------------- */
 
-.axis-ratings {
-  margin-block-end: var(--space-s);
+[data-matchup] {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: var(--gl-space-xs);
 }
 
-.rating-list {
-  padding-inline-start: var(--space-m);
+[data-matchup] button {
+  width: 100%;
+  min-height: 8rem;
+  padding: var(--gl-space-s);
+  background: var(--gl-color-surface-card);
+  border-radius: var(--gl-radius-lg);
+  font: 600 var(--gl-step-1)/1.25 var(--gl-font-sans);
+  white-space: normal;
 }
 
-.rating-list li {
-  padding-block: var(--space-3xs);
-}
+[data-matchup] + .btn { align-self: center; }
diff --git a/public/css/reset.css b/public/css/reset.css
deleted file mode 100644
index 1959e7c..0000000
--- a/public/css/reset.css
+++ /dev/null
@@ -1,79 +0,0 @@
-/* -------------------- */
-/* 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
deleted file mode 100644
index f5d9f67..0000000
--- a/public/css/utopia.css
+++ /dev/null
@@ -1,83 +0,0 @@
-/* -------------------- */
-/* 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/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..f2ea4f5
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,43 @@
+// Axis tags: Enter turns what was typed into a tag, × removes one. See
+// lib/rank_king/views/axis_tags.rb for the markup this expects.
+(function () {
+  "use strict";
+
+  document.addEventListener("keydown", function (event) {
+    var input = event.target;
+    if (event.key !== "Enter" || !input.closest || !input.closest("[data-tags] [data-dashed]")) return;
+
+    event.preventDefault();
+    var name = input.value.trim();
+    if (!name) return;
+
+    var tag = document.createElement("span");
+    tag.className = "tag";
+    tag.append(name);
+
+    var hidden = document.createElement("input");
+    hidden.type = "hidden";
+    hidden.name = "axes[]";
+    hidden.value = name;
+
+    var remove = document.createElement("button");
+    remove.type = "button";
+    remove.setAttribute("aria-label", "Remove " + name);
+    remove.textContent = "×";
+
+    tag.append(hidden, remove);
+    input.closest("[data-dashed]").before(tag);
+    input.value = "";
+  });
+
+  document.addEventListener("click", function (event) {
+    var remove = event.target.closest("[data-tags] .tag button");
+    if (remove) remove.closest(".tag").remove();
+  });
+
+  // A destructive form asks first: <form data-confirm="Delete this list?">.
+  document.addEventListener("submit", function (event) {
+    var message = event.target.dataset.confirm;
+    if (message && !window.confirm(message)) event.preventDefault();
+  });
+})();
diff --git a/public/vendor/gloss/base.css b/public/vendor/gloss/base.css
new file mode 100644
index 0000000..2ebe7ff
--- /dev/null
+++ b/public/vendor/gloss/base.css
@@ -0,0 +1,178 @@
+/* Vendored from kejadlen/gloss @ b9484458e0c83d957f1265c8ec54ffaaace4dea1, 2026-10-27.
+   Refresh with `rake gloss:vendor GLOSS=/path/to/gloss/checkout`. */
+
+/* A short reset. Everything it sets is something the system would otherwise
+   have to override in every component.
+
+   The rem values used for this documentation site's own chrome (measure,
+   content width, sidebar width, header height) belong to this site, not to
+   the design system it documents — the source design system defines no
+   container-width tokens at all, so there is nothing under `--gl-*` to reach
+   for here. See the header comment in site/shell.css. */
+
+*,
+*::before,
+*::after { box-sizing: border-box; }
+
+html {
+  -webkit-text-size-adjust: 100%;
+  scroll-behavior: smooth;
+  scroll-padding-top: calc(3.5rem + var(--gl-space-s));
+}
+
+@media (prefers-reduced-motion: reduce) {
+  html { scroll-behavior: auto; }
+  *,
+  *::before,
+  *::after {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+  }
+}
+
+body {
+  margin: 0;
+  background: var(--gl-color-surface-page);
+  color: var(--gl-color-text-primary);
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step-0);
+  line-height: 1.55;
+  font-synthesis-weight: none;
+  -webkit-font-smoothing: antialiased;
+  text-rendering: optimizeLegibility;
+}
+
+::selection {
+  background: var(--gl-color-accent-soft);
+  color: var(--gl-color-accent-ink);
+}
+
+h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd { margin: 0; }
+ul[class], ol[class] { list-style: none; margin: 0; padding: 0; }
+
+img, svg, video { display: block; max-width: 100%; height: auto; }
+
+button, input, select, textarea {
+  font: inherit;
+  color: inherit;
+}
+
+hr {
+  height: 1px;
+  margin: var(--gl-space-l) 0;
+  border: 0;
+  background: var(--gl-color-border-hairline);
+}
+
+/* One focus ring, defined once. No component is allowed its own. */
+:focus-visible {
+  outline: 2px solid var(--gl-color-accent);
+  outline-offset: 2px;
+  border-radius: var(--gl-radius-sm);
+}
+:focus:not(:focus-visible) { outline: none; }
+
+[hidden] { display: none !important; }
+
+.gl-visually-hidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+  border: 0;
+}
+
+/* Every rule here reads a type token for family and size. Weight, line-height,
+   and letter-spacing are hardcoded to the exact numbers the source ships —
+   there is no `--gl-weight-*` / `--gl-leading-*` / `--gl-tracking-*` scale in
+   the real system, only eight fixed compound styles (see the Typography
+   foundation page, which this file implements verbatim). */
+
+.type-display { font: 600 var(--gl-step-4)/1.08 var(--gl-font-sans); letter-spacing: -0.015em; }
+.type-h1, h1 { font: 600 var(--gl-step-3)/1.12 var(--gl-font-sans); letter-spacing: -0.012em; }
+.type-h2, h2 { font: 600 var(--gl-step-2)/1.16 var(--gl-font-sans); letter-spacing: -0.01em; }
+.type-h3, h3 { font: 600 var(--gl-step-1)/1.25 var(--gl-font-sans); }
+.type-body    { font: 400 var(--gl-step-0)/1.55 var(--gl-font-sans); }
+.type-body-sm { font: 400 var(--gl-step--1)/1.5 var(--gl-font-sans); }
+.type-label {
+  font: 500 var(--gl-step--2)/1 var(--gl-font-mono);
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--gl-color-text-secondary);
+}
+.type-mono { font: 400 var(--gl-step--1)/1.5 var(--gl-font-mono); }
+
+/* Prose defaults for the documentation body copy. These read the same
+   tokens the compound classes above do; they are not a second scale. */
+h1, h2, h3, h4 { font-weight: 600; text-wrap: balance; }
+
+h4 { font: 600 var(--gl-step-0)/1.3 var(--gl-font-sans); }
+
+p, ul, ol { margin: 0 0 var(--gl-space-s); }
+li + li { margin-top: var(--gl-space-3xs); }
+
+a {
+  color: var(--gl-color-accent);
+  text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
+  text-underline-offset: 0.18em;
+  text-decoration-thickness: 1px;
+  transition: color var(--gl-dur-fast) var(--gl-ease),
+              text-decoration-color var(--gl-dur-fast) var(--gl-ease);
+}
+a:hover {
+  color: var(--gl-color-accent-ink);
+  text-decoration-color: currentColor;
+}
+
+strong, b { font-weight: 600; }
+
+small { font-size: var(--gl-step--1); }
+
+.gl-lead {
+  font-size: var(--gl-step-1);
+  line-height: 1.5;
+  color: var(--gl-color-text-secondary);
+  max-width: 68ch;
+  text-wrap: pretty;
+}
+
+.gl-muted { color: var(--gl-color-text-secondary); }
+.gl-subtle { color: var(--gl-color-text-tertiary); }
+
+code, kbd, samp, pre {
+  font-family: var(--gl-font-mono);
+  font-feature-settings: "liga" 0;
+}
+
+:not(pre) > code {
+  padding: 0.15em 0.4em;
+  font-size: 0.875em;
+  background: var(--gl-color-surface-fill);
+  border: 1px solid var(--gl-color-border-hairline);
+  border-radius: var(--gl-radius-sm);
+  white-space: nowrap;
+}
+
+kbd {
+  display: inline-block;
+  padding: 0.1em 0.45em;
+  font-size: var(--gl-step--2);
+  line-height: 1.6;
+  background: var(--gl-color-surface-card);
+  border: 1px solid var(--gl-color-border-default);
+  border-bottom-width: 2px;
+  border-radius: var(--gl-radius-sm);
+  color: var(--gl-color-text-secondary);
+}
+
+blockquote {
+  margin-block: var(--gl-space-s);
+  padding-left: var(--gl-space-s);
+  border-left: 2px solid var(--gl-color-accent);
+  color: var(--gl-color-text-secondary);
+}
diff --git a/public/vendor/gloss/components.css b/public/vendor/gloss/components.css
new file mode 100644
index 0000000..018bccd
--- /dev/null
+++ b/public/vendor/gloss/components.css
@@ -0,0 +1,964 @@
+/* Vendored from kejadlen/gloss @ b9484458e0c83d957f1265c8ec54ffaaace4dea1, 2026-10-27.
+   Refresh with `rake gloss:vendor GLOSS=/path/to/gloss/checkout`. */
+
+/* Icon
+   Icons are tokens (--gl-icon-* in tokens.css), placed by setting a custom
+   property — no <svg> in the markup. Adapted from Lea Verou's @nudeui/icons
+   0.0.3 (MIT), https://lea.verou.me/blog/2026/icons/, minus its opt-in
+   classes: style queries reach every browser this system supports, and
+   the class list is closed.
+
+   --gl-icon-start renders in ::before, --gl-icon-end in ::after. --gl-icon
+   means "give this component an icon"; each component redirects it to a
+   location (see button.css). An icon takes over that pseudo-element, so a
+   component that already draws its own ::before or ::after must not
+   redirect there.
+
+   All three are non-inheriting: an icon on a <p> must not repeat on every
+   <a> inside it. The pseudo-element inherits explicitly instead. */
+
+@property --gl-icon {
+  syntax: "*";
+  inherits: false;
+}
+@property --gl-icon-start {
+  syntax: "*";
+  inherits: false;
+}
+@property --gl-icon-end {
+  syntax: "*";
+  inherits: false;
+}
+
+/* A 1em square of the text color, masked by the icon. The image-set()
+   first candidate is a transparent placeholder for the box's content; the
+   second only exists to make the declaration fail — and so drop the
+   pseudo-element — when the icon property is unset. overflow: hidden puts
+   the box's baseline at its bottom edge so vertical-align can center it on
+   the cap height (https://blog.kizu.dev/cap-height-align/). */
+@container style(--gl-icon-start) {
+  ::before {
+    --gl-icon-start: inherit;
+    content: image-set(linear-gradient(transparent 0 100%), var(--gl-icon-start));
+    display: inline-block;
+    vertical-align: calc(0.5cap - var(--gl-icon-style-size, 1em) / 2 + var(--gl-icon-style-voffset, -0.02em));
+    overflow: hidden;
+    width: var(--gl-icon-style-size, 1em);
+    height: var(--gl-icon-style-size, 1em);
+    background-color: var(--gl-icon-style-color, currentColor);
+    mask: var(--gl-icon-start) no-repeat center / contain;
+    pointer-events: none;
+    forced-color-adjust: none;
+    flex: none;
+  }
+  :where(:not(:empty))::before { margin-inline-end: var(--gl-icon-style-spacing, 0.3em); }
+}
+
+@container style(--gl-icon-end) {
+  ::after {
+    --gl-icon-end: inherit;
+    content: image-set(linear-gradient(transparent 0 100%), var(--gl-icon-end));
+    display: inline-block;
+    vertical-align: calc(0.5cap - var(--gl-icon-style-size, 1em) / 2 + var(--gl-icon-style-voffset, -0.02em));
+    overflow: hidden;
+    width: var(--gl-icon-style-size, 1em);
+    height: var(--gl-icon-style-size, 1em);
+    background-color: var(--gl-icon-style-color, currentColor);
+    mask: var(--gl-icon-end) no-repeat center / contain;
+    pointer-events: none;
+    forced-color-adjust: none;
+    flex: none;
+  }
+  :where(:not(:empty))::after { margin-inline-start: var(--gl-icon-style-spacing, 0.3em); }
+}
+
+/* WebKit only, each fix in its own layer so any unlayered rule beats it.
+   https://bugs.webkit.org/show_bug.cgi?id=301609 — icons stay missing
+   until the first interaction; an animation forces style to re-resolve
+   against the right container. Its default duration is already zero.
+   https://bugs.webkit.org/show_bug.cgi?id=325900 — `inherit` on a
+   non-inheriting registered property is unreliable, so WebKit gets them
+   inheriting (the later registration wins) and descendants reset instead. */
+@keyframes gl-webkit-301609 {}
+
+@supports ((-webkit-nbsp-mode: normal) or (-webkit-touch-callout: none) or (font: -apple-system-body)) and (content-visibility: auto) {
+  @layer gl-webkit-301609 {
+    ::before,
+    ::after { animation-name: gl-webkit-301609; }
+  }
+
+  @property --gl-icon-start {
+    syntax: "*";
+    inherits: true;
+  }
+  @property --gl-icon-end {
+    syntax: "*";
+    inherits: true;
+  }
+
+  @layer gl-webkit-325900 {
+    @container style(--gl-icon-start) {
+      * { --gl-icon-start: initial; }
+    }
+    @container style(--gl-icon-end) {
+      * { --gl-icon-end: initial; }
+    }
+  }
+}
+
+/* Button
+   No border, no ornament — hierarchy is ink density, not an outline. A
+   STAMP (primary, dark, danger) is a solid block of colour; a QUIET action
+   (default, ghost) carries no fill at rest and picks up a flat tint of the
+   stock on hover, the same gesture as a CardRow. Variants: default, primary,
+   dark, ghost, danger. Sizes: sm, md (default), lg — padding and font step
+   only, weight and radius stay put.
+
+   The default used to pad itself with --gl-space-xs/-s, which are the rungs
+   a layout reaches for between two controls, not inside one: on a wide
+   viewport they grew the default button taller than the large one. Every
+   size now sits one rung further down the same scale — see the block at
+   the bottom.
+
+   The contract is the element itself: a bare <button> is styled directly, no
+   class required. An <a> that acts like a button (navigates rather than
+   changes something — see the Button page's "Links that act like buttons")
+   opts in with a single `.btn` class, since a bare `a` selector would catch
+   every ordinary prose link on the site. `:not(.icon-button)` keeps this
+   block from leaking onto IconButton, which shares the <button> tag but is
+   an entirely different shape. */
+
+button:not(.icon-button),
+a.btn {
+  --_bg: transparent;
+  --_fg: var(--gl-color-text-primary);
+  --_pad-y: var(--gl-space-2xs);
+  --_pad-x: var(--gl-space-xs);
+  --_font: var(--gl-step--1);
+  /* The flex gap already separates the icon from the label. */
+  --gl-icon-start: var(--gl-icon);
+  --gl-icon-style-spacing: 0;
+
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--gl-space-2xs);
+  padding: var(--_pad-y) var(--_pad-x);
+  border: none;
+  border-radius: var(--gl-radius-md);
+  background: var(--_bg);
+  color: var(--_fg);
+  font-family: var(--gl-font-mono);
+  font-size: var(--_font);
+  font-weight: 500;
+  letter-spacing: 0.01em;
+  line-height: 1.15;
+  text-align: center;
+  text-decoration: none;
+  white-space: nowrap;
+  cursor: pointer;
+  transition: background-color var(--gl-dur-fast) var(--gl-ease),
+              color var(--gl-dur-fast) var(--gl-ease),
+              filter var(--gl-dur-fast) var(--gl-ease);
+}
+
+/* Quiet variants (default, ghost) carry no fill at rest; hovering picks up
+   a flat tint of the stock instead of darkening a border that isn't there.
+   Tabs are <button>s too but own their hover (tabs.css): this rule's
+   specificity would otherwise paint the selected segment the colour of its
+   own track until the pointer left it. */
+:is(button:not(.icon-button), a.btn):not([role="tab"]):not([data-variant="primary"]):not([data-variant="dark"]):not([data-variant="danger"]):hover:not(:disabled):not([aria-disabled="true"]) {
+  background: var(--gl-color-surface-fill);
+  color: var(--gl-color-text-primary);
+}
+
+/* Stamp variants (primary, dark, danger) are already ink — hovering just
+   darkens the fill in place. */
+:is(button:not(.icon-button), a.btn)[data-variant="primary"]:hover:not(:disabled):not([aria-disabled="true"]),
+:is(button:not(.icon-button), a.btn)[data-variant="dark"]:hover:not(:disabled):not([aria-disabled="true"]),
+:is(button:not(.icon-button), a.btn)[data-variant="danger"]:hover:not(:disabled):not([aria-disabled="true"]) { filter: brightness(0.96); }
+:is(button:not(.icon-button), a.btn)[data-variant="primary"]:active:not(:disabled):not([aria-disabled="true"]),
+:is(button:not(.icon-button), a.btn)[data-variant="dark"]:active:not(:disabled):not([aria-disabled="true"]),
+:is(button:not(.icon-button), a.btn)[data-variant="danger"]:active:not(:disabled):not([aria-disabled="true"]) { filter: brightness(0.92); }
+
+button:not(.icon-button):disabled,
+:is(button:not(.icon-button), a.btn)[aria-disabled="true"] {
+  opacity: 0.5;
+  cursor: default;
+}
+
+:is(button:not(.icon-button), a.btn)[data-variant="primary"] {
+  --_bg: var(--gl-color-accent);
+  --_fg: var(--gl-color-accent-fg);
+}
+
+/* Reserved for the single most-committing action in a view — a final Save,
+   never a whole row of them. */
+:is(button:not(.icon-button), a.btn)[data-variant="dark"] {
+  --_bg: var(--gl-color-text-primary);
+  --_fg: var(--gl-color-surface-page);
+}
+
+/* Same quiet block as default, one step further back: text-secondary at
+   rest instead of text-primary. */
+:is(button:not(.icon-button), a.btn)[data-variant="ghost"] {
+  --_fg: var(--gl-color-text-secondary);
+}
+
+/* The source hardcodes this label white rather than deriving it — danger
+   is not "on a filled accent surface", so --gl-color-accent-fg is reused
+   here only because its resolved value is the same literal white, not
+   because the two roles are the same thing. */
+:is(button:not(.icon-button), a.btn)[data-variant="danger"] {
+  --_bg: var(--gl-color-danger);
+  --_fg: var(--gl-color-accent-fg);
+}
+
+/* Each size is one step of the space scale wide and, at the ends, one step
+   of the type scale. Medium and large share a rung vertically and part on
+   type size, which is what keeps them in order at every viewport width: the
+   scale is fluid, so two sizes that differed only by which fluid rung they
+   padded with could — and did — cross over on a wide screen. */
+:is(button:not(.icon-button), a.btn)[data-size="sm"] { --_pad-y: var(--gl-space-3xs); --_pad-x: var(--gl-space-2xs); --_font: 0.8125rem; }
+:is(button:not(.icon-button), a.btn)[data-size="lg"] { --_pad-x: var(--gl-space-s); --_font: var(--gl-step-0); }
+
+:is(button:not(.icon-button), a.btn)[data-block] { width: 100%; }
+
+/* Button group — [role="group"] is already the correct ARIA role for a
+   toolbar of related buttons, so it doubles as the styling hook for free.
+   A hairline divider stands in for the border the buttons themselves no
+   longer carry — without one, two adjacent quiet buttons would read as a
+   single wide button. */
+[role="group"] {
+  display: inline-flex;
+  isolation: isolate;
+}
+[role="group"] > button { border-radius: 0; }
+[role="group"] > button:first-child { border-start-start-radius: var(--gl-radius-md); border-end-start-radius: var(--gl-radius-md); }
+[role="group"] > button:last-child { border-start-end-radius: var(--gl-radius-md); border-end-end-radius: var(--gl-radius-md); }
+[role="group"] > button + button { border-inline-start: 1px solid var(--gl-color-border-hairline); }
+[role="group"] > button:hover,
+[role="group"] > button:focus-visible { z-index: 1; }
+
+/* IconButton — a square icon-only button. Always needs an aria-label; it
+   becomes both the accessible name and the native tooltip. There is no
+   attribute that distinguishes "a button that is only an icon" from a plain
+   Button, so this is the one sanctioned class in this file.
+
+   The box is not a number of its own: it is written the way Button's height
+   arrives — two rungs of padding around one 1.15 line of the label type it
+   would carry if it had a label. So the two are the same height at every
+   viewport width, and stay that way when the scale is retuned, instead of a
+   fixed square drifting away from a fluid button. */
+.icon-button {
+  --_pad: var(--gl-space-2xs);
+  --_line: var(--gl-step--1);
+  --_size: calc(2 * var(--_pad) + 1.15 * var(--_line));
+  --_bg: transparent;
+  --_fg: var(--gl-color-text-secondary);
+  /* No spacing, so stray whitespace in the markup can't push the icon
+     off-center. */
+  --gl-icon-start: var(--gl-icon);
+  --gl-icon-style-spacing: 0;
+
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: var(--_size);
+  height: var(--_size);
+  padding: 0;
+  flex: none;
+  border: none;
+  border-radius: var(--gl-radius-md);
+  background: var(--_bg);
+  color: var(--_fg);
+  font-size: var(--_line);
+  cursor: pointer;
+  transition: background-color var(--gl-dur-fast) var(--gl-ease),
+              color var(--gl-dur-fast) var(--gl-ease);
+}
+.icon-button:hover:not(:disabled) { background: var(--gl-color-surface-fill); }
+.icon-button:disabled { opacity: 0.5; cursor: default; }
+
+/* Same two overrides Button's own sm and lg make, so the pairs stay level. */
+.icon-button[data-size="sm"] { --_pad: var(--gl-space-3xs); --_line: 0.8125rem; }
+.icon-button[data-size="lg"] { --_line: var(--gl-step-0); }
+
+/* No border on either variant — "outline" keeps its name for API
+   compatibility, but reads as a raised fill instead of a bordered box. */
+.icon-button[data-variant="outline"] { --_bg: var(--gl-color-surface-fill); --_fg: var(--gl-color-text-primary); }
+.icon-button[data-variant="accent"]  { --_bg: var(--gl-color-accent-soft); --_fg: var(--gl-color-accent-ink); }
+
+/* Field
+   The labeled field wrapper — a <label> that wraps its own caption text
+   together with the control, so the association is native and needs no
+   `for`/`id` pair. The caption is mono, uppercase, tracked — the same
+   catalog-label convention as Badge and type-label.
+
+   A <label> is reused for three different shapes across this system (this
+   wrapper, Checkbox/Radio's row, Switch's row), so it cannot be styled bare
+   without one shape leaking into another. Field is the one of the three with
+   no distinguishing attribute of its own, so it gets the sanctioned class;
+   Checkbox/Radio tell themselves apart structurally instead (see below). */
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-2xs);
+  min-width: 0;
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--2);
+  font-weight: 500;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--gl-color-text-secondary);
+}
+
+/* Hint and error read the native fine-print element. `role="alert"` is a
+   real live-region role for validation text, so it doubles as the selector
+   that turns a hint red. */
+.field > small {
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--2);
+  text-transform: none;
+  letter-spacing: normal;
+  font-weight: 400;
+  color: var(--gl-color-text-tertiary);
+}
+.field > small[role="alert"] { color: var(--gl-color-danger); }
+
+/* Input / Select — bordered, matched to each other exactly. Both tags ARE
+   the component, so they are styled bare; nothing else on this site uses a
+   text input or a select. Inline padding is the xs rung less the border;
+   block padding is a full xs rung, so a control stands a touch taller than
+   a default Button. */
+input:not([type="checkbox"]):not([type="radio"]),
+select {
+  width: 100%;
+  padding: var(--gl-space-xs) calc(var(--gl-space-xs) - 1px);
+  line-height: 1.15;
+  background: var(--gl-color-surface-card);
+  border: 1px solid var(--gl-color-border-default);
+  border-radius: var(--gl-radius-sm);
+  color: var(--gl-color-text-primary);
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--1);
+  text-transform: none;
+  letter-spacing: normal;
+  font-weight: 400;
+  outline: none;
+  transition: border-color var(--gl-dur-fast) var(--gl-ease),
+              box-shadow var(--gl-dur-fast) var(--gl-ease);
+}
+input::placeholder { color: var(--gl-color-text-tertiary); }
+
+input:not([type="checkbox"]):not([type="radio"]):focus,
+select:focus {
+  border-color: var(--gl-color-accent);
+  box-shadow: 0 0 0 2px var(--gl-color-accent-soft);
+}
+
+input:not([type="checkbox"]):not([type="radio"]):disabled,
+select:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+select {
+  appearance: none;
+  padding-inline-end: var(--gl-space-l);
+  background-image: var(--gl-icon-chevron-down);
+  background-repeat: no-repeat;
+  background-position: right var(--gl-space-xs) center;
+}
+
+input[aria-invalid="true"],
+select[aria-invalid="true"] {
+  border-color: var(--gl-color-danger);
+  background-color: var(--gl-color-danger-bg);
+}
+
+/* Checkbox / Radio — the real native controls, tinted with accent-color
+   instead of a hand-drawn box. This is not just a rename: it deletes the
+   custom SVG-checkmark markup entirely and lets the browser draw its own
+   (platform-appropriate) check and dot. `role="switch"` is excluded here so
+   Switch — a checkbox playing a different role — can style itself below
+   without this rule fighting it. A <label> wrapping a checkbox/radio is told
+   apart from Field's <label> structurally: it :has() one as a direct child,
+   no class required. */
+input[type="checkbox"]:not([role="switch"]),
+input[type="radio"] {
+  width: 1.125rem;
+  height: 1.125rem;
+  margin: 0;
+  accent-color: var(--gl-color-accent);
+  cursor: pointer;
+}
+input[type="checkbox"]:not([role="switch"]):focus-visible,
+input[type="radio"]:focus-visible {
+  outline: 2px solid var(--gl-color-accent);
+  outline-offset: 2px;
+}
+
+label:has(> input[type="checkbox"]:not([role="switch"])),
+label:has(> input[type="radio"]) {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--gl-space-2xs);
+  cursor: pointer;
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--1);
+  font-weight: 400;
+  text-transform: none;
+  letter-spacing: normal;
+  color: var(--gl-color-text-primary);
+}
+label:has(> input:disabled) { cursor: default; opacity: 0.5; }
+
+/* Switch — for on/off settings only, never a substitute for Checkbox.
+   `input[type="checkbox"][role="switch"]` is real ARIA (the correct role for
+   a toggle), not an invented hook, but the visual track+thumb needs a second
+   element for the thumb's independent position and transition — more than
+   `appearance: none` plus pseudo-elements on the input itself can cleanly
+   carry — so `.switch` is a deliberate, documented fallback class rather
+   than a first choice. */
+.switch {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--gl-space-2xs);
+  cursor: pointer;
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--1);
+  color: var(--gl-color-text-primary);
+}
+.switch input[role="switch"] { position: absolute; opacity: 0; width: 2.25rem; height: 1.25rem; margin: 0; }
+
+.switch span {
+  position: relative;
+  flex: none;
+  width: 2.25rem;
+  height: 1.25rem;
+  border-radius: var(--gl-radius-full);
+  background: var(--gl-color-border-default);
+  transition: background-color var(--gl-dur-base) var(--gl-ease);
+}
+.switch span::after {
+  content: "";
+  position: absolute;
+  inset-block-start: 2px;
+  inset-inline-start: 2px;
+  width: 1rem;
+  height: 1rem;
+  border-radius: var(--gl-radius-full);
+  background: var(--gl-color-accent-fg);
+  box-shadow: var(--gl-shadow-sm);
+  transition: translate var(--gl-dur-base) var(--gl-ease);
+}
+.switch input[role="switch"]:checked + span { background: var(--gl-color-accent); }
+.switch input[role="switch"]:checked + span::after { translate: 1rem 0; }
+.switch input[role="switch"]:focus-visible + span { outline: 2px solid var(--gl-color-accent); outline-offset: 2px; }
+.switch:has(input:disabled) { cursor: default; opacity: 0.5; }
+
+/* Rows of fields — used by the settings-form pattern. `<form>`/`<fieldset>`
+   are the elements that already mean this, so they are styled bare. A row
+   grouping two fields side by side has no element of its own, so it keeps
+   one plain class. */
+form {
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-s);
+  max-width: 28rem;
+}
+.form-row {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+  gap: var(--gl-space-xs);
+}
+/* Vertically stacked fields — the other half of form layout alongside
+   .form-row's side-by-side pair. */
+.field-stack { display: flex; flex-direction: column; gap: var(--gl-space-s); }
+fieldset {
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-2xs);
+  margin: 0;
+  padding: 0;
+  border: 0;
+}
+fieldset > legend {
+  padding: 0 0 var(--gl-space-2xs);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--2);
+  font-weight: 500;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--gl-color-text-secondary);
+}
+
+/* Badge
+   A catalog mark, not a tinted pill: a small ink square in the tone colour
+   plus mono uppercase text, on no background. No border, no radius — status
+   reads as a marginal annotation on the row it belongs to, not a control.
+
+   A <span> is too generic to style globally — it is used all over running
+   prose for unrelated reasons — so Badge gets the one sanctioned class this
+   shape needs. Tone is an attribute, not a modifier class. */
+
+.badge {
+  --_fg: var(--gl-color-text-secondary);
+
+  display: inline-flex;
+  align-items: center;
+  gap: 0.375rem;
+  color: var(--_fg);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--2);
+  font-weight: 500;
+  text-transform: uppercase;
+  letter-spacing: 0.09em;
+  line-height: 1;
+  white-space: nowrap;
+}
+.badge::before {
+  content: "";
+  width: 0.375rem;
+  height: 0.375rem;
+  flex: none;
+  background: var(--_fg);
+}
+
+.badge[data-tone="accent"]  { --_fg: var(--gl-color-accent-ink); }
+.badge[data-tone="success"] { --_fg: var(--gl-color-success); }
+.badge[data-tone="danger"]  { --_fg: var(--gl-color-danger); }
+
+.badge-set {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--gl-space-2xs);
+}
+
+/* Tag
+   A removable mono chip for freeform tags — locations, categories.
+   `[data-dashed]` renders an "add tag" affordance instead of a value. Same
+   reasoning as Badge: <span> is too generic to select bare, so Tag gets one
+   class. The remove control is a real <button>, styled through the
+   descendant selector rather than a class of its own. */
+
+.tag {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.375rem;
+  padding: 0.25rem 0.625rem;
+  border: none;
+  border-radius: var(--gl-radius-sm);
+  background: var(--gl-color-surface-fill);
+  color: var(--gl-color-text-secondary);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--2);
+  line-height: 1;
+}
+
+.tag[data-dashed] {
+  background: transparent;
+  border-style: dashed;
+  color: var(--gl-color-text-tertiary);
+  cursor: pointer;
+}
+
+.tag button {
+  all: unset;
+  cursor: pointer;
+  color: var(--gl-color-text-tertiary);
+  line-height: 1;
+  font-size: 0.75rem;
+}
+.tag button:hover { color: var(--gl-color-text-primary); }
+
+.tag-set {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.375rem;
+}
+
+/* Card
+   Flat surface, no border, radius-lg. `--gl-color-surface-card` is one
+   neutral step lighter than the page around it — that tonal step, not an
+   outline, is what marks the edge. `[data-elevated]` is opt-in and reserved
+   for the one focused/primary card in a view — a dialog, the primary
+   capture card — flat is the default everywhere else.
+
+   A <div>/<article> is too generic to select bare, so Card keeps one
+   sanctioned class. When Card's job is holding a list of rows — the common
+   case, entry lists, task lists, settings rows — it is a real <ul class="
+   card">, and CardRow is a bare <li> underneath it: no second class needed,
+   the row look comes from the `.card > li` descendant selector alone. The
+   first row's top border is removed automatically so callers never have to
+   override it by hand. */
+
+.card {
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  background: var(--gl-color-surface-card);
+  border: none;
+  border-radius: var(--gl-radius-lg);
+  box-shadow: none;
+  overflow: hidden;
+}
+
+.card[data-elevated] { box-shadow: var(--gl-shadow-float); }
+
+.card > li {
+  display: flex;
+  align-items: center;
+  gap: var(--gl-space-xs);
+  padding: var(--gl-space-xs) var(--gl-space-s);
+  border-top: 1px solid var(--gl-color-border-hairline);
+  color: var(--gl-color-text-primary);
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--1);
+}
+.card > li:first-child { border-top: none; }
+
+/* A row whose ENTIRE content is wrapped in a real <button> or <a> — the
+   accessible way to make a row clickable — fills the row edge-to-edge and
+   picks up a hover fill and pointer cursor. `:only-child` is what tells this
+   apart from a row that merely contains a small button among other content
+   (a leading checkbox next to a title and a trailing value, say): only a
+   button/anchor that IS the row's one and only child means "the whole row
+   is the control." */
+.card > li:has(> button:only-child),
+.card > li:has(> a:only-child) {
+  padding: 0;
+}
+.card > li > button:only-child,
+.card > li > a:only-child {
+  all: unset;
+  display: flex;
+  align-items: center;
+  gap: var(--gl-space-xs);
+  width: 100%;
+  padding: var(--gl-space-xs) var(--gl-space-s);
+  box-sizing: border-box;
+  color: inherit;
+  font: inherit;
+  cursor: pointer;
+  transition: background-color var(--gl-dur-fast) var(--gl-ease);
+}
+.card > li > button:only-child:hover,
+.card > li > a:only-child:hover { background: var(--gl-color-surface-fill); }
+
+/* Documentation-only extras: a body/footer pair and a responsive grid, used
+   by the prose pages (rationale.md, index.md, refusals.md) to show a Card
+   as a plain content tile rather than a data row. Neither is part of
+   Card's props — they exist because a content tile's inner layout has no
+   element of its own to hang a selector on. .card-body ul is the same idea
+   applied to a checklist tile (refusals.md) instead of a single paragraph. */
+.card-body {
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-2xs);
+  padding: var(--gl-space-m);
+}
+.card-body h3 { margin: 0; font: 600 var(--gl-step-0)/1.3 var(--gl-font-sans); }
+.card-body p,
+.card-body ul { margin: 0; color: var(--gl-color-text-secondary); font-size: var(--gl-step--1); }
+.card-body ul { padding-left: 1.125em; }
+.card-footer {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--gl-space-xs);
+  margin-top: auto;
+  padding: var(--gl-space-xs) var(--gl-space-m);
+  border-top: 1px solid var(--gl-color-border-hairline);
+  background: var(--gl-color-surface-fill);
+}
+
+.card[data-link] { transition: box-shadow var(--gl-dur-fast) var(--gl-ease); }
+.card[data-link]:hover { box-shadow: var(--gl-shadow-sm); }
+.card[data-link]:focus-within { outline: 2px solid var(--gl-color-accent); outline-offset: 2px; }
+
+.card-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
+  gap: var(--gl-space-s);
+}
+
+/* Avatar — lives here because it almost never appears outside a card or a
+   row (the Loop dashboard's initials chip, Archive's file-type chip). A
+   <span> is too generic for a bare selector, so it keeps one class. */
+.avatar {
+  --_size: 1.875rem;
+  --gl-icon-start: var(--gl-icon);
+  /* Sized from the box, not the initials' small font, so it follows data-size. */
+  --gl-icon-style-size: calc(var(--_size) * 0.55);
+  --gl-icon-style-spacing: 0;
+
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: var(--_size);
+  height: var(--_size);
+  flex: none;
+  border-radius: var(--gl-radius-full);
+  background: var(--gl-color-accent-soft);
+  color: var(--gl-color-accent-ink);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--2);
+  font-weight: 600;
+  overflow: hidden;
+}
+.avatar[data-square] { border-radius: var(--gl-radius-md); background: var(--gl-color-surface-fill); color: var(--gl-color-text-secondary); border: 1px solid var(--gl-color-border-hairline); }
+.avatar[data-size="sm"] { --_size: 1.5rem; }
+.avatar[data-size="lg"] { --_size: 2.25rem; }
+
+/* Tooltip — a dark mono label on hover, positioned above the child. Pure CSS:
+   the trigger and bubble are siblings inside one relatively-positioned span,
+   shown on :hover/:focus-within so it works from the keyboard too.
+
+   `[role="tooltip"]` is the correct ARIA role for the bubble itself, so it
+   doubles as the styling hook — zero classes there. The wrapping span has no
+   attribute of its own, but it does not need one: `:has()` picks out "a span
+   that contains a [role=tooltip] bubble" precisely, without reaching for a
+   class on an otherwise-generic element. */
+span:has(> [role="tooltip"]) {
+  position: relative;
+  display: inline-flex;
+}
+
+[role="tooltip"] {
+  position: absolute;
+  bottom: 100%;
+  left: 50%;
+  transform: translateX(-50%);
+  margin-bottom: 0.375rem;
+  padding: 0.25rem 0.5rem;
+  border-radius: var(--gl-radius-sm);
+  background: var(--gl-color-text-primary);
+  color: var(--gl-color-surface-page);
+  font-family: var(--gl-font-mono);
+  /* 11px is the source's own literal choice — smaller than --gl-step--2, the
+     smallest step on the scale, because a tooltip is meant to read as a
+     glance-only aside rather than body text. */
+  font-size: 11px;
+  white-space: nowrap;
+  line-height: 1.4;
+  z-index: 20;
+  opacity: 0;
+  visibility: hidden;
+  pointer-events: none;
+  transition: opacity var(--gl-dur-fast) var(--gl-ease);
+}
+span:has(> [role="tooltip"]):hover > [role="tooltip"],
+span:has(> [role="tooltip"]):focus-within > [role="tooltip"] {
+  opacity: 1;
+  visibility: visible;
+}
+
+/* Toast — the real pattern used for delete confirmations: a flat bar with an
+   optional destructive action and a dismiss. `[data-fixed]` pins it
+   bottom-right for real use; without it the toast stays in document flow for
+   documentation. `role="status"` is the correct live-region role for a
+   transient status message, so it is both the accessibility hook and the
+   style selector — no base class needed. The action and dismiss buttons are
+   plain <button>s told apart by `aria-label="Dismiss"`, an attribute they
+   need anyway for the icon-only close control. */
+[role="status"] {
+  display: flex;
+  align-items: center;
+  gap: var(--gl-space-xs);
+  min-width: 20rem;
+  padding: var(--gl-space-xs) var(--gl-space-s);
+  background: var(--gl-color-surface-card);
+  border: 1px solid var(--gl-color-border-default);
+  border-radius: var(--gl-radius-md);
+  box-shadow: var(--gl-shadow-float);
+  color: var(--gl-color-text-primary);
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--2);
+}
+[role="status"][data-fixed] {
+  position: fixed;
+  inset-block-end: var(--gl-space-s);
+  inset-inline-end: var(--gl-space-s);
+  z-index: 60;
+}
+[role="status"] > span { flex: 1; }
+[role="status"] button {
+  all: unset;
+  cursor: pointer;
+  font-weight: 600;
+  color: var(--gl-color-danger);
+}
+[role="status"] button[aria-label="Dismiss"] {
+  color: var(--gl-color-text-tertiary);
+  font-size: 1rem;
+  font-weight: 400;
+  line-height: 1;
+}
+[role="status"] button[aria-label="Dismiss"]:hover { color: var(--gl-color-text-primary); }
+
+/* Dialog — the native <dialog> element: a centered modal, flat surface, no
+   colored header, hairline dividers between title/body/footer. A <header>
+   holds the title and a <footer> holds the actions, matching the elements
+   the tag already wants; everything else in between is the body, styled by
+   excluding those two rather than by a class of its own.
+
+   `.dialog-demo` scopes a static, always-open example to its own preview
+   container instead of the viewport, so it can sit safely inside a
+   documentation page — this is a doc-only affordance, not part of Dialog's
+   contract. It relies on the browser's own dialog centering: the UA
+   stylesheet already gives an open <dialog> `position: absolute; inset: 0;
+   margin: auto`, which centers it inside the nearest positioned ancestor —
+   here, `.dialog-demo` itself. A real `.showModal()` call gets the genuine
+   viewport-fixed overlay and `::backdrop` for free; a plain `open` attribute
+   (used here so the demo renders without JavaScript) does not paint a
+   backdrop at all, since `::backdrop` only exists for a dialog shown
+   modally — wire the real open/close state and call `.showModal()` in the
+   consuming app. */
+.dialog-demo {
+  position: relative;
+  min-height: 20rem;
+  background: var(--gl-color-surface-fill);
+  border-radius: var(--gl-radius-lg);
+}
+
+dialog {
+  width: 26rem;
+  max-width: calc(100% - (2 * var(--gl-space-s)));
+  height: fit-content;
+  padding: 0;
+  background: var(--gl-color-surface-card);
+  border: 1px solid var(--gl-color-border-hairline);
+  border-radius: var(--gl-radius-lg);
+  box-shadow: var(--gl-shadow-float);
+  color: var(--gl-color-text-primary);
+  overflow: hidden;
+}
+dialog::backdrop {
+  background: rgba(20, 18, 14, 0.35);
+}
+dialog > header {
+  padding: var(--gl-space-s) var(--gl-space-m);
+  border-bottom: 1px solid var(--gl-color-border-hairline);
+  font-family: var(--gl-font-sans);
+  font-weight: 600;
+  font-size: var(--gl-step-0);
+  color: var(--gl-color-text-primary);
+}
+dialog > footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: var(--gl-space-2xs);
+  padding: var(--gl-space-s) var(--gl-space-m);
+  border-top: 1px solid var(--gl-color-border-hairline);
+}
+dialog > *:not(header):not(footer) {
+  padding: var(--gl-space-m);
+  margin: 0;
+  color: var(--gl-color-text-primary);
+  font-size: var(--gl-step--1);
+}
+
+/* Tabs
+   Follows the real WAI-ARIA tabs pattern: a `[role="tablist"]` of
+   `[role="tab"]` buttons controlling `[role="tabpanel"]`s. Those roles are
+   required for the pattern to be accessible in the first place, so they
+   double as the entire styling contract — no classes at all for the base
+   shape. The keyboard wiring lives in assets/js/system.js and is
+   variant-agnostic — it only cares about `[data-tabs]` / `[role=tab]`.
+
+   Two real variants: `underline` (mono labels over a hairline, the selected
+   one marked by a short accent bar — primary section nav, the default) and
+   `segmented` (pill track, active segment lifts to the surface — filters
+   like All · Overdue · Upcoming, never primary navigation), chosen with `data-variant="segmented"` on the tablist since
+   neither has a natural attribute of its own. */
+
+/* Every rule below the list container pairs [role="tablist"] with
+   [role="tab"] rather than styling [role="tab"] alone — Tab buttons are
+   still literal <button> elements, so the compound selector's extra
+   specificity is what keeps Button's base styling (button.css) from
+   winning the cascade on shared properties like padding and background. */
+
+[role="tablist"] {
+  display: flex;
+  gap: var(--gl-space-3xs);
+  border-bottom: var(--gl-border-w) solid var(--gl-color-border-hairline);
+}
+
+/* Underline. Mono labels standing on the hairline, with a 2px accent bar
+   drawn over the hairline under the selected label. Hover fills the tab
+   with surface-fill-2, squared off at the bottom so it reads as a tab
+   standing on the line rather than a floating pill. It is one step darker
+   than the surface-fill a quiet Button uses, because at ~1.1:1 that one
+   all but vanished on the card; hover also darkens the label to
+   text-primary. The bar is a pseudo-element, not a border, so it lies flat
+   instead of inheriting the button's radius and curling up at the ends.
+   Scoped away from segmented, which shares the element but none of this
+   shape. */
+[role="tablist"]:not([data-variant="segmented"]) [role="tab"] {
+  position: relative;
+  padding: var(--gl-space-3xs) var(--gl-space-2xs);
+  background: none;
+  border: 0;
+  border-radius: var(--gl-radius-sm) var(--gl-radius-sm) 0 0;
+  color: var(--gl-color-text-secondary);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--1);
+  font-weight: 500;
+  letter-spacing: 0.01em;
+  line-height: 1.15;
+  cursor: pointer;
+  transition: background-color var(--gl-dur-fast) var(--gl-ease), color var(--gl-dur-fast) var(--gl-ease);
+}
+[role="tablist"]:not([data-variant="segmented"]) [role="tab"]::after {
+  content: "";
+  position: absolute;
+  inset-inline: var(--gl-space-2xs);
+  bottom: calc(-1 * var(--gl-border-w));
+  height: 2px;
+  background: transparent;
+  transition: background-color var(--gl-dur-fast) var(--gl-ease);
+}
+[role="tablist"]:not([data-variant="segmented"]) [role="tab"]:hover {
+  background: var(--gl-color-surface-fill-2);
+  color: var(--gl-color-text-primary);
+}
+[role="tablist"]:not([data-variant="segmented"]) [role="tab"][aria-selected="true"] {
+  color: var(--gl-color-text-primary);
+}
+[role="tablist"]:not([data-variant="segmented"]) [role="tab"][aria-selected="true"]::after {
+  background: var(--gl-color-accent);
+}
+
+[role="tablist"][data-variant="segmented"] {
+  display: inline-flex;
+  gap: 0.125rem;
+  padding: 0.1875rem;
+  border-bottom: 0;
+  background: var(--gl-color-surface-fill);
+  border-radius: var(--gl-radius-md);
+}
+[role="tablist"][data-variant="segmented"] [role="tab"] {
+  all: unset;
+  cursor: pointer;
+  padding: 0.375rem 0.875rem;
+  border-radius: var(--gl-radius-sm);
+  font-family: var(--gl-font-sans);
+  font-size: var(--gl-step--1);
+  font-weight: 550;
+  color: var(--gl-color-text-secondary);
+  transition: background-color var(--gl-dur-fast) var(--gl-ease), color var(--gl-dur-fast) var(--gl-ease);
+}
+[role="tablist"][data-variant="segmented"] [role="tab"]:hover { color: var(--gl-color-text-primary); }
+[role="tablist"][data-variant="segmented"] [role="tab"][aria-selected="true"] {
+  color: var(--gl-color-text-primary);
+  background: var(--gl-color-surface-card);
+  box-shadow: var(--gl-shadow-sm);
+}
+
+[role="tabpanel"] { padding-top: var(--gl-space-s); }
+[role="tabpanel"][hidden] { display: none; }
+[role="tabpanel"]:focus-visible { outline-offset: 4px; }
diff --git a/public/vendor/gloss/system.js b/public/vendor/gloss/system.js
new file mode 100644
index 0000000..f850182
--- /dev/null
+++ b/public/vendor/gloss/system.js
@@ -0,0 +1,127 @@
+/* Vendored from kejadlen/gloss @ b9484458e0c83d957f1265c8ec54ffaaace4dea1, 2026-10-27.
+   Refresh with `rake gloss:vendor GLOSS=/path/to/gloss/checkout`. */
+
+// Behavior for the pieces of the system that need it. Everything else is
+// CSS and semantic HTML on purpose — a design system that only works with
+// JavaScript running is a component library with extra steps.
+(function () {
+  "use strict";
+
+  var STORAGE_KEY = "gl-theme";
+
+  function readStored() {
+    try {
+      return localStorage.getItem(STORAGE_KEY);
+    } catch (error) {
+      return null;
+    }
+  }
+
+  function writeStored(value) {
+    try {
+      localStorage.setItem(STORAGE_KEY, value);
+    } catch (error) {
+      /* Nothing to do — the media query still gives a sensible default. */
+    }
+  }
+
+  function systemPrefersDark() {
+    return window.matchMedia("(prefers-color-scheme: dark)").matches;
+  }
+
+  function currentTheme() {
+    var explicit = document.documentElement.getAttribute("data-theme");
+    if (explicit) return explicit;
+    return systemPrefersDark() ? "dark" : "light";
+  }
+
+  function paintToggle(theme) {
+    document.querySelectorAll("[data-theme-toggle]").forEach(function (toggle) {
+      var next = theme === "dark" ? "light" : "dark";
+      toggle.setAttribute("aria-label", "Switch to the " + next + " theme");
+      toggle.setAttribute("title", "Switch to the " + next + " theme");
+      var label = toggle.querySelector("[data-theme-label]");
+      if (label) label.textContent = "Switch to the " + next + " theme";
+    });
+  }
+
+  function setUpTheme() {
+    paintToggle(currentTheme());
+
+    document.addEventListener("click", function (event) {
+      var toggle = event.target.closest("[data-theme-toggle]");
+      if (!toggle) return;
+
+      var next = currentTheme() === "dark" ? "light" : "dark";
+      document.documentElement.setAttribute("data-theme", next);
+      writeStored(next);
+      paintToggle(next);
+    });
+
+    // Follow the OS while the reader has not made an explicit choice.
+    window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function () {
+      if (!readStored()) paintToggle(currentTheme());
+    });
+  }
+
+  // Tabs, wired to the WAI-ARIA authoring practices: arrows move, Home/End
+  // jump, and only the selected tab stays in the tab order.
+  function setUpTabs() {
+    document.querySelectorAll("[data-tabs]").forEach(function (root) {
+      var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
+      if (!tabs.length) return;
+
+      function select(tab, focus) {
+        tabs.forEach(function (candidate) {
+          var selected = candidate === tab;
+          candidate.setAttribute("aria-selected", String(selected));
+          candidate.tabIndex = selected ? 0 : -1;
+          var panel = document.getElementById(candidate.getAttribute("aria-controls"));
+          if (panel) panel.hidden = !selected;
+        });
+        if (focus) tab.focus();
+      }
+
+      root.addEventListener("click", function (event) {
+        var tab = event.target.closest('[role="tab"]');
+        if (tab) select(tab, false);
+      });
+
+      root.addEventListener("keydown", function (event) {
+        var index = tabs.indexOf(document.activeElement);
+        if (index === -1) return;
+
+        var next = null;
+        if (event.key === "ArrowRight") next = tabs[(index + 1) % tabs.length];
+        else if (event.key === "ArrowLeft") next = tabs[(index - 1 + tabs.length) % tabs.length];
+        else if (event.key === "Home") next = tabs[0];
+        else if (event.key === "End") next = tabs[tabs.length - 1];
+
+        if (next) {
+          event.preventDefault();
+          select(next, true);
+        }
+      });
+
+      select(tabs.filter(function (tab) {
+        return tab.getAttribute("aria-selected") === "true";
+      })[0] || tabs[0], false);
+    });
+  }
+
+  function setUpNavToggle() {
+    document.addEventListener("click", function (event) {
+      var toggle = event.target.closest("[data-nav-toggle]");
+      if (!toggle) return;
+
+      var sidebar = toggle.closest("[data-sidebar]");
+      var collapsed = sidebar.dataset.collapsed !== "false";
+      sidebar.dataset.collapsed = String(!collapsed);
+      toggle.setAttribute("aria-expanded", String(collapsed));
+    });
+  }
+
+  setUpTheme();
+  setUpTabs();
+  setUpNavToggle();
+})();
diff --git a/public/vendor/gloss/tokens.css b/public/vendor/gloss/tokens.css
new file mode 100644
index 0000000..f470af2
--- /dev/null
+++ b/public/vendor/gloss/tokens.css
@@ -0,0 +1,156 @@
+/* Vendored from kejadlen/gloss @ b9484458e0c83d957f1265c8ec54ffaaace4dea1, 2026-10-27.
+   Refresh with `rake gloss:vendor GLOSS=/path/to/gloss/checkout`. */
+
+/* Gloss — design tokens. Hand-maintained; edit values directly. */
+
+:root {
+  /* Palette ramps — reference only. Components use the aliases below. */
+  --gl-neutral-0: #faf9f7;
+  --gl-neutral-50: #fdfdfb;
+  --gl-neutral-100: #f1f0ed;
+  --gl-neutral-150: #e8e6e2;
+  --gl-neutral-200: #dcdad7;
+  --gl-neutral-300: #c0bdb9;
+  --gl-neutral-500: #888681;
+  --gl-neutral-600: #5f5d59;
+  --gl-neutral-800: #2a2926;
+  --gl-neutral-900: #100f0d;
+  --gl-neutral-dark-0: #0e0d0b;
+  --gl-neutral-dark-50: #151412;
+  --gl-neutral-dark-100: #1e1d1a;
+  --gl-neutral-dark-150: #252421;
+  --gl-neutral-dark-200: #2c2b29;
+  --gl-neutral-dark-300: #44423f;
+  --gl-neutral-dark-500: #7c7a76;
+  --gl-neutral-dark-600: #a09e9a;
+  --gl-neutral-dark-800: #d9d7d4;
+  --gl-neutral-dark-900: #e9e8e6;
+  --gl-accent-teal: #3a6a63;
+  --gl-accent-clay: #9a5a3c;
+  --gl-accent-ink-blue: #2b4a78;
+  --gl-accent-ochre: #93701f;
+  --gl-accent-plum: #6a3a5c;
+
+  /* Surfaces */
+  --gl-color-surface-page: #faf9f7;
+  --gl-color-surface-card: #fdfdfb;
+  --gl-color-surface-fill: #f1f0ed;
+  --gl-color-surface-fill-2: #e8e6e2;
+
+  /* Borders */
+  --gl-color-border-hairline: #dcdad7;
+  --gl-color-border-default: #c0bdb9;
+
+  /* Text */
+  --gl-color-text-primary: #100f0d;
+  --gl-color-text-secondary: #5f5d59;
+  --gl-color-text-tertiary: #888681;
+
+  /* Icons — monochrome SVGs, painted as a mask in the text color by
+     components/icon.css, so their own stroke color never shows. */
+  --gl-icon-camera: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8a2 2 0 0 1 2-2h1.5l1-1.5h9l1 1.5H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Z'/%3E%3Ccircle cx='12' cy='13' r='3.5'/%3E%3C/svg%3E");
+  --gl-icon-chevron-left: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
+  --gl-icon-filter: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 5h16l-6 8v5l-4 2v-7z'/%3E%3C/svg%3E");
+  --gl-icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 14.2A8.4 8.4 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2Z'/%3E%3C/svg%3E");
+  --gl-icon-package: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 8 12 3 3 8v8l9 5 9-5V8Z'/%3E%3Cpath d='M3 8l9 5 9-5M12 13v8'/%3E%3C/svg%3E");
+  --gl-icon-plus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14M5 12h14'/%3E%3C/svg%3E");
+  --gl-icon-settings: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cpath d='M12 2.5v3M12 18.5v3M3.6 7.2l2.6 1.5M17.8 15.3l2.6 1.5M3.6 16.8l2.6-1.5M17.8 8.7l2.6-1.5'/%3E%3C/svg%3E");
+  --gl-icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4.5'/%3E%3Cpath d='M12 1.8v2.4M12 19.8v2.4M4.6 4.6l1.7 1.7M17.7 17.7l1.7 1.7M1.8 12h2.4M19.8 12h2.4M4.6 19.4l1.7-1.7M17.7 6.3l1.7-1.7'/%3E%3C/svg%3E");
+  --gl-icon-trash: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13'/%3E%3C/svg%3E");
+
+  /* Select paints its chevron as a background, not a mask, and a data URI
+     can't read a custom property — so this one stroke repeats
+     --gl-color-text-secondary by hand and is redefined in the dark blocks. */
+  --gl-icon-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%235f5d59' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+
+  /* Accent */
+  --gl-color-accent: #3a6a63;
+  --gl-color-accent-fg: #ffffff;
+  --gl-color-accent-ink: color-mix(in oklch, var(--gl-color-accent) 78%, black);
+  --gl-color-accent-soft: color-mix(in oklch, var(--gl-color-accent) 12%, white);
+
+  /* Status (fixed — never swapped like the accent) */
+  --gl-color-success: #3f7a4a;
+  --gl-color-success-bg: color-mix(in oklch, var(--gl-color-success) 12%, white);
+  --gl-color-danger: #ad3a2c;
+  --gl-color-danger-bg: color-mix(in oklch, var(--gl-color-danger) 10%, white);
+
+  /* Type families */
+  --gl-font-sans: -apple-system, "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
+  --gl-font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace;
+  --gl-font-serif: Georgia, "Times New Roman", "Iowan Old Style", serif;
+
+  /* Type steps — fluid (Utopia scale), the design system's own body-text size */
+  --gl-step--2: clamp(0.7813rem, 0.7736rem + 0.0341vw, 0.8rem);
+  --gl-step--1: clamp(0.9375rem, 0.9119rem + 0.1136vw, 1rem);
+  --gl-step-0: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
+  --gl-step-1: clamp(1.35rem, 1.2631rem + 0.3864vw, 1.5625rem);
+  --gl-step-2: clamp(1.62rem, 1.4837rem + 0.6057vw, 1.9531rem);
+  --gl-step-3: clamp(1.944rem, 1.7405rem + 0.9044vw, 2.4414rem);
+  --gl-step-4: clamp(2.3328rem, 2.0387rem + 1.3072vw, 3.0518rem);
+  --gl-step-5: clamp(2.7994rem, 2.384rem + 1.8461vw, 3.8147rem);
+
+  /* Space */
+  --gl-space-3xs: clamp(0.3125rem, 0.3125rem + 0vw, 0.3125rem);
+  --gl-space-2xs: clamp(0.5625rem, 0.5369rem + 0.1136vw, 0.625rem);
+  --gl-space-xs: clamp(0.875rem, 0.8494rem + 0.1136vw, 0.9375rem);
+  --gl-space-s: clamp(1.125rem, 1.0739rem + 0.2273vw, 1.25rem);
+  --gl-space-m: clamp(1.6875rem, 1.6108rem + 0.3409vw, 1.875rem);
+  --gl-space-l: clamp(2.25rem, 2.1477rem + 0.4545vw, 2.5rem);
+  --gl-space-xl: clamp(3.375rem, 3.2216rem + 0.6818vw, 3.75rem);
+  --gl-space-2xl: clamp(4.5rem, 4.2955rem + 0.9091vw, 5rem);
+  --gl-space-3xl: clamp(6.75rem, 6.4432rem + 1.3636vw, 7.5rem);
+
+  /* Radius */
+  --gl-radius-sm: 4px;
+  --gl-radius-md: 6px;
+  --gl-radius-lg: 10px;
+  --gl-radius-full: 999px;
+  --gl-border-w: 1px;
+
+  /* Elevation */
+  --gl-shadow-sm: 0 1px 2px rgba(20, 18, 14, 0.05);
+  --gl-shadow-float: 0 2px 6px rgba(20, 18, 14, 0.06), 0 10px 24px -12px rgba(20, 18, 14, 0.18);
+
+  /* Motion */
+  --gl-dur-fast: 0.12s;
+  --gl-dur-base: 0.16s;
+  --gl-ease: cubic-bezier(0.2, 0, 0, 1);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme="light"]) {
+    color-scheme: dark;
+    --gl-color-surface-page: #0e0d0b;
+    --gl-color-surface-card: #151412;
+    --gl-color-surface-fill: #1e1d1a;
+    --gl-color-surface-fill-2: #252421;
+    --gl-color-border-hairline: #2c2b29;
+    --gl-color-border-default: #44423f;
+    --gl-color-text-primary: #e9e8e6;
+    --gl-color-text-secondary: #a09e9a;
+    --gl-color-text-tertiary: #7c7a76;
+    --gl-icon-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%23a09e9a' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+  }
+}
+
+:root[data-theme="dark"] {
+  color-scheme: dark;
+  --gl-color-surface-page: #0e0d0b;
+  --gl-color-surface-card: #151412;
+  --gl-color-surface-fill: #1e1d1a;
+  --gl-color-surface-fill-2: #252421;
+  --gl-color-border-hairline: #2c2b29;
+  --gl-color-border-default: #44423f;
+  --gl-color-text-primary: #e9e8e6;
+  --gl-color-text-secondary: #a09e9a;
+  --gl-color-text-tertiary: #7c7a76;
+  --gl-icon-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%23a09e9a' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+}
+
+@media (prefers-reduced-motion: reduce) {
+  :root {
+    --gl-dur-fast: 0ms;
+    --gl-dur-base: 0ms;
+  }
+}
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 0bccc54..5fff59b 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -35,6 +35,12 @@ module Sequel
     def all: () -> Array[T]
 
     def first: () -> T?
+
+    def select: (*Symbol columns) -> Dataset[row]
+
+    def each: () { (T) -> void } -> void
+
+    def delete: () -> Integer
   end
 
   class Model
@@ -61,6 +67,11 @@ module Sequel
 
     # Sets the columns given and saves the row.
     def update: (**untyped values) -> self
+
+    def destroy: () -> self
+
+    # A hook Sequel runs inside destroy, before the row is deleted.
+    def before_destroy: () -> void
   end
 
   # Applies the migrations in a directory that the database has not run
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index d203e49..76e948a 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -31,7 +31,10 @@ module RankKing
   class Axis < Sequel::Model
     def name: () -> String
     def list_id: () -> Integer
+    def list: () -> List
     def results: () -> Array[Result]
+    def results_dataset: () -> Sequel::Dataset[Result]
+    def ranking: () -> [Array[Item], Array[Item]]
   end
 
   class Result < Sequel::Model
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index 22251db..bc7f3af 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -37,6 +37,9 @@ module RankKing
     interface _SourceClass
       # The source for a list, configured from the kind's own table.
       def for: (List list) -> _Source
+
+      # What the kind is called where a list's source is chosen or shown.
+      def title: () -> String
     end
 
     # A view component, as a source's form is.
diff --git a/sig/rank_king/views.rbs b/sig/rank_king/views.rbs
index 517f3d6..ce245a1 100644
--- a/sig/rank_king/views.rbs
+++ b/sig/rank_king/views.rbs
@@ -4,7 +4,7 @@
 module RankKing
   module Views
     class Home
-      def initialize: (lists: Array[List], csrf: Method) -> void
+      def initialize: (lists: Array[List]) -> void
       def call: () -> String
     end
 
@@ -16,8 +16,23 @@ module RankKing
     end
 
     module ListViews
+      class New
+        def initialize: (sources: Hash[String, String], csrf: Method) -> void
+        def call: () -> String
+      end
+
       class Show
-        def initialize: (list: List, csrf: Method) -> void
+        def initialize: (list: List, csrf: Method, ?axis: Axis?) -> void
+        def call: () -> String
+      end
+
+      class Compare
+        def initialize: (list: List, axis: Axis, csrf: Method) -> void
+        def call: () -> String
+      end
+
+      class Settings
+        def initialize: (list: List, source: String, csrf: Method) -> void
         def call: () -> String
       end
     end
diff --git a/test/test_web.rb b/test/test_web.rb
index 5d98363..71bfb6e 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -30,7 +30,7 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_list
-    create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: "Taste\nValue")
+    create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: %w[Taste Value])
     assert last_response.redirect?
 
     list = RankKing::DB[:lists].first
@@ -54,7 +54,7 @@ class TestWeb < Minitest::Test
   end
 
   def test_create_list_without_axes
-    create_list(name: "Simple", items: "A\nB", axes: "")
+    create_list(name: "Simple", items: "A\nB", axes: [""])
     assert last_response.redirect?
 
     list = RankKing::DB[:lists].first
@@ -62,7 +62,7 @@ class TestWeb < Minitest::Test
   end
 
   def test_show_list
-    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: %w[Taste])
     list = RankKing::DB[:lists].first
 
     get "/lists/#{list[:id]}"
@@ -79,7 +79,7 @@ class TestWeb < Minitest::Test
   end
 
   def test_record_result
-    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: %w[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])
@@ -88,7 +88,7 @@ class TestWeb < Minitest::Test
       axis_id: axis[:id],
       winner_id: items[0][:id],
       loser_id: items[1][:id],
-    }
+    }, from: "/lists/#{list[:id]}/axes/#{axis[:id]}/compare"
     assert last_response.redirect?
 
     result = RankKing::DB[:results].first
@@ -98,7 +98,7 @@ class TestWeb < Minitest::Test
   end
 
   def test_record_result_rejects_same_item
-    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: %w[Taste])
     list = RankKing::DB[:lists].first
     item = RankKing::DB[:items].first(list_id: list[:id])
     axis = RankKing::DB[:axes].first(list_id: list[:id])
@@ -107,7 +107,7 @@ class TestWeb < Minitest::Test
       axis_id: axis[:id],
       winner_id: item[:id],
       loser_id: item[:id],
-    }
+    }, from: "/lists/#{list[:id]}/axes/#{axis[:id]}/compare"
     assert_equal 422, last_response.status
   end
 
@@ -145,14 +145,14 @@ class TestWeb < Minitest::Test
   end
 
   def test_text_sync_renames_archives_and_adds_by_id
-    create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: "Taste")
+    create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: %w[Taste])
     list = RankKing::List.first
     pizza, sushi, tacos = list.items_dataset.order(:id).all
 
-    csrf_post "/lists/#{list.id}/sync", {
+    sync(list, {
       "items" => { pizza.id.to_s => "Pizza Margherita", sushi.id.to_s => "  ", tacos.id.to_s => "Tacos" },
       "new_items" => "Ramen\n\nCurry\n",
-    }
+    })
     assert last_response.redirect?
 
     assert_equal "Pizza Margherita", pizza.reload.name
@@ -168,24 +168,27 @@ class TestWeb < Minitest::Test
     create_list(name: "Foods", items: "Pizza\nSushi")
     list = RankKing::List.first
 
-    csrf_post "/lists/#{list.id}/sync", { "new_items" => "Tacos" }
+    sync(list, { "new_items" => "Tacos" })
     assert_equal %w[Pizza Sushi Tacos], list.items_dataset.order(:id).map(:name)
   end
 
   def test_record_result_rejects_archived_item
-    create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: "Taste")
-    list = RankKing::List.first
+    list, axis = create_list_with_axis(items: "Pizza\nSushi\nTacos")
     sushi = list.items.find { |i| i.name == "Sushi" }
     sushi.update(archived_at: Time.now)
 
-    csrf_post "/lists/#{list.id}/results", {
-      axis_id: list.axes.first.id,
-      winner_id: sushi.id,
-      loser_id: list.items.first.id,
-    }
+    record_result(list, axis, winner: sushi, loser: list.items.first)
     assert_equal 422, last_response.status
   end
 
+  def test_delete_list_with_archived_items
+    list, = create_list_with_axis(items: "Pizza\nSushi\nTacos")
+    list.items.first.update(archived_at: Time.now)
+
+    csrf_post "/lists/#{list.id}/delete", {}, from: "/lists/#{list.id}/settings"
+    assert_equal 0, RankKing::DB[:items].count
+  end
+
   def test_lists_belong_to_the_current_user
     create_list(name: "Foods", items: "Pizza\nSushi")
 
@@ -212,8 +215,149 @@ class TestWeb < Minitest::Test
     end
   end
 
+  def test_new_list_form
+    get "/lists/new"
+    assert last_response.ok?
+    assert_includes last_response.body, "Typed list"
+    assert_includes last_response.body, "Create list"
+  end
+
+  def test_create_list_rejects_unknown_source
+    post "/lists", { name: "Games", source: "bgg", items: "A\nB", _csrf: csrf_token_for("/lists", from: "/lists/new") }
+    assert_equal 422, last_response.status
+  end
+
+  def test_show_list_ranks_items_on_each_axis
+    list, axis = create_list_with_axis(items: "Pizza\nSushi\nTacos")
+    pizza, sushi, = list.items
+    record_result(list, axis, winner: sushi, loser: pizza)
+
+    get "/lists/#{list.id}"
+    body = last_response.body[/role="tabpanel".*/m]
+    assert_operator body.index("Sushi"), :<, body.index("Pizza")
+    assert_operator body.index("Pizza"), :<, body.index("Tacos")
+    assert_includes body, "Unranked"
+    assert_includes body, "Compare on Taste"
+  end
+
+  def test_show_list_selects_the_requested_axis
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: %w[Taste Value])
+    list = RankKing::List.first
+    value = list.axes.last
+
+    get "/lists/#{list.id}?axis=#{value.id}"
+    assert_match(/id="axis-#{value.id}-tab"[^>]*aria-selected="true"/, last_response.body)
+  end
+
+  def test_show_list_without_axes
+    create_list(name: "Simple", items: "A\nB")
+    get "/lists/#{RankKing::List.first.id}"
+    assert_includes last_response.body, "No axes yet."
+  end
+
+  def test_compare_shows_a_pair
+    list, axis = create_list_with_axis(items: "Pizza\nSushi")
+
+    get "/lists/#{list.id}/axes/#{axis.id}/compare"
+    assert last_response.ok?
+    assert_includes last_response.body, "Which wins on Taste?"
+    assert_includes last_response.body, "Pizza"
+    assert_includes last_response.body, "Sushi"
+  end
+
+  def test_compare_404_for_an_axis_on_another_list
+    _, axis = create_list_with_axis(items: "Pizza\nSushi")
+    other, = create_list_with_axis(items: "A\nB")
+
+    get "/lists/#{other.id}/axes/#{axis.id}/compare"
+    assert_equal 404, last_response.status
+  end
+
+  def test_record_result_returns_to_compare
+    list, axis = create_list_with_axis(items: "Pizza\nSushi")
+    record_result(list, axis, winner: list.items[0], loser: list.items[1])
+
+    assert_equal "/lists/#{list.id}/axes/#{axis.id}/compare", URI(last_response.location).path
+
+    follow_redirect!
+    assert_includes last_response.body, "Recent"
+  end
+
+  def test_settings_shows_the_fixed_source
+    list, = create_list_with_axis(items: "Pizza\nSushi")
+
+    get "/lists/#{list.id}/settings"
+    assert last_response.ok?
+    assert_includes last_response.body, "Typed list"
+    refute_includes last_response.body, '<select name="source"'
+  end
+
+  def test_settings_renames_and_edits_axes
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: %w[Taste Value])
+    list = RankKing::List.first
+    taste, value = list.axes
+    record_result(list, value, winner: list.items[0], loser: list.items[1])
+
+    update_settings(list, name: "Meals", axis_ids: [taste.id], axes: ["Price", ""])
+    assert last_response.redirect?
+
+    list.reload
+    assert_equal "Meals", list.name
+    assert_equal %w[Taste Price], list.axes.map(&:name)
+    assert_equal 0, RankKing::DB[:results].count
+  end
+
+  def test_settings_rejects_empty_name
+    list, = create_list_with_axis(items: "Pizza\nSushi")
+    update_settings(list, name: " ")
+    assert_equal 422, last_response.status
+  end
+
+  def test_delete_list
+    list, axis = create_list_with_axis(items: "Pizza\nSushi")
+    record_result(list, axis, winner: list.items[0], loser: list.items[1])
+
+    csrf_post "/lists/#{list.id}/delete", {}, from: "/lists/#{list.id}/settings"
+    assert last_response.redirect?
+
+    %i[lists items axes results].each { |table| assert_equal 0, RankKing::DB[table].count, table }
+  end
+
+  def test_settings_are_private
+    list = as("alice@example.com") { create_list_with_axis(items: "A\nB").first }
+
+    get "/lists/#{list.id}/settings"
+    assert_equal 404, last_response.status
+  end
+
   private
 
+  def create_list_with_axis(items:)
+    create_list(name: "Foods", items: items, axes: %w[Taste])
+    list = RankKing::List.order(:id).last
+    [list, list.axes.first]
+  end
+
+  def record_result(list, axis, winner:, loser:)
+    csrf_post "/lists/#{list.id}/results",
+              { axis_id: axis.id, winner_id: winner.id, loser_id: loser.id },
+              from: "/lists/#{list.id}/axes/#{axis.id}/compare"
+  end
+
+  def sync(list, params)
+    csrf_post "/lists/#{list.id}/sync", params, from: "/lists/#{list.id}"
+  end
+
+  def update_settings(list, name:, axis_ids: list.axes.map(&:id), axes: [])
+    csrf_post "/lists/#{list.id}/settings", { name: name, axis_ids: axis_ids, axes: axes },
+              from: "/lists/#{list.id}/settings"
+  end
+
+  def csrf_token_for(path, from:)
+    get from
+    last_response.body[/action="#{Regexp.escape(path)}".*?name="_csrf" value="([^"]+)"/m, 1]
+  end
+
   def as(login)
     header "Remote-User", login
     yield
@@ -221,23 +365,12 @@ class TestWeb < Minitest::Test
     header "Remote-User", LOGIN
   end
 
-  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.match?(%r{\A/lists/\d+/})
-    token = last_response.body[/action="#{Regexp.escape(path)}".*?name="_csrf" value="([^"]+)"/m, 1] || token
-    post path, params.merge("_csrf" => token)
+  # Posts with the CSRF token of the form on page `from` that targets `path`.
+  def csrf_post(path, params = {}, from:)
+    post path, params.merge("_csrf" => csrf_token_for(path, from: from))
   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,
-    }
+  def create_list(name:, items:, axes: [])
+    csrf_post "/lists", { name: name, source: "Text", items: items, axes: axes }, from: "/lists/new"
   end
 end