Cut comments that narrate history, go stale, or re-derive a plan
Comments only, apart from moving web.rb's CSRF comment and plugin line
back above the error handler the 401 comment describes. Four kinds:
- History: what the code used to be or what was tried first, which
the log holds. Where it carried a reason, the reason stays in the
present tense.
- Stale: methods, classes and files that no longer exist
(Web#open_card, ImportReview, ContactsShow#has_data?), claims the
code now contradicts (no icon set yet, no writer of partial
birthdays, the exports under the data dir).
- Re-derivation: blocks written since the last pass that re-argue a
docs/plans section, cut to the citation plus what is local.
- Restatement of the line below or a test name.
lib's prose comment lines go from 4,808 to 4,375.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HimVgQzhhbw45FU7xGKpts
diff --git a/Rakefile b/Rakefile
index 04c1f07..8b579de 100644
--- a/Rakefile
+++ b/Rakefile
@@ -13,22 +13,17 @@ end
desc "Start development server, reloading on changes"
task :dev do
- # The dev server serves the fixture book, which demo.ru seeds from
- # test/fixtures/cards when it finds no database — so a client always
- # sees known state and the real data/ directory stays out of the dev
- # loop. Seeded there rather than here because the preview app serves
- # the same book from the same file. Only rackup reloads under entr, and
- # a reload finds the database already seeded, so a client's edits
- # survive a restart and a fresh `rake dev` is what resets to the
- # fixtures: the data lives in a session-scoped tmpdir, so two servers
- # running at once each get their own database, the directory goes when
- # the task does, and the next run starts from no database at all.
+ # The dev server serves the fixture book demo.ru seeds (see there for
+ # when it reseeds), so a client sees known state and the real data/
+ # directory stays out of the dev loop. The data lives in a
+ # session-scoped tmpdir: two servers running at once each get their
+ # own database, and the directory goes when the task does.
# entr's watch list is fd's snapshot at launch: a file created
# after `rake dev` starts is never watched, and edits to it never
# reload the server — restart the task when work adds a file.
# A dev session's exchanges go to their own log, truncated per start,
# so reading a session back never means picking it out of older ones —
- # log/exchange.log stays the deployment default. An exported
+ # the data directory's log/exchange.log stays the default. An exported
# PRO_TACTS_EXCHANGE_LOG (e.g. stderr) wins over both.
ENV["PRO_TACTS_EXCHANGE_LOG"] ||= "log/dev.log"
File.truncate("log/dev.log", 0) if File.exist?("log/dev.log")
diff --git a/Steepfile b/Steepfile
index 37793a9..437122c 100644
--- a/Steepfile
+++ b/Steepfile
@@ -3,9 +3,8 @@
# (`#:` before a method, `# @rbs` for everything else), so the type and
# the implementation are read and changed together.
#
-# sig/ holds only what inline comments cannot express: the gems we call
-# into, which ship no signatures of their own, and the two Data classes
-# (see sig/pro_tacts/). Both are documented where they are defined.
+# sig/ holds only what inline comments cannot express, each file saying
+# why (docs/plans/2026-08-20-type-checking.md).
target :lib do
check "lib", inline: true
# Phlex ships no RBS of its own, and the admin views are built on it —
diff --git a/demo.ru b/demo.ru
index f088f32..326ee39 100644
--- a/demo.ru
+++ b/demo.ru
@@ -37,8 +37,6 @@ config = ProTacts.config
# PRO_TACTS_DATABASE alone so the two paths agree).
unless config.database_path.exist?
store = FixtureData.install(config.data_dir)
- # The book the stand-in login syncs; `rake dev` named it here before
- # the preview wanted the same book.
store.name_book(ProTacts::StubLogin::LOGIN, "alpha")
store.close
end
diff --git a/lib/pro_tacts/admin/avatar.rb b/lib/pro_tacts/admin/avatar.rb
index 569cddf..5668b72 100644
--- a/lib/pro_tacts/admin/avatar.rb
+++ b/lib/pro_tacts/admin/avatar.rb
@@ -9,7 +9,7 @@ module ProTacts
# the views that render this slot — the dashboard's rows, the
# birthdays' rows, the detail header — should not disagree about
# what an avatar is. (The detail header asks for it only when a
- # picture exists; see ContactsShow.)
+ # picture exists; see ContactCard.)
#
# The picture renders from the photo route rather than the decoded
# bytes in hand, so the page carries a URL where it would otherwise
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 5a4ff89..819ae15 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -349,8 +349,8 @@ module ProTacts
end
# FN's text, from the boxes that make it: the western order the
- # create dialog has always written (Admin::ContactDialog), each
- # blank box left out rather than spacing the line.
+ # fixture sessions show macOS writing, each blank box left out
+ # rather than spacing the line.
#: (String first, String middle, String last) -> String
def self.display_name(first, middle, last)
[first, middle, last].reject(&:empty?).join(" ")
@@ -378,8 +378,6 @@ module ProTacts
readings.replace(property.components)
end
components << "" while components.length < 5
- # The form's three, in the value's own order: family, given,
- # additional.
[last, first, middle].each_with_index do |value, position|
current = readings[position].to_s
components[position] = value == current ? components.fetch(position) : VCard.escape(value)
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 51bf848..c24b17d 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -10,9 +10,10 @@ require "pro_tacts/vcard"
module ProTacts
module Admin
# A contact's details as a card: the header its rows sit under and
- # the rows themselves, every row's type in one column and its
- # value in the other (docs/DESIGN.md, "Alignment is the layout"),
- # and an attribute with no data getting no row at all.
+ # the rows themselves, on docs/DESIGN.md's grid ("Alignment is the
+ # layout", "Empty attributes do not render"). Each row leads with
+ # its mono type label, no row's glyph being in Icon's set yet
+ # (docs/DESIGN.md, the icon rule).
#
# The contact's own page (ContactsShow) renders it inside its
# record, and an import's saved row renders it beside the card
@@ -78,17 +79,11 @@ module ProTacts
# the avatar is the row's visual anchor, not a caption.
render Avatar.new(contact: @contact, size: "xl") if @contact.photo
end
- # The grid holds what the contact has and nothing it does
- # not — a groupless contact on a walk screen has no groups
- # row, there being no member to name and no dialog to
- # open (#groups_row's own reason).
dl(class: "detail-grid") { rows }
end
private
- # A missing TYPE parameter still gets a key: the fallback names
- # the kind of value, so no row renders unlabeled in the grid.
# Every row hands over the group that lends it, nil where the
# card's own — provenance carried on the row
# (Contact::Phone#lent_by and kin), not re-derived from the
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index c85cb1e..1aa97cf 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -22,32 +22,20 @@ module ProTacts
# form above it by id, since a footer sibling is not inside the
# form.
#
- # Name only, by task uw's first cut: a created card has no prior
- # octets to preserve, so a form is safe for it, and everything else
- # a contact carries arrives by editing the record — which belongs
- # on the details page, where the surgical mutator will live. N and
- # FN both written because RFC 2426 section 4 requires them of every
- # card; the three name fields are N's first three components
- # (family;given;additional), and FN is derived from them in the
- # western order the fixture sessions show macOS writing. The middle
- # box stands outside the required pair — it is never the name a
- # contact is made by — so it carries no rule of NamePair's.
+ # Name only: what a created card carries, and why nothing more, is
+ # CardForm.new_card's; everything else arrives through the editor
+ # (ContactsEdit). The middle box stands outside the required pair —
+ # it is never the name a contact is made by — so it carries no rule
+ # of NamePair's.
#
- # A popover cannot be declared open in markup, so the empty-name
- # refusal is not a re-opened dialog: each name box is `required`
- # while the other is blank (NamePair) — native validation, which no
- # browser submit gets past — and the server's backstop for a
- # hand-crafted empty POST answers with a toast on the dashboard
- # instead (see Web's POST handler).
- # `autofocus` fires when the popover is shown, being scoped to the
- # popover.
+ # Each name box is `required` while the other is blank (NamePair),
+ # native validation no browser submit gets past; the server's
+ # backstop is Web's POST /contacts. `autofocus` fires when the
+ # popover is shown, being scoped to the popover.
#
- # No company variant of this dialog: organizations are created by
- # the clients (Contacts.app's own Company flow PUTs a finished
- # card) and by import, and each web-side affordance tried for one
- # cost the common path chrome it could not pay for
- # (docs/plans/2026-09-24-company-cards.md, "Creating"). The editor
- # knows a company card when it edits one (Contact#company?).
+ # No company variant (docs/plans/2026-09-24-company-cards.md,
+ # "Creating"). The editor knows a company card when it edits one
+ # (Contact#company?).
class ContactDialog < Phlex::HTML
def view_template
dialog(id: "new-contact", popover: "auto") do
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 1220949..67b8bed 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -121,11 +121,8 @@ module ProTacts
# composed contact's, the one a save is guarded against.
@own = contact.own
@notice = notice
- # N's first three components (RFC 2426 section 3.1.2: family;
- # given; additional) — the three fields the create dialog also
- # asks for. The prefixes and suffixes after them are preserved
- # byte-for-byte by the save's raw splice (CardForm.n_line),
- # never rendered here.
+ # N's first three components; the prefixes and suffixes after
+ # them are never rendered here, and CardForm.n_line keeps them.
family, given, additional = contact.name_components || []
@first = given
@middle = additional
@@ -292,8 +289,8 @@ module ProTacts
# one per type named in the dialog, in the order they were
# asked for. Nothing stands here until something is added and
# nothing lingers after: an empty row waiting to be used is the
- # scaffold docs/DESIGN.md refuses ("Records are cards, not
- # forms"). That verb — make a row, now, without a round trip —
+ # scaffold docs/DESIGN.md refuses ("Empty attributes do not
+ # render"). That verb — make a row, now, without a round trip —
# is what CSS could not do and what Alpine is here for (see
# Layout).
#
@@ -376,9 +373,9 @@ module ProTacts
# bytes, so the prefill is Contact#birthday and not a reading
# off the card — why, and why every shape the grammar admits is
# editable here, is docs/plans/2026-09-07-web-birthday-editor.md.
- # It renders only over a held birthday: an empty row was an
- # attribute rendering nothing (docs/DESIGN.md), and a birthday
- # arrives through the add dialog like every other property. Every
+ # It renders only over a held birthday (docs/DESIGN.md, "Empty
+ # attributes do not render"), and a birthday arrives through the
+ # add dialog like every other property. Every
# standing row holds one, so the removal state rides on it
# unconditionally.
#: () -> void
@@ -496,9 +493,8 @@ module ProTacts
end
end
- # Where a property a contact does not have yet gets named —
- # docs/DESIGN.md's "a quiet add affordance opens a native dialog
- # that names the attribute types available." It holds no value,
+ # Where a property a contact does not have yet gets named
+ # (docs/DESIGN.md, "Adding is explicit"). It holds no value,
# so there is nothing staged out of sight and Cancel is a plain
# hide with nothing to undo; Add is the only thing that changes
# the form, and what it changes is visible in the card the
@@ -512,10 +508,10 @@ module ProTacts
#
# A radiogroup rather than a fieldset, and no legend: the
# dialog's header already names what is being picked, and a
- # legend under it read as a second heading for one control.
+ # legend under it reads as a second heading for one control.
# Gloss's `dialog > *` padding also outranks its `fieldset`
# reset, and a legend renders above the padding box rather than
- # inside it, so the two stacked into a gap nothing asked for.
+ # inside it, so the two stack into a gap nothing asked for.
def add_property_dialog
dialog(id: "add-property", popover: "auto") do
header { "Add a property" }
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index c59e197..9b93286 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -15,8 +15,8 @@ module ProTacts
#
# Nothing on the page narrows the list. Finding a contact is the
# header's search, which matches every field rather than the name
- # alone; a filter here was a second, weaker match that could only
- # disagree with it. This page is for reading the book by letter.
+ # alone; a filter here would be a second, weaker match that could
+ # only disagree with it. This page is for reading the book by letter.
class ContactsIndex < Phlex::HTML
# @rbs @rows: Array[Contact]
# @rbs @login: String
@@ -26,11 +26,10 @@ module ProTacts
def initialize(contacts:, groups:, login:)
@login = login
@rows = contacts.sort_by { [Format.sort_key(it), it.id] }
- # Whole groups inverted into memberships, the way the dashboard
- # inverts them for search: which groups a contact is in is the
- # store's fact, read from that side once instead of per row.
- # all_groups arrives ordered by id, so the inversion keeps the
- # order a rename must not move a tag in.
+ # Whole groups inverted into memberships, the way Admin::Search
+ # inverts them: which groups a contact is in is the store's
+ # fact, read from that side once instead of per row. The
+ # inversion keeps Store#all_groups' order.
@groups_of = {}
groups.each { |group| group.members.each { (@groups_of[it] ||= []) << group } }
end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 5f0ad95..6b60522 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -6,16 +6,8 @@ require "pro_tacts/admin/group_dialog"
module ProTacts
module Admin
- # GET /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.
+ # GET /contacts/:id — a contact's card (ContactCard), read-only;
+ # its edit link opens ContactsEdit.
class ContactsShow < Phlex::HTML
# The groups come from the store beside the contact rather than
# off it: a contact's card carries what its groups lend it, never
@@ -53,10 +45,8 @@ module ProTacts
end
render ContactCard.new(contact: @contact, groups: @groups, dialog: true)
end
- # The stored card in its own card under the record: the bytes
- # are the truth the grid above interprets, and they stay
- # reachable — collapsed by default, because the record is
- # about the person and the bytes are about the wire. Native
+ # The stored card in its own card under the record, collapsed
+ # (docs/DESIGN.md, "The stored card is reachable"). Native
# <details>, because a disclosure needs no script (see
# Layout — Alpine is loaded, and this is not what for);
# and the one thing a card that will not parse has to show
@@ -70,11 +60,10 @@ module ProTacts
end
end
# What this card has done, under the bytes it currently is:
- # the log the sync tokens count on (Store#changes_of), which
- # until now nothing here showed. Collapsed and in the raw
- # card's shape for the raw card's reason — the record is
- # about the person, and a sequence of etags is about the
- # wire — and last of the three, because a history is read
+ # the log the sync tokens count on (Store#changes_of).
+ # Collapsed and in the raw card's shape for the raw card's
+ # reason — a sequence of etags is about the wire, not the
+ # person — and last of the three, because a history is read
# after the thing that has one.
div(class: "card") do
div(class: "card-body") do
@@ -145,8 +134,7 @@ module ProTacts
end
# The stored card for display: byte for byte, except a value long
- # enough to be a wall. The count says exactly what is there; what
- # it stands in for is not readable text.
+ # enough to be a wall (Format::ELIDE_ABOVE).
#: () -> String
def raw_card
@contact.vcard.lines.map { Format.elide_line(it) }.join
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index 7b42fc8..e00976f 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -54,8 +54,6 @@ module ProTacts
end
render UpcomingBirthdays.new(upcoming: @upcoming)
end
- # In the page but out of the columns: a popover opens from
- # wherever it sits, and hidden is its resting state.
render ContactDialog.new
end
end
diff --git a/lib/pro_tacts/admin/device_setup.rb b/lib/pro_tacts/admin/device_setup.rb
index d02a90b..3e3ed3f 100644
--- a/lib/pro_tacts/admin/device_setup.rb
+++ b/lib/pro_tacts/admin/device_setup.rb
@@ -5,10 +5,8 @@ require "pro_tacts/admin/layout"
module ProTacts
module Admin
# GET /setup — the screen a device installs the CardDAV account
- # from. Until this existed the profile was rendered by rake on the
- # machine the repository is checked out on, so provisioning a phone
- # meant mailing a file to it; here the device that can reach the app
- # is the device that can provision itself from it.
+ # from, so the device that can reach the app provisions itself
+ # rather than being mailed a profile rendered on a checkout.
#
# /setup rather than /profile: in a web app a profile is the reader's
# own account page, and this is the device's setup — the one thing
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index f2ba8a5..ebb7949 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -112,11 +112,10 @@ module ProTacts
# not readable text.
ELIDE_ABOVE = 120 #: Integer
- # A diff's line or a card's, elided: text in, for the lines a
- # change log holds, a parsed line in for the ones a card does
- # (#elide_line). Two views render one of these — a contact's raw
- # card and a group's lent lines — which is why the machinery
- # lives here rather than in either view.
+ # A change log's line, elided as a card's is (#elide_line): text
+ # in rather than a parsed line. Both change logs render these — a
+ # contact's and a group's — which is why the machinery lives here
+ # rather than in either view.
#: (String text) -> String
def self.elide_text(text)
line = VCard::Parser.lines(text).first
@@ -192,8 +191,8 @@ module ProTacts
"#{digest[0, ETAG_DIGITS]}…"
end
- # The other way to render one of those stamps: as itself, minus
- # the milliseconds. What the change log records happened at a
+ # A stamp like #time_ago's, rendered as itself minus the
+ # milliseconds. What the change log records happened at a
# moment someone may need to line up against a client's own logs,
# so this one stays absolute where #time_ago goes coarse — and the
# fraction is the clock's precision rather than the change's.
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index aea0c1a..e14a7e2 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -14,20 +14,15 @@ module ProTacts
# two where a contact's groups are picked — the dialog on a stored
# contact's card (Admin::GroupDialog) and the boxes beside an
# arriving one (Admin::ImportGroups) — and the members screen,
- # where a group's contacts are (Admin::GroupsMembers). A book with
- # three groups and a book with three hundred are the same screen,
- # and the import's is the screen the walk opens once per contact,
- # so the two are the same problem — and a second answer to it
- # would only be a place where they could disagree. The members
+ # where a group's contacts are (Admin::GroupsMembers); why one
+ # answer for all of them is the plan's "Both pickers". The members
# screen renders the filter, the cap, and the show-all button,
# and not the offer to make what the filter names: a filter word
# cannot mint a contact — that is the dashboard's dialog, with
# its name fields — so its placeholder says nothing about adding,
# and `named` idles, nothing rendering Fresh to push onto it.
#
- # The contacts page does not use it: a list that is only read, not
- # picked from, leaves finding to the header's search, which matches
- # more than a name (Admin::ContactsIndex).
+ # The contacts page does not use it (Admin::ContactsIndex).
#
# Alpine does the filtering, matching each row's label as
# Format.search_key folds it — lowercased, accents folded —
@@ -35,9 +30,8 @@ module ProTacts
# is ever spliced into script.
# A row it hides still submits its box: hiding is not unticking,
# and a contact's other groups are not being answered for by a
- # word typed to find one of them. Every row stays in the page for
- # the same reason the plan gives — the filter is client-side, so a
- # row the server left out is a row nothing here can reach.
+ # word typed to find one of them. Every row stays in the page (the
+ # plan, "Why the rows stay in the page").
class GroupFilter < Phlex::HTML
# How many rows a list opens with. The dialog is a popover and
# cannot scroll past its own footer (see the plan, and the
@@ -92,13 +86,10 @@ module ProTacts
{data: {label: Format.search_key(label), capped:}, ":hidden": "!visible($el)"}
end
- # Which of `ids` the cap holds back, in the order they render.
- # A row already ticked is never capped, however far down the
- # list it sorts: those are the boxes a person opens the list to
- # untick, and one hidden from them makes the list disagree with
- # the screen it was opened from. They take rows from the limit
- # rather than standing outside it, so what stands shown is the
- # limit or the ticked rows, whichever is longer. `shown` is for
+ # Which of `ids` the cap holds back, in the order they render. A
+ # row already ticked is never capped and takes a row from the
+ # limit (the plan, "The groups a contact is in are never
+ # capped"). `shown` is for
# rows that are always there and are not in `ids` — the names an
# import is about to make — which take from the limit the same
# way.
@@ -225,11 +216,8 @@ module ProTacts
end
# The way past the cap for someone who is looking rather than
- # naming: the filter reaches a group already known by name, this
- # reaches the list. It counts the whole list rather than the part
- # under the cap, that being the number a person is deciding
- # whether to read. It stands down while the filter is typed
- # into, which lifts the cap itself.
+ # naming; what it counts and when it stands down are the plan's
+ # "What lifts it".
#
# It goes both ways, the label saying which way the next press
# goes: a list unrolled is the long list the cap exists to
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index af8b1d3..9483548 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -9,8 +9,8 @@ require "pro_tacts/admin/record_card"
module ProTacts
module Admin
# GET /groups/:id/edit — a group's editor: Admin::ContactsEdit's
- # shape over what a group holds, which is a name, the addresses and
- # note it lends, and who it lends them to. The address rows are the
+ # shape over what a group holds, which is a name and the addresses
+ # and note it lends. The address rows are the
# contact editor's own — addressed by digest, blank removes
# (docs/plans/2026-09-05-web-card-editor.md) — and the save runs
# the contact editor's own splice over the group's lines
@@ -19,9 +19,8 @@ module ProTacts
#
# Membership is edited from the group's card (Admin::GroupsShow),
# not here: it is a relationship, not a line the group holds. The
- # form carries
- # no `members[]`, so a save says nothing about membership
- # (#apply_edit's is-a-Hash posture) and leaves it as it stands.
+ # form carries no `members[]`, so a save leaves membership as it
+ # stands (Web#apply_group_edit).
class GroupsEdit < Phlex::HTML
# @rbs @group: Group
# @rbs @login: String
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
index d43e8d1..a7247a0 100644
--- a/lib/pro_tacts/admin/groups_members.rb
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -26,10 +26,8 @@ module ProTacts
# the state reach only inside their x-data element — which puts
# the filter inside the form it filters: Enter in it is guarded
# rather than submitted, the import's own answer where there is
- # no outside to stand in (Admin::GroupFilter). The filter, the
- # cap, and the show-all button are GroupFilter's; the offer to
- # make what the filter names is not rendered, a filter word not
- # being able to mint a contact.
+ # no outside to stand in (Admin::GroupFilter), whose filter, cap,
+ # and show-all button these are, without its offer.
class GroupsMembers < Phlex::HTML
# @rbs @group: Group
# @rbs @login: String
@@ -47,8 +45,7 @@ module ProTacts
# The order the show screen's list reads (Format.sort_key),
# which is the order the book is browsed in everywhere else.
@contacts = contacts.sort_by { [Format.sort_key(it), it.id] }
- # The members are never capped, GroupFilter.capped's own rule:
- # those are the boxes the screen is opened to untick.
+ # The members are never capped (GroupFilter.capped).
@capped = GroupFilter.capped(@contacts.map(&:id), joined: group.members)
end
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index e7257c7..bfc1d13 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -2,23 +2,18 @@ require "pro_tacts/admin/phlex"
module ProTacts
module Admin
- # Lucide icons, inlined: docs/DESIGN.md's icon register — Lucide
- # at 16–20px, stroke in currentColor, never filled — and this is
- # the set landing there. The path data below is lucide-static's
- # own (v0.548.0, ISC license), copied verbatim rather than redrawn,
- # and inlined rather than served as a file because currentColor
- # cannot cross an <img>: the stroke has to inherit from the
- # element that carries the glyph, Gloss's IconButton included. A
- # new glyph lands the same way this one did — named in the
- # registry, its shapes verbatim, this comment's version moved with
- # it.
+ # Lucide icons, inlined: the set docs/DESIGN.md's icon rule names.
+ # The path data below is lucide-static's own (v0.548.0, ISC
+ # license), copied verbatim rather than redrawn. A new glyph lands
+ # the same way — named in the registry, its shapes verbatim, this
+ # comment's version moved with it.
class Icon < Phlex::SVG
# @rbs @name: Symbol
# @rbs @size: Integer
# One glyph's shapes, each an element name and its attributes,
# verbatim from the lucide-static SVG's own children: x, the
- # remove control's two strokes; calendar, the birthday row's
+ # remove and clear controls' two strokes; calendar, the birthday row's
# picker; the import row's verdict, a circle either way and
# a check in it when the row is in the book;
# chevron-down, the account menu's opening direction;
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index b30ede3..6be97e2 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -24,12 +24,9 @@ module ProTacts
# sight, ticked: everyone's book, and the group for the import.
# Both can be unticked — an arrival that does not belong with the
# lot is a thing the walk is for deciding, and so is a card that
- # is to sit on the server without going out to anybody's phone. The filter, the cap on how many rows
- # stand shown and the button that lifts it are the dialog's
- # (Admin::GroupFilter,
- # docs/plans/2026-09-22-a-few-groups-at-a-time.md): a book with
- # three hundred groups buries either picker, and this is the one
- # the walk opens once per contact.
+ # is to sit on the server without going out to anybody's phone.
+ # The filter, the cap on how many rows stand shown and the button
+ # that lifts it are Admin::GroupFilter's.
#
# A named group is not created here: it is made by the Save, with
# the contact it was named for (Import::Write#group_id), because a
@@ -62,11 +59,10 @@ module ProTacts
# the Save that would have staged it is the Save that writes the
# contact.
#
- # Both are rows the cap spares, for the dialog's own reason: a
- # box this contact has already been given is one the walk came
- # back to untick. The names take rows from the limit without
- # being in the list the cap reads, so they are counted into it
- # rather than listed in it.
+ # Both are rows the cap spares, the names counted into the limit
+ # rather than listed in it (GroupFilter.capped;
+ # docs/plans/2026-09-22-a-few-groups-at-a-time.md, "Both
+ # pickers").
#
# `was` is the groups a contact the book already has is in, for a
# card being folded into it (Import::Merge): sent back beside the
@@ -87,10 +83,8 @@ module ProTacts
@was.each do |id|
input(type: "hidden", name: "was[]", value: id)
end
- # Enter guarded rather than the filter moved: the dialog
- # keeps its filter outside the form it filters, and here
- # there is no outside — the boxes are the editor's own
- # fields (GroupFilter#initialize).
+ # Enter guarded, the boxes being the editor's own fields with
+ # no outside to the form (GroupFilter#initialize).
render GroupFilter.new(in_form: true)
div(class: "field-stack", x_ref: "options") do
@groups.each do |group|
diff --git a/lib/pro_tacts/admin/import_original.rb b/lib/pro_tacts/admin/import_original.rb
index d304f87..c665d81 100644
--- a/lib/pro_tacts/admin/import_original.rb
+++ b/lib/pro_tacts/admin/import_original.rb
@@ -14,15 +14,11 @@ module ProTacts
# answer neither question this card is for: what did the source
# actually say, and what of it is not coming with it.
#
- # A line that is not coming in is struck in Gloss's danger color,
- # which is what that color means on this surface — the blanked
- # editor row wears it for the same reason (admin.css) — and says
- # so in words beside it, because a strike alone is a color and
- # colors are not read the same by everyone.
- #
- # Nothing here is a control. What to do about a struck line is
- # done in the editor beside it: read the spouse's name off this
- # card and type it into the note on that one.
+ # A line that is not coming in is struck in Gloss's danger color
+ # and says so in words beside it (the plan, "The walk"); the
+ # blanked editor row wears that color for the same reason
+ # (admin.css). Nothing here is a control: what to do about a
+ # struck line is done in the editor beside it.
class ImportOriginal < Phlex::HTML
# @rbs @card: ::ProTacts::VCard
# @rbs @dropped: Array[String]
diff --git a/lib/pro_tacts/admin/import_sidebar.rb b/lib/pro_tacts/admin/import_sidebar.rb
index edd84a9..c97f29c 100644
--- a/lib/pro_tacts/admin/import_sidebar.rb
+++ b/lib/pro_tacts/admin/import_sidebar.rb
@@ -6,38 +6,13 @@ require "pro_tacts/admin/icons"
module ProTacts
module Admin
# The contacts an uploaded .vcf holds, down the side of every
- # screen of the import (docs/plans/2026-09-21-import-a-vcf.md).
- #
- # Beside the content rather than a page of its own, because the
- # walk is a list worked down: opening a row used to mean leaving
- # the list, looking at one card, and coming back to find your
- # place in it again. Here the place is never lost — the row being
- # read is marked, the row after it is the next thing to click, and
- # what the last ten screens settled is in view while the eleventh
- # is open.
- #
- # Each row carries the two facts the walk turns on. Whether the
- # contact is in the book — the verdict at the row's trailing
- # edge, a circle either way with the check landed in it when the
- # row is: the success color, confirmed being that color's one
- # meaning (docs/DESIGN.md), and the name a step quieter with it,
- # a done thing receding so the rows still to do carry the walk's
- # own ink. Everything here is unsaved until its own screen says
- # otherwise, and a walk broken off overnight has to say which
- # rows those are. Words for it were the first try and were too
- # much: "not saved" on every row of a list that starts out
- # entirely unsaved is a column of the same sentence, and it took
- # the one slot the row's other fact wants. The word is still
- # there for a screen reader, which cannot see a mark
- # (.gl-visually-hidden).
- #
- # And what the card is losing, as a diff's own mark under the
- # name it is about — `-3` on a row still to do, in the
- # sign-and-color shape a change log entry wears
- # (Admin::ContactsShow#diff_lines, .diff-removed in admin.css) —
- # because the number is the whole of what a row has to say about
- # it and a sentence for it made every marked row a line of
- # prose.
+ # screen of the import. Why a sidebar, and the two facts each row
+ # carries — the verdict at its trailing edge, the count of lines
+ # the card is losing under its name — are
+ # docs/plans/2026-09-21-import-a-vcf.md, "The list is a sidebar".
+ # The verdict's word stays for a screen reader
+ # (.gl-visually-hidden); the count wears a change log entry's mark
+ # (Admin::ContactsShow#diff_lines, .diff-removed in admin.css).
class ImportSidebar < Phlex::HTML
# @rbs @upload: String
# @rbs @rows: Array[[::ProTacts::Contact, Integer, Integer]]
@@ -47,10 +22,9 @@ module ProTacts
# `rows` is the contact each card will be written as, how many
# of its original's lines are not coming with it, and the card's
# place in the file, in the order the list reads
- # (Web#walk_order); `saved`
- # the contact each row that has come in was written as, by the
- # row's place in the file; `current` the row whose screen is
- # open.
+ # (Web#walk_order); `saved` the contact each row that has come in
+ # was written as, by the row's place in the file; `current` the
+ # row whose screen is open.
#: (upload: String, rows: Array[[::ProTacts::Contact, Integer, Integer]], saved: Hash[String, String], current: Integer) -> void
def initialize(upload:, rows:, saved:, current:)
@upload = upload
@@ -59,12 +33,8 @@ module ProTacts
@current = current
end
- # The record's own column shape (.record in admin.css), the
- # thing beside it being a record too — but no caption row: the
- # rows are self-evidently contacts, and the screen about the
- # file they came from is gone, so there is nothing for a
- # caption over the list to name or reach. What sits over the
- # editor is its own action's row and nothing else.
+ # The record's own column shape (.record in admin.css), with no
+ # caption row (the plan, "The list is a sidebar").
def view_template
nav(class: "record walk-list") do
ul(class: "card") do
@@ -81,30 +51,22 @@ module ProTacts
def row(contact, dropped, index)
stored = @saved[index.to_s]
li do
- # Every row opens in the walk, a saved one included: the
- # card was written the moment its Save was pressed, and its
- # row opens it as the contact it became beside the card it
- # arrived as — rather than navigating away from the list to
- # the contact's own page (Web#open_card). `aria-current`
- # marks the row being read, which is the one thing a list
- # beside its own content has to say that a list on a page
- # of its own does not.
+ # Every row opens in the walk, a saved one included, as the
+ # contact it became beside the card it arrived as
+ # (Web#card_screen; the plan, "Nothing waits for the end").
+ # `aria-current` marks the row being read, the one thing a
+ # list beside its own content has to say that a list on a
+ # page of its own does not.
a(href: "/import/#{@upload}/#{index}",
aria_current: ("page" if index == @current),
data: {state: stored ? "saved" : "unsaved"}) do
div(style: "flex: 1; min-width: 0;") do
div(style: "font-weight: 550;") { Format.name_label(contact) }
- # What the card is losing, under the name it is about
- # and only while there is a save to decide about: a
- # settled row's losses are settled with it.
unless stored
span(class: "type-label diff-removed") { "-#{dropped}" } if dropped.positive?
end
end
span(class: "gl-visually-hidden") { stored ? "saved" : "not saved" }
- # The trailing edge is the row's verdict: the circle for
- # a row not in the book, the check landed in it when the
- # row is.
render Icon.new(stored ? :circle_check : :circle)
end
end
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index 0dac586..85c4be9 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -6,17 +6,14 @@ require "pro_tacts/admin/record_card"
module ProTacts
module Admin
# GET /import — the screen an address book arrives on
- # (docs/plans/2026-09-21-import-a-vcf.md).
- # Until this existed an import meant rake tasks reading Contacts.app
- # on a Mac and carrying a plan to a host over HTTP; now it is the
- # file that Mac already knows how to export.
+ # (docs/plans/2026-09-21-import-a-vcf.md): the file a Mac already
+ # knows how to export.
#
# The form takes one file: a .vcf, or the JSON Monica 4 exports an
- # account as (Import::Monica), which is read as the .vcf it composes.
- # Looking over what is
- # in it needs the file open, so that is the next screen's
- # (ImportReview, over the walk's list of its contacts and the pair
- # of cards a row there opens).
+ # account as (Import::Monica), which is read as the .vcf it
+ # composes. Looking over what is in it needs the file open, so
+ # that is the walk's (Web#card_screen: the list of its contacts
+ # and the pair of cards a row there opens).
#
# Nothing comes back here at the end of a walk. What arrived is
# the group the import filed it under, a page that lists the same
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index ec3b174..a620c58 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -14,15 +14,14 @@ module ProTacts
# screen: search-first (docs/DESIGN.md) wants finding a contact
# possible from anywhere, and the dialog it opens rides along in
# every render. `wide` opts a screen out of the reading width a
- # single column wants (see admin.css) — the dashboard root is the
- # one screen that asks.
+ # single column wants (see admin.css) — the dashboard, and the
+ # import walk's screens.
#
# Alpine loads on every screen, and it is the only script here.
- # The admin UI was script-free until the edit screen needed to
- # add a row to a form without a round trip: CSS can reveal a
- # fixed set of elements but cannot make one, so every version of
- # that built out of `:has()` and hidden inputs traded a real
- # affordance away — a cap on how many, or a field that stayed
+ # It is for adding a row to a form without a round trip: CSS can
+ # reveal a fixed set of elements but cannot make one, and any
+ # version built out of `:has()` and hidden inputs trades a real
+ # affordance away — a cap on how many, or a field that stays
# behind after it was used. Alpine is the smallest thing that
# buys back the missing verb. Where markup alone still does the
# job it keeps doing it: the popovers open and close by the
@@ -64,11 +63,7 @@ module ProTacts
# (`rake dev` names its own).
title { "#{ProTacts.config.instance_name} — #{@title}" }
link(rel: "icon", href: ProTacts.config.favicon)
- # No webfont (docs/DESIGN.md), so there is no font link
- # among these. What was here before was a remote Google
- # Fonts stylesheet, and the self-hosted woff2s meant to
- # replace it were never added — the @font-face rules
- # pointed at three files that 404ed.
+ # No webfont (docs/DESIGN.md), so no font link among these.
link(rel: "stylesheet", href: "/vendor/gloss/tokens.css")
link(rel: "stylesheet", href: "/vendor/gloss/base.css")
link(rel: "stylesheet", href: "/vendor/gloss/components.css")
@@ -192,9 +187,8 @@ module ProTacts
# A server-rendered toast, Gloss's [role=status] contract:
# the one line a refused write leaves behind, fixed to the
# corner until the next navigation carries it off — no
- # dismiss control, because with no script there is nothing
- # to dismiss it with, and "leave" is the design doc's own
- # word for what a toast does next.
+ # dismiss control, "leave" being the design doc's own word
+ # for what a toast does next.
if @notice
div(role: "status", data: {fixed: true}) { span { @notice } }
end
diff --git a/lib/pro_tacts/admin/list_item.rb b/lib/pro_tacts/admin/list_item.rb
index 7bacce2..2e15037 100644
--- a/lib/pro_tacts/admin/list_item.rb
+++ b/lib/pro_tacts/admin/list_item.rb
@@ -4,9 +4,9 @@ module ProTacts
module Admin
# One row of a card list: an li holding a link, an optional avatar
# in front, the block's label in a bold column that flexes, and an
- # optional muted label behind it — the row shape every browse
- # screen shares (the dashboard's contacts and group results,
- # /contacts, /groups). The middle column is the one piece of
+ # optional muted label behind it — the row shape the browse
+ # screens share (the dashboard's and the search's rows, /groups, a
+ # group's members). The middle column is the one piece of
# layout this owns: min-width 0 lets a long name shrink instead
# of pushing what trails it off the card.
class ListItem < Phlex::HTML
diff --git a/lib/pro_tacts/admin/record_card.rb b/lib/pro_tacts/admin/record_card.rb
index 05981ad..3f9c6bc 100644
--- a/lib/pro_tacts/admin/record_card.rb
+++ b/lib/pro_tacts/admin/record_card.rb
@@ -4,15 +4,14 @@ module ProTacts
module Admin
# The trunk every form card shares: the record's caption row and
# card (admin.css), the form as the card's body, and the action
- # bar under both — a shape ContactsEdit, GroupsEdit, and
- # ImportUpload each carried by hand before this.
+ # bar under both.
#
# The form is the caller's block rather than the component's,
# because its rhythm is the caller's question: an editor's rows
# are a field-stack on the record's grid, and an upload's lone
# field is a Gloss settings field with no grid to align to.
- # Everything around the form is the same for all three, which is
- # why it lives here.
+ # Everything around the form is the same for every caller, which
+ # is why it lives here.
#
# `back` names the caption row's link, `[href, label]`, the label
# a string or whatever `render` takes (GroupsEdit's is a
@@ -110,14 +109,11 @@ module ProTacts
h1(class: "type-h2") { @heading } if @heading
yield
end
- # A dialog footer's shape (admin.css), outside the form and
- # reaching it by id, and absent outright when there is
- # nothing to submit and nowhere to cancel to — a card that
- # only reads (ImportSaved). The submit is the form's; Cancel
- # is navigation — a link in Gloss's `.btn` contract, which is
- # what an anchor that acts like a button opts into — and
- # goes where the back link goes, so each is absent when its
- # half is.
+ # A dialog footer's shape (admin.css), absent outright when
+ # there is nothing to submit and nowhere to cancel to — a
+ # card that only reads (ImportSaved). Cancel is navigation: a
+ # link in Gloss's `.btn` contract, which is what an anchor
+ # that acts like a button opts into.
if @submit || @back
footer do
a(href: @back&.fetch(0), class: "btn") { "Cancel" } if @back
diff --git a/lib/pro_tacts/admin/search.rb b/lib/pro_tacts/admin/search.rb
index 6f353dc..b33f32a 100644
--- a/lib/pro_tacts/admin/search.rb
+++ b/lib/pro_tacts/admin/search.rb
@@ -66,8 +66,9 @@ module ProTacts
private_class_method :matches?
# Every value of the contact a word is matched against as text:
- # its names, emails, addresses, notes, and groups. An address is
- # its components run together, so "main st" finds a street.
+ # its names, organization, emails, addresses, notes, and groups.
+ # An address is its components run together, so "main st" finds
+ # a street.
#: (Contact contact, Array[String] labels) -> Array[String]
def self.text_fields(contact, labels)
[
diff --git a/lib/pro_tacts/change_id.rb b/lib/pro_tacts/change_id.rb
index 1c1bd3c..6c66726 100644
--- a/lib/pro_tacts/change_id.rb
+++ b/lib/pro_tacts/change_id.rb
@@ -10,8 +10,8 @@ module ProTacts
# The length a contact's id is minted at. Store#put overwrites
# rather than refuses, so the width is the only guard a create has:
- # 48 bits, for a family's book. The admin create draws it (Web's
- # contacts branch); Store#reid measures ids against it.
+ # 48 bits, for a family's book. The admin create, the import, and
+ # Store#reid draw at it; #minted? measures ids against it.
CONTACT_LENGTH = 12 #: Integer
# SecureRandom rather than rand: the draws have to be independent of
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index 6d42c13..bd65aed 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -42,8 +42,8 @@ module ProTacts
!value.nil? && value.match?(TRUTHY)
end
- # Root data directory: holds the contacts database, and the exported
- # card mirror once there is one. Overridable with
+ # Root data directory: the database, the exchange log, staged
+ # imports, and the dump default under it. Overridable with
# PRO_TACTS_DATA_DIR.
#: () -> Pathname
def data_dir
@@ -54,7 +54,7 @@ module ProTacts
# derived from the cards (see ProTacts::Store). PRO_TACTS_DATABASE
# overrides the whole path rather than a name under the data
# directory, so a deployment can put the database on a different
- # volume from the exports.
+ # volume from the rest of the data.
#: () -> Pathname
def database_path
path = @env.fetch("PRO_TACTS_DATABASE", nil)
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index b83ac09..c11c1b3 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -150,22 +150,19 @@ module ProTacts
# shared line from a line of its own (EditedContact).
attr_reader :inherited
- # The card to serve: the stored one with the inherited lines and
- # the birthday composed back in, in that order, each immediately
- # before END:VCARD — stored + inherited + birthday, the card a
- # client downloads. A lent NOTE is the one exception: it composes
- # as a labeled section inside the member's single NOTE, not as a
- # second line, because macOS keeps only the last of two and a PUT
- # stores the truncation (docs/plans/2026-09-25-one-note-per-
- # contact.md, "The served shape"). A birthday with no wire form,
- # or none at all, and an empty inheritance leave their halves as
- # they lie, and an empty inheritance is #insert's own no-op — the
- # same card object — so a contact in no group serves exactly the
- # bytes it did before groups existed, etag included. Composed on
- # first ask and
+ # The card to serve: the stored one with the inherited lines and the
+ # birthday composed back in, in that order, each immediately before
+ # END:VCARD — stored + inherited + birthday, the card a client
+ # downloads. A lent NOTE is the one exception: it composes as a
+ # labeled section inside the member's single NOTE, not as a second
+ # line (docs/plans/2026-09-25-one-note-per-contact.md). A birthday
+ # with no wire form, or none at all, and an empty inheritance leave
+ # their halves as they lie, and an empty inheritance is #insert's
+ # own no-op — the same card object — so a contact in no group serves
+ # exactly its stored bytes, etag included. Composed on first ask and
# memoized, and the etag's derivation deferred with it: a caller
- # that reads neither — and the listing paths read neither — pays
- # for no composition of its own.
+ # that reads neither — and the listing paths read neither — pays for
+ # no composition of its own.
#: () -> VCard
def vcard
return @vcard if defined?(@vcard)
@@ -385,21 +382,20 @@ module ProTacts
private
- # Which of the composed card's lines the groups lend, keyed by
- # where each sits in #vcard's parse: #vcard appends what the
- # groups lend after the stored card's own lines, so a walk that
- # spends the stored card's copies first leaves every lent line to
- # pair with the inherited row composition placed there. A member
- # whose own card spells a line exactly as its group lends — an
- # address already on the card when its holder joined — reads as
- # the member's own first and the group's second, where a match on
- # bytes named the group over both copies and the member's own
- # address read as the group's. Keyed by position rather than by
- # the Line itself because a Line is a value: the two copies a card
- # holds of one line hash alike, and only where each sits tells
- # them apart. A NOTE is never matched: a lent one composes as a
- # section of the member's single NOTE, so a screen marking one
- # reads the inheritance directly.
+ # Which of the composed card's lines the groups lend, keyed by where
+ # each sits in #vcard's parse: #vcard appends what the groups lend
+ # after the stored card's own lines, so a walk that spends the
+ # stored card's copies first leaves every lent line to pair with the
+ # inherited row composition placed there. A member whose own card
+ # spells a line exactly as its group lends — an address already on
+ # the card when its holder joined — reads as the member's own first
+ # and the group's second; a match on bytes would name the group over
+ # both copies. Keyed by position rather than by the Line itself
+ # because a Line is a value: the two copies a card holds of one line
+ # hash alike, and only where each sits tells them apart. A NOTE is
+ # never matched: a lent one composes as a section of the member's
+ # single NOTE, so a screen marking one reads the inheritance
+ # directly.
#: () -> Hash[Integer, Group]
def lent_at
return @lent_at if defined?(@lent_at)
diff --git a/lib/pro_tacts/edited_contact.rb b/lib/pro_tacts/edited_contact.rb
index 2e96e21..71c51b4 100644
--- a/lib/pro_tacts/edited_contact.rb
+++ b/lib/pro_tacts/edited_contact.rb
@@ -248,10 +248,9 @@ module ProTacts
# The one reading that is not the section grammar's: a value with
# no headers that is exactly a lent note's text is a client of the
# two-line shape sending back what macOS truncated it to — the
- # last NOTE, which composition put last (docs/apple-contacts.md,
+ # last NOTE, which that shape put last (docs/apple-contacts.md,
# "Only the last of two notes survives"). It reads as that note
- # present and every other one gone, the reading the line matching
- # made of the same card before sections existed.
+ # present and every other one gone.
#: (VCard vcard, Array[Contact::Inherited] rows) -> [VCard, Array[GroupEdit]]
def subtract_notes(vcard, rows)
notes = vcard.lines.select { it.names?("NOTE") }
@@ -264,8 +263,8 @@ module ProTacts
end
# No NOTE at all is every section deleted — the cleared field,
- # which takes the member's own text with it the way taking the
- # lent line always did.
+ # which takes the member's own text with it (the plan's "The
+ # split").
deletions = rows.map { |row|
GroupEdit.new(group_id: row.group.id, position: row.position, line: nil)
}
@@ -279,13 +278,7 @@ module ProTacts
value = values.join("\n\n")
split = NoteSections.split(value, labels)
if split.sections.empty?
- # A value with no headers that is exactly a lent note's text is
- # a client of the two-line shape sending back what macOS
- # truncated it to — the last NOTE, which composition put last
- # (docs/apple-contacts.md, "Only the last of two notes
- # survives"). It reads as that note present and every other
- # one gone, the reading the line matching made of the same
- # card before sections existed.
+ # The two-line shape's truncation (see the method comment).
kept = rows.find { |row| lent_text(row) == value }
unless kept.nil?
return [
diff --git a/lib/pro_tacts/import/match.rb b/lib/pro_tacts/import/match.rb
index 0dcf103..b8d1aca 100644
--- a/lib/pro_tacts/import/match.rb
+++ b/lib/pro_tacts/import/match.rb
@@ -5,18 +5,9 @@ module ProTacts
# The contacts already in the book that an arriving card may be
# the same person as, offered beside that card's editor as the
# ones it could update instead of arriving new
- # (docs/plans/2026-09-23-merging-on-import.md).
- #
- # Edit distance over the three things a person is recognised by —
- # the name, an email address, a phone number — because what an
- # import meets is the same person spelled a little differently: a
- # typo, a middle initial, a dot in an address, a number written
- # with its country code on one side and without on the other. A
- # contact is offered when any one of the
- # three comes close enough — the same email under a nickname is
- # the case worth catching, and a sum would bury it — and the
- # offers are ranked by all three, so the one agreeing on more
- # comes first. The offer changes nothing until it is taken.
+ # (docs/plans/2026-09-23-merging-on-import.md, "Finding them": edit
+ # distance over the name, each email and each phone, offered on
+ # the best one field and ranked by the sum).
module Match
# How alike two values must be to offer the contact, as the share
# of the longer one an edit leaves alone: "Jon Smith" and "John
@@ -111,13 +102,8 @@ module ProTacts
end
# Two addresses as alike as the parts before their @, at one
- # domain, and not alike at all at two: "jane@booles.family" and
- # "jane@work.example" are different mailboxes, however alike the
- # names on them. The domain has to agree rather than count
- # toward the distance, because it is shared by everyone at it —
- # over whole addresses "ada@example.com" is four edits in
- # sixteen from "mary@example.com", two people alike for the
- # length of a provider.
+ # domain, and not alike at all at two (the plan's "Finding
+ # them").
#: (Array[String] ours, Array[String] theirs) -> Float
def self.closest_address(ours, theirs)
ours.product(theirs).map { |a, b|
diff --git a/lib/pro_tacts/import/merge.rb b/lib/pro_tacts/import/merge.rb
index 7ba1351..2629e4d 100644
--- a/lib/pro_tacts/import/merge.rb
+++ b/lib/pro_tacts/import/merge.rb
@@ -8,23 +8,12 @@ module ProTacts
module Import
# An arriving card folded into a contact the book already has: what
# the walk's editor renders when its toggle names that contact
- # rather than a new one (docs/plans/2026-09-23-merging-on-import.md).
- #
- # The contact's own card is the base and keeps every byte it has;
- # the arriving card adds only what the contact does not hold yet. A
- # phone, an email or an address comes in beside the ones the
- # contact has, unless it is one of them. A name, a nickname, a
- # note, a photo, an organization or a birthday — what a contact
- # holds one of — comes in only where the contact has none, and
- # where the two differ the book's own stands. What that leaves behind is #left, struck on
- # the card as exported the way every other line not coming in is
- # (Admin::ImportOriginal), so nothing is lost without having been
- # shown first.
+ # rather than a new one (docs/plans/2026-09-23-merging-on-import.md,
+ # "The fold"). What it leaves behind is #left, struck on the card
+ # as exported (Admin::ImportOriginal).
#
# Nothing here writes. The editor renders over #contact, and the
- # save derives it again to check the etag the form carried, so a
- # contact that changed in between refuses the save the way any
- # stale editor does.
+ # save derives it again to check the etag the form carried.
class Merge
# @rbs @into: Contact
# @rbs @labels: Hash[String, Array[VCard::Parser::Line]]
diff --git a/lib/pro_tacts/import/monica.rb b/lib/pro_tacts/import/monica.rb
index 9ee66b6..a18fd10 100644
--- a/lib/pro_tacts/import/monica.rb
+++ b/lib/pro_tacts/import/monica.rb
@@ -8,28 +8,9 @@ module ProTacts
module Import
# Monica 4's account export (Settings, Export, "Export to JSON"),
# read as the .vcf the rest of the import already walks
- # (docs/plans/2026-09-25-importing-from-monica.md).
- #
- # Monica's own vCards leave out most of what Monica is for — notes,
- # relationships, pets — and its JSON export is the one place those
- # come out whole. So this composes a card per contact out of it, and
- # everything after the upload is the walk as it was: the composed
- # file is staged as the import's original, pared by Vcf.read, and
- # read beside the editor a contact at a time.
- #
- # A composed card carries everything, not only what this book
- # reads. What Monica knows and no screen here shows goes in as an
- # X-MONICA- line, struck on the card as exported like any other
- # line not coming in: the left-hand card is the whole record, so
- # nothing is lost without having been shown first. What reads as
- # prose about the person — the notes, the relationships, the pets —
- # is joined into the one NOTE instead, that being the one place in
- # a contact that holds prose.
- #
- # The shapes read here are Monica's own export resources
- # (app/ExportResources on its 4.x branch): each record is its
- # columns, a `properties` object, and a `data` array of collections
- # `{count, type, values}` named by the resource they hold.
+ # (docs/plans/2026-09-25-importing-from-monica.md, which says why
+ # the JSON, where each part of a contact goes, and the shapes of
+ # Monica's export resources read here).
module Monica
# Monica's relationship type names, as its English interface
# spells them (resources/lang/en/app.php). A type an account made
@@ -73,10 +54,8 @@ module ProTacts
end
# The export as a .vcf, a card per contact in the order the export
- # lists them. A contact Monica only knows by name — `is_partial`,
- # the relative added from someone else's page — is not a card of
- # its own; it is a name in the relationships of the contacts it
- # is related to.
+ # lists them, less the `is_partial` ones Monica only knows by name
+ # (the plan's "Where each part goes").
#: (String bytes) -> String
def self.vcf(bytes)
account = account_of(bytes)
@@ -189,11 +168,8 @@ module ProTacts
end
# A SpecialDate (Monica's own date model) as BDAY, where a card can
- # spell it: a whole date, or a month and day when Monica was told
- # the year is unknown. One Monica worked out from an age holds a
- # year it guessed and a day it made up, which no card can spell
- # truthfully, so it stays behind as a line of its own for the
- # importer to read and enter on the contact's own page.
+ # spell it, and an age-based one as a line of its own left behind
+ # (the plan's "Where each part goes").
#: (untyped date) -> String?
def self.birthday(date)
return nil unless date.is_a?(Hash)
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index 7407526..a7b1f36 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -8,32 +8,12 @@ require "pro_tacts/change_id"
module ProTacts
module Import
# An import in progress, on disk between the requests that make it
- # up (docs/plans/2026-09-21-import-a-vcf.md). A review is a walk
- # over the contacts in a file, a screen at a time, and what the
- # walk is reading and writing has to outlive each request.
- #
- # Three slots per import. ORIGINAL is the uploaded file, written
- # once and never again: it is what the left-hand card renders, and
- # a card that has been edited still has to show what it arrived
- # as. REVISED is the cards as they will be written — pared to what
- # this book reads when the import opens (Vcf.read), and rewritten
- # whole each time the editor saves one of them. SAVED is what the
- # import has already done: the group it files its arrivals under,
- # and which of its cards are contacts now.
- #
- # That last one is what makes the walk resumable rather than
- # batched. A card is written the moment its Save is pressed
- # (Import::Write), so the store is the record of it and this slot
- # is only how the list knows which rows are done. Nothing here
- # holds a decision back: the groups a contact joins are ticked and
- # written by that same Save, so they are never staged at all.
- #
- # On disk rather than in the browser: a book with pictures in it is
- # tens of megabytes, and a form carrying it back and forth is the
- # upload done once per screen. Under the data directory, which is
- # the one place this app writes (Config#data_dir), and not the
- # system temp dir: a half-finished import is this server's own
- # state, short-lived without being anyone else's.
+ # up: its three slots, and why on disk, are
+ # docs/plans/2026-09-21-import-a-vcf.md, "The import waits on the
+ # server". Under the data directory, which is the one place this
+ # app writes (Config#data_dir), and not the system temp dir: a
+ # half-finished import is this server's own state, short-lived
+ # without being anyone else's.
module Staged
# Where they wait, a directory per import. Beside the database
# rather than in it: these are not cards yet, and nothing reads
@@ -51,10 +31,8 @@ module ProTacts
GROUP = "group" #: String
CONTACTS = "contacts" #: String
- # How long an import is worth keeping. Long enough to work down
- # a book's worth of contacts over an evening, and short enough
- # that a window closed on the review screen does not leave that
- # book on disk for a week.
+ # How long an import is worth keeping: an evening's walk, and no
+ # longer (the plan's "The import waits on the server").
LIFETIME = 86_400 #: Integer
# A new import, under a fresh id, which is what every screen of
@@ -69,11 +47,8 @@ module ProTacts
FileUtils.mkdir_p(directory)
File.binwrite(directory / file_name(ORIGINAL), original)
File.binwrite(directory / file_name(REVISED), revised)
- # The group is settled here rather than at the end, there
- # being no end to settle it at any more: every Save files its
- # contact under it, and nothing renames it afterwards — a
- # rename would leave what is already in the book under the old
- # name and put the rest somewhere else. Nothing saved yet, and
+ # The group is settled here, and never renamed (the plan's
+ # "Nothing waits for the end"). Nothing saved yet, and
# empty rather than absent, so every slot of an import that
# exists is a file that exists and a write can say which of
# the two it found (#write).
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index e0f668d..957efd5 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -5,24 +5,12 @@ module ProTacts
module Import
# An uploaded .vcf, read as the cards it holds and as what this
# server does not recognise in them
- # (docs/plans/2026-09-21-import-a-vcf.md).
+ # (docs/plans/2026-09-21-import-a-vcf.md, "What comes in").
#
- # Nothing here rebuilds a card out of fields: a contact arrives as a
- # vCard and is stored as one, and every line this address book reads
- # travels through untouched. What an import does not do is carry in
- # what nothing here will ever show. RFC 6352 section 6.3.2.2 binds
- # this server to keep what a client submits and does not understand,
- # and it does (vcard.rb); an import is the other direction, a person
- # choosing what their own book is made of.
- #
- # So this module owes two readings. The split into cards, and then
- # per card the split #read makes: what is coming in, and what is
- # being left behind. A contact's own screen shows both at once —
- # the card as it was exported with the doomed lines struck, beside
- # the card that is coming in, open in the editor — so nothing is
- # lost without having been shown first (Admin::ImportOriginal),
- # and the count of what one is losing is the mark its row in the
- # walk wears (Admin::ImportSidebar).
+ # Two readings: the split into cards, and per card the split #read
+ # makes into what is coming in and what is left behind, which the
+ # walk strikes on the card as exported (Admin::ImportOriginal) and
+ # counts on its row (Admin::ImportSidebar).
module Vcf
# A file that is not a set of vCards.
class Invalid < StandardError; end
@@ -35,38 +23,22 @@ module ProTacts
LABEL = "X-ABLABEL" #: String
# The properties this address book reads, and so the properties
- # an import brings in. The envelope (BEGIN, END, VERSION, UID),
- # the fields a screen shows (Contact's own readers: N, FN,
- # NICKNAME, TEL, EMAIL, ADR, NOTE, PHOTO, and the X-ABLabel that
- # names a row), the two a company card is read by (ORG, whose
- # name the editor's Organization field falls back to and which
- # the dashboard's search reads, and X-ABShowAs, which puts that
- # field there — docs/plans/2026-09-24-company-cards.md), and
- # BDAY, which the store takes into the model on the way in
+ # an import brings in: the envelope, the fields a screen shows
+ # (Contact's own readers, and the X-ABLabel that names a row), the
+ # two a company card is read by (ORG and X-ABShowAs,
+ # docs/plans/2026-09-24-company-cards.md), and BDAY, which the
+ # store takes into the model on the way in
# (docs/plans/2026-09-11-every-birthday-in-the-model.md).
- #
- # Everything else is unknown, which here means "no screen will
- # show it". The list is the reader's, not the writer's: adding a
- # screen for a property is what brings it in, and until then
- # importing one would only pad the cards with what nobody can
- # read.
KNOWN = [
*%w[BEGIN END VERSION UID N FN NICKNAME BDAY TEL EMAIL ADR NOTE PHOTO ORG X-ABSHOWAS],
LABEL,
].freeze #: Array[String]
- # Unknown, and not worth saying so. PRODID names the program
- # that wrote the file rather than anything about the person, and
- # every export carries one; SOURCE and REV, which Monica writes
- # on every card, are where the card came from and when it last
- # changed there — the export's facts again. Counted as a loss it would put a
- # line in every file's summary and a mark on every contact's
- # row, for a fact nobody is going to copy into a card. What goes
- # quiet is the asking, not the dropping — the line still does
- # not come in, and the card as exported still shows it struck
- # (Admin::ImportOriginal), because that card is the file's own
- # bytes and a line shown plain there would be a line claiming to
- # arrive.
+ # Unknown, and not worth saying so: facts about the export rather
+ # than the person, still dropped and still struck, but never
+ # counted as a loss (docs/plans/2026-09-21-import-a-vcf.md, "What
+ # comes in"; SOURCE and REV,
+ # docs/plans/2026-09-25-importing-from-monica.md).
NOISE = %w[PRODID SOURCE REV].freeze #: Array[String]
# Tags (RFC 2426 section 3.6.1), which come in as groups rather
@@ -77,19 +49,15 @@ module ProTacts
CATEGORIES = "CATEGORIES" #: String
# What an import makes of one card: the card as it will come in,
- # and the lines it leaves behind. Both, because the review
- # screen shows them side by side — the original with its doomed
- # lines struck, and the card that is actually coming in, open in
- # the editor (Admin::ImportOriginal). Signed in
+ # and the lines it leaves behind. Signed in
# sig/pro_tacts/import.rbs, being a Data class.
# @rbs skip
Reading = Data.define(:card, :dropped)
# One property the file carries that KNOWN does not name, across
# every card in it: how many lines wear it, and a few of their
- # real values. The review screen's summary of what the whole
- # file is losing, read before working down the contacts one by
- # one. Signed in sig/pro_tacts/import.rbs, being a Data class.
+ # real values. Signed in sig/pro_tacts/import.rbs, being a Data
+ # class.
# @rbs skip
Unknown = Data.define(:name, :count, :examples)
@@ -133,16 +101,10 @@ module ProTacts
end
# Every property in these cards that KNOWN does not name, by
- # name rather than by the spelling the line wore: the three
- # choices read the same whatever parameters a line carries, so
- # `X-SOCIALPROFILE;type=twitter` is not a second decision from
- # `X-SOCIALPROFILE`. Ordered by name, a list to work down.
- #
- # An unreadable line is not among them and asks nothing: the
- # parser hands one back without a property, so there is no
- # property name to decide about and nothing to say about it on
- # a screen. It travels with the card rather than being thrown
- # away unnamed (vcard/parser.rb's own posture).
+ # name rather than by the spelling the line wore, so
+ # `X-SOCIALPROFILE;type=twitter` is not a second entry from
+ # `X-SOCIALPROFILE`. Ordered by name. An unreadable line has no
+ # property name and is not among them (#read keeps it).
#: (Array[VCard] cards) -> Array[Unknown]
def self.unknown(cards)
seen = {} #: Hash[String, Array[String]]
@@ -163,8 +125,7 @@ module ProTacts
end
# Whether a property is one this address book reads, asked of a
- # name as a line spells it — the one question the survey, the
- # review screen and the write all ask.
+ # name as a line spells it.
#: (String name) -> bool
def self.known?(name)
KNOWN.include?(name.upcase)
@@ -205,11 +166,8 @@ module ProTacts
# ones are what no screen here would ever show, which is what
# the review screen strikes through.
#
- # A line that would not read is kept. The parser hands one back
- # without a property name, so there is nothing to have shown on
- # a screen and nothing anyone could have decided about it, and
- # throwing it away unnamed is worse than letting it ride in the
- # card's bytes (vcard/parser.rb's own posture).
+ # A line that would not read is kept (the plan's "What comes
+ # in").
#: (VCard card) -> Reading
def self.read(card)
lines = card.lines
@@ -245,9 +203,8 @@ module ProTacts
# A line unfolded, without its terminator, cut short of a
# payload nobody wants to read — the one rendering of a line
- # this import shows, in the survey's examples and in the review
- # screen's reading of the original card
- # (Admin::ImportOriginal).
+ # this import shows (Admin::ImportOriginal), and Unknown's
+ # examples.
#: (VCard::Parser::Line line) -> String
def self.summary(line)
text = VCard::Parser.unfold(line.verbatim).chomp
diff --git a/lib/pro_tacts/import/write.rb b/lib/pro_tacts/import/write.rb
index c246ec0..c440756 100644
--- a/lib/pro_tacts/import/write.rb
+++ b/lib/pro_tacts/import/write.rb
@@ -4,31 +4,11 @@ require "pro_tacts/vcard"
module ProTacts
module Import
# Writes one card an import has settled on into this server's
- # store (docs/plans/2026-09-21-import-a-vcf.md).
- #
- # What arrives here is already what is coming in: the review screen
- # pared each card to the properties this book reads and let the
- # importer edit the result (Vcf.read, Admin::ImportOriginal). So this
- # step adds one thing only, the identity a stored contact has — a
- # minted id, and the UID that spells it — and then makes the two
- # writes a client's PUT and the groups dialog make, Store#put and
- # Store#regroup. It is not a second way to write a card.
- #
- # One card per call, because the walk is one card per Save: a
- # contact is in the book the moment its own screen says so, and an
- # import abandoned halfway keeps everything it had already written
- # (Import::Staged). Nothing here knows about the rest of the file.
- #
- # Every group it joins comes from the boxes beside that card,
- # everyone's book included: an import is a person deciding what
- # their book is made of, and a membership this applied on its own
- # would be one no screen asked for.
- #
- # Not idempotent, and cannot be: a .vcf carries no id this server
- # minted, so a second upload of the same file is a second set of
- # cards. The walk is what stands in for that — a card already
- # saved is a contact, and both its row and its screen lead there
- # rather than back into the editor (Web#open_card).
+ # store, on that card's Save: a minted id and the UID that spells
+ # it, then Store#put and Store#regroup into every group the boxes
+ # beside it ticked, everyone's book included
+ # (docs/plans/2026-09-21-import-a-vcf.md, "Writing them in", which
+ # also says why this is not idempotent).
class Write
# @rbs @store: Store
# @rbs @group_ids: Hash[String, String]
@@ -45,18 +25,11 @@ module ProTacts
# `joins` is the ids of groups this book already has, as the
# screen beside this card ticked them. `named` is the same
# answer in names, for the groups that screen asked for that do
- # not exist yet, made here because here is where the card they
- # were asked for is written. The group for the import is one of
- # the two, whichever it has become by now
- # (Web#import_picker) — nothing here knows it from any other
- # answer, there being nothing this can do about it that it does
- # not do about the rest.
- #
- # `everyone` is the one that cannot ride in either list on the
- # screen where it matters most: the first card into an empty
- # book is the write that creates `sync:*`, so there is no id to
- # tick and no row to have unticked. It arrives as the answer
- # rather than as a group, and is resolved here like a name.
+ # not exist yet, made here with the card they were asked for. The
+ # group for the import is one of the two, whichever it has become
+ # by now (Web#import_picker). `everyone` is whether to join
+ # `sync:*`, which may not exist yet to have an id, resolved here
+ # like a name (the plan's "Writing them in").
#: (Store store, VCard card, ?joins: Array[String], ?named: Array[String], ?everyone: bool) -> Contact
def self.call(store, card, joins: [], named: [], everyone: true)
new(store).call(card, joins, named, everyone)
@@ -74,13 +47,9 @@ module ProTacts
#: (VCard card, Array[String] chosen, Array[String] named, bool everyone) -> Contact
def call(card, chosen, named, everyone)
id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
- # `client: false`, though this is a create and a client's own
- # create joins `sync:*` for it
- # (docs/plans/2026-09-15-client-creates-join-everyone.md).
- # That flag answers a question the screen beside this card has
- # already asked, and would answer it the one way; here the box
- # is there to be unticked, so the join goes through #regroup
- # with the rest and the answer has somewhere to be no.
+ # `client: false`, though this is a create: everyone's book is
+ # a box here, joined through #regroup with the rest
+ # (docs/plans/2026-09-21-import-a-vcf.md, "Writing them in").
@store.put(id, identified(card, id), client: false)
# The chosen ids need no resolving, the screen having read
@@ -127,13 +96,10 @@ module ProTacts
private
- # The card under the id it is stored as. The source's UID goes
- # and the minted id takes its place, the shape every other create
- # here writes (Admin::CardForm.new_card) and the one rake
- # contacts:reid exists to restore: a UID from the book this file
- # was exported from names a record on some other server, and
- # keeping it would leave a contact with two spellings of who it
- # is.
+ # The card under the id it is stored as: the source's UID
+ # replaced by the minted id, the shape every other create here
+ # writes (Admin::CardForm.new_card; why, the plan's "Writing them
+ # in").
#: (VCard card, String id) -> VCard
def identified(card, id)
_, rest = card.extract("UID")
@@ -146,10 +112,6 @@ module ProTacts
# carries the group for the import across the walk: the first
# Save makes it, and from then on it is an ordinary box.
#
- # The lookup is per name rather than a list read once up front,
- # because the put above makes a group of its own: the first card
- # into an empty store creates `sync:*` (Store#everyone_group_id),
- # and a list read before that would send this to create it again.
# Memoized so that a card given the same name twice reads the
# list once rather than making the group twice. The choices read,
# not the whole groups read: the match is on name, and a group's
diff --git a/lib/pro_tacts/note_sections.rb b/lib/pro_tacts/note_sections.rb
index 574cdcc..cc0e407 100644
--- a/lib/pro_tacts/note_sections.rb
+++ b/lib/pro_tacts/note_sections.rb
@@ -6,16 +6,11 @@ module ProTacts
# header is words, is docs/plans/2026-09-25-one-note-per-contact.md.
#
# Pure text algebra, the two halves of one round trip: #join is what
- # composition writes, exactly one way, so an untouched card composes
- # back to the bytes it was served with; #split is what a PUT is read
- # with, tolerant of the whitespace a person's editing moves between
- # sections, because headers and not separators are the structure.
- # Neither knows what a group is — attribution is EditedContact's,
- # which knows what the contact's groups lend.
+ # composition writes, #split what a PUT is read with. Neither knows
+ # what a group is — attribution is EditedContact's, which knows what
+ # the contact's groups lend.
module NoteSections
- # The fixed half of every header. Rare enough at line start that a
- # collision needs a member's own text to open a line with the
- # phrase plus a lending group's label.
+ # The fixed half of every header.
HEADER = "Shared · ".freeze #: String
# One labeled part of a combined value: the lending group's label
@@ -47,18 +42,17 @@ module ProTacts
end
# The value read back: everything before the first header is the
- # member's own text, each header opens the section of its label,
- # and a section runs to the next header or the end. A header is a
- # line exactly equal to `Shared · <label>:` with trailing
- # whitespace gone, for none of the labels it names — a label
- # nothing lends is a header nowhere, so stale text from an old
- # value reads as member text and not as a mangle. Duplicate labels
- # are the caller's to refuse before this: one header cannot say
- # which of two it names. Leading and trailing blank lines come
- # off both halves — they are the separators the composer writes and
- # whatever a person left behind — and blank lines inside a body
- # stay, a multiline note keeping its own paragraphs. A member text
- # that was all blanks reads as "", which is the caller's no-note.
+ # member's own text, each header opens the section of its label, and
+ # a section runs to the next header or the end. A header is a line
+ # exactly equal to `Shared · <label>:` with trailing whitespace
+ # gone, for one of the labels given — a label nothing lends is a
+ # header nowhere. Duplicate labels are the caller's to refuse before
+ # this: one header cannot say which of two it names. Leading and
+ # trailing blank lines come off both halves — they are the
+ # separators the composer writes and whatever a person left behind —
+ # and blank lines inside a body stay, a multiline note keeping its
+ # own paragraphs. A member text that was all blanks reads as "",
+ # which is the caller's no-note.
#: (String value, Array[String] labels) -> Split
def self.split(value, labels)
headers = labels.to_h {
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index e43ab44..189cec8 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -41,14 +41,13 @@ module ProTacts
# records, describe the card a client downloads, not the bytes on
# disk.
#
- # The signature lives in sig/pro_tacts/store.rbs, for the Change Data
- # class the inline syntax cannot read.
+ # Its Data classes, which the inline syntax cannot read, are signed in
+ # sig/pro_tacts/store.rbs.
class Store
# @rbs @database: Sequel::Database
# One entry in the change log: what happened to a card, what it
- # hashed to afterwards, and which of its lines the write moved. The
- # signature is in sig/pro_tacts/store.rbs with Store's own.
+ # hashed to afterwards, and which of its lines the write moved.
# @rbs skip
Change = Data.define(:sequence, :card_id, :action, :etag, :diff, :created_at)
@@ -67,8 +66,7 @@ module ProTacts
# at which moment, with a detail shaped by the action — the name a
# create or rename left, the lines a `lines` write moved in
# CardDiff's spelling, the card a join or leave moved, and a
- # delete's tombstone carrying the whole group. The signature is in
- # sig/pro_tacts/store.rbs with Store's own.
+ # delete's tombstone carrying the whole group.
# @rbs skip
GroupChange = Data.define(:sequence, :group_id, :action, :detail, :created_at)
@@ -99,8 +97,7 @@ module ProTacts
# stored cards by id, the birthdays by card id, the groups, their
# photos by group id, and the books — the name each named one goes
- # by, keyed by login. What
- # `rake db:dump` writes (tasks/db.rake).
+ # by, keyed by login. What `rake db:dump` writes (tasks/db.rake).
# @rbs skip
Snapshot = Data.define(:cards, :birthdays, :groups, :group_photos, :books)
@@ -111,8 +108,7 @@ module ProTacts
# #group_choices reads only the row and a count of who is in it,
# which the import's picker sorts by (Admin::ImportGroups). The
# label is the same SQL one (#group_label), so a choice and a tag
- # never disagree about what to call a nameless group. Signed in
- # sig/pro_tacts/store.rbs, being a Data class.
+ # never disagree about what to call a nameless group.
# @rbs skip
GroupChoice = Data.define(:id, :name, :label, :member_count)
@@ -270,9 +266,8 @@ module ProTacts
# comparing two of them learns whether to resync — never what
# changed. The change log's sequence is that value, for the one
# indexed read it costs. It moves on a rewrite that stores identical
- # bytes too, where the hash this replaced did not: the promise
- # trades a spurious resync — every etag unchanged, nothing fetched —
- # for never missing a change.
+ # bytes too: the promise trades a spurious resync — every etag
+ # unchanged, nothing fetched — for never missing a change.
#: () -> String
def ctag
latest_sequence.to_s
@@ -289,17 +284,9 @@ module ProTacts
end
# The id a sync token names this database by: a digest of the
- # change log's first entry — the write every token's sequences
- # count from, and the one row nothing ever rewrites. #reid can move
- # its card_id, so the stamp is the digest's whole input; no write
- # touches a log row's created_at. Derived rather than stored
- # (docs/plans/2026-09-24-sync-tokens-name-their-database.md), so a
- # reseed or a dump restored — a new first entry at a new moment —
- # makes every token the old database issued stop reading, and the
- # route answers the 410 rather than a delta against a history the
- # client never saw. An empty log digests nothing, so a token taken
- # from a book that was empty 410s once after its first card lands:
- # a resync of nothing.
+ # change log's first entry's created_at, derived rather than stored
+ # (docs/plans/2026-09-24-sync-tokens-name-their-database.md, "The
+ # token" and "The costs").
#: () -> String
def database_id
first = change_log.order(:sequence).first
@@ -905,8 +892,8 @@ module ProTacts
# Drops the index and derives it again from the stored cards alone.
# Always safe to run: nothing is authoritative here, so if the
# projection ever disagrees with the cards, this is the repair. Raw
- # rows rather than #contacts, which compose birthdays in: the index
- # reflects what is stored, and no stored card carries a BDAY.
+ # rows rather than #contacts, which compose: the index reflects
+ # what is stored (#reindex).
#: () -> void
def rebuild_index
@database.transaction do
@@ -1207,10 +1194,6 @@ module ProTacts
Sequel::Migrator.run(@database, MIGRATIONS.to_s)
end
- # The birthday travels beside the card rather than inside it, and
- # Contact composes the served one — its vcard, its etag, everything
- # a caller reads — with the inherited lines beside it, so all
- # describe what a client downloads rather than the bytes on disk.
#: (Hash[Symbol, untyped] row, Birthday? birthday, Array[Contact::Inherited] inherited) -> Contact
def contact_from(row, birthday, inherited)
Contact.new(
diff --git a/lib/pro_tacts/sync_token.rb b/lib/pro_tacts/sync_token.rb
index 671701a..a854c2e 100644
--- a/lib/pro_tacts/sync_token.rb
+++ b/lib/pro_tacts/sync_token.rb
@@ -4,13 +4,9 @@ module ProTacts
# A sync token as this server spells one: the ctag it was minted at,
# the book it was minted for, and the database that minted it, in a
# URI. Opaque to the client (RFC 6578 section 3) and the URI form is
- # conventional, but not opaque to this server, which has to tell its
- # own token from one another book minted — the one a rename leaves a
- # user holding included (docs/plans/2026-09-12-per-user-books.md,
- # "The wire") — and from one another database minted: a reseed or a
- # dump restored repeats the sequences, and the id is what refuses
- # that token rather than answering a delta against a history it
- # never saw
+ # conventional, but not opaque to this server, which refuses a token
+ # another book minted (docs/plans/2026-09-12-per-user-books.md, "The
+ # wire") or another database did
# (docs/plans/2026-09-24-sync-tokens-name-their-database.md).
# Built on the ctag so that a client polling either one sees changes
# at the same points.
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 64a953c..310151c 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -34,9 +34,8 @@ module ProTacts
# 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.
+ # out unparsed — but a reader of values does (Parser::Property#text
+ # and #components).
#: (String text) -> String
def self.unescape(text)
text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 0c8d6b4..9d2be7e 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -39,12 +39,12 @@ module ProTacts
# index rows, and the card is served from its bytes regardless.
class ParseError < StandardError; end
- # Something this parser was built to assume it would never read.
- # Not a judgment on the card: the assumptions are about macOS
- # Contacts, the only client this address book serves
- # (test/fixtures/macos-exchange is the evidence for each), and a
- # card can break one while being a perfectly good vCard. Raising
- # is how the assumption gets checked instead of merely held.
+ # Something this parser was built to assume it would never read. Not
+ # a judgment on the card: the assumptions are about the Apple
+ # clients this address book serves (test/fixtures/macos-exchange is
+ # the evidence for each), and a card can break one while being a
+ # perfectly good vCard. Raising is how the assumption gets checked
+ # instead of merely held.
#
# A ParseError so it rides the same rails as any line that will
# not read — the bytes are still served, index rows are what is
@@ -150,9 +150,7 @@ module ProTacts
# Whether this line failed to read without breaking an
# assumption: ordinary bad input, against the news a broken
- # assumption is. Asked here for the same reason
- # broke_assumption? is, so no caller has to know the error
- # taxonomy to tell the two apart.
+ # assumption is. Asked here for broke_assumption?'s reason.
#: () -> bool
def unreadable? = !error.nil? && !broke_assumption?
@@ -337,8 +335,6 @@ module ProTacts
values = [parameter_value]
values << parameter_value while @scanner.skip(/,/)
values.map {
- # A two-element literal is an Array until something says
- # otherwise, and an inline annotation needs its own line.
[name, it] #: [String, String]
}
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 4f14c01..b581f3b 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -74,6 +74,15 @@ module ProTacts
"Not Found"
end
+ # The login is ambient — the proxy writes it for whichever tailnet
+ # device is asking — so no cookie is involved and SameSite holds
+ # nothing back: a page on another site could submit a form here and
+ # the app would apply it as whoever's device opened it. A write the
+ # browser marks as anything but same-origin gets an empty 403. One
+ # carrying no Sec-Fetch-Site at all passes, because that is every
+ # DAV client and curl, and no page a current browser sends.
+ plugin :sec_fetch_site_csrf, allow_missing: true, csrf_failure: :empty_403
+
# The identity gate's refusal, as an exception mapped to a
# response: ProxyAuth.login raises MissingLogin for a request that
# names nobody, and this answers it with the 401. `classes:` keeps
@@ -91,15 +100,6 @@ module ProTacts
# it hands an attacker nothing: the proxy overwrites the header on
# every request, so knowing which one it is buys no way to forge
# it.
- # The login is ambient — the proxy writes it for whichever tailnet
- # device is asking — so no cookie is involved and SameSite holds
- # nothing back: a page on another site could submit a form here and
- # the app would apply it as whoever's device opened it. A write the
- # browser marks as anything but same-origin gets an empty 403. One
- # carrying no Sec-Fetch-Site at all passes, because that is every
- # DAV client and curl, and no page a current browser sends.
- plugin :sec_fetch_site_csrf, allow_missing: true, csrf_failure: :empty_403
-
plugin :error_handler, classes: [ProxyAuth::MissingLogin] do |_e|
response.status = 401
response["WWW-Authenticate"] = "Proxy-Identity"
diff --git a/lib/pro_tacts/web/api.rb b/lib/pro_tacts/web/api.rb
index 134343d..1608561 100644
--- a/lib/pro_tacts/web/api.rb
+++ b/lib/pro_tacts/web/api.rb
@@ -2,13 +2,8 @@ require "json"
module ProTacts
class Web < Roda
- # JSON for scripts, beside the HTML screens a person uses. It was
- # written for `rake import:execute`, which looked a group up by
- # name over HTTP before making it; that import is gone
- # (docs/plans/2026-09-21-import-a-vcf.md) and the route stays as
- # what it always was — the group list, for anything scripted
- # pointed at this server, reading the order every listing does
- # (`Group#<=>`).
+ # JSON for scripts, beside the HTML screens a person uses: the
+ # group list, in the order every listing reads (`Group#<=>`).
hash_branch("api") do |r|
r.get "groups" do
response["Content-Type"] = "application/json"
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index c6f53f4..c61e1ff 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -188,8 +188,8 @@ module ProTacts
# differently now was edited in between — another tab, or a
# client sync — so applying this save over that one would revert
# it. The refusal re-renders from the current card, so the screen
- # shows what changed; the check shares write_card's millisecond
- # race window between check and write, noted there.
+ # shows what changed. Like write_card's, the check is not atomic
+ # with the write.
return refuse.call(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
# The one state the birthday row cannot write, refused whole:
@@ -209,9 +209,7 @@ module ProTacts
# or left elsewhere since stays as it is, and a stale page has
# nothing to revert and no snapshot to refuse. Only ids naming a
# group: Store#add_member reads the group with `sole`, so a doctored
- # one would be a 500 rather than the bad input it is. A `new` name
- # is a group the filter box offered to create, joined in the same
- # save; a blank one is none.
+ # one would be a 500 rather than the bad input it is.
#: (untyped r, String id) -> String?
def apply_groups(r, id)
contact = store.contact(id)
@@ -220,9 +218,9 @@ module ProTacts
known = store.group_choices.map(&:id)
checked = ids_in(r.params["groups"]) & known
was = ids_in(r.params["was"]) & known
- # The names the picker's offer committed, each made with this
- # save (Admin::GroupFilter::Named) — a tick is what commits a
- # name, so there is no single `new` riding the filter to read.
+ # The names the picker's offer committed by their ticks, each
+ # made with this save (Admin::GroupFilter::Named); a blank one is
+ # none.
names = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
# The walk's row, when the dialog was opened from one
# (Admin::ImportSaved's `land`): where a save returns to and a
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index bfbbc97..62931e6 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -237,8 +237,7 @@ module ProTacts
# resource supports. Answering an unsupported report with an
# empty 207 reads to the client as a successful empty result,
# and to us as nothing at all: 207 is not a status the
- # exchange log keeps, so the one signal that a client wanted
- # something unimplemented never fired.
+ # exchange log keeps.
#
# 403 with the precondition named in a DAV:error body is the
# marshalling RFC 4918 section 16 defines, and 403 is its
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index c1619cc..369b4c8 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -115,8 +115,9 @@ module ProTacts
# Only ids that name a card: a membership row is a foreign key,
# and a doctored id is ordinary bad input rather than a 500. A
- # POST carrying no list keeps the membership it found, the
- # phones' is-a-Hash posture (Admin::GroupsEdit).
+ # POST carrying no list keeps the membership it found, as an
+ # absent field keeps a card's lines (Admin::CardForm.edited_values);
+ # the editor's own form never carries one (Admin::GroupsEdit).
submitted = r.params["members"]
members =
if submitted.is_a?(Array)
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 1d3ed16..34875b0 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -18,35 +18,19 @@ require "pro_tacts/import/vcf"
module ProTacts
class Web < Roda
# The import screens: a .vcf, read, looked over contact by
- # contact, and written into the store
- # (docs/plans/2026-09-21-import-a-vcf.md).
- # They replace the rake tasks that read Contacts.app on a Mac,
- # built a plan directory, and carried it to a host over HTTP — a
- # .vcf is what every address book on earth already exports, and
- # the machine holding it is whichever one the browser is on.
- #
- # Two screens, because an import is a walk rather than a
- # submission: choose the file, and open any of its contacts
- # beside the card it arrived as. The Save on that screen is the
- # import of that one contact — not a note kept until a confirm
- # at the end — and then the walk is on the next row nobody has
- # read, so what is left when the walk stops is only the part
- # nobody looked at. The list is a sidebar rather than a screen
- # of its own (Admin::ImportSidebar): it stands beside every
- # card, so opening a row never costs your place in it, and what
- # the file is losing is said on the rows (the dropped count) and
- # on the cards (the struck lines) rather than on a screen about
- # the file — a stop between saves that repeated what the list
- # beside every card already said.
+ # contact beside the list of them (Admin::ImportSidebar), and
+ # written into the store a Save at a time
+ # (docs/plans/2026-09-21-import-a-vcf.md, "The walk" through
+ # "Nothing waits for the end").
#
# Every screen re-reads the whole staged file, so each step that
# grows with it is a span (Web#span) counting cards, never their
# content.
#
- # Under the same identity gate as every other route (web.rb),
- # which is also where the arrivals' books come from: a card this
- # creates joins everyone's book the way a client's create does, so
- # the person importing does not have to be the person syncing.
+ # Under the same identity gate as every other route (web.rb). A
+ # card this creates is ticked into everyone's book rather than the
+ # importer's own, so the person importing does not have to be the
+ # person syncing.
hash_branch("import") do |r|
r.is do
r.get do
@@ -95,8 +79,8 @@ module ProTacts
# The upload's own POST: the file — a .vcf, or a Monica export
# read as one — read and judged whole before anything is staged,
- # the old `execute`'s rule that a source which will not read
- # brings in nothing. What it stages is both readings —
+ # so a source that will not read brings in nothing. What it
+ # stages is both readings —
# the file as it arrived, and the cards pared to what this book
# shows — and then it is the walk's first card.
#: (untyped r) -> untyped
@@ -107,16 +91,13 @@ module ProTacts
name = File.basename(upload[:filename].to_s)
# Relabelled and judged in the same breath, Web#write_card's rule
# for the one other body this app reads: a multipart part arrives
- # as binary, and a force_encoding nobody validates is a lie every
- # later reader inherits.
+ # as binary.
bytes = upload.fetch(:tempfile).read.force_encoding(Encoding::UTF_8)
return import_screen(notice: "#{name} is not UTF-8 text.") unless bytes.valid_encoding?
begin
# A Monica export is read as the cards it composes, and from
- # here on is a .vcf like any other: the composed file is what
- # the walk stages as the import's original and strikes lines
- # out of (Import::Monica).
+ # here on is a .vcf like any other (Import::Monica).
bytes = span("import.monica") { Import::Monica.vcf(bytes) } if Import::Monica.export?(bytes)
cards = span("import.split") { |step| Import::Vcf.cards(bytes).tap { step.set_attribute("cards", it.length) } }
rescue Import::Vcf::Invalid => error
@@ -128,25 +109,16 @@ module ProTacts
Import::Staged.open(
original: bytes,
revised: joined(revised),
- # Named here and not at the end, there being no end to name
- # it at: every Save files its contact under it. Not the
- # importer's to rename either — a name typed before anything
- # has been looked at is a name for nothing, and one typed
- # after would split an import across two groups. What is
- # editable is the membership, beside each contact's own card
- # and a contact at a time (#import_picker).
+ # Named now and never renamed (the plan's "Nothing waits
+ # for the end"); its membership is ticked a contact at a time
+ # (#import_picker).
group: Import::Write.default_group,
)
}
- # Straight to the first contact rather than to a screen about
- # the file: the walk is the point, and a list that has just been
- # built from a file nobody has looked at yet is a stop on the
- # way to the same place — the list stands beside every card
- # anyway (Admin::ImportSidebar). The first contact is the list's
- # first row (#walk_order), not the file's first card.
- #
- # A 303, the other writes' answer, because what follows is a
- # walk: every screen of it is a GET a back button can revisit.
+ # Straight to the first contact (the plan's "The walk"): the
+ # list's first row (#walk_order), not the file's first card. A
+ # 303, the other writes' answer, because every screen of the
+ # walk is a GET a back button can revisit.
r.redirect "/import/#{id}/#{walk_order(revised).fetch(0)}", 303
end
@@ -158,8 +130,6 @@ module ProTacts
def walk_sidebar(upload, originals, revised, saved, current:)
rows = span("import.sidebar") {
walk_order(revised).map { |index|
- # A literal of several elements is an Array until something
- # says otherwise, and an inline annotation needs its own line.
dropped = Import::Vcf.read(originals.fetch(index)).dropped
[import_contact(revised.fetch(index), index), Import::Vcf.losses(dropped).length, index] #: [Contact, Integer, Integer]
}
@@ -184,17 +154,12 @@ module ProTacts
}
end
- # A row opened: the card it arrived as beside the card that is
- # coming in. The right-hand half is the contact editor itself, not a
- # copy of it: a field the two disagreed about would be a field an
- # import writes and an edit cannot undo.
+ # A row opened: the card it arrived as beside the contact editor
+ # itself (docs/plans/2026-09-21-import-a-vcf.md, "The walk").
#
- # A row this walk has saved opens over the stored contact rather
- # than the staged card — a look back, not a navigation away from
- # the list to the contact's own page — and reads: the write
- # happened at the row's Save, so there is no form to submit and
- # what it wrote is changed where every other contact is
- # (Admin::ImportSaved). A stored id naming no contact (deleted
+ # A row this walk has saved opens over the stored contact, to read
+ # rather than to submit (Admin::ImportSaved; the plan's "Nothing
+ # waits for the end"). A stored id naming no contact (deleted
# from the book since its Save) is the unsaved screen, there
# being nothing stored to open; its Save is the import POST's own
# double-write guard to catch.
@@ -203,18 +168,14 @@ module ProTacts
# which: nil for the screen no choice was made about, which folds
# into the contact the card looks like most (Import::Match), blank
# for a new contact, or the id of one the book already has, whose
- # editor this becomes with the card folded in
- # (Import::Merge) — the lines that fold leaves behind struck on the
- # card as exported beside it, as the ones the import itself drops
- # are. An id naming nobody (a contact deleted since the toggle was
- # drawn) is the new contact, there being nothing to fold into.
+ # editor this becomes with the card folded in (Import::Merge), the
+ # lines the fold leaves behind struck beside it. An id naming
+ # nobody (a contact deleted since the toggle was drawn) is the new
+ # contact, there being nothing to fold into.
#
# `joined` and `named` are the groups a refused save had ticked,
- # and none on the way in: nothing is staged between screens, the
- # Save that would have written an answer being the Save that
- # writes the contact, so a card opened afresh is asked the
- # question with the defaults ticked (#import_picker) rather than
- # with an earlier answer read back.
+ # and none on the way in: nothing is staged between screens (the
+ # plan's "Writing them in").
#: (String upload, Integer index, ?into: String?, ?notice: String?, ?joined: Array[String]?, ?named: Array[String]?) -> String?
def card_screen(upload, index, into: "", notice: nil, joined: nil, named: nil)
staged = staged_cards(upload)
@@ -264,16 +225,10 @@ module ProTacts
login: @login,
notice:,
action: "/import/#{upload}/#{index}",
- # The submit says what it does — the import of this one
- # contact, or the update of the one it is folded into —
- # because this is the one editor in the app whose Save can
- # create the record rather than amend it. An update names the
- # contact it writes, the two modes differing nowhere else the
- # eye lands: the fields over a fold are the same shape as over
- # an arriving card, and the toggle above them
- # (Admin::ImportTarget) a pill whose taken side lifts one step.
- # The row beside it wears its check only once this has been
- # pressed (Admin::ImportSidebar).
+ # The submit says what it does, this being the one editor
+ # whose Save can create the record (the plan's "The walk"). An
+ # update names the contact it writes, the two modes differing
+ # nowhere else the eye lands.
save: target ? "Update #{target.name || target.id}" : "Import",
aside: Admin::ImportOriginal.new(card: original, dropped: merge ? dropped + merge.left : dropped),
fields: import_picker(group, joined, named, member: target,
@@ -333,36 +288,17 @@ module ProTacts
)
end
- # The groups beside one card: this book's own list, with the two
- # an arrival comes in under already ticked. Both are boxes like
- # any other — unticking everyone's book is how a card comes in
- # without going out to anybody's phone, and unticking the
- # import's own group is how an arrival that does not belong with
- # the lot says so.
- #
- # The group for the import is a name rather than an id until
- # something makes it, which is the first Save to go in under it
- # (Import::Write#group_id), so it rides as one of the staged
- # names on that first screen and as an ordinary box after.
- # Everyone's book is missing from the list on one screen in the
- # life of a server — the first card of the first import into an
- # empty book, which is the write that creates it — and a row for
- # a group that does not exist would be a worse answer than none.
- #
- # A card being folded into a contact the book already has
- # (`member`) comes in under that contact's groups instead of
- # everyone's book: they are ticked, and sent back as `was[]` so the
- # save moves only what was toggled. The group for the import is
- # ticked beside them all the same, so the group the walk ends on
- # lists every contact it touched.
- #
- # The card's own tags (CATEGORIES, which Monica writes a contact's
- # tags as) are ticked too: a group of that name where the book has
- # one, and a name for the Save to make where it does not
- # (Import::Write#group_id), so an import from a book that sorted
- # its people by tag arrives sorted the same way. A tag spelling a
- # sync group is left alone — which phones a card goes out to is
- # the one box here a tag should not be able to tick.
+ # The groups beside one card: this book's own list, ticked with
+ # the import's group (a staged name until the first Save makes it,
+ # Import::Write#group_id) and everyone's book where it exists yet
+ # (docs/plans/2026-09-21-import-a-vcf.md, "Writing them in"). A
+ # fold into `member` ticks that contact's groups instead of
+ # everyone's book, sent back as `was[]`
+ # (docs/plans/2026-09-23-merging-on-import.md, "The Save"). The
+ # card's own tags are ticked too, as a group or a name to make,
+ # less any spelling a sync group
+ # (docs/plans/2026-09-25-importing-from-monica.md, "Tags are
+ # groups").
#: (String group, Array[String]? joined, Array[String]? named, ?member: Contact?, ?tags: Array[String]) -> Admin::ImportGroups
def import_picker(group, joined, named, member: nil, tags: [])
choices = store.group_choices
@@ -411,11 +347,10 @@ module ProTacts
end
# A card being folded into a contact the book already has
- # (#card_screen's `into`): that contact's editor, the fold
- # derived again here rather than trusted from the page, so the
- # etag below refuses a save over a contact that changed since.
- # One gone since is refused outright — writing the card as a new
- # contact instead would be a different Save than the one pressed.
+ # (#card_screen's `into`), the fold derived again here rather
+ # than trusted from the page, and refused outright if the
+ # contact is gone (docs/plans/2026-09-23-merging-on-import.md,
+ # "The Save").
into = r.params["into"].to_s
target = into.empty? ? nil : store.contact(into)
if !into.empty? && target.nil?
@@ -428,41 +363,23 @@ module ProTacts
into = target ? target.id : ""
# The groups ride in the same form and are written by the same
- # Save (Admin::ImportGroups): a card the walk has looked at is
- # decided about in both ways at once. Only ids naming a group,
- # #apply_groups' own rule, and the boxes are the whole answer —
- # none ticked is none joined, the group for the import included,
- # which is a box like any other here (#import_picker). A name
- # typed into the filter is committed by its tick into the
- # standing names (Admin::GroupFilter), so it survives the
- # filter being cleared, and is made by the write below
- # (Import::Write#group_id). `was` is the groups a contact being
- # updated was in when the page loaded, #apply_groups' own `was`,
- # and none for a new one.
- #
- # Read before the refusals rather than after, so a card sent
- # back to be fixed comes back with its boxes as they were
- # ticked: nothing is staged, and the form is the only record of
- # them until the write.
+ # Save (Admin::ImportGroups). Only ids naming a group, and `was`,
+ # are #apply_groups' own; the boxes are the whole answer, the
+ # group for the import included, and a ticked name is made by
+ # the write (Import::Write#group_id). Read before the refusals,
+ # so a card sent back comes back with its boxes as they were
+ # ticked (the plan's "Writing them in").
choices = store.group_choices
known = choices.map(&:id)
ticked = ids_in(r.params["groups"]) & known
was = ids_in(r.params["was"]) & known
wanted = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
- # Everyone's book is one of those boxes, and the only one the
- # write cannot take as an id: on the screen where unticking it
- # matters least — the first card into an empty book — the group
- # does not exist yet, so there is no row and no answer, and the
- # card joins it the way every other create does
- # (Import::Write#call).
+ # Everyone's book, which may not exist yet to tick, rides as an
+ # answer rather than an id (Import::Write.call's `everyone`).
everyone = choices.find { it.name == Group::EVERYONE }
syncing = everyone.nil? || ticked.include?(everyone.id)
- # N and FN are mandatory (RFC 2426 section 4), so a save blank
- # throughout is refused — the toast is the backstop, the form's
- # name pair (Admin::NamePair) being the browser's own refusal of
- # the same, and a company's organization field the one name it
- # has (Admin::CardForm.nameless?).
+ # A blank name is refused, #apply_edit's check.
first = r.params["first"].to_s.strip
middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
@@ -506,11 +423,8 @@ module ProTacts
written = span("import.write") {
Import::Write.update(store, target.id, edited, birthday:, joins: ticked - was, leaves: was - ticked, named: wanted)
}
- # Staged after the write rather than before, unlike a new
- # contact's below: until it lands this row is still the card
- # it arrived as, and staging the contact's card in its place
- # would make that the card a refused or failed save came back
- # to.
+ # Staged after the write, unlike a new contact's below
+ # (docs/plans/2026-09-23-merging-on-import.md, "The Save").
revised[index] = edited
span("import.restage") { Import::Staged.update(upload, joined(revised)) }
return walk_on(r, upload, index, written, saved, revised)
@@ -523,13 +437,9 @@ module ProTacts
# rendered for it, and a replace here would delete it.
unless carries_own_bday?(contact)
line = birthday&.to_line
- # A birthday with no wire form — a year on its own, a month
- # without its day — is one a vCard 3.0 card cannot spell
- # (docs/plans/2026-08-31-partial-birthdays.md). A stored
- # contact keeps one in the model and serves a card without it;
- # a staged contact has no model, so the card is the whole of
- # what it is, and writing it without the date would lose the
- # date the moment it was typed. Say so instead.
+ # A birthday no vCard 3.0 card can spell is refused rather
+ # than lost, a staged contact being only its card
+ # (docs/plans/2026-09-21-import-a-vcf.md, "The walk").
if birthday && line.nil?
return card_screen(upload, index,
notice: "A card cannot hold a birthday that partial. Import the contact, then add the date on its own page.",
@@ -562,9 +472,7 @@ module ProTacts
return close_walk(r, upload) if saved.length + 1 == revised.length
# Onto the next row still to save below this one, in the list's
- # order (#walk_order) — the walk steps from card to card, a
- # screen between saves having only ever repeated what the list
- # beside every card says. Below rather than first, so a row
+ # order (#walk_order). Below rather than first, so a row
# passed over stays passed over: skipping one is opening the
# next, and the walk comes back round to it only once the rows
# after it are saved. `saved` was read before this write, so the
@@ -578,19 +486,12 @@ module ProTacts
r.redirect "/import/#{upload}/#{below.find { remaining.include?(it) } || remaining.fetch(0)}", 303
end
- # The end of the walk: the file and the walk's own notes gone, and
- # the group the import filed under standing where the walk stood.
- # Reached one way only, by saving the last card there was to save,
- # because that is the only end a walk has. Leaving one needs no
- # screen and no button: the staged file expires on its own
- # (Import::Staged::LIFETIME), and what has come in has come in.
- #
- # There is no screen of its own for what arrived, because the
- # group for the import is that screen and outlives this one — a
- # list of the same contacts, rendered once rather than twice, on a
- # page that is still there tomorrow. A walk whose every card was
- # saved out of that group has none, and the whole book is where
- # they went.
+ # The end of the walk, reached only by saving the last card there
+ # was to save: the file and the walk's own notes gone, and the
+ # group the import filed under standing where the walk stood — or
+ # the whole book, for a walk whose every card left that group
+ # (docs/plans/2026-09-21-import-a-vcf.md, "Nothing waits for the
+ # end").
#: (untyped r, String upload) -> untyped
def close_walk(r, upload)
return expired_screen if Import::Staged.read(upload, Import::Staged::SAVED).nil?
@@ -602,10 +503,8 @@ module ProTacts
r.redirect(filed ? "/groups/#{filed.id}" : "/contacts", 303)
end
- # The import's two readings, or none when it is gone — swept out
- # from under a screen left open overnight, or closed already by
- # the Save that took the last card in. Ordinary enough for the
- # screen to say so and ask for the file again.
+ # The import's two readings, or none when it is gone
+ # (Import::Staged.read).
#
# The re-read parses files this already parsed, which is not a
# second judgment of them: the bytes are what was staged, and this
@@ -630,12 +529,9 @@ module ProTacts
# written, and no group lends a card that is not stored yet a
# line.
#
- # The birthday comes out of the card and into the model, which is
- # the split Store#put makes on the way in
- # (docs/plans/2026-09-11-every-birthday-in-the-model.md) — made
- # here too, so the editor renders the same row over an import as
- # over a contact. A BDAY the model does not read stays in the
- # card, that method's own rule.
+ # The birthday comes out of the card and into the model, Store#put's
+ # split and its rule for a BDAY the model does not read, so the
+ # editor renders the same row over an import as over a contact.
#: (VCard card, Integer index) -> Contact
def import_contact(card, index)
bdays, rest = card.extract("BDAY")
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index e926500..7ab6004 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -4,11 +4,9 @@ require "pro_tacts/profile"
module ProTacts
class Web < Roda
# The configuration profile, served so the device reading this can
- # provision itself: until this route existed the only renderer was
- # the Rakefile, so putting the account on a phone meant running
- # rake on the machine holding the checkout and mailing the file
- # over. It is now the only renderer — rake profile:install downloads
- # from here. The screen and the document are separate paths because a
+ # provision itself, and the only renderer of it: rake
+ # profile:install downloads from here. The screen and the document
+ # are separate paths because a
# mobileconfig is not a page — the screen says what the download
# will do, and only the link fires the install flow.
#
diff --git a/public/admin.css b/public/admin.css
index 92e9624..5ff6cbe 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -3,9 +3,8 @@
system elevates by. The page takes the fill step instead: one
alias repointed to a value already on the ramp, no new color. Dark
keeps its own block in tokens.css (its page/card step already
- reads), so the repoint carries tokens.css's own scoping — the
- media-query light block plus forced light, both excluding forced
- dark — and wins by stylesheet order on equal specificity. */
+ reads), so the repoint takes that block's scoping inverted: light
+ by preference unless forced dark, or forced light. */
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--gl-color-surface-page: var(--gl-color-surface-fill);
@@ -17,9 +16,7 @@
/* 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. */
+ component for. */
/* The record: its caption row and its card, one block. The gap is
the dashboard's section-head-to-card step (s, not the column's m)
@@ -34,22 +31,19 @@
/* Each record page's one action, on the back-link's line: the
details page's edit link at the right edge, the editor's add
- button in the same place, pinned the way the dashboard's
- section-head pins its action. In flow the control — half again
- taller than a type-label line — grew the row, and the card under
- it moved with it. Pinning takes it out of flow, centered on the
- row it acts on and straddling it a few pixels either side, into
- padding above and gap below that nothing else occupies.
+ button in the same place, pinned for .section-head's reason.
+ Centered on the row it acts on, it straddles it a few pixels
+ either side, into padding above and gap below that nothing else
+ occupies.
The row is the link's own height — the div is a flex box so the
anchor sizes it as an item, at the label's step--2/1 — and not
- the body-text line box the div would otherwise inherit, which
- stood a good 15px taller and read as the page's content starting
- that much lower than a list page's: the site header owes the
- first ink under it the same distance on every page, and a
- record's back link is that ink. Navigation is a link and opening
- a popover is a button, so the pinning rule below reaches for
- both, and either mode leaves the row the same height. */
+ the body-text line box the div would otherwise inherit, a good
+ 15px taller, which reads as the page's content starting that much
+ lower than a list page's: the site header owes the first ink
+ under it the same distance on every page, and a record's back
+ link is that ink. Navigation is a link and opening a popover is a
+ button, so the pinning rule below reaches for both. */
.record-nav {
position: relative;
display: flex;
@@ -77,17 +71,13 @@
top than the padding the body gives prose: the heading is the
card's own edge, and the m of air above content a page has
already headed reads as blank space rather than as room to read
- in — the detail-header's own reason, both on the contact's page
- and in a walk's read screen (ContactCard, the one renderer).
- Every record card takes the step, not only the named ones: this
- is the gap a page's header owes its content (the record-nav row
- over the card, held to the s a section-head's list gets) — an
- editor's fields at the body's m landed a good 10px deeper than
- a list page's first row, and the page-to-page difference is
- what the eye reads as the gap changing. The lone name of a
- group that lends nothing once kept the body's even padding to
- sit centered in the card; it takes the step now with the rest
- (Admin::GroupsShow). */
+ in, both on the contact's page and in a walk's read screen
+ (ContactCard, the one renderer). Every record card takes the
+ step, editors and a group's lone name included: this is the gap
+ a page's header owes its content (the record-nav row over the
+ card, held to the s a section-head's list gets), and an editor's
+ fields at the body's m land a good 10px deeper than a list
+ page's first row, which the eye reads as the gap changing. */
.record .card-body {
padding-block-start: var(--gl-space-s);
}
@@ -161,22 +151,19 @@
/* An editor row is a detail row that happens to be editable: caption
in the type column, control in the value column, on .detail-grid's
- own template (docs/DESIGN.md, "Alignment is the layout" — the same
- record before and after an edit should read as the same object).
+ own template (docs/DESIGN.md, "Alignment is the layout").
Gloss's .field stacks its caption over its control, which is right
for the settings-form pattern it was written for and wrong for a
record: it spends a line and a gap on every row, and this card is
eight rows deep before a contact has an email. Baseline for the
- reason .detail-grid gives — caption and control carry different
- type sizes, so lining their boxes up leaves their text on
- different lines. The class rather than the element: a row is a
- label when it holds one control and a div when it holds a stack
+ reason .detail-grid gives. The class rather than the element: a
+ row is a label when it holds one control and a div when it holds a stack
of them (an address — see the nested-stack rule below), and both
are the record's rows. The value track is minmax(0, 1fr) rather
than a bare 1fr: a bare fr floors the track at its content's
intrinsic minimum, and a form control's minimum — a select's
widest option, an input's default size — is wider than a phone's
- value column, which pushed rows past the card's edge. */
+ value column, and pushes rows past the card's edge. */
.card-body form.field-stack > .field {
display: grid;
grid-template-columns: var(--type-column) minmax(0, 1fr);
@@ -565,9 +552,9 @@ textarea:focus {
/* A run with an accent configured (PRO_TACTS_ACCENT; Layout marks the
root) wears that Gloss accent instead of the default, and the chrome
bars fill with it, so a working copy cannot pass for the deployment
- at a glance — `rake dev` picks ink blue. The whole root repoints
- rather than the bars alone, so a view still shows one accent. Clay
- was passed over for dev for sitting too near danger. The fill is the
+ at a glance (docs/DESIGN.md). The whole root repoints rather than
+ the bars alone, so a view still shows one accent. Dev wears ink
+ blue, not clay, which sits too near danger. The fill is the
accent under accent-fg, the pairing Gloss's primary button uses;
accent-soft would be a pale band in dark mode, where tokens.css
never derives it again. */
@@ -613,8 +600,7 @@ textarea:focus {
/* The viewport-tall column the footer's `margin-top: auto` measures
against. Gloss's own body rule sets type and surface and leaves
- layout to the page, which until there was a footer meant nothing at
- all. */
+ layout to the page. */
body {
min-height: 100dvh;
display: flex;
@@ -624,14 +610,13 @@ body {
/* The reading column's width: wide enough that the editor's widest
row — the birthday's three controls, ~343px at minimum — fits the
value column with room for the month select to grow (the value
- column at 40rem is ~412px), which 34rem missed by ~30px.
+ column at 40rem is ~412px), which 34rem misses by ~30px.
The declared width is what the body's column costs: auto inline
- margins on a flex item override the cross-axis stretch that used to
- size this box, leaving it shrink-to-fit — which took the dashboard's
- two columns down to one, its grid tracks being fractions of a width
- that had stopped existing. The auto margins go on centering it under
- the cap. */
+ margins on a flex item override the cross-axis stretch, leaving it
+ shrink-to-fit — which takes the dashboard's two columns down to
+ one, its grid tracks being fractions of a width that does not
+ exist. The auto margins center it under the cap. */
.admin-main {
width: 100%;
max-width: 40rem;
@@ -649,10 +634,9 @@ body {
max-width: 68rem;
}
-/* The dashboard itself: equal fluid columns that collapse to one when
- the viewport cannot hold two side by side, the ambient birthdays
- column stacking under the primary one. `align-items: start` keeps a
- short column from stretching its card to match its neighbor. */
+/* The dashboard's two columns (docs/DESIGN.md). `align-items: start`
+ keeps a short column from stretching its card to match its
+ neighbor. */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
@@ -662,12 +646,10 @@ body {
/* The import review's two cards: the contact as the file wrote it
beside the contact that is landing, open in the editor
- (Admin::ImportOriginal, Admin::ContactsEdit). The dashboard's own
- columns and for its reason -- two where the viewport holds two,
- one stacked column where it does not -- with `align-items: start`
- so the shorter card does not stretch to match its neighbor. 22rem
- rather than the dashboard's 20rem: the left card holds content
- lines, which are longer than a contact's name. */
+ (Admin::ImportOriginal, Admin::ContactsEdit), on .dashboard's
+ columns for its reason. 22rem rather than the dashboard's 20rem:
+ the left card holds content lines, which are longer than a
+ contact's name. */
.paired {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
@@ -675,9 +657,8 @@ body {
align-items: start;
}
-/* A card's content lines as the file wrote them, and the review
- list's property names one rung up (Admin::ImportOriginal,
- Admin::ImportReview): monospace, because these are the bytes and
+/* A card's content lines as the file wrote them
+ (Admin::ImportOriginal): monospace, because these are the bytes and
not a rendering of them, and wrapping rather than scrolling
because a long value is the thing being judged. */
.card-lines {
@@ -874,10 +855,9 @@ input[type="search"]::-webkit-search-cancel-button {
rotate: 180deg;
}
-/* Gloss zeroes every heading's margin (base/typography.css), which
- leaves a section's type-label flush against the list it names. The
- one vertical rhythm this layer adds, and shared by both dashboard
- columns — every section is a type-label over a list. */
+/* Gloss zeroes every heading's margin (base.css), which leaves a
+ section's type-label flush against the list it names; every
+ section is a type-label over a list. */
section > h2 {
margin-bottom: var(--gl-space-s);
}
@@ -891,8 +871,8 @@ section > h2 {
the two columns' headings and cards would no longer sit on shared
lines. Centered on the line it carries the row's own geometry,
and its text lands on the heading's: both are near-identical mono
- sizes, so center-on-line is baseline-on-line. The h2 is no longer
- the section's direct child, so the row takes over the heading's
+ sizes, so center-on-line is baseline-on-line. The h2 is not the
+ section's direct child here, so the row takes over the heading's
spacing to the list below. */
.section-head {
position: relative;
@@ -1016,7 +996,7 @@ section > h2 {
gap: var(--gl-space-xs);
}
-/* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
+/* base.css's `li + li { margin-top: var(--gl-space-3xs); }`
is a prose-list rule (Markdown bullet lists) that also matches
Card's row list, adding a stray gap below every row but the last. */
.card > li {
@@ -1093,17 +1073,14 @@ span.avatar[data-size="lg"] {
align-items: start;
}
/* Breathing room before the property grid — but only when there's a
- grid to make room for. A contact with no properties renders no
- .detail-grid at all (see ContactsShow#has_data?), and this rule
- staying silent then is what keeps its name centered in the card
- rather than pinned to the top with dead space below it. */
+ grid to make room for. A group that lends nothing renders no
+ .detail-grid at all (GroupsShow#lends?), and this rule staying
+ silent then is what keeps no dead space under its lone name. */
.detail-header:has(+ .detail-grid) {
margin-bottom: 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
+/* The detail card's grid: docs/DESIGN.md, "Alignment is the
layout." `align-items: baseline` rather than the grid default
(stretch, which top-aligns each cell's content): dt and dd carry
different type styles — .type-label at --gl-step--2/1, .type-body-sm
@@ -1126,15 +1103,15 @@ span.avatar[data-size="lg"] {
}
/* The group a row is lent by, tagged at the right edge of the value
- column on the row's own first line (ContactsShow#row). Under the
- value it read as a mark between two rows — the grid's row gap is
- wider than a line, but not by enough to say which row a line below
- a value belongs to. Pinned to row 1 of the value cell, it can only
+ column on the row's own first line (ContactCard#row). Under the
+ value it would read as a mark between two rows — the grid's row
+ gap is wider than a line, but not by enough to say which row a
+ line below a value belongs to. Pinned to row 1 of the value cell, it can only
be read as this row's.
A nested grid rather than a float or a flex row, because the value
is several block lines for an address and one for everything else:
- the lines stack down column 1 as they always did, and the tag
+ the lines stack down column 1, and the tag
takes column 2 of the first row alone. No wrapper element to make
them one flex item, and no source-order inversion — the value is
still read before the group that lends it.
@@ -1181,8 +1158,8 @@ a.tag {
Gloss's own spend more of a short dialog on chrome than on content.
The body takes one step on every side and the bars take it on
theirs, so the title, the fields, and the buttons share one edge:
- Gloss's side step left the buttons adrift from the corner, and a
- narrower step for the bars alone broke the edge. */
+ Gloss's side step leaves the buttons adrift from the corner, and a
+ narrower step for the bars alone breaks the edge. */
dialog > header,
dialog > footer {
padding: var(--gl-space-2xs) var(--gl-space-s);
@@ -1225,10 +1202,8 @@ dialog > .field-stack > .filter > input[type="search"] {
}
/* The groups dialog's list is the one that can outgrow the screen, so
- it is the one that scrolls — the popover cannot, Gloss giving a
- dialog height: fit-content and overflow: hidden, and what a dialog
- taller than the viewport loses off the bottom is the footer holding
- Save (docs/plans/2026-09-22-a-few-groups-at-a-time.md). The cap is
+ it is the one that scrolls, not the popover, which clips its footer
+ (docs/plans/2026-09-22-a-few-groups-at-a-time.md). The cap is
still what keeps the opening list short; this is for the two lists
the cap does not shorten, the whole list once it is asked for and a
contact's own memberships, which are never capped. The filter and
@@ -1241,7 +1216,7 @@ dialog > .field-stack > .filter > input[type="search"] {
/* The stored card in its own collapsed card under the record — the
column's gap spaces the two, so no margin here. <details> is native,
- keeping the admin UI script-free; the summary rides the label
+ needing no script; the summary rides the label
register, the pre scrolls rather than wraps: the bytes are the wire
format, folded lines and all. */
.card-body summary {
@@ -1312,14 +1287,14 @@ dialog > .field-stack > .filter > input[type="search"] {
/* Wider than the wide column, because three things stand in it: the
list, the card as exported, and the editor. The editor's own widest
row is sized against the value column the reading width names (see
- .admin-main below), so squeezing it to a third of 68rem is what put
- the pair one under the other in the first place. */
+ .admin-main above), so a third of 68rem would put the pair one
+ under the other. */
.admin-main:has(.walk) {
max-width: 92rem;
}
/* The pair inside the walk is two columns or one, never the auto-fit
- in between: at this width the fallback was always the stack, and a
+ in between: at this width auto-fit's fallback is always the stack, and a
card read beside its own source is the whole point of the screen.
It collapses with the walk itself, on the walk's own breakpoint. */
.walk .paired {
@@ -1338,10 +1313,9 @@ dialog > .field-stack > .filter > input[type="search"] {
gains a check when the row is — in the success color, confirmed
being that color's one meaning (docs/DESIGN.md) — and the name a
step quieter with it, a done thing receding so the rows still to
- do carry the walk's own ink. The first try was a rail in the
- accent down the inside edge of every row that was not, which said
- which ones were left without saying the other half of it. The
- word is still in the row for a screen reader, which cannot see a
+ do carry the walk's own ink — not an accent rail on the rows
+ still to do, which says which are left without saying the other
+ half. The word is still in the row for a screen reader, which cannot see a
check.
Both rules below carry `:only-child` for the specificity rather
@@ -1359,9 +1333,7 @@ dialog > .field-stack > .filter > input[type="search"] {
/* The row being read. The fill is the hover gesture too, so the name
takes the weight that says this one is not merely under a pointer.
- accent-soft would have said it in one declaration and is not an
- option here: tokens.css never derives it again in dark mode (see
- the accent block above). */
+ Not accent-soft, for the accent block's reason above. */
.walk-list a:only-child[aria-current] {
background: var(--gl-color-surface-fill-2);
}
diff --git a/sig/gems/nokogiri.rbs b/sig/gems/nokogiri.rbs
index 0e31a1d..6448db4 100644
--- a/sig/gems/nokogiri.rbs
+++ b/sig/gems/nokogiri.rbs
@@ -5,7 +5,7 @@
# and the plist elements Profile calls directly are declared below.
#
# `root` is declared non-nil because Nokogiri::XML only returns a
-# document without one for input that is not XML at all, and web.rb
+# document without one for input that is not XML at all, and web/dav.rb
# raises on that case explicitly rather than reading a nil root.
module Nokogiri
def self.XML: (String string_or_io) -> Nokogiri::XML::Document
diff --git a/sig/gems/rack.rbs b/sig/gems/rack.rbs
index b37c5ab..a2fff5b 100644
--- a/sig/gems/rack.rbs
+++ b/sig/gems/rack.rbs
@@ -1,8 +1,8 @@
# Rack ships no signatures. This names the vocabulary the middleware in
# lib/pro_tacts speaks — the environment, the response triple, and an
# app to delegate to — plus the two constants used directly: the status
-# phrases the debug logger prints, and the middleware config.ru's
-# comment explains (web.rb mounts it so a body can be read twice).
+# phrases ExchangeLog and RefusalAlerts print, and the middleware web.rb
+# mounts so a body can be read twice.
module Rack
# The CGI-ish hash a middleware is called with. Values stay untyped:
# anything upstream may put anything in it, which is why the code
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 5282214..762e1f6 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -57,7 +57,7 @@ class Roda
# The host the request named, without any port, inherited from
# Rack::Request for the same reason as #media_type. The setup route
- # provisions an account for it (see web.rb).
+ # provisions an account for it (see web/setup.rb).
def host: () -> String
# The request's query and form parameters, inherited from
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index 2f94f01..85dfc6f 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -1,9 +1,10 @@
# Sentry is reached wherever a handler has something to report: the
# exception-capturing middleware web.rb mounts, the refusal warnings
-# RefusalAlerts sends, the arrival messages dav.rb and store.rb send,
-# and the photo decode refusal contact.rb captures. ExchangeLog tags the
-# request's scope and reads whether anything was reported under it, and
-# Tracing hands Sentry its spans and scrubs the transactions they become.
+# RefusalAlerts sends, the arrival messages dav.rb and edited_contact.rb
+# send, and the photo decode refusal contact.rb captures. ExchangeLog
+# tags the request's scope and reads whether anything was reported under
+# it, and Tracing hands Sentry its spans and scrubs the transactions
+# they become.
module Sentry
class TransactionEvent
def contexts: () -> Hash[Symbol, Hash[Symbol, untyped]]
diff --git a/sig/gems/sequel.rbs b/sig/gems/sequel.rbs
index 0021220..502cca5 100644
--- a/sig/gems/sequel.rbs
+++ b/sig/gems/sequel.rbs
@@ -1,5 +1,5 @@
# Sequel ships no signatures, so this describes the parts pro-tacts
-# calls: one SQLite database, four datasets over it, and the schema DSL
+# calls: one SQLite database, the datasets over it, and the schema DSL
# that creates them. A row is a hash keyed by column, whose values carry
# whatever type the column held, which is why the store converts them at
# its edge rather than trusting them.
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 8416773..958903f 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -1,5 +1,5 @@
# lib/pro_tacts/admin is ignored by the Steepfile — built on Phlex,
-# which ships no signatures — but web.rb still calls its views, and
+# which ships no signatures — but checked code still calls its views, and
# a checked file cannot reference a constant nothing declares. This
# holds exactly that entry surface; the views' own files carry none.
# When Phlex gets a stand-in under sig/gems and admin is checked for
@@ -90,7 +90,7 @@ module ProTacts
end
# Rendered by Dashboard, not called from checked code; only
- # its LIMIT is read in web.rb.
+ # its LIMIT is read, in web/contacts.rb.
class UpcomingBirthdays
LIMIT: Integer
end
diff --git a/sig/pro_tacts/birthday_line.rbs b/sig/pro_tacts/birthday_line.rbs
index 05fc917..354ac06 100644
--- a/sig/pro_tacts/birthday_line.rbs
+++ b/sig/pro_tacts/birthday_line.rbs
@@ -1,6 +1,5 @@
# BirthdayLine's variants are Data classes, whose members the inline
-# parser cannot read (see sig/pro_tacts/birthday.rbs for the same
-# limit). Each is marked `@rbs skip` in birthday_line.rb and signed
+# parser cannot read (see sig/pro_tacts/contact.rbs). Each is marked `@rbs skip` in birthday_line.rb and signed
# here; the module's own reader carries its signature inline.
module ProTacts
module BirthdayLine
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 635caa0..93bd045 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -1,9 +1,8 @@
# Contact::Phone, ::Email, ::Note, ::Inherited, ::Photo, and ::Address are
-# Data classes, whose
-# members the inline parser cannot read (see sig/pro_tacts/birthday.rbs
-# for the same limit). Each is marked `@rbs skip` in contact.rb and
-# signed here; Contact itself is a plain class and carries its
-# signature inline.
+# Data classes, whose members the inline parser cannot read: Data.define
+# leaves them no constant super class. Each is marked `@rbs skip` in
+# contact.rb and signed here; Contact itself is a plain class and
+# carries its signature inline.
module ProTacts
class Contact
class Phone
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index e7eee48..2250e7f 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -1,4 +1,5 @@
-# Change is a Data class, whose members the inline parser cannot read
+# Change, GroupChange, RecentContact, UpcomingBirthday, GroupChoice, and
+# Snapshot are Data classes, whose members the inline parser cannot read
# (see sig/pro_tacts/contact.rbs). Store itself is an ordinary class and
# its types are inline in store.rb.
module ProTacts
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 2d568fc..361d80a 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -162,10 +162,9 @@ module FixtureData
# A group, seeded without a change-log entry for any member it moves
# (see MEMBERS for why). The group's own log — which nothing replays
# or syncs from — keeps the create's entry, which is true: the group
- # really was created, and the fixtures' lines and members land as
- # though the log had not been invented yet. Tests seed theirs here
- # too, so the cards' change log they assert on holds only what the
- # test wrote.
+ # really was created; its lines and members land unlogged. Tests
+ # seed theirs here too, so the cards' change log they assert on holds
+ # only what the test wrote.
#: (ProTacts::Store store, ?name: String?, ?lines: Array[String], ?members: Array[String]) -> String
def self.seed_group(store, name: nil, lines: [], members: [])
group_id = store.create_group(name:)
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 2485914..27d42e1 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -319,13 +319,10 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # Every asset the layout asks for is this app's own. The webfont
- # that used to be linked here came from Google, and the self-hosted
- # faces meant to replace it were never added — the @font-face rules
- # pointed at three files that 404ed. Both are gone, and
- # --gl-font-mono falls through to the platform's own monospace.
- # Alpine is vendored beside the Gloss CSS for the same reason (see
- # tasks/alpine.rake): this app is reachable only over Tailscale.
+ # This app is reachable only over Tailscale, so Alpine is vendored
+ # (tasks/alpine.rake) and there is no webfont: --gl-font-mono falls
+ # through to the platform's monospace. The IBM Plex refutation
+ # guards against @font-face rules pointing at files that 404.
def test_the_layout_asks_for_nothing_off_this_host
with_contacts({}) do
get "/"
@@ -342,10 +339,8 @@ class AdminContactsPagesTest < Minitest::Test
# The search rides every screen's header (docs/DESIGN.md), the
# dashboard's and a record's alike — finding a contact never
- # requires going home first. The dashboard no longer focuses
- # anything on load: the search is a button and `/` away
- # (Admin::SearchDialog), and the only autofocus left is inside the
- # popovers, which fires as each one opens.
+ # requires going home first. The dashboard focuses nothing on
+ # load: the search is a button and `/` away (Admin::SearchDialog).
def test_the_search_renders_on_every_screen
with_contacts({"ada" => ADA}) do
["/", "/contacts/ada"].each do |path|
@@ -371,8 +366,7 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # The dashboard answers no query: the results page it used to be is
- # gone, and a stale ?q= link lands on recency like any other visit.
+ # A stale ?q= link lands on recency like any other visit.
def test_the_dashboard_ignores_a_query
grace = ADA.sub("Ada Lovelace", "Grace Hopper").sub("UID:ada", "UID:grace")
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index c8c6f46..902c544 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -952,9 +952,6 @@ class AdminImportPagesTest < Minitest::Test
end
end
- # Swept out from under a screen left open, or come back to after
- # its last row was saved: the import is gone and only the person
- # has another copy.
# A card that looks like someone already in the book offers to
# update them instead, at the top of its editor, and that offer is
# the default: a row opened with no choice made folds into the best
@@ -1109,6 +1106,9 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # Swept out from under a screen left open, or come back to after
+ # its last row was saved: the import is gone and only the person
+ # has another copy.
def test_a_walk_that_is_gone_says_so
with_contacts({}) do |store|
id = upload(JANE)
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 2ae03e8..551e1f8 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -30,12 +30,8 @@ class BirthdayTest < Minitest::Test
assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 10_000, month: 4, day: 12) }
end
- # The shape pattern matches by range, and a range admits any
- # Comparable within its bounds — so a String component is refused as a
- # shape problem. A non-Integer numeric would pass; no producer can
- # make one (SQLite integer columns and String#to_i are Integers),
- # which the constructor's comment records as the boundary of the
- # check.
+ # A String misses the range patterns and is refused; a non-Integer
+ # numeric would pass, a boundary Birthday.new's comment explains.
def test_components_that_are_not_integers_are_refused
assert_raises(ArgumentError) { ProTacts::Birthday.new(year: "1985", month: 4, day: 12) }
assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, month: "4", day: 12) }
@@ -46,7 +42,6 @@ class BirthdayTest < Minitest::Test
assert_equal "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12", ProTacts::Birthday.new(month: 4, day: 12).to_line
end
- # The four shapes no client displays are never sent.
def test_the_shapes_no_client_displays_serve_nothing
[ProTacts::Birthday.new(year: 1985, month: 4), ProTacts::Birthday.new(year: 1985),
ProTacts::Birthday.new(month: 4), ProTacts::Birthday.new(day: 12)].each do |birthday|
@@ -92,7 +87,6 @@ class BirthdayTest < Minitest::Test
assert_equal Date.new(2026, 9, 3), ProTacts::Birthday.new(month: 9, day: 3).next_on(today)
end
- # One already passed wraps onto next year rather than going missing.
def test_next_on_wraps_a_passed_birthday_onto_next_year
assert_equal Date.new(2027, 3, 15), ProTacts::Birthday.new(year: 1990, month: 3, day: 15).next_on(Date.new(2026, 9, 3))
end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 963b40f..be3127d 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -363,9 +363,7 @@ class ContactTest < Minitest::Test
)
end
- # No inheritance composes the same bytes and the same etag as before
- # groups existed: an empty inheritance is insert's own no-op, so a
- # contact in no group serves its stored card untouched.
+ # An empty inheritance is insert's own no-op.
def test_a_contact_in_no_group_serves_its_stored_bytes_and_etag
card = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
index 15f6b7f..940f096 100644
--- a/test/pro_tacts/test_profile.rb
+++ b/test/pro_tacts/test_profile.rb
@@ -8,9 +8,8 @@ class ProfileTest < Minitest::Test
ProTacts::Profile.render(hostname:, username:)
end
- # The name comes from the environment now rather than an argument, so
- # the tests that care about it swap the config the same way the
- # install screen's do.
+ # The name comes from the environment, so the tests that care about
+ # it swap the config the same way the install screen's do.
def with_config(env = {})
original = ProTacts.config
ProTacts.config = ProTacts::Config.new(env)
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 49d99d6..519034b 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -227,10 +227,8 @@ class StoreTest < Minitest::Test
end
# The ctag is the change log's sequence, so it moves forward on a
- # removal too — it never returns to an earlier value, where the
- # membership hash this replaced did. A client that saw the earlier
- # value resyncs and finds every etag unchanged; the trade the
- # sequence makes for one indexed read and a monotonic answer.
+ # removal too. A membership hash would return to an earlier value,
+ # and a client that saw it would miss the change.
def test_the_ctag_moves_on_a_removal_and_never_returns
with_store({"aiden" => AIDEN}) do |store|
alone = store.ctag
@@ -851,7 +849,7 @@ class StoreTest < Minitest::Test
# A birthday no client can see — year alone, month alone, day alone,
# year and month — survives a round trip the client never saw a
- # birthday in. Planted directly, because no writer produces one yet.
+ # birthday in.
def test_a_birthday_no_client_can_see_survives_a_card_without_one
with_store({"aiden" => AIDEN}) do |store|
database(store)[:birthdays].insert(card_id: "aiden", year: 1985)
@@ -1099,7 +1097,6 @@ class StoreTest < Minitest::Test
end
end
- # Planted directly, because no writer produces one yet.
def test_birthdays_on_no_calendar_day_are_left_out
september = Date.new(2026, 9, 3)
@@ -1111,8 +1108,8 @@ class StoreTest < Minitest::Test
end
end
- # The transaction the whole design turns on, now with a third piece:
- # a birthday must not survive a write whose log entry failed.
+ # The transaction the whole design turns on, birthdays included: a
+ # birthday must not survive a write whose log entry failed.
def test_a_failed_birthday_write_leaves_no_birthday
Dir.mktmpdir do |dir|
path = Pathname.new(dir) / "contacts.db"
@@ -1306,12 +1303,6 @@ class StoreTest < Minitest::Test
end
end
- # The schema is the only gate on what a group may hold until
- # authoring exists, so what it admits is worth pinning: an address
- # or a note, bare or parameterized, in whatever case the author
- # spelled the name. Everything else is refused — a phone or an email
- # reaches a person rather than a household, and a grouped ADR is half
- # of a labeled property whose other half no group may hold.
def test_a_group_inspects_to_its_label_and_member_count
with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
id = FixtureData.seed_group(store, name: "Household", members: %w[znorth aiden])
@@ -1322,6 +1313,12 @@ class StoreTest < Minitest::Test
end
end
+ # The schema is the gate on what a group may hold
+ # (Store#set_group_lines), so what it admits is worth pinning: an
+ # address or a note, bare or parameterized, in whatever case the
+ # author spelled the name. Everything else is refused — a phone or an
+ # email reaches a person rather than a household, and a grouped ADR is
+ # half of a labeled property whose other half no group may hold.
def test_a_group_holds_only_addresses_and_notes
with_store({"aiden" => AIDEN}) do |store|
properties = database(store)[:group_properties]
diff --git a/test/pro_tacts/test_sync_token.rb b/test/pro_tacts/test_sync_token.rb
index 6ffca1d..02eed48 100644
--- a/test/pro_tacts/test_sync_token.rb
+++ b/test/pro_tacts/test_sync_token.rb
@@ -30,7 +30,7 @@ class SyncTokenTest < Minitest::Test
# The shapes macOS was recorded sending before tokens named their
# database: no book at all (fixture 08), and a book but no database
- # (fixture 12's request, the spelling this change retires).
+ # (fixture 12's request).
def test_a_token_from_before_databases_reads_as_nothing
assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/1", BOOK, DATABASE)
assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/22/#{BOOK}", BOOK, DATABASE)
diff --git a/test/pro_tacts/test_tracing.rb b/test/pro_tacts/test_tracing.rb
index ed20669..9e4df53 100644
--- a/test/pro_tacts/test_tracing.rb
+++ b/test/pro_tacts/test_tracing.rb
@@ -4,8 +4,8 @@ require "rack/test"
require "pro_tacts/web"
-# Which requests Sentry keeps a transaction for, now that Tracing's scrub
-# rather than trace_ignore_status_codes decides it.
+# Tracing's scrub, not trace_ignore_status_codes, decides which
+# requests Sentry keeps a transaction for.
class TracingTest < Minitest::Test
include Rack::Test::Methods
include SentryTracing
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 4878ef9..696089a 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -113,9 +113,8 @@ class VCardTest < Minitest::Test
# UTF-8 at all never become a card: better than a store serving them
# back as `text/vcard; charset=utf-8` while they are no such thing.
# Relabelled first, as the route would (Web#write_card), and this
- # pins the line that catches it — the card, before any walk or any
- # bind below it could meet the bytes, and now before a store is even
- # handed one.
+ # pins the line that catches it: the card, before any walk, bind, or
+ # store could meet the bytes.
def test_bytes_that_are_not_utf_8_are_not_a_card
invalid = "BEGIN:VCARD\r\nFN:\xFF\xFE\r\nEND:VCARD\r\n".dup.force_encoding(Encoding::UTF_8)