import: a saved row reads and edits in the walk
A row in the book opened the contact's own page, losing the list;
it now reads — and edits, through apply_edit with its refusal screen
and landing spot made injectable — beside the card it arrived as. The
details are ContactCard, extracted from the contact's page so the
two renderings cannot drift.
Assisted-by: GLM-5.3 via pi
diff --git a/Steepfile b/Steepfile
index cb652d7..17620e3 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,8 +21,8 @@ target :lib do
# `check ... inline: true`. A bare `ignore` feeds the other list and
# the files stay checked.
ignore(*%w[
- avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
- group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_sidebar
+ avatar contact_card contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
+ group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_saved import_sidebar
import_upload layout list_item phlex record_card upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index bd52710..4f31f7e 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -113,8 +113,8 @@ So the review is a walk, two screens rather than a submission:
looking at it. The Save on that screen is the import of that contact: it
goes into the book there and then, in whatever the boxes beside it say.
It is the one editor in the app whose Save creates the record rather than
- amending it, so it says where the card is going — "save to the book" —
- and the row beside it wears the unsaved rail until it has been pressed.
+ amending it, so it says what it does — "Import" — and the row
+ beside it wears its check only once it has been pressed.
The right-hand card is `Admin::ContactsEdit` itself, not a copy of it.
A second editor for imports would be a place where the two could
@@ -160,21 +160,27 @@ back to yet, the card not being in the book until its Save.
Two facts per row, and the walk turns on both.
-**Whether the contact is in the book** — a rail in the accent down the
-inside edge of every row that is not. Everything is unsaved until its
-own screen says otherwise, and a walk broken off overnight and come
-back to has to say which rows those are. Words 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. It is the rows still to do that wear
-the mark, because what is worth marking is what is left. The word is
-still in the row for a screen reader, which cannot see a rail.
-
-**What the card is losing**, as a diff's own mark at the row's
-trailing edge — `-3`, in the sign-and-color shape a change-log entry
-wears (`.diff-removed` in admin.css). A sentence for it made every
-marked row a line of prose to read, and the number is the whole of
-what a row has to say about it: how many, and that it is a loss.
+**Whether the contact is in the book** — the verdict at every
+row's trailing edge, a circle that gains a check when the row is,
+in the success color, confirmed being that color's one meaning, and
+the name a step quieter with it: a done thing recedes, so the rows
+still to do carry the walk's own ink. Everything is unsaved until
+its own screen says otherwise, and a walk broken off overnight and
+come back to has to say which rows those are. Words 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 in
+the row for a screen reader, which cannot see a mark. The mark was a
+rail in the accent down the inside edge of every row that was not,
+first, and it said which ones were left without saying the other
+half of it.
+
+**What the card is losing**, as a diff's own mark under the name it
+is about — `-3`, in the sign-and-color shape a change-log entry
+wears (`.diff-removed` in admin.css) — on a row still to do, a
+settled row's losses being settled with it. A sentence for it made
+every marked row a line of prose to read, and the number is the whole
+of what a row has to say about it: how many, and that it is a loss.
The walk is wider than the reading column the rest of the app gets,
because three things stand in it: the list, the card as exported, and
@@ -206,10 +212,14 @@ what it cost was everything around it.
So the Save is the import. A contact is in the book the moment its own
screen says so, and the list reads that back: a row that has come in
-says so and leads to the contact rather than back into the editor,
-which is also the answer to what a second Save on it would mean. The
-walk is resumable because nothing is being held back — a tab left
-overnight, a window closed, a crash, all read the same way.
+wears its check and opens in the walk again, the contact it became
+read beside the card it arrived as — with the contact page's own
+edit link to put it back in the editor, the amend landing on the
+row's read screen — not an editor a second time by default, which is
+also the answer to what a second Save on it would mean, and not a
+navigation away to the contact's own page, which would lose the
+list. The walk is resumable because nothing is being held back — a
+tab left overnight, a window closed, a crash, all read the same way.
That makes the end of the walk a thing to say rather than a thing to
press, and the last Save is what says it. There is no "finish" beside
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
new file mode 100644
index 0000000..a370b41
--- /dev/null
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -0,0 +1,167 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_dialog"
+require "pro_tacts/admin/group_label"
+
+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 contact's own page (ContactsShow) renders it inside its
+ # record, and an import's saved row renders it beside the card
+ # that row arrived as (ImportSaved) — one renderer, so a property
+ # one screen shows is a property the other cannot drop.
+ #
+ # `groups` is the contact's membership (Store#groups_of), the row
+ # framing the values under it. `dialog` names whether that row
+ # carries its "edit groups" button, which needs GroupDialog on the
+ # page: the contact's page has it, a walk's screen does not, and
+ # membership there is the contact page's to change. `card` names
+ # whether this renders the card around its own body or the body
+ # alone for a card someone else supplies — the walk's read screen
+ # (ImportSaved), whose trunk RecordCard owns.
+ class ContactCard < Phlex::HTML
+ # @rbs @contact: Contact
+ # @rbs @groups: Array[Store::Group]
+ # @rbs @dialog: bool
+ # @rbs @card: bool
+
+ #: (contact: Contact, groups: Array[Store::Group], ?dialog: bool, ?card: bool) -> void
+ def initialize(contact:, groups:, dialog: false, card: true)
+ @contact = contact
+ @groups = groups
+ @dialog = dialog
+ @card = card
+ @birthday = Format.birthday(contact)
+ end
+
+ def view_template
+ return body unless @card
+
+ div(class: "card") do
+ div(class: "card-body") do
+ body
+ end
+ end
+ end
+
+ #: () -> void
+ def body
+ div(class: "detail-header") do
+ div do
+ h1(class: "type-h2") { @contact.name || @contact.id }
+ # The nickname rides under the name in the heading
+ # family — type-h3 to the name's h2 — so it reads as
+ # a name rather than a property value; muted, so the
+ # formal name stays the heading.
+ if @contact.nickname
+ div(class: "type-h3 gl-muted", style: "margin-top: var(--gl-space-2xs);") { @contact.nickname }
+ end
+ end
+ # Only a picture earns this slot: an initials circle
+ # beside the name in type-h2 would repeat what the name
+ # already says. The dashboard rows keep theirs — there
+ # 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 that reads from a line hands it over, because which
+ # group lends a row is a question about the line it came from.
+ # The birthday hands over none: it is the model beside the card,
+ # and a group holds only addresses and notes anyway (see
+ # db/migrations/004_groups.rb).
+ def rows
+ groups_row if @dialog || @groups.any?
+ @contact.phones.each do |phone|
+ row(Format.type_label(phone.label, phone.types, "phone"), phone.value, phone.line)
+ end
+ @contact.emails.each do |email|
+ row(Format.type_label(nil, email.types, "email"), email.value, email.line)
+ end
+ @contact.addresses.each do |address|
+ row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.line)
+ end
+ row("birthday", @birthday) if @birthday
+ @contact.notes.each do |note|
+ row("notes", note.value, note.line)
+ end
+ end
+
+ # Membership as its own row, above the card's own: what a contact
+ # belongs to frames the values under it, and several of those
+ # values are the group's rather than the contact's. Every tag opens
+ # the group it names (docs/DESIGN.md, "Relationships are
+ # navigable"), the other half of the member tags on a group's own
+ # card (Admin::GroupsShow). Rendered for a contact in no group
+ # too when the dialog is on the page, even before any group
+ # exists, because the row holds the way to join or start one —
+ # the group card's members row, for the same reason, with its
+ # "edit members" in the same place. Without the dialog the row
+ # stands only when there is a member to name: an empty row with
+ # nothing to press is nothing at all.
+ #: () -> void
+ def groups_row
+ dt(class: "type-label") { "groups" }
+ dd(class: "type-body-sm") do
+ if @groups.any?
+ div(class: "tag-set") do
+ @groups.each { group_tag(it) }
+ end
+ end
+ button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit groups" } if @dialog
+ end
+ end
+
+ #: (Store::Group group) -> void
+ def group_tag(group)
+ a(href: "/groups/#{group.id}", class: "tag") { render GroupLabel.new(group:) }
+ end
+
+ # The type in one column and the value in the other, with the
+ # group that lends the line named under the value when one does.
+ # In the value cell rather than beside the type label: the type
+ # column holds one thing for every row in the card
+ # (docs/DESIGN.md, "Alignment is the layout"), and a mark on
+ # the value is what says this address is the household's rather
+ # than this contact's.
+ #: (String type, String | Array[String] value, ?VCard::Parser::Line? line) -> void
+ def row(type, value, line = nil)
+ group = line && @contact.group_of(line)
+ dt(class: "type-label") { type }
+ dd(class: "type-body-sm") do
+ render_value(value)
+ # The groups row's own tag, pinned to this row's first line
+ # at the right edge of the value column (admin.css) — under
+ # the value it could be read as marking the row below.
+ group_tag(group) if group
+ end
+ end
+
+ #: (String | Array[String] value) -> void
+ def render_value(value)
+ if value.is_a?(Array)
+ value.each do |line|
+ div { line }
+ end
+ else
+ div(style: "white-space: pre-wrap;") { value }
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 157dbaf..8e8808f 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -1,10 +1,8 @@
require "pro_tacts/admin/phlex"
-require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/contact_card"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_dialog"
-require "pro_tacts/admin/group_label"
-require "pro_tacts/admin/layout"
module ProTacts
module Admin
@@ -42,7 +40,6 @@ module ProTacts
@all_groups = all_groups
@changes = changes
@notice = notice
- @birthday = Format.birthday(contact)
end
def view_template
@@ -51,36 +48,16 @@ module ProTacts
# line its caption row: spaced like the dashboard's
# section-head over its card (see .record in admin.css), not
# like a block of the column's own. The raw vCard card below
- # stays a sibling at the column's rhythm.
+ # stays a sibling at the column's rhythm. The card itself is
+ # ContactCard's, the one renderer of a contact's details,
+ # with the groups dialog on the page to back its row's
+ # button.
div(class: "record") do
div(class: "record-nav") do
a(href: "/", class: "type-label") { "‹ contacts" }
a(href: "/contacts/#{@contact.id}/edit", class: "btn", data_size: "sm") { "edit" }
end
- div(class: "card") do
- div(class: "card-body") do
- div(class: "detail-header") do
- div do
- h1(class: "type-h2") { @contact.name || @contact.id }
- # The nickname rides under the name in the heading
- # family — type-h3 to the name's h2 — so it reads as
- # a name rather than a property value; muted, so the
- # formal name stays the heading.
- if @contact.nickname
- div(class: "type-h3 gl-muted", style: "margin-top: var(--gl-space-2xs);") { @contact.nickname }
- end
- end
- # Only a picture earns this slot: an initials circle
- # beside the name in type-h2 would repeat what the name
- # already says. The dashboard rows keep theirs — there
- # the avatar is the row's visual anchor, not a caption.
- render Avatar.new(contact: @contact, size: "xl") if @contact.photo
- end
- # Never empty, #groups_row standing unconditionally
- # for the reason it gives, so no guard around it.
- dl(class: "detail-grid") { rows }
- end
- 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
@@ -123,88 +100,6 @@ module ProTacts
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 that reads from a line hands it over, because which
- # group lends a row is a question about the line it came from.
- # The birthday hands over none: it is the model beside the card,
- # and a group holds only addresses and notes anyway (see
- # db/migrations/004_groups.rb).
- def rows
- groups_row
- @contact.phones.each do |phone|
- row(Format.type_label(phone.label, phone.types, "phone"), phone.value, phone.line)
- end
- @contact.emails.each do |email|
- row(Format.type_label(nil, email.types, "email"), email.value, email.line)
- end
- @contact.addresses.each do |address|
- row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.line)
- end
- row("birthday", @birthday) if @birthday
- @contact.notes.each do |note|
- row("notes", note.value, note.line)
- end
- end
-
- # Membership as its own row, above the card's own: what a contact
- # belongs to frames the values under it, and several of those
- # values are the group's rather than the contact's. Every tag opens
- # the group it names (docs/DESIGN.md, "Relationships are
- # navigable"), the other half of the member tags on a group's own
- # card (Admin::GroupsShow). Rendered for a contact in no group too,
- # even before any group exists, because the row holds the way to
- # join or start one — the group card's members row, for the same
- # reason, with its "edit members" in the same place.
- #: () -> void
- def groups_row
- dt(class: "type-label") { "groups" }
- dd(class: "type-body-sm") do
- if @groups.any?
- div(class: "tag-set") do
- @groups.each { group_tag(it) }
- end
- end
- button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit groups" }
- end
- end
-
- #: (Store::Group group) -> void
- def group_tag(group)
- a(href: "/groups/#{group.id}", class: "tag") { render GroupLabel.new(group:) }
- end
-
- # The type in one column and the value in the other, with the
- # group that lends the line named under the value when one does.
- # In the value cell rather than beside the type label: the type
- # column holds one thing for every row in the card
- # (docs/DESIGN.md, "Alignment is the layout"), and a mark on the
- # value is what says this address is the household's rather than
- # this contact's.
- #: (String type, String | Array[String] value, ?VCard::Parser::Line? line) -> void
- def row(type, value, line = nil)
- group = line && @contact.group_of(line)
- dt(class: "type-label") { type }
- dd(class: "type-body-sm") do
- render_value(value)
- # The groups row's own tag, pinned to this row's first line
- # at the right edge of the value column (admin.css) — under
- # the value it could be read as marking the row below.
- group_tag(group) if group
- end
- end
-
- #: (String | Array[String] value) -> void
- def render_value(value)
- if value.is_a?(Array)
- value.each do |line|
- div { line }
- end
- else
- div(style: "white-space: pre-wrap;") { value }
- end
- end
-
# One row per entry, on the record's own grid: the action in the
# type column, because it is the kind of thing the row is, and
# the moment, the etag, and the lines the write moved in the
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index effd330..54f9911 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -18,11 +18,12 @@ module ProTacts
# 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, and calendar, the birthday row's
- # picker. A tag per shape rather than a bare list of path data,
- # because calendar's frame is a rect and redrawing it as a path
- # would be this file copying something other than what Lucide
- # ships.
+ # remove control's two strokes; calendar, the birthday row's
+ # picker; and the import row's verdict, a circle either way and
+ # a check in it when the row is in the book. A tag per shape
+ # rather than a bare list of path data, because calendar's
+ # frame is a rect and redrawing it as a path would be this file
+ # copying something other than what Lucide ships.
GLYPHS = {
x: [
[:path, {d: "M18 6 6 18"}],
@@ -34,6 +35,13 @@ module ProTacts
[:rect, {width: 18, height: 18, x: 3, y: 4, rx: 2}],
[:path, {d: "M3 10h18"}],
],
+ circle: [
+ [:circle, {cx: 12, cy: 12, r: 10}],
+ ],
+ circle_check: [
+ [:circle, {cx: 12, cy: 12, r: 10}],
+ [:path, {d: "m9 12 2 2 4-4"}],
+ ],
} #: Hash[Symbol, Array[[Symbol, Hash[Symbol, untyped]]]]
#: (Symbol name, ?size: Integer) -> void
diff --git a/lib/pro_tacts/admin/import_original.rb b/lib/pro_tacts/admin/import_original.rb
index 93ff325..d304f87 100644
--- a/lib/pro_tacts/admin/import_original.rb
+++ b/lib/pro_tacts/admin/import_original.rb
@@ -4,7 +4,7 @@ require "pro_tacts/import/vcf"
module ProTacts
module Admin
- # The left-hand card of an import's review: one contact exactly as
+ # The left-hand card of an import's walk: one contact exactly as
# the file wrote it (docs/plans/2026-09-21-import-a-vcf.md).
#
# Content lines rather than a rendered contact, and in the
@@ -40,10 +40,6 @@ module ProTacts
def view_template
div(class: "card") do
div(class: "card-body") do
- div(class: "section-head") do
- h2(class: "type-label") { "as exported" }
- span(class: "type-label") { "#{@dropped.length} not imported" } if @dropped.any?
- end
ul(class: "card-lines") do
@card.lines.each { line(it) }
end
diff --git a/lib/pro_tacts/admin/import_saved.rb b/lib/pro_tacts/admin/import_saved.rb
new file mode 100644
index 0000000..f282e29
--- /dev/null
+++ b/lib/pro_tacts/admin/import_saved.rb
@@ -0,0 +1,50 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/contact_card"
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/record_card"
+
+module ProTacts
+ module Admin
+ # GET /import/:id/:n for a row the walk has already saved: the
+ # contact it became, read beside the card it arrived as
+ # (docs/plans/2026-09-21-import-a-vcf.md). A look back, not an
+ # editor — the write happened at the row's own Save, and what it
+ # wrote is changed where every other contact is, not re-decided
+ # inside the walk. The details are ContactCard's, the one renderer
+ # of a contact's record, without the groups dialog — membership is
+ # the contact page's question (dialog: false, #groups_row's own
+ # reason).
+ class ImportSaved < Phlex::HTML
+ # @rbs @contact: Contact
+ # @rbs @groups: Array[Store::Group]
+ # @rbs @aside: Phlex::HTML
+ # @rbs @sidebar: Phlex::HTML
+ # @rbs @edit: String
+ # @rbs @notice: String?
+
+ #: (contact: Contact, groups: Array[Store::Group], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, ?notice: String?) -> void
+ def initialize(contact:, groups:, aside:, sidebar:, edit:, notice: nil)
+ @contact = contact
+ @groups = groups
+ @aside = aside
+ @sidebar = sidebar
+ @edit = edit
+ @notice = notice
+ end
+
+ def view_template
+ edit = @edit
+ render Layout.new(title: @contact.name || @contact.id, wide: true, notice: @notice) do
+ render RecordCard.new(
+ nav: -> { a(href: edit, class: "btn", data_size: "sm") { "edit" } },
+ aside: @aside,
+ sidebar: @sidebar,
+ ) do
+ render ContactCard.new(contact: @contact, groups: @groups, card: false)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/import_sidebar.rb b/lib/pro_tacts/admin/import_sidebar.rb
index daa1f51..5f1eb3c 100644
--- a/lib/pro_tacts/admin/import_sidebar.rb
+++ b/lib/pro_tacts/admin/import_sidebar.rb
@@ -1,6 +1,7 @@
require "pro_tacts/admin/phlex"
require "pro_tacts/admin/format"
+require "pro_tacts/admin/icons"
module ProTacts
module Admin
@@ -16,22 +17,27 @@ module ProTacts
# is open.
#
# Each row carries the two facts the walk turns on. Whether the
- # contact is in the book — a rail in the accent down the inside
- # edge of every row that is not, because 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
- # rail (.gl-visually-hidden).
+ # 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 — `-3` at the
- # row's trailing edge, 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.
+ # 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.
class ImportSidebar < Phlex::HTML
# @rbs @upload: String
# @rbs @rows: Array[[::ProTacts::Contact, Integer]]
@@ -73,18 +79,31 @@ module ProTacts
def row(contact, dropped, index)
stored = @saved[index.to_s]
li do
- # A row that has come in leads to the contact it became: the
- # card was written the moment its Save was pressed, and from
- # then on it is edited where every other contact is
- # (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.
- a(href: stored ? "/contacts/#{stored}" : "/import/#{@upload}/#{index}",
+ # 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.
+ a(href: "/import/#{@upload}/#{index}",
aria_current: ("page" if index == @current),
data: {state: stored ? "saved" : "unsaved"}) do
- div(style: "flex: 1; min-width: 0; font-weight: 550;") { Format.name_label(contact) }
+ 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" }
- span(class: "type-label diff-removed") { "-#{dropped}" } if dropped.positive?
+ # 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
end
diff --git a/lib/pro_tacts/admin/record_card.rb b/lib/pro_tacts/admin/record_card.rb
index ff638e8..05981ad 100644
--- a/lib/pro_tacts/admin/record_card.rb
+++ b/lib/pro_tacts/admin/record_card.rb
@@ -30,11 +30,11 @@ module ProTacts
# @rbs @heading: String?
# @rbs @aside: Phlex::HTML?
# @rbs @sidebar: Phlex::HTML?
- # @rbs @submit: String
- # @rbs @form: String
+ # @rbs @submit: String?
+ # @rbs @form: String?
- #: (?back: ([String, (String | Phlex::HTML | Proc)])?, submit: String, form: String, ?nav: (Proc | Phlex::HTML)?, ?heading: String?, ?aside: Phlex::HTML?, ?sidebar: Phlex::HTML?) -> void
- def initialize(back: nil, submit:, form:, nav: nil, heading: nil, aside: nil, sidebar: nil)
+ #: (?back: ([String, (String | Phlex::HTML | Proc)])?, ?submit: String?, ?form: String?, ?nav: (Proc | Phlex::HTML)?, ?heading: String?, ?aside: Phlex::HTML?, ?sidebar: Phlex::HTML?) -> void
+ def initialize(back: nil, submit: nil, form: nil, nav: nil, heading: nil, aside: nil, sidebar: nil)
@back = back
@nav = nav
@heading = heading
@@ -111,14 +111,18 @@ module ProTacts
yield
end
# A dialog footer's shape (admin.css), outside the form and
- # reaching it by id. The submit is the form's; Cancel is
- # navigation — a link in Gloss's `.btn` contract, which is
+ # 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 it is absent when that
- # is.
- footer do
- a(href: @back&.fetch(0), class: "btn") { "Cancel" } if @back
- button(type: "submit", form: @form, data: {variant: "primary"}) { @submit }
+ # goes where the back link goes, so each is absent when its
+ # half is.
+ if @submit || @back
+ footer do
+ a(href: @back&.fetch(0), class: "btn") { "Cancel" } if @back
+ button(type: "submit", form: @form, data: {variant: "primary"}) { @submit } if @submit
+ end
end
end
end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index ae07fd0..4a84d21 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -133,8 +133,13 @@ module ProTacts
# Web#write_card is one, and its checks in the same order: the
# request's own validity first, the conditionals on stored state
# after.
- #: (untyped r, String id) -> String?
- def apply_edit(r, id)
+ #
+ # `refuse` renders the editor back with the notice — this page's
+ # own screen by default; the walk renders its row's card in
+ # place — and `land` is where a successful save redirects, the
+ # contact's page by default.
+ #: (untyped r, String id, ?refuse: ^(Contact, ?notice: String) -> String, ?land: String?) -> String?
+ def apply_edit(r, id, refuse: ->(contact, notice:) { edit_screen(contact, notice:) }, land: nil)
contact = store.contact(id)
return if contact.nil?
@@ -145,7 +150,7 @@ module ProTacts
first = r.params["first"].to_s.strip
middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
- return edit_screen(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
+ return refuse.call(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
# Request validity, standing with the name check rather than the
# stored-state conditionals below (write_card's ordering rule). A
@@ -158,7 +163,7 @@ module ProTacts
begin
Admin::CardForm.birthday(fields)
rescue ArgumentError
- return edit_screen(contact, notice: "That birthday is not a shape a date can take.")
+ return refuse.call(contact, notice: "That birthday is not a shape a date can take.")
end
else
contact.birthday
@@ -172,18 +177,18 @@ module ProTacts
# 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.
- return edit_screen(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
+ 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:
# docs/plans/2026-09-07-web-birthday-editor.md, "The one hazard:
# a card that carries its own BDAY", which also records the
# migration not taken.
if birthday && contact.stored.lines.any? { it.names?("BDAY") }
- return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
+ return refuse.call(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
end
store.save_edit(id, Admin::CardForm.contact_card(contact, first, middle, last, r.params), birthday:)
- r.redirect "/contacts/#{id}", 303
+ r.redirect land || "/contacts/#{id}", 303
end
# The dialog's save as what it toggled rather than the set it shows:
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 325edef..370cf67 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -2,6 +2,7 @@ require "pro_tacts/admin/card_form"
require "pro_tacts/admin/contacts_edit"
require "pro_tacts/admin/import_groups"
require "pro_tacts/admin/import_original"
+require "pro_tacts/admin/import_saved"
require "pro_tacts/admin/import_sidebar"
require "pro_tacts/admin/import_upload"
require "pro_tacts/birthday"
@@ -56,13 +57,22 @@ module ProTacts
r.on Integer do |index|
r.is do
r.get do
- open_card(r, upload, index)
+ card_screen(upload, index)
end
r.post do
save_card(r, upload, index)
end
end
+
+ # The stored contact's editor, reached from its row's read
+ # screen: the same amend the contact's own page's edit link
+ # writes, kept inside the walk (Web#edit_card_screen). A
+ # row still to do is already its editor, so its edit is the
+ # row screen itself.
+ r.get "edit" do
+ edit_card_screen(r, upload, index)
+ end
end
end
end
@@ -133,24 +143,21 @@ module ProTacts
# A row opened: the card it arrived as beside the card that is
# coming in — or the contact itself, for a card this walk has
- # already saved. The editor here writes into the import, and an
- # import that has already written this one has nothing left to say
- # about it: from then on it is a contact, edited where every other
- # contact is.
- #: (untyped r, String upload, Integer index) -> untyped
- def open_card(r, upload, index)
- _group, saved = Import::Staged.saved(upload)
- contact = saved[index.to_s]
- return r.redirect "/contacts/#{contact}" if contact
-
- card_screen(upload, index)
- end
-
# One contact, 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 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
+ # 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.
+ #
# `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
@@ -171,20 +178,66 @@ module ProTacts
return nil if original.nil? || card.nil?
group, saved = Import::Staged.saved(upload)
+ stored = saved[index.to_s]
+ contact = stored ? store.contact(stored) : nil
+ aside = Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped)
+ sidebar = walk_sidebar(upload, originals, revised, saved, current: index)
response["Content-Type"] = "text/html; charset=utf-8"
+ return Admin::ImportSaved.call(
+ contact:,
+ groups: store.groups_of(contact.id),
+ aside:,
+ sidebar:,
+ edit: "/import/#{upload}/#{index}/edit",
+ notice:,
+ ) if contact
+
Admin::ContactsEdit.call(
contact: import_contact(card, index),
notice:,
action: "/import/#{upload}/#{index}",
- # The submit says where the card is going, because it is not
- # there yet: this is the one editor in the app whose Save
- # creates the record rather than amending it, and the row
- # beside it wears the unsaved rail until it has been pressed
+ # The submit says what it does — the import of this one
+ # contact — because it is the one editor in the app whose Save
+ # creates the record rather than amending it. The row beside
+ # it wears its check only once this has been pressed
# (Admin::ImportSidebar).
- save: "save to the book",
- aside: Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped),
+ save: "Import",
+ aside:,
fields: import_picker(group, joined, named),
+ sidebar:,
+ )
+ end
+
+ # The editor over a stored row's contact, reached from that row's
+ # read screen: #apply_edit's form — the contact's own card, its
+ # etag, no group boxes — posting to the row itself, where the
+ # amend branch of #save_card answers (its `refuse` and `land` are
+ # this screen and the row's). `contact` is the fresh one a
+ # refusal is re-rendering, and none passed means the stored one;
+ # a row with nothing stored is already its editor, so its edit
+ # goes back to the row screen.
+ #: (untyped r, String upload, Integer index, ?contact: Contact?, ?notice: String?) -> untyped
+ def edit_card_screen(r, upload, index, contact: nil, notice: nil)
+ staged = staged_cards(upload)
+ return expired_screen if staged.nil?
+
+ originals, revised = staged
+ original = originals[index]
+ return nil if original.nil?
+
+ _group, saved = Import::Staged.saved(upload)
+ stored = saved[index.to_s]
+ contact ||= stored ? store.contact(stored) : nil
+ return r.redirect "/import/#{upload}/#{index}", 303 if contact.nil?
+
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::ContactsEdit.call(
+ contact:,
+ notice:,
+ action: "/import/#{upload}/#{index}",
+ save: "Save",
+ aside: Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped),
sidebar: walk_sidebar(upload, originals, revised, saved, current: index),
)
end
@@ -230,12 +283,20 @@ module ProTacts
return nil if card.nil?
_group, saved = Import::Staged.saved(upload)
- # A Save pressed twice, or a back button onto the screen of a
- # contact that is already in the book: a second write here would
- # be a second contact rather than an edit of the first, there
- # being no id in the file to recognise it by.
+ # A Save pressed twice, or a back button onto the editor of a
+ # contact that is already in the book: a second write here
+ # would be a second contact rather than an edit of the first,
+ # there being no id in the file to recognise it by, so the POST
+ # over a stored row is that row's own editor's Save instead —
+ # the same amend the contact's own page writes (#apply_edit),
+ # landing back on the row's read screen.
stored = saved[index.to_s]
- return r.redirect "/contacts/#{stored}", 303 if stored
+ contact = stored ? store.contact(stored) : nil
+ if contact
+ return apply_edit(r, contact.id,
+ refuse: ->(fresh, notice:) { edit_card_screen(r, upload, index, contact: fresh, notice:) },
+ land: "/import/#{upload}/#{index}")
+ end
contact = import_contact(card, index)
diff --git a/public/admin.css b/public/admin.css
index 73ec0b8..269ef03 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -67,6 +67,16 @@
.card-body h1 { margin: 0; }
.card-body > h1 { margin-block-end: var(--gl-space-xs); }
+/* A record's name sits a step closer to its card's top than the
+ padding the body gives prose: the heading is the card's own edge,
+ and the m of air above it read as blank space rather than as
+ room to read in (the detail-header, both on the contact's own
+ page and in a walk's read screen — ContactCard, the one
+ renderer). */
+.card-body:has(> .detail-header:first-child) {
+ padding-block-start: var(--gl-space-s);
+}
+
/* The type column both record modes share: the details grid's label
column and the editor's caption column are the same column, read
from one place so they cannot drift apart. The one structural rem
@@ -948,22 +958,27 @@ dialog > .field-stack > input[type="search"] {
}
/* Whether a row is in the book yet, which is the one thing the walk
- turns on: a rail in the accent down the inside edge of every row
- that is not. Drawn as an inset shadow rather than a border so the
- 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.
+ turns on: the verdict at every row's trailing edge, a circle that
+ 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
+ check.
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);
+ color 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="saved"] > div {
+ color: var(--gl-color-text-secondary);
+}
+.walk-list a:only-child[data-state="saved"] > svg {
+ color: var(--gl-color-success);
}
/* The row being read. The fill is the hover gesture too, so the name
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 8e94e41..2a1c793 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -59,6 +59,10 @@ module ProTacts
def self.new: (upload: String, rows: Array[[Contact, Integer]], saved: Hash[String, String], current: Integer) -> instance
end
+ class ImportSaved
+ def self.call: (contact: Contact, groups: Array[Store::Group], aside: untyped, sidebar: untyped, edit: String, ?notice: String?) -> String
+ end
+
# Rendered by Dashboard, not called from checked code; only
# its LIMIT is read in web.rb.
class UpcomingBirthdays
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index ba61fdf..8eafedb 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -224,7 +224,6 @@ class AdminImportPagesTest < Minitest::Test
assert_equal 200, last_response.status
assert_includes last_response.body, %(<div class="paired">)
- assert_includes last_response.body, "as exported"
# Every line of the original, struck where it is not coming in.
assert_includes last_response.body, "TEL;type=CELL:+1 555 0100"
assert_includes last_response.body,
@@ -234,7 +233,7 @@ class AdminImportPagesTest < Minitest::Test
assert_includes last_response.body, %(<form action="/import/#{id}/0" method="post")
assert_includes last_response.body, %(value="+1 555 0100")
# The one editor in the app whose Save creates the record.
- assert_includes last_response.body, "save to the book"
+ assert_includes last_response.body, %(>Import</button>)
end
end
@@ -264,14 +263,18 @@ class AdminImportPagesTest < Minitest::Test
assert_equal "Jane Booles", jane.name
# And the walk steps straight to the next row nobody has read,
# the list beside it saying the row that was saved is in: it
- # leads to the contact now, and the row nobody has opened still
- # leads into the walk.
+ # opens in the walk again, and the row nobody has opened still
+ # leads into it.
assert_equal "/import/#{id}/1", last_response.headers["location"]
follow_redirect!
- assert_includes last_response.body, %(href="/contacts/#{jane.id}")
+ assert_includes last_response.body, %(href="/import/#{id}/0")
assert_includes last_response.body, %(data-state="saved")
assert_includes last_response.body, %(<span class="gl-visually-hidden">saved</span>)
+ # The row's verdict now: the check landed in its circle, and no
+ # count of losses — they were settled by the save.
+ assert_includes last_response.body, %(<path d="m9 12 2 2 4-4">)
+ refute_includes last_response.body, "diff-removed"
end
end
@@ -290,19 +293,78 @@ class AdminImportPagesTest < Minitest::Test
# A row already in the book is a contact, edited where every other
# contact is: the import's editor writes, and writing it a second
# time would be a second contact rather than an edit of the first.
- def test_a_row_already_in_the_book_leads_to_the_contact
+ # The row stays in the walk either way — it opens as the contact it
+ # became, read beside the card it arrived as, not as a form again.
+ def test_a_row_already_in_the_book_reads_in_the_walk
+ with_contacts({}) do |_store|
+ id = upload(JANE + PLAIN)
+ save(id, 0, first: "Jane", last: "Booles")
+
+ get "/import/#{id}/0"
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, %(<div class="walk">)
+ # The details it landed with, and the card it arrived as.
+ assert_includes last_response.body, %(<dl class="detail-grid">)
+ assert_includes last_response.body, "+1 555 0100"
+ assert_includes last_response.body, %(data-dropped)
+ # No editor, no dialog, no footer: nothing to submit, and
+ # nothing to change here.
+ refute_includes last_response.body, %(name="etag")
+ refute_includes last_response.body, "edit groups"
+ refute_includes last_response.body, %(<footer>)
+ # And the row's verdict is the check landed in its circle.
+ assert_includes last_response.body, %(<path d="m9 12 2 2 4-4">)
+ # The way into edit mode: the contact page's own edit link, in
+ # the walk's caption row.
+ assert_includes last_response.body,
+ %(<a href="/import/#{id}/0/edit" class="btn" data-size="sm">edit</a>)
+ end
+ end
+
+ # Edit mode for a stored row: the contact's own editor over the
+ # card it arrived as, posting to the row — where the amend branch
+ # of the save answers — and landing back on the row's read screen.
+ # A row still to do is already its editor, so its edit is the row
+ # screen itself.
+ def test_a_stored_row_edits_in_the_walk
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
save(id, 0, first: "Jane", last: "Booles")
jane = store.contacts.fetch(0)
- get "/import/#{id}/0"
+ get "/import/#{id}/0/edit"
- assert_equal 302, last_response.status
- assert_equal "/contacts/#{jane.id}", last_response.headers["location"]
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, %(<form action="/import/#{id}/0" method="post")
+ assert_includes last_response.body, %(name="etag" value="")
+ assert_includes last_response.body, "Sam Booles"
+ # No group boxes: the amend is #apply_edit's form, and membership
+ # is the contact page's question.
+ refute_includes last_response.body, %(name="groups[]")
+
+ post "/import/#{id}/0",
+ "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "Jay", "note" => ""
+
+ assert_equal "/import/#{id}/0", last_response.headers["location"]
+ jane = store.contacts.fetch(0)
+
+ assert_equal "Jay", jane.nickname
+
+ # And an unsaved row's edit is the row screen.
+ get "/import/#{id}/1/edit"
+
+ assert_equal 303, last_response.status
+ assert_equal "/import/#{id}/1", last_response.headers["location"]
end
end
+ # A Save pressed twice is not a second contact: the first makes
+ # the contact, and the second is that row's editor's Save — the
+ # amend the contact's own page would write — met by the etag guard
+ # the editor always carries, the staged card the re-posted form
+ # came from hashing differently than the contact now in the book.
def test_a_save_pressed_twice_writes_one_contact
with_contacts({}) do |store|
id = upload(JANE + PLAIN)
@@ -316,7 +378,8 @@ class AdminImportPagesTest < Minitest::Test
end
assert_equal 1, store.contacts.length
- assert_equal "/contacts/#{store.contacts.fetch(0).id}", last_response.headers["location"]
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "This contact changed since the page loaded; nothing was saved."
end
end