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