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
change xxonrntyukvuzuzroszlrknmknpluswq
commit ce8ac9c7bda3956c9acffe7b46a6a9aa35740014
author Alpha Chen <alpha@kejadlen.dev>
date
parent mmnlskym
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="&quot;)
+      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