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