Add DESIGN.md and a read-only admin Contacts view
Adopts Gloss (kejadlen/gloss) as the design system in docs/DESIGN.md,
vendoring its CSS under public/vendor/gloss. Adds the first slice of
the admin UI on top of it: GET /admin/contacts (search, or recently
updated) and GET /admin/contacts/:id, rendered with Phlex and reading
straight off the stored vCards. Read-only for now — no editing, no
groups yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYiigu3aFNaiTP9SyKW9mX
change yquszywtylpnpzurqsronzzmsxvmvuxs
commit e32a7b9ad90e775017764a7c362961e4dee9160f
author Claude <noreply@anthropic.com>
date
parent rlwokrly
diff --git a/Gemfile b/Gemfile
index b62c9a7..2b3c8a7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,6 +2,7 @@ source "https://rubygems.org"
 
 gem "logger"
 gem "nokogiri"
+gem "phlex"
 gem "puma"
 gem "rackup"
 gem "rake"
diff --git a/Gemfile.lock b/Gemfile.lock
index d5b6241..471ebfa 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -44,6 +44,9 @@ GEM
     parser (3.3.12.0)
       ast (~> 2.4.1)
       racc
+    phlex (2.4.1)
+      refract (~> 1.0)
+      zeitwerk (~> 2.7)
     pp (0.6.4)
       prettyprint
     prettyprint (0.2.0)
@@ -70,6 +73,9 @@ GEM
       prism (>= 1.6.0)
       rbs (>= 4.0.0)
       tsort
+    refract (1.1.0)
+      prism
+      zeitwerk
     reline (0.7.0)
       io-console (~> 0.5)
     roda (3.107.0)
@@ -112,6 +118,7 @@ GEM
       unicode-emoji (~> 4.1)
     unicode-emoji (4.2.0)
     uri (1.1.1)
+    zeitwerk (2.8.3)
 
 PLATFORMS
   aarch64-linux
@@ -124,6 +131,7 @@ DEPENDENCIES
   logger
   minitest
   nokogiri
+  phlex
   puma
   rack-test
   rackup
@@ -159,6 +167,7 @@ CHECKSUMS
   nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
   nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
   parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
+  phlex (2.4.1) sha256=e596717fbfe38b5271840266758779ebe75092e02629f0c170287e6290a70b12
   pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
   prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
   prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
@@ -173,6 +182,7 @@ CHECKSUMS
   rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
   rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
   rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
+  refract (1.1.0) sha256=ee3b9627e39f7692831101e2fedd73e0d09a592ff5d5c05f171d14211fc7a9c7
   reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
   roda (3.107.0) sha256=bf40c7a0f1fcd037ec10589a1dc98fc52ced1aece803670194ec7af0f0c77c06
   ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d
@@ -189,6 +199,7 @@ CHECKSUMS
   unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
   unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
   uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+  zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5
 
 BUNDLED WITH
   4.0.3
diff --git a/Steepfile b/Steepfile
index 472c068..c814e37 100644
--- a/Steepfile
+++ b/Steepfile
@@ -8,6 +8,12 @@
 # (see sig/pro_tacts/). Both are documented where they are defined.
 target :lib do
   check "lib", inline: true
+  # Phlex ships no RBS of its own, and lib/pro_tacts/admin is built on
+  # it — unlike Sequel, Roda, Nokogiri, and friends, which have hand
+  # written stand-ins under sig/gems. Ignored rather than stubbed for
+  # now; add sig/gems/phlex.rbs and drop this once the admin surface is
+  # worth typing.
+  ignore "lib/pro_tacts/admin"
   signature "sig"
 
   library "digest", "fileutils", "logger", "pathname", "strscan"
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 7345a23..d84a6ca 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -9,21 +9,48 @@ This document describes the system the interface is built on — the rules that
 should hold as screens get added or reworked. It deliberately says nothing
 about file layout or the current screen inventory, both of which will change.
 
-## Foundation
-
-Built on the **Arbitrary Definitions** design system: its tokens, its
-components, its voice. Nothing here overrides that system — this document only
-records the decisions Pro-tacts makes *within* it.
-
-- Type, space, and radius come from the Utopia scale tokens (`--step-*`,
-  `--space-*`, `--radius-*`). No hardcoded px for type or rhythm.
-- One accent, used sparingly: active nav, links, focus. Never two.
-- Semantic red and green mean destructive and confirmed. They are not
-  decoration and never carry emphasis.
-- Flat surfaces, hairline borders, at most one floated element per view
-  (the dialog). No gradients, no blur, no shadow stacking.
-- Motion is a 120–160ms background or color transition on hover and focus.
-  Nothing enters, nothing bounces, nothing animates on press.
+## Foundation: Gloss
+
+Pro-tacts is a project under **Arbitrary Definitions**, so it inherits
+[**Gloss**](https://github.com/kejadlen/gloss) — that umbrella's design
+system. Gloss is the visual spine; this document only records the decisions
+Pro-tacts makes *within* it. When the two disagree, Gloss wins.
+
+What that inheritance actually means:
+
+- Three stylesheets, in order: `tokens.css`, `base.css`, `components.css`. No
+  build step. IBM Plex Mono is the one webfont. Pro-tacts vendors its own copy
+  under `public/vendor/gloss` (see `rake gloss:vendor`) rather than depending
+  on Gloss at runtime.
+- **Every value is a `var(--gl-*)`.** No hex, no duration, no px for type or
+  rhythm anywhere in Pro-tacts' own CSS. Type is `--gl-step-*`, space is
+  `--gl-space-*`, corners are `--gl-radius-sm|md|lg` (4/6/10px — nothing
+  pill-shaped but true pills).
+- **Read aliases, never ramps** — `--gl-color-text-secondary`, not
+  `--gl-neutral-600`. This is what lets dark mode be a block of repointed
+  aliases instead of a second stylesheet.
+- **No class API to memorize.** Bare elements (`button`, `input`, `select`,
+  native `dialog`), ARIA roles as style hooks (`[role="status"]`,
+  `[role="tablist"]`, `[role="switch"]`), `data-*` for variants
+  (`data-variant`, `data-size`, `data-tone`, `data-elevated`), and exactly
+  seven plain classes. Pro-tacts adds no eighth and no `--`/`__` modifier.
+  Before writing a component's markup, read its contract in Gloss'
+  `_components/<name>.md` rather than guessing from a sibling.
+- **One accent, held constant.** Pro-tacts sets `--gl-color-accent` once and
+  never shows two accents in a view. `-ink` and `-soft` re-derive themselves.
+- `--gl-color-success` and `--gl-color-danger` are **fixed** — they mean
+  confirmed and destructive, never decoration, never emphasis.
+- Flat surfaces and hairline borders do the elevation work. At most one
+  `--gl-shadow-float` on screen — in this app that's the dialog or a toast.
+  Border or shadow, never both.
+- Motion is `--gl-dur-fast` on hover and focus, named properties only, never
+  `all`. Press is not animated. Nothing enters, nothing bounces.
+- Icons are Lucide at 16–20px, stroke in `currentColor`, never filled. Nothing
+  under `lib/pro_tacts/admin` vendors an icon set yet — until one lands, a
+  screen leads a row with its mono type label instead of an icon rather than
+  inventing a substitute.
+- No gradients, no blur, no translucency, no photography, no brand mark —
+  "Pro-tacts" is rendered in type wherever a mark would go.
 
 ## The core idea: search first, no browsing
 
@@ -54,11 +81,12 @@ Consequences worth keeping:
 - **Empty attributes do not render.** A record with one phone number shows one
   row, not a scaffold of blank fields. The card's height is the record's real
   weight — an unfilled record should look unfilled.
-- **Adding is explicit.** A quiet add affordance opens a dialog that names the
-  attribute types available. Multi-part additions (picking a group, then a
-  role) are steps inside that one dialog, never a chain of popovers.
+- **Adding is explicit.** A quiet add affordance opens a native `dialog` that
+  names the attribute types available. Multi-part additions (picking a group,
+  then a role) are steps inside that one dialog, never a chain of popovers —
+  and the dialog is the view's one floated layer.
 - **Removal is inline and reversible-feeling.** A small, low-contrast control
-  on the row itself; destructive color only on hover.
+  on the row itself; `--gl-color-danger` only on hover.
 
 ## Alignment is the layout
 
@@ -72,30 +100,32 @@ side, or the same record before and after an edit, should read as the same
 object. Any new attribute type earns a row in the existing grid; it does not
 earn a new section shape.
 
-Density is uniform: value text at one step below body, type labels in mono
-uppercase at two steps below. Multi-line values (an address) break inside the
-value column and stay in it.
+Density is uniform: value text at `--gl-step--1`, type labels in
+`--gl-font-mono` uppercase at `--gl-step--2`. Multi-line values (an address)
+break inside the value column and stay in it.
 
 ## Relationships are navigable
 
-A contact shows its groups; a group shows its contacts. Both render as chips,
-and **every chip is a link** — clicking it opens that record's card. A remove
-control inside a chip stops there and does not navigate.
+A contact shows its groups; a group shows its contacts. Both render as tags,
+and **every tag is a link** — clicking it opens that record's card. A remove
+control inside a tag stops there and does not navigate.
 
 Relationships are symmetric in the data and should feel symmetric in the UI:
 whatever a contact can say about a group, a group can say about the contact,
 and adding from either side is the same dialog.
 
-Groups are first-class records, not tags. They have their own attributes and
+Groups are first-class records, not labels. They have their own attributes and
 their own notes, and their card uses the same grid as a contact's.
 
 ## Voice
 
-Plain, present tense, no encouragement.
+Gloss' voice, applied here: plain, present tense, no encouragement.
 
-- Labels are catalog tags: `MOBILE`, `WORK`, `HOME`. Mono, uppercase, short.
+- Type labels are catalog tags: `MOBILE`, `WORK`, `HOME`. Mono, uppercase,
+  short.
 - Empty states state the fact: "No contacts match." Not "Try another search!"
-- Confirmations describe what happened, once, in a toast, and leave.
+- Confirmations describe what happened, once, in a `[role="status"]` toast,
+  and leave.
 - No exclamation points. No emoji. No addressing the user as a customer.
 
 ## When adding something new
@@ -108,4 +138,7 @@ Ask, in order:
    its own layout, the layout is probably wrong.
 3. Does it render nothing when empty?
 4. Is it reachable from both sides of any relationship it participates in?
-5. Is there a Save button? There shouldn't be.
+5. Does Gloss already define the component? Use it as documented rather than
+   restyling something adjacent.
+6. Is there a literal hex, duration, or new class in the diff? There shouldn't
+   be. Is there a Save button? There shouldn't be.
diff --git a/lib/pro_tacts/admin/contact_fields.rb b/lib/pro_tacts/admin/contact_fields.rb
new file mode 100644
index 0000000..6af1dc4
--- /dev/null
+++ b/lib/pro_tacts/admin/contact_fields.rb
@@ -0,0 +1,93 @@
+require "date"
+
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+  module Admin
+    # The read-only fields an admin screen shows for a contact, parsed
+    # fresh from its stored vCard on every read. Deliberately shallow,
+    # like VCard::Parser itself: a card can carry properties this has no
+    # field for, and CardDAV still serves every one of them — this is
+    # only what one screen knows how to display.
+    class ContactFields
+      Phone = Data.define(:value, :type)
+      Email = Data.define(:value, :type)
+      Address = Data.define(:lines, :type)
+
+      # RFC 2426 section 3.1.5 defines BDAY as a date or a date-time; the
+      # reduced-precision "--MM-DD" form (ISO 8601, not RFC 2426 itself)
+      # is how a year gets omitted. Matched explicitly rather than
+      # handed to Date.iso8601, which parses "--12-10" by silently
+      # filling in the current year — exactly the wrong answer for a
+      # birthday that deliberately has none. See the open question on
+      # year omission in docs/plans/2026-08-24-vcard-storage-and-groups.md.
+      FULL_DATE = /\A(\d{4})-(\d{2})-(\d{2})/
+      NO_YEAR_DATE = /\A--(\d{2})-(\d{2})/
+
+      attr_reader :name, :phones, :emails, :addresses, :birthday, :notes
+
+      #: (Contact contact) -> ContactFields
+      def self.from(contact)
+        properties = VCard::Parser.parse(contact.vcard)
+        new(properties)
+      rescue VCard::ParseError
+        new([])
+      end
+
+      #: (Array[VCard::Property] properties) -> void
+      def initialize(properties)
+        by_name = properties.group_by { it.name.upcase }
+        @name = by_name.fetch("FN", []).first&.value
+        @phones = by_name.fetch("TEL", []).map { |p| Phone.new(value: VCard.unescape(p.value), type: type_of(p)) }
+        @emails = by_name.fetch("EMAIL", []).map { |p| Email.new(value: VCard.unescape(p.value), type: type_of(p)) }
+        @addresses = by_name.fetch("ADR", []).map { |p| address_from(p) }
+        @birthday = format_birthday(by_name.fetch("BDAY", []).first&.value)
+        note = by_name.fetch("NOTE", []).first&.value
+        @notes = note && VCard.unescape(note)
+      end
+
+      private
+
+      # RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
+      # comma-list (TYPE=work,voice) — either arrives here as one
+      # parameter per value, so the first is the one this shows.
+      #: (VCard::Property property) -> String?
+      def type_of(property)
+        property.parameters.find { |name, _| name.casecmp?("TYPE") }&.last&.downcase
+      end
+
+      # ADR's components, RFC 2426 section 3.2.1: post office box,
+      # extended address, street, locality, region, postal code,
+      # country. Displayed as two lines — street, then everything after
+      # it — the same shape the design's own address rows settled on.
+      #: (VCard::Property property) -> Address
+      def address_from(property)
+        _po_box, extended, street, city, region, postal_code, country = VCard.split_components(property.value)
+        lines = [
+          [extended, street].reject { |s| s.nil? || s.empty? }.join(" "),
+          [city, region, postal_code].reject { |s| s.nil? || s.empty? }.join(", "),
+          country,
+        ].reject { |s| s.nil? || s.empty? }
+        Address.new(lines:, type: type_of(property))
+      end
+
+      # A value this cannot recognize is shown as stored rather than
+      # dropped or guessed at.
+      #: (String? value) -> String?
+      def format_birthday(value)
+        return nil if value.nil? || value.empty?
+
+        if (m = value.match(FULL_DATE))
+          Date.new(m[1].to_i, m[2].to_i, m[3].to_i).strftime("%B %-d, %Y")
+        elsif (m = value.match(NO_YEAR_DATE))
+          Date.new(2000, m[1].to_i, m[2].to_i).strftime("%B %-d")
+        else
+          value
+        end
+      rescue ArgumentError
+        value
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
new file mode 100644
index 0000000..d5d28b8
--- /dev/null
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -0,0 +1,67 @@
+require "phlex"
+
+require "pro_tacts/admin/contact_fields"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/layout"
+
+module ProTacts
+  module Admin
+    # GET /admin/contacts — search first, no browsing (docs/DESIGN.md):
+    # a query narrows to matches across every contact, an empty query
+    # shows the ten most recently updated. Read-only: no add, no edit,
+    # nothing to commit, so there is no state here beyond the query.
+    class ContactsIndex < Phlex::HTML
+      RECENT_LIMIT = 10
+
+      Row = Data.define(:contact, :fields, :updated_at)
+      private_constant :Row
+
+      #: (recent: Array[Store::RecentContact], query: String?) -> void
+      def initialize(recent:, query:)
+        @query = query.to_s.strip
+        rows = recent.map { Row.new(contact: it.contact, fields: ContactFields.from(it.contact), updated_at: it.updated_at) }
+        @rows = @query.empty? ? rows.first(RECENT_LIMIT) : rows.select { matches?(it, @query) }
+      end
+
+      def view_template
+        render Layout.new(title: "Contacts") do
+          h1(class: "type-h1") { "Contacts" }
+          form(action: "/admin/contacts", method: "get") do
+            input(type: "search", name: "q", value: @query, placeholder: "Search contacts", autofocus: @query.empty?)
+          end
+          span(class: "type-label") { @query.empty? ? "recently updated" : "results" }
+          if @rows.empty?
+            p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
+          else
+            ul(class: "card") { @rows.each { render_row(it) } }
+          end
+        end
+      end
+
+      private
+
+      #: (Row row, String query) -> bool
+      def matches?(row, query)
+        q = query.downcase
+        return true if row.fields.name&.downcase&.include?(q)
+        return true if row.fields.phones.any? { it.value.downcase.include?(q) }
+        return true if row.fields.emails.any? { it.value.downcase.include?(q) }
+
+        false
+      end
+
+      #: (Row row) -> void
+      def render_row(row)
+        li do
+          a(href: "/admin/contacts/#{row.contact.id}") do
+            span(class: "avatar") { Format.initials(row.fields.name || row.contact.id) }
+            div(style: "flex: 1; min-width: 0;") do
+              div(style: "font-weight: 550;") { row.fields.name || row.contact.id }
+            end
+            span(class: "type-label") { Format.time_ago(row.updated_at) }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
new file mode 100644
index 0000000..4e59e94
--- /dev/null
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -0,0 +1,67 @@
+require "phlex"
+
+require "pro_tacts/admin/contact_fields"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/layout"
+
+module ProTacts
+  module Admin
+    # GET /admin/contacts/:id — a contact's card, read-only. Follows
+    # docs/DESIGN.md's alignment rule even before there is anything to
+    # edit: every row's type sits in one column and its value in the
+    # other, and an attribute with no data gets no row at all.
+    #
+    # The mockup this was built from leads each row with a Lucide icon
+    # instead of a type label once there is more than one row of the
+    # same kind; nothing here vendors an icon set yet, so every row
+    # keeps its mono type label for now. Revisit once Gloss or this
+    # project settles on how icons ship.
+    class ContactsShow < Phlex::HTML
+      #: (Contact contact) -> void
+      def initialize(contact:)
+        @contact = contact
+        @fields = ContactFields.from(contact)
+      end
+
+      def view_template
+        render Layout.new(title: @fields.name || @contact.id) do
+          a(href: "/admin/contacts", class: "type-label") { "‹ contacts" }
+          div(class: "card") do
+            div(class: "card-body") do
+              div(style: "display: flex; align-items: center; gap: var(--gl-space-s);") do
+                span(class: "avatar", data_size: "lg") { Format.initials(@fields.name || @contact.id) }
+                h1(class: "type-h2", style: "margin: 0;") { @fields.name || @contact.id }
+              end
+              dl(class: "detail-grid") { rows }
+            end
+          end
+        end
+      end
+
+      private
+
+      def rows
+        @fields.phones.each { |phone| row(phone.type, phone.value) }
+        @fields.emails.each { |email| row(email.type, email.value) }
+        @fields.addresses.each { |address| row(address.type || "address", address.lines) }
+        row("birthday", @fields.birthday) if @fields.birthday
+        row("notes", @fields.notes) if @fields.notes
+      end
+
+      #: (String? type, String | Array[String] value) -> void
+      def row(type, value)
+        dt(class: "type-label") { type }
+        dd(class: "type-body-sm") { render_value(value) }
+      end
+
+      #: (String | Array[String] value) -> void
+      def render_value(value)
+        if value.is_a?(Array)
+          value.each { |line| div { line } }
+        else
+          div(style: "white-space: pre-wrap;") { value }
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
new file mode 100644
index 0000000..6016a48
--- /dev/null
+++ b/lib/pro_tacts/admin/format.rb
@@ -0,0 +1,33 @@
+require "time"
+
+module ProTacts
+  module Admin
+    # Small display helpers shared by the admin views.
+    module Format
+      #: (String name) -> String
+      def self.initials(name)
+        name.to_s.split(/\s+/).reject(&:empty?).first(2).map { it[0].upcase }.join
+      end
+
+      # A card's updated_at, UTC ISO 8601 to the millisecond (see
+      # Store), rendered the way "recently updated" wants it: coarse and
+      # relative, not a timestamp to read precisely.
+      #: (String iso8601) -> String
+      def self.time_ago(iso8601)
+        seconds = (Time.now.utc - Time.iso8601(iso8601)).round
+        return "just now" if seconds < 60
+
+        minutes = seconds / 60
+        return "#{minutes}m ago" if minutes < 60
+
+        hours = (minutes / 60.0).round
+        return "#{hours}h ago" if hours < 24
+
+        days = (hours / 24.0).round
+        return "#{days}d ago" if days < 30
+
+        "#{(days / 30.0).round}mo ago"
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
new file mode 100644
index 0000000..42543a5
--- /dev/null
+++ b/lib/pro_tacts/admin/layout.rb
@@ -0,0 +1,38 @@
+require "phlex"
+
+module ProTacts
+  module Admin
+    # The one page shell every admin screen renders inside: head, the
+    # vendored Gloss stylesheets (see public/vendor/gloss and
+    # docs/DESIGN.md), and a one-line header naming the app in type —
+    # "no brand mark" is one of the rules that document inherits from
+    # Gloss. No JavaScript: nothing served under /admin yet needs any.
+    class Layout < Phlex::HTML
+      #: (title: String) -> void
+      def initialize(title:)
+        @title = title
+      end
+
+      def view_template
+        doctype
+        html(lang: "en") do
+          head do
+            meta(charset: "utf-8")
+            meta(name: "viewport", content: "width=device-width, initial-scale=1")
+            title { "pro-tacts — #{@title}" }
+            link(rel: "preconnect", href: "https://fonts.googleapis.com")
+            link(rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap")
+            link(rel: "stylesheet", href: "/vendor/gloss/tokens.css")
+            link(rel: "stylesheet", href: "/vendor/gloss/base.css")
+            link(rel: "stylesheet", href: "/vendor/gloss/components.css")
+            link(rel: "stylesheet", href: "/admin.css")
+          end
+          body do
+            header(class: "admin-header") { a(href: "/admin/contacts") { "pro-tacts" } }
+            main(class: "admin-main") { yield }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 61acb5d..e143dbf 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -49,6 +49,13 @@ module ProTacts
     # @rbs skip
     Change = Data.define(:sequence, :card_id, :action, :etag, :created_at)
 
+    # A contact paired with when its card last changed. Contact itself
+    # carries no timestamp — it is derived from the card alone, see its
+    # own comment — so a surface that sorts or displays recency (the
+    # admin UI's "recently updated" list) gets it from here instead.
+    # @rbs skip
+    RecentContact = Data.define(:contact, :updated_at)
+
     # SQLite has no ON UPDATE, so the column default stamps a row on
     # insert and this stamps it again on the way past. Same expression as
     # the migration's, deliberately: the database keeps the clock, so two
@@ -114,6 +121,17 @@ module ProTacts
       cards.order(:id).map { contact_from(it, birthdays[it.fetch(:id).to_s]) }
     end
 
+    # Every contact paired with its card's updated_at, newest first — the
+    # read behind any "recently updated" surface. Same shape and cost as
+    # #contacts, ordered by the column SQLite already stamps rather than
+    # by id.
+    #: () -> Array[RecentContact]
+    def contacts_by_recency
+      cards.order(Sequel.desc(:updated_at)).map {
+        RecentContact.new(contact: contact_from(it), updated_at: it.fetch(:updated_at).to_s)
+      }
+    end
+
     # The collection's content tag: one value that changes when any card
     # is added, removed, or changed and never otherwise, so a client
     # comparing two of them learns whether to resync — never what
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index a54fc34..836e57e 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -83,6 +83,25 @@ module ProTacts
       text.gsub(/\r\n|\r/, "\n").gsub(/[\\;,\n]/) { TEXT_ESCAPES.fetch(it) }
     end
 
+    # escape's inverse: unescapes backslash, the component separators, and
+    # the `\n` line-break escape (RFC 2426 section 2.4.2). Nothing that
+    # serves a card needs this — a served card is the stored bytes going
+    # out unparsed — but a view that displays a structured value (an
+    # admin screen, not this server's CardDAV responses) has to undo the
+    # escaping or show the reader literal backslashes.
+    #: (String text) -> String
+    def self.unescape(text)
+      text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
+    end
+
+    # Splits a structured value's ";"-delimited components (RFC 2426
+    # section 3.2.1, e.g. ADR and N) without breaking on an escaped
+    # "\;", and unescapes each component in the same pass.
+    #: (String value) -> Array[String]
+    def self.split_components(value)
+      value.split(/(?<!\\);/, -1).map { unescape(it) }
+    end
+
     # Folds a logical line into physical lines of at most LINE_LIMIT
     # octets, each continuation starting with a single space (RFC 2426
     # section 2.6). The walk is character-wise so a multibyte character
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 2da37bf..3763e97 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -1,4 +1,6 @@
 
+require "pathname"
+
 require "pro_tacts"
 require "sentry-ruby"
 
@@ -6,6 +8,8 @@ require "rack/rewindable_input"
 require "nokogiri"
 require "roda"
 
+require "pro_tacts/admin/contacts_index"
+require "pro_tacts/admin/contacts_show"
 require "pro_tacts/debug_logger"
 require "pro_tacts/contact"
 require "pro_tacts/store"
@@ -18,6 +22,15 @@ module ProTacts
     # @rbs @contacts: Array[Contact]?
     # @rbs @ctag: String?
 
+    # The vendored Gloss CSS and the admin app's own stylesheet (see
+    # docs/DESIGN.md); relative to this file rather than $0 for the same
+    # reason Store::MIGRATIONS is, and served by Roda's own `public`
+    # plugin rather than a reverse proxy — there is no reverse proxy
+    # here, `tailscale serve` hands requests straight to this app.
+    PUBLIC_ROOT = Pathname.new(
+      __dir__ #: String
+    ).parent.parent / "public" #: Pathname
+
     # The store this app serves from. config.ru builds it and hands it in;
     # nothing here reaches for a global to find one, which is what lets a
     # test point the app at a throwaway database. Kept in Roda's own opts
@@ -55,6 +68,7 @@ module ProTacts
 
     plugin :all_verbs
     plugin :dav_verbs
+    plugin :public, root: PUBLIC_ROOT.to_s
 
     plugin :not_found do
       Sentry.capture_message("404 Not Found", level: :warning)
@@ -66,6 +80,34 @@ module ProTacts
     # and service discovery (RFC 6764). Each handler cites its section, and
     # the texts are vendored under docs/rfcs to check them against.
     route do |r|
+      r.public
+
+      # The read-only admin UI (docs/DESIGN.md). Under the same auth
+      # gate as the CardDAV routes above it — "a few family members, all
+      # trusted" is the whole access model this app has, see README's
+      # simplifying assumptions.
+      r.on "admin" do
+        r.on "contacts" do
+          r.is do
+            r.get do
+              response["Content-Type"] = "text/html; charset=utf-8"
+              Admin::ContactsIndex.call(recent: store.contacts_by_recency, query: r.params["q"])
+            end
+          end
+
+          r.get String do |id|
+            contact = store.contact(id)
+
+            # No match falls through to the empty-body 404 the
+            # not_found handler fills in, same as the CardDAV GET.
+            if contact
+              response["Content-Type"] = "text/html; charset=utf-8"
+              Admin::ContactsShow.call(contact:)
+            end
+          end
+        end
+      end
+
       r.is "" do
         # DAV:current-user-principal (RFC 5397 section 3) — what a client
         # asks the root for to find the principal it is acting as.
diff --git a/public/admin.css b/public/admin.css
new file mode 100644
index 0000000..96d14ef
--- /dev/null
+++ b/public/admin.css
@@ -0,0 +1,45 @@
+/* Pro-tacts' own layer on top of Gloss (see docs/DESIGN.md) — page
+   chrome and the detail grid, neither of which Gloss defines a
+   component for. Every value below is a var(--gl-*) or a structural
+   rem width in the spirit of card.css's own max-width: 18rem; nothing
+   here is a hex, a px, or a duration. */
+
+.admin-header {
+  padding: var(--gl-space-s) var(--gl-space-m);
+  border-bottom: 1px solid var(--gl-color-border-hairline);
+}
+.admin-header a {
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--1);
+  color: var(--gl-color-text-primary);
+  text-decoration: none;
+}
+
+.admin-main {
+  max-width: 34rem;
+  margin: 0 auto;
+  padding: var(--gl-space-l) var(--gl-space-m);
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-m);
+}
+
+/* The detail card's load-bearing rule: type in one column, value in
+   the other, every row aligned to the same grid regardless of what
+   kind of attribute it is. See docs/DESIGN.md, "Alignment is the
+   layout." */
+.detail-grid {
+  display: grid;
+  grid-template-columns: 6rem 1fr;
+  row-gap: var(--gl-space-2xs);
+  column-gap: var(--gl-space-s);
+  margin: 0;
+}
+.detail-grid dt {
+  grid-column: 1;
+  padding-top: 0.2em;
+}
+.detail-grid dd {
+  grid-column: 2;
+  margin: 0;
+}
diff --git a/public/vendor/gloss/base.css b/public/vendor/gloss/base.css
new file mode 100644
index 0000000..dd67260
--- /dev/null
+++ b/public/vendor/gloss/base.css
@@ -0,0 +1,179 @@
+/* Vendored from kejadlen/gloss @ d1d0aa0abdc4a70d461ff1878b412424be583091, 2026-08-31.
+   Gloss ships this as plain CSS meant to be copied into a consuming
+   project (see install.md) — there is no build step to run here.
+   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..05fd37f
--- /dev/null
+++ b/public/vendor/gloss/components.css
@@ -0,0 +1,769 @@
+/* Vendored from kejadlen/gloss @ d1d0aa0abdc4a70d461ff1878b412424be583091, 2026-08-31.
+   Gloss ships this as plain CSS meant to be copied into a consuming
+   project (see install.md) — there is no build step to run here.
+   Refresh with `rake gloss:vendor GLOSS=/path/to/gloss/checkout`. */
+
+/* Button
+   A flat, quiet button that recedes rather than performs — no gradients, no
+   shine. Variants: default, primary, dark, ghost, danger. Sizes: sm, md
+   (default), lg — padding and font step only, weight and radius stay put.
+
+   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: var(--gl-color-surface-card);
+  --_fg: var(--gl-color-text-primary);
+  --_border: var(--gl-color-border-default);
+  --_pad: var(--gl-space-xs) var(--gl-space-s);
+  --_font: var(--gl-step--1);
+
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--gl-space-2xs);
+  padding: var(--_pad);
+  border: 1px solid var(--_border);
+  border-radius: var(--gl-radius-md);
+  background: var(--_bg);
+  color: var(--_fg);
+  font-family: var(--gl-font-sans);
+  font-size: var(--_font);
+  font-weight: 550;
+  letter-spacing: -0.01em;
+  line-height: 1.2;
+  text-align: center;
+  text-decoration: none;
+  white-space: nowrap;
+  cursor: pointer;
+  transition: filter var(--gl-dur-fast) var(--gl-ease),
+              border-color var(--gl-dur-fast) var(--gl-ease);
+}
+
+:is(button:not(.icon-button), a.btn):hover:not(:disabled):not([aria-disabled="true"]) { filter: brightness(0.96); }
+:is(button:not(.icon-button), a.btn):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);
+  --_border: var(--gl-color-accent);
+}
+
+/* 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);
+  --_border: var(--gl-color-text-primary);
+}
+
+:is(button:not(.icon-button), a.btn)[data-variant="ghost"] {
+  --_bg: transparent;
+  --_fg: var(--gl-color-text-primary);
+  --_border: transparent;
+}
+
+/* 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);
+  --_border: var(--gl-color-danger);
+}
+
+:is(button:not(.icon-button), a.btn)[data-size="sm"] { --_pad: 0.375rem 0.75rem; --_font: 0.8125rem; }
+:is(button:not(.icon-button), a.btn)[data-size="lg"] { --_pad: 0.75rem 1.375rem; --_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. */
+[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 { margin-inline-start: -1px; }
+[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. */
+.icon-button {
+  --_size: 2.125rem;
+  --_bg: transparent;
+  --_border: transparent;
+  --_fg: var(--gl-color-text-secondary);
+
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: var(--_size);
+  height: var(--_size);
+  padding: 0;
+  flex: none;
+  border: 1px solid var(--_border);
+  border-radius: var(--gl-radius-md);
+  background: var(--_bg);
+  color: var(--_fg);
+  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-2); }
+.icon-button:disabled { opacity: 0.5; cursor: default; }
+
+.icon-button[data-size="sm"] { --_size: 1.75rem; }
+.icon-button[data-size="lg"] { --_size: 2.5rem; }
+
+.icon-button[data-variant="outline"] { --_bg: var(--gl-color-surface-card); --_border: var(--gl-color-border-default); --_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. */
+input:not([type="checkbox"]):not([type="radio"]),
+select {
+  width: 100%;
+  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-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;
+}
+
+/* The chevron is the one flagged color exception in this component set:
+   CSS cannot point a data-URI background-image at a custom property, so its
+   stroke is a literal hex rather than a var(--gl-*) reference. It is picked
+   to match --gl-color-text-secondary and commented here so it is not
+   mistaken for a stray color. */
+select {
+  appearance: none;
+  padding-inline-end: var(--gl-space-l);
+  background-image: 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");
+  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
+   Mono, uppercase, tracked — the catalog-label convention for status and
+   meta values (SAVED, OVERDUE, RECOMMENDED). A label, never a button.
+
+   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 {
+  --_bg: var(--gl-color-surface-fill);
+  --_fg: var(--gl-color-text-secondary);
+  --_border: var(--gl-color-border-hairline);
+
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25rem;
+  padding: 0.1875rem 0.5rem;
+  border: 1px solid var(--_border);
+  border-radius: var(--gl-radius-sm);
+  background: var(--_bg);
+  color: var(--_fg);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--2);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  line-height: 1;
+  white-space: nowrap;
+}
+
+.badge[data-tone="accent"]  { --_bg: var(--gl-color-accent-soft);  --_fg: var(--gl-color-accent-ink);  --_border: transparent; }
+.badge[data-tone="success"] { --_bg: var(--gl-color-success-bg);   --_fg: var(--gl-color-success);      --_border: transparent; }
+.badge[data-tone="danger"]  { --_bg: var(--gl-color-danger-bg);    --_fg: var(--gl-color-danger);       --_border: transparent; }
+
+.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-radius: var(--gl-radius-sm);
+  background: var(--gl-color-surface-fill);
+  border: 1px solid var(--gl-color-border-default);
+  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, hairline border, radius-lg. `[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: 1px solid var(--gl-color-border-hairline);
+  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) 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 {
+  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 { margin: 0; color: var(--gl-color-text-secondary); font-size: var(--gl-step--1); }
+.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: border-color var(--gl-dur-fast) var(--gl-ease), box-shadow var(--gl-dur-fast) var(--gl-ease); }
+.card[data-link]:hover { border-color: var(--gl-color-border-default); 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;
+
+  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, accent underline — 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-m);
+  border-bottom: 1px solid var(--gl-color-border-hairline);
+}
+[role="tablist"] [role="tab"] {
+  padding: 0.5rem 0;
+  margin-bottom: -1px;
+  background: none;
+  border: 0;
+  border-bottom: 2px solid transparent;
+  color: var(--gl-color-text-secondary);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--1);
+  letter-spacing: 0.02em;
+  cursor: pointer;
+  transition: color var(--gl-dur-fast) var(--gl-ease), border-color var(--gl-dur-fast) var(--gl-ease);
+}
+[role="tablist"] [role="tab"]:hover { color: var(--gl-color-text-primary); }
+[role="tablist"] [role="tab"][aria-selected="true"] {
+  color: var(--gl-color-text-primary);
+  border-bottom-color: 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"][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/tokens.css b/public/vendor/gloss/tokens.css
new file mode 100644
index 0000000..a7711bc
--- /dev/null
+++ b/public/vendor/gloss/tokens.css
@@ -0,0 +1,139 @@
+/* Vendored from kejadlen/gloss @ d1d0aa0abdc4a70d461ff1878b412424be583091, 2026-08-31.
+   Gloss ships this as plain CSS meant to be copied into a consuming
+   project (see install.md) — there is no build step to run here.
+   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;
+
+  /* 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;
+  }
+}
+
+: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;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  :root {
+    --gl-dur-fast: 0ms;
+    --gl-dur-base: 0ms;
+  }
+}
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 283b202..828316a 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -12,5 +12,12 @@ module ProTacts
 
       def self.new: (sequence: Integer, card_id: String, action: String, etag: String?, created_at: String) -> instance
     end
+
+    class RecentContact
+      attr_reader contact: Contact
+      attr_reader updated_at: String
+
+      def self.new: (contact: Contact, updated_at: String) -> instance
+    end
   end
 end
diff --git a/tasks/gloss.rake b/tasks/gloss.rake
new file mode 100644
index 0000000..75bd83b
--- /dev/null
+++ b/tasks/gloss.rake
@@ -0,0 +1,48 @@
+# Pro-tacts inherits Gloss (https://github.com/kejadlen/gloss) as its
+# design system, see docs/DESIGN.md. 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, not a symlink or a submodule — this task is how it gets
+# refreshed.
+
+namespace :gloss do
+  desc "Refresh the vendored Gloss CSS 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__).parent / "public" / "vendor" / "gloss"
+    FileUtils.mkdir_p(out)
+
+    header = <<~CSS
+      /* Vendored from kejadlen/gloss @ #{commit}, #{Time.now.utc.strftime('%Y-%m-%d')}.
+         Gloss ships this as plain CSS meant to be copied into a consuming
+         project (see install.md) — there is no build step to run here.
+         Refresh with `rake gloss:vendor GLOSS=/path/to/gloss/checkout`. */
+
+    CSS
+
+    bundles = {
+      "tokens.css" => %w[assets/css/tokens.css],
+      "base.css" => %w[_sass/base/reset.css _sass/base/typography.css],
+      "components.css" => %w[
+        _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 { (checkout / it).read }.join
+      (out / name).write(header + body)
+      puts "wrote #{out / name}"
+    end
+  end
+end
diff --git a/test/pro_tacts/admin/test_contact_fields.rb b/test/pro_tacts/admin/test_contact_fields.rb
new file mode 100644
index 0000000..23c3dd8
--- /dev/null
+++ b/test/pro_tacts/admin/test_contact_fields.rb
@@ -0,0 +1,90 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/admin/contact_fields"
+require "pro_tacts/contact"
+
+class ContactFieldsTest < Minitest::Test
+  def fields(vcard)
+    ProTacts::Admin::ContactFields.from(ProTacts::Contact.for(id: "aiden", vcard:))
+  end
+
+  CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\n" \
+    "TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
+    "EMAIL;TYPE=home:ada@example.com\r\n" \
+    "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
+    "BDAY:1985-12-10\r\nNOTE:Countess\\, mathematician.\r\nUID:ada\r\nEND:VCARD\r\n"
+
+  def test_reads_the_name
+    assert_equal "Ada Lovelace", fields(CARD).name
+  end
+
+  def test_reads_every_phone_with_its_type
+    assert_equal [["+1-555-0100", "mobile"], ["+1-555-0199", "work"]],
+      fields(CARD).phones.map { [it.value, it.type] }
+  end
+
+  def test_reads_email
+    emails = fields(CARD).emails
+    assert_equal 1, emails.length
+    assert_equal "ada@example.com", emails.fetch(0).value
+    assert_equal "home", emails.fetch(0).type
+  end
+
+  def test_reads_address_as_two_lines
+    addresses = fields(CARD).addresses
+    assert_equal 1, addresses.length
+    address = addresses.fetch(0)
+    assert_equal ["12 Analytical Way", "London, England, NW1 1AA", "United Kingdom"], address.lines
+    assert_equal "home", address.type
+  end
+
+  def test_formats_the_birthday
+    assert_equal "December 10, 1985", fields(CARD).birthday
+  end
+
+  def test_unescapes_notes
+    assert_equal "Countess, mathematician.", fields(CARD).notes
+  end
+
+  def test_attributes_with_no_data_are_nil_or_empty
+    bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bare\r\nUID:bare\r\nEND:VCARD\r\n"
+    fields = fields(bare)
+
+    assert_empty fields.phones
+    assert_empty fields.emails
+    assert_empty fields.addresses
+    assert_nil fields.birthday
+    assert_nil fields.notes
+  end
+
+  # Fail open, the same contract Store#rebuild_index has: a card the
+  # parser cannot read still shows something rather than raising.
+  def test_a_card_that_will_not_parse_shows_no_fields
+    fields = fields("not a vCard at all")
+
+    assert_nil fields.name
+    assert_empty fields.phones
+  end
+
+  # RFC 2426 section 3.1.5 lets BDAY carry a date-time, and a client is
+  # free to send one; the display only reads the date part of it.
+  def test_a_birthday_with_a_time_component_still_formats
+    with_time = CARD.sub("BDAY:1985-12-10", "BDAY:1985-12-10T00:00:00Z")
+
+    assert_equal "December 10, 1985", fields(with_time).birthday
+  end
+
+  # ISO 8601's reduced-precision form for "no year on file", not RFC
+  # 2426 itself — see the comment on ContactFields::NO_YEAR_DATE.
+  def test_a_birthday_with_no_year_omits_one
+    no_year = CARD.sub("BDAY:1985-12-10", "BDAY:--12-10")
+
+    assert_equal "December 10", fields(no_year).birthday
+  end
+
+  def test_a_birthday_that_will_not_parse_is_shown_as_stored
+    unparseable = CARD.sub("BDAY:1985-12-10", "BDAY:not-a-date")
+
+    assert_equal "not-a-date", fields(unparseable).birthday
+  end
+end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
new file mode 100644
index 0000000..cffad56
--- /dev/null
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -0,0 +1,128 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/test"
+require "tmpdir"
+
+require "pro_tacts/store"
+require "pro_tacts/web"
+
+# The read-only admin UI (docs/DESIGN.md), exercised the same way
+# WebTest exercises the CardDAV routes: real requests through the Roda
+# app, against a throwaway store.
+class AdminContactsPagesTest < Minitest::Test
+  include Rack::Test::Methods
+
+  def app
+    ProTacts::Web
+  end
+
+  def setup
+    header "Tailscale-User-Login", "test@example.com"
+  end
+
+  ADA = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
+    "TEL;TYPE=mobile:+1-555-0100\r\nEMAIL;TYPE=home:ada@example.com\r\n" \
+    "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
+    "BDAY:1985-12-10\r\nNOTE:Countess of Lovelace.\r\nUID:ada\r\nEND:VCARD\r\n"
+
+  def with_contacts(cards)
+    Dir.mktmpdir do |dir|
+      original = ProTacts::Web.store
+
+      ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
+        ProTacts::Web.store = store
+        cards.each { |id, card| store.put(id, card) }
+        yield store
+      ensure
+        ProTacts::Web.store = original
+      end
+    end
+  end
+
+  def test_index_lists_recently_updated_contacts
+    with_contacts({"ada" => ADA}) do
+      get "/admin/contacts"
+
+      assert_equal 200, last_response.status
+      assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
+      assert_includes last_response.body, "Ada Lovelace"
+      assert_includes last_response.body, "recently updated"
+    end
+  end
+
+  def test_index_with_no_contacts_says_so
+    with_contacts({}) { get "/admin/contacts" }
+
+    assert_includes last_response.body, "No contacts yet."
+  end
+
+  def test_search_narrows_to_matches_across_contacts
+    grace = ADA.sub("Ada Lovelace", "Grace Hopper")
+      .sub("ada@example.com", "grace@example.com")
+      .sub("UID:ada", "UID:grace")
+
+    with_contacts({"ada" => ADA, "grace" => grace}) do
+      get "/admin/contacts", q: "hopper"
+
+      assert_includes last_response.body, "Grace Hopper"
+      refute_includes last_response.body, "Ada Lovelace"
+      assert_includes last_response.body, "results"
+    end
+  end
+
+  # Match generously (docs/DESIGN.md): a contact is findable by name,
+  # any of its values, or the groups it belongs to.
+  def test_search_matches_a_phone_or_email_value
+    with_contacts({"ada" => ADA}) do
+      get "/admin/contacts", q: "555-0100"
+      assert_includes last_response.body, "Ada Lovelace"
+
+      get "/admin/contacts", q: "ada@example.com"
+      assert_includes last_response.body, "Ada Lovelace"
+    end
+  end
+
+  def test_search_with_no_matches_says_so
+    with_contacts({"ada" => ADA}) do
+      get "/admin/contacts", q: "nobody"
+
+      assert_includes last_response.body, "No contacts match."
+    end
+  end
+
+  def test_show_renders_the_contacts_fields
+    with_contacts({"ada" => ADA}) do
+      get "/admin/contacts/ada"
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "Ada Lovelace"
+      assert_includes last_response.body, "mobile"
+      assert_includes last_response.body, "+1-555-0100"
+      assert_includes last_response.body, "12 Analytical Way"
+      assert_includes last_response.body, "London"
+      assert_includes last_response.body, "December 10, 1985"
+      assert_includes last_response.body, "Countess of Lovelace."
+    end
+  end
+
+  # Empty attributes do not render (docs/DESIGN.md) — a bare card shows
+  # only the header, not a scaffold of blank rows.
+  def test_show_hides_attributes_the_contact_has_no_data_for
+    bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bare Contact\r\nUID:bare\r\nEND:VCARD\r\n"
+
+    with_contacts({"bare" => bare}) do
+      get "/admin/contacts/bare"
+
+      assert_equal 200, last_response.status
+      refute_includes last_response.body, "notes"
+      refute_includes last_response.body, "birthday"
+    end
+  end
+
+  def test_show_of_an_unknown_contact_is_404
+    with_contacts({}) { get "/admin/contacts/nope" }
+
+    assert_equal 404, last_response.status
+  end
+end
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
new file mode 100644
index 0000000..315ebf3
--- /dev/null
+++ b/test/pro_tacts/admin/test_format.rb
@@ -0,0 +1,25 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/admin/format"
+
+class FormatTest < Minitest::Test
+  def test_initials_takes_the_first_letter_of_up_to_two_words
+    assert_equal "AL", ProTacts::Admin::Format.initials("Ada Lovelace")
+    assert_equal "A", ProTacts::Admin::Format.initials("Ada")
+    assert_equal "RE", ProTacts::Admin::Format.initials("Riverside Elementary Front Office")
+  end
+
+  def test_initials_of_a_blank_name_is_blank
+    assert_equal "", ProTacts::Admin::Format.initials("")
+  end
+
+  def test_time_ago_buckets_by_how_long_ago
+    now = Time.now.utc
+
+    assert_equal "just now", ProTacts::Admin::Format.time_ago(now.iso8601(3))
+    assert_equal "5m ago", ProTacts::Admin::Format.time_ago((now - 5 * 60).iso8601(3))
+    assert_equal "2h ago", ProTacts::Admin::Format.time_ago((now - 2 * 3600).iso8601(3))
+    assert_equal "3d ago", ProTacts::Admin::Format.time_ago((now - 3 * 86_400).iso8601(3))
+    assert_equal "2mo ago", ProTacts::Admin::Format.time_ago((now - 60 * 86_400).iso8601(3))
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 5322a17..88be821 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -205,6 +205,24 @@ class StoreTest < Minitest::Test
     end
   end
 
+  def test_contacts_by_recency_orders_newest_first
+    with_store({"aiden" => AIDEN}) do |store|
+      sleep 0.002
+      store.put("znorth", ZED)
+
+      assert_equal %w[znorth aiden], store.contacts_by_recency.map { it.contact.id }
+    end
+  end
+
+  def test_contacts_by_recency_carries_the_stamp
+    with_store({"aiden" => AIDEN}) do |store|
+      recent = store.contacts_by_recency.fetch(0)
+
+      assert_match TIMESTAMP, recent.updated_at
+      assert_equal card_row(store, "aiden").fetch(:updated_at), recent.updated_at
+    end
+  end
+
   def test_a_change_is_stamped_when_it_is_logged
     with_store({"aiden" => AIDEN}) do |store|
       assert_match TIMESTAMP, store.changes.last.created_at
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index dd1ff18..0093235 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -41,6 +41,36 @@ class VCardTest < Minitest::Test
     end
   end
 
+  ## Unescaping
+
+  def test_unescape_reverses_escape
+    assert_equal "Smith, John; Jr.", ProTacts::VCard.unescape("Smith\\, John\\; Jr.")
+    assert_equal "a\\b", ProTacts::VCard.unescape("a\\\\b")
+  end
+
+  def test_unescape_reverses_the_line_break_escape
+    assert_equal "a\nb\nc", ProTacts::VCard.unescape("a\\nb\\nc")
+  end
+
+  def test_escape_and_unescape_round_trip
+    Hegel.test do |tc|
+      value = tc.draw(text(max_size: 300))
+      normalized = value.gsub(/\r\n|\r/, "\n")
+      raise "unescape did not reverse escape" unless ProTacts::VCard.unescape(ProTacts::VCard.escape(value)) == normalized
+    end
+  end
+
+  ## Structured values
+
+  def test_split_components_respects_the_component_separator
+    assert_equal ["", "", "12 Analytical Way", "London", "England", "NW1 1AA", "United Kingdom"],
+      ProTacts::VCard.split_components(";;12 Analytical Way;London;England;NW1 1AA;United Kingdom")
+  end
+
+  def test_split_components_does_not_split_on_an_escaped_separator
+    assert_equal ["Smith; Jr.", "John"], ProTacts::VCard.split_components("Smith\\; Jr.;John")
+  end
+
   ## Property tests
 
   def test_unfolding_reverses_folding