import: the rail draws, and everyone's book is a box like the rest
The rail was declared and never drawn. A whole-row link in Gloss is
`all: unset` at .card > li > a:only-child, which resets box-shadow and
background along with everything else, and both sidebar rules were a
step under it on specificity -- so the rail and the current-row fill
lost whatever they declared. Both carry :only-child now, for the weight
rather than for the match, the way the hover rule further up admin.css
already did.
And everyone's book can be unticked. It was drawn fixed because
Store#put's `client: true` joins it whatever the form says -- but that
flag answers this very question, and answers it the one way. An import
passes `client: false` and joins everyone's book through Store#regroup
with the rest, so the box has somewhere to be no: a card that is to sit
on the server without going out to anybody's phone. Nothing else rides
on the flag; it adds that one membership and does nothing else.
The one screen where the box cannot be an id is the first card of the
first import into an empty book, which is the write that creates the
group. It arrives at Import::Write as the answer rather than as a
group, and is resolved there the way a name is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index ad59c7f..1ee2a7a 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -260,9 +260,11 @@ the same two writes a client's PUT and the groups dialog make:
own UID names a record in a book this is not, and a contact with two
spellings of its identity is what `rake contacts:reid` exists to
undo.
-2. `Store#put` with `client: true`, so a card this creates joins
- everyone's book the way a client's create does
- (`2026-09-15-client-creates-join-everyone.md`).
+2. `Store#put` with `client: false`, and everyone's book joined below
+ with the rest. A client's own create takes `client: true` and joins
+ it unconditionally (`2026-09-15-client-creates-join-everyone.md`);
+ an import is a person deciding what their book is made of, and that
+ is a box on the screen.
3. `Store#regroup` into whichever groups this contact's screen settled
on — all of them, the group for the import included, `Import::Write`
knowing that one from no other answer because there is nothing it
@@ -278,22 +280,30 @@ the same two writes a client's PUT and the groups dialog make:
everyone's book.
Both groups a card comes in under stand among those boxes rather
- than out of sight, because the question this screen answers is what
- the contact will be in, and leaving one out is the same silence the
- walk exists to undo. Everyone's book is ticked and fixed —
- `Store#put`'s `client: true` joins it whatever the form says, so a
- box that could be cleared would be a box that lies, and disabling
- it is also what keeps the value out of the submission. The group
- for the import is ticked and can be unticked: an arrival that does
- not belong with the lot is exactly the kind of thing the walk is
- for deciding. That one is a name rather than an id until something
- makes it, which is the first Save to go in under it, so it rides as
- one of the staged names on that first screen and as an ordinary box
- on every screen 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, where that same put is what creates it —
- and a row for a group that does not exist would be a worse answer
- than none.
+ than out of sight, ticked, because the question this screen answers
+ is what the contact will be in and leaving one out is the same
+ silence the walk exists to undo. Both can be unticked, for the same
+ reason: an arrival that does not belong with the lot is exactly the
+ kind of 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.
+
+ That second one costs `Store#put`'s `client: true`, which every
+ other create here passes. The flag answers this very question and
+ answers it the one way, so an import passes `client: false` and
+ joins everyone's book through `Store#regroup` with the rest — the
+ box is there to be unticked, and the answer has to have somewhere
+ to be no. Nothing else rides on the flag: it adds that one
+ membership and does nothing else.
+
+ 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, 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.
+ So it arrives at `Import::Write` as the answer rather than as a
+ group, and is resolved there the way a name is.
The per-contact boxes ride in the editor's own form, so one Save
writes the card and its groups together and there is no second
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index 8f76e07..92d7640 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -21,11 +21,10 @@ module ProTacts
# has been told to make and has not made yet, then the filter
# itself offered as another. The two a card comes in under
# whatever else is ticked stand among them rather than out of
- # sight: everyone's book, which every card this creates joins
- # (Store#put's `client: true`) and which is therefore ticked and
- # fixed, and the group for the import, which is ticked and can be
- # unticked — an arrival that does not belong with the lot is a
- # thing the walk is for deciding. The filter, the cap on how many rows
+ # 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
@@ -42,7 +41,6 @@ module ProTacts
# @rbs @groups: Array[Store::GroupChoice]
# @rbs @joined: Array[String]
# @rbs @named: Array[String]
- # @rbs @fixed: Array[String]
# @rbs @capped: Array[String]
# Alphabetical, the groups dialog's own order: this is a list to
@@ -57,26 +55,17 @@ module ProTacts
# the Save that would have staged it is the Save that writes the
# contact.
#
- # `fixed` is the ids ticked that cannot be unticked, which is
- # everyone's book and nothing else: a card this writes joins it
- # whatever the form says, so a box that could be cleared would
- # be a box that lies. Fixed rather than hidden because the
- # question this screen answers is what the contact will be in,
- # and leaving one out is the same silence the walk exists to
- # undo.
- #
- # All three 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.
- #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String], ?fixed: Array[String]) -> void
- def initialize(groups:, joined:, named:, fixed: [])
+ # 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.
+ #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String]) -> void
+ def initialize(groups:, joined:, named:)
@groups = groups.sort_by { it.label.downcase }
@joined = joined
@named = named
- @fixed = fixed
- @capped = GroupFilter.capped(@groups.map(&:id), joined: joined + fixed, shown: named.length)
+ @capped = GroupFilter.capped(@groups.map(&:id), joined:, shown: named.length)
end
def view_template
@@ -89,15 +78,9 @@ module ProTacts
render GroupFilter.new(in_form: true)
div(class: "field-stack", x_ref: "options") do
@groups.each do |group|
- # A fixed box is disabled, so the browser does not send
- # it back and the save never reads it: the membership is
- # the store's doing rather than this form's, and a value
- # submitted for it would only be something to filter out
- # again (Web#save_card).
- fixed = @fixed.include?(group.id)
label(**GroupFilter.row(group.label, capped: @capped.include?(group.id))) do
input(type: "checkbox", name: "groups[]", value: group.id,
- checked: fixed || @joined.include?(group.id), disabled: fixed)
+ checked: @joined.include?(group.id))
render GroupLabel.new(group:)
end
end
diff --git a/lib/pro_tacts/import/write.rb b/lib/pro_tacts/import/write.rb
index defd016..075acd1 100644
--- a/lib/pro_tacts/import/write.rb
+++ b/lib/pro_tacts/import/write.rb
@@ -19,6 +19,11 @@ module ProTacts
# 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
@@ -45,11 +50,16 @@ module ProTacts
# 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. Either list, or neither: a card in no
- # group is still in everyone's book (#call).
- #: (Store store, VCard card, ?joins: Array[String], ?named: Array[String]) -> Contact
- def self.call(store, card, joins: [], named: [])
- new(store).call(card, joins, named)
+ # 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.
+ #: (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)
end
#: (Store store) -> void
@@ -61,21 +71,26 @@ module ProTacts
# The card, written as a contact: then its groups, the order a
# create writes them in — the put makes the contact, and the
# memberships that follow put it somewhere to be found.
- #: (VCard card, Array[String] chosen, Array[String] named) -> Contact
- def call(card, chosen, named)
+ #: (VCard card, Array[String] chosen, Array[String] named, bool everyone) -> Contact
+ def call(card, chosen, named, everyone)
id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
- # `client: true` for what a client's PUT means here: a card this
- # creates joins `sync:*`, or it would be on the server and in
- # nobody's book (docs/plans/2026-09-15-client-creates-join-everyone.md).
- @store.put(id, identified(card, id), client: true)
+ # `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.
+ @store.put(id, identified(card, id), client: false)
# The chosen ids need no resolving, the screen having read
# them off this same store. Deduplicated because two of these
# can mean one group — a name typed for a group that the box
- # above it already offers, say — and a membership written
- # twice is a constraint violation rather than a second
- # membership.
+ # above it already offers, or everyone's book arriving both
+ # ticked and asked for — and a membership written twice is a
+ # constraint violation rather than a second membership.
join = [*chosen, *named.map { group_id(it) }]
+ join << group_id(Store::EVERYONE) if everyone
@store.regroup(id, join: join.uniq, leave: []) unless join.empty?
# Read back rather than kept from the put, because the group
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 1f1de21..402dfb8 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -227,29 +227,29 @@ module ProTacts
end
# The groups beside one card: this book's own list, with the two
- # an arrival comes in under already ticked.
+ # 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.
#
- # Everyone's book is ticked and fixed, `Store#put`'s `client:
- # true` joining it whatever this form says. It 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, where that put is what
- # creates it — and a row for a group that does not exist would be
- # a worse answer than none.
- #
- # The group for the import is ticked and can be unticked. It 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 on every screen after.
+ # 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.
#: (String group, Array[String]? joined, Array[String]? named) -> Admin::ImportGroups
def import_picker(group, joined, named)
choices = store.group_choices
lot = group.empty? ? nil : choices.find { it.name == group }
+ everyone = choices.find { it.name == Store::EVERYONE }
Admin::ImportGroups.new(
groups: choices,
- joined: joined || [lot&.id].compact,
+ joined: joined || [everyone&.id, lot&.id].compact,
named: named || [(group unless lot || group.empty?)].compact,
- fixed: choices.select { it.name == Store::EVERYONE }.map(&:id),
)
end
@@ -290,10 +290,19 @@ module ProTacts
# 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.
- ticked = ids_in(r.params["groups"]) & store.group_choices.map(&:id)
+ choices = store.group_choices
+ ticked = ids_in(r.params["groups"]) & choices.map(&:id)
standing = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
fresh = r.params["new"].to_s.strip
wanted = (fresh.empty? ? standing : standing + [fresh]).uniq
+ # 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 = choices.find { it.name == Store::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
@@ -357,7 +366,7 @@ module ProTacts
# is still a row the list has to be able to name.
revised[index] = edited
Import::Staged.update(upload, joined(revised))
- written = Import::Write.call(store, edited, joins: ticked, named: wanted)
+ written = Import::Write.call(store, edited, joins: ticked, named: wanted, everyone: syncing)
Import::Staged.record(upload, index.to_s, written.id)
# The last one: there is nothing left to come back to, so the
diff --git a/public/admin.css b/public/admin.css
index 59b6b71..26694c4 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -908,8 +908,16 @@ dialog > .field-stack > input[type="search"] {
text of one row stays in line with the others, and it is the rows
still to do that wear it -- a list starts out entirely unsaved, and
what is worth marking is what is left. The word is still in the row
- for a screen reader, which cannot see a rail. */
-.walk-list a[data-state="unsaved"] {
+ for a screen reader, which cannot see a rail.
+
+ Both rules below carry `:only-child` for the specificity rather
+ than for the match: a whole-row link is Gloss's `all: unset`
+ (components.css, .card > li > a:only-child), which resets
+ box-shadow and background along with everything else, and a
+ selector that does not out-weigh it loses whatever it declares
+ here. The hover rule further up this file names the same shape for
+ the same reason. */
+.walk-list a:only-child[data-state="unsaved"] {
box-shadow: inset 3px 0 0 var(--gl-color-accent);
}
@@ -918,10 +926,10 @@ dialog > .field-stack > input[type="search"] {
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). */
-.walk-list a[aria-current] {
+.walk-list a:only-child[aria-current] {
background: var(--gl-color-surface-fill-2);
}
-.walk-list a[aria-current] > div {
+.walk-list a:only-child[aria-current] > div {
font-weight: 700;
}
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 3edfd48..bd6fe65 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -56,7 +56,7 @@ module ProTacts
end
class ImportGroups
- def self.new: (groups: Array[::ProTacts::Store::GroupChoice], joined: Array[String], named: Array[String], ?fixed: Array[String]) -> instance
+ def self.new: (groups: Array[::ProTacts::Store::GroupChoice], joined: Array[String], named: Array[String]) -> instance
end
class ImportSidebar
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 22ab50b..39954fd 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -75,11 +75,9 @@ class AdminImportPagesTest < Minitest::Test
"groups" => ticked("groups[]"), "named" => ticked("named[]")}.merge(params)
end
- # The values of the boxes the last page rendered ticked. A fixed box
- # is disabled and a browser sends none of those back, so neither
- # does this (Admin::ImportGroups).
+ # The values of the boxes the last page rendered ticked.
def ticked(name)
- last_response.body.scan(/name="#{Regexp.escape(name)}" value="([^"]*)" checked(?! disabled)/).flatten
+ last_response.body.scan(/name="#{Regexp.escape(name)}" value="([^"]*)" checked/).flatten
end
# The screen about the file rather than about a contact: what the
@@ -528,10 +526,10 @@ class AdminImportPagesTest < Minitest::Test
end
end
- # Everyone's book is shown with the rest, ticked and fixed: every
- # card this writes joins it whatever the form says, so a box that
- # could be cleared would be a box that lies.
- def test_everyones_book_is_shown_beside_a_card_and_cannot_be_cleared
+ # Everyone's book stands with the rest, ticked: a card comes in
+ # going out to every phone this book syncs, and the question is
+ # asked where every other group question is.
+ def test_everyones_book_is_shown_beside_a_card_and_ticked
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
@@ -540,7 +538,7 @@ class AdminImportPagesTest < Minitest::Test
get "/import/#{id}/1"
assert_includes last_response.body,
- %(<input type="checkbox" name="groups[]" value="#{everyone.id}" checked disabled>)
+ %(<input type="checkbox" name="groups[]" value="#{everyone.id}" checked>)
save(id, 1, first: "Sam", last: "Booles")
@@ -548,6 +546,23 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # And it can be unticked, like the rest: a card that is to sit on
+ # the server without going out to anybody's phone.
+ def test_an_arrival_can_be_kept_out_of_everyones_book
+ with_contacts({}) do |store|
+ id = upload(JANE + PLAIN)
+ save(id, 0, first: "Jane", last: "Booles")
+ everyone = store.all_groups.find { it.name == ProTacts::Store::EVERYONE }
+
+ save(id, 1, first: "Sam", last: "Booles", "groups" => [], "named" => [])
+
+ sam = store.contacts.find { it.name == "Sam Booles" }
+
+ assert sam
+ refute_includes store.group(everyone.id).members, sam.id
+ end
+ end
+
# A save sent back to be fixed comes back with its boxes as they
# were ticked: nothing is staged between screens, so the form is
# the only record of them until the write.
diff --git a/test/pro_tacts/import/test_write.rb b/test/pro_tacts/import/test_write.rb
index a88b40c..ec870ec 100644
--- a/test/pro_tacts/import/test_write.rb
+++ b/test/pro_tacts/import/test_write.rb
@@ -161,7 +161,8 @@ class ImportWriteTest < Minitest::Test
end
# Nothing ticked is nothing joined, the group for the import
- # included: a card in no group is still in everyone's book.
+ # included: a card in no group is still in everyone's book, which
+ # is an answer of its own and the one a card arrives with.
def test_no_group_at_all_puts_the_card_in_everyones_book_alone
write_card do |imported, store|
assert_equal [ProTacts::Store::EVERYONE], store.all_groups.map(&:name)
@@ -169,6 +170,17 @@ class ImportWriteTest < Minitest::Test
end
end
+ # And no is one of the answers: a card can sit on the server
+ # without going out to anybody's phone.
+ def test_an_arrival_can_be_written_into_no_book_at_all
+ with_contacts({}) do |store|
+ id = Write.call(store, card, everyone: false).id
+
+ assert_empty store.all_groups
+ assert store.contact(id)
+ end
+ end
+
# Named for when it happened, so what imported together can be found
# together.
def test_the_default_group_is_named_for_the_moment