import: the walk steps from card to card
The screen between saves repeated what the list beside every card
says. The caption links went with it, and ContactsEdit's back link
moved to the route — the walk's editor has no contact to point back at.

Assisted-by: GLM-5.3 via pi
change mmnlskymkxlqyxrusnpurzvztqzovxnw
commit cba4dc342d16a1eebc7f37025185f915f8fcb4d2
author Alpha Chen <alpha@kejadlen.dev>
date
parent nxxomwum
diff --git a/Steepfile b/Steepfile
index 81e88a3..cb652d7 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ target :lib do
   # 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_review import_sidebar
+    group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original 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 4bdd2e6..bd52710 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -95,14 +95,13 @@ the wrong remedy: it asks about `X-ABRELATEDNAMES` in the abstract,
 and the answer it can give back is a line this app composed. What the
 importer actually wants is to look at a contact and fix it.
 
-So the review is a walk, three screens rather than a submission:
+So the review is a walk, two screens rather than a submission:
 
 1. **Choose the file**, which opens straight into the first contact
    in it. A list built from a file nobody has looked at yet is a stop
    on the way to the same place, and it stands beside every card
    anyway.
-2. **What the whole file is losing**, a click back up that list.
-3. **One contact, two cards**, side by side. On the left, the card exactly
+2. **One contact, two cards**, side by side. On the left, the card exactly
    as the file wrote it: content lines in monospace, with every line that is
    not coming in struck in Gloss's danger color and labelled "not imported"
    beside it, a strike on its own being a color. On the right, the contact
@@ -139,19 +138,25 @@ it was typed.
 ## The list is a sidebar
 
 The contacts are not a screen of their own. They are a column down
-the side of both screens after the upload, so opening a row never
+the side of the card screens, so opening a row never
 costs your place in the list: the row being read is marked, the row
 after it is the next thing to click, and what the last ten screens
 settled is in view while the eleventh is open. A book is hundreds of
 contacts and only some of them will have anything worth looking at,
 so the list is what says which rows are worth opening — and that is
-a thing to read *while* reading one, not instead of.
-
-It is a `.record` block like the thing beside it, caption row over
-card, so the two columns' cards start at the same height. Two columns
-beginning a rung apart read as a mistake before they read as anything
-else. Its caption is the link back to the screen about the file,
-which has no row of its own to be reached from.
+a thing to read *while* reading one, not instead of. What the file
+as a whole is losing was a screen of its own first — a click back up
+this list, and a stop between every save — and is gone: its facts
+live on the rows (the dropped count) and on the cards (the struck
+lines), which repeat them on every screen the walk has, and a save
+now steps straight to the next row nobody has read.
+
+It is a `.record` block like the thing beside it, but with no
+caption row: the rows are evidently contacts, and the screen about
+the file they came from is gone, so a caption over the list would
+name what the screen already shows. What sits above the editor is
+its own action's row and nothing else — there is no contact to go
+back to yet, the card not being in the book until its Save.
 
 Two facts per row, and the walk turns on both.
 
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 943fb29..04e4210 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -80,7 +80,7 @@ module ProTacts
       # @rbs @middle: String?
       # @rbs @last: String?
       # @rbs @action: String
-      # @rbs @back: [String, String]
+      # @rbs @back: ([String, String])?
       # @rbs @aside: Phlex::HTML?
       # @rbs @fields: Phlex::HTML?
 
@@ -103,7 +103,7 @@ module ProTacts
                      sidebar: nil, save: nil)
         @contact = contact
         @action = action || "/contacts/#{contact.id}"
-        @back = back || ["/contacts/#{contact.id}", contact.name || contact.id]
+        @back = back
         @aside = aside
         @fields = fields
         @sidebar = sidebar
diff --git a/lib/pro_tacts/admin/import_review.rb b/lib/pro_tacts/admin/import_review.rb
deleted file mode 100644
index 1172b3e..0000000
--- a/lib/pro_tacts/admin/import_review.rb
+++ /dev/null
@@ -1,153 +0,0 @@
-require "pro_tacts/admin/phlex"
-
-require "pro_tacts/admin/layout"
-
-module ProTacts
-  module Admin
-    # The screen an import opens on, and the one it comes back to: what
-    # the whole file is losing, what it is coming in under, and the way
-    # to stop (docs/plans/2026-09-21-import-a-vcf.md).
-    #
-    # The contacts themselves are not here. They are the sidebar beside
-    # this (Admin::ImportSidebar), which stands on every screen of the
-    # walk, so this card is only what is true of the file rather than
-    # of any one row in it: which properties are not coming in, and how
-    # many lines each of them wears. It is what says whether the list
-    # beside it is worth working down at all. That count does not
-    # mention the noise a file is losing (Vcf::NOISE): a mark every row
-    # wears is a mark that says nothing, and an export's own PRODID is
-    # not something anyone is going to copy across by hand.
-    #
-    # The import happens a row at a time, on the Save of the screen a
-    # row opens, so nothing here writes a contact and there is no
-    # "import everything" — and nothing here ends the walk either. A
-    # walk ends by being over, which is the last row's own Save, or by
-    # being left, which needs no button: the staged file expires on
-    # its own (Import::Staged::LIFETIME), and the contacts already
-    # written stay written. A control whose only power is to throw
-    # away the rows nobody has read yet is one with nothing to gain
-    # by pressing it.
-    class ImportReview < Phlex::HTML
-      # @rbs @contacts: Integer
-      # @rbs @saved: Integer
-      # @rbs @unknown: Array[::ProTacts::Import::Vcf::Unknown]
-      # @rbs @group: String
-      # @rbs @sidebar: Phlex::HTML
-      # @rbs @notice: String?
-
-      # `contacts` is how many the file holds and `saved` how many of
-      # them are in the book. `group` is the name of the group this
-      # import files its arrivals under, and `sidebar` the rows
-      # themselves.
-      #: (contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: Phlex::HTML, ?notice: String?) -> void
-      def initialize(contacts:, saved:, unknown:, group:, sidebar:, notice: nil)
-        @contacts = contacts
-        @saved = saved
-        @unknown = unknown
-        @group = group
-        @sidebar = sidebar
-        @notice = notice
-      end
-
-      def view_template
-        render Layout.new(title: "Import", wide: true, notice: @notice) do
-          div(class: "walk") do
-            render @sidebar
-            div(class: "record") do
-              div(class: "record-nav") do
-                a(href: "/import", class: "type-label") { "‹ another file" }
-              end
-              summary_card
-            end
-          end
-        end
-      end
-
-      private
-
-      #: () -> void
-      def summary_card
-        div(class: "card") do
-          div(class: "card-body") do
-            h1(class: "type-h2") { count(@contacts, "contact") }
-            losses
-            group_line
-            progress
-          end
-        end
-      end
-
-      # The group made for this import and taking in every contact it
-      # brings in, named for the moment it opened so one import can be
-      # found — or undone — apart from the next. A fact rather than a
-      # field: every Save files its contact under it, so a rename
-      # halfway would leave what is already in the book under the old
-      # name and put the rest somewhere else, and a rename before that
-      # is a name for a thing nobody has seen yet. Which groups a
-      # contact actually joins — this one among them, ticked and
-      # removable — is asked beside that contact's card
-      # (Admin::ImportGroups).
-      #: () -> void
-      def group_line
-        p(class: "type-body-sm gl-muted") do
-          @group.empty? ? "Coming in with no group of their own." : "Coming in as #{@group}."
-        end
-      end
-
-      # How far down the file the walk has got, which is the whole of
-      # what this screen has to say about it: the rows themselves are
-      # beside it, each saying for itself whether it is in the book.
-      # Said and not asked — there is nothing to press here, an import
-      # being over when its last row is saved and left when it is
-      # left.
-      #: () -> void
-      def progress
-        p(class: "type-body-sm gl-muted") do
-          if @saved.zero?
-            "Nothing has come in yet. Open a contact to look it over; its Save is what puts it in the book."
-          else
-            "#{@saved} of #{count(@contacts, "contact")} in the book."
-          end
-        end
-      end
-
-      # What the file is losing, by property rather than by line: a
-      # reader deciding whether to open any of the rows beside this
-      # wants to know it is `X-SOCIALPROFILE` and `PRODID` going, not
-      # that eight hundred lines are.
-      #: () -> void
-      def losses
-        if @unknown.empty?
-          # Not "every property here is one pro-tacts reads": the
-          # noise is not read either (Vcf::NOISE), it is only not
-          # worth a sentence. What is true in both cases is that
-          # there is nothing in this file to stop over.
-          p(class: "type-body-sm gl-muted") { "Nothing in this file needs reading before it comes in." }
-          return
-        end
-
-        p(class: "type-body-sm") do
-          plain "No screen in pro-tacts shows "
-          plain @unknown.length == 1 ? "this property" : "these properties"
-          plain ", so they stay behind. Open a contact to read what its own card says and "
-          plain "copy across anything worth keeping."
-        end
-        ul(class: "card-lines") do
-          @unknown.each do |property|
-            li do
-              span { property.name }
-              span(class: "type-label") { count(property.count, "line") }
-            end
-          end
-        end
-      end
-
-      # A count with its noun, the plural spelled out where an "s" on
-      # the end will not do it.
-      #: (Integer number, String noun, ?String? plural) -> String
-      def count(number, noun, plural = nil)
-        number == 1 ? "#{number} #{noun}" : "#{number} #{plural || "#{noun}s"}"
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/admin/import_sidebar.rb b/lib/pro_tacts/admin/import_sidebar.rb
index 661968c..daa1f51 100644
--- a/lib/pro_tacts/admin/import_sidebar.rb
+++ b/lib/pro_tacts/admin/import_sidebar.rb
@@ -42,26 +42,23 @@ module ProTacts
       # many of its original's lines are not coming with it; `saved`
       # the contact each row that has come in was written as, by the
       # row's place in the file; `current` the row whose screen is
-      # open, or none on the screen that is about the file itself.
-      #: (upload: String, rows: Array[[::ProTacts::Contact, Integer]], saved: Hash[String, String], ?current: Integer?) -> void
-      def initialize(upload:, rows:, saved:, current: nil)
+      # open.
+      #: (upload: String, rows: Array[[::ProTacts::Contact, Integer]], saved: Hash[String, String], current: Integer) -> void
+      def initialize(upload:, rows:, saved:, current:)
         @upload = upload
         @rows = rows
         @saved = saved
         @current = current
       end
 
-      # A record's own shape — caption row over card — because the
-      # thing beside this is one too, and two columns whose cards
-      # start at different heights read as a mistake before they read
-      # as anything else (.record in admin.css). The caption is the
-      # way back to what the file as a whole is losing, that screen
-      # having no row of its own to be reached from.
+      # The record's own column shape (.record in admin.css), the
+      # thing beside it being a record too — but no caption row: the
+      # rows are self-evidently contacts, and the screen about the
+      # file they came from is gone, so there is nothing for a
+      # caption over the list to name or reach. What sits over the
+      # editor is its own action's row and nothing else.
       def view_template
         nav(class: "record walk-list") do
-          div(class: "record-nav") do
-            a(href: "/import/#{@upload}", class: "type-label") { "contacts" }
-          end
           ul(class: "card") do
             @rows.each_with_index do |(contact, dropped), index|
               row(contact, dropped, index)
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index af0f00d..ddf2449 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -33,8 +33,7 @@ module ProTacts
 
       def view_template
         render Layout.new(title: "Import", notice: @notice) do
-          render RecordCard.new(back: ["/", "contacts"], heading: "Import contacts",
-                                submit: "Import", form: FORM) do
+          render RecordCard.new(heading: "Import contacts", submit: "Import", form: FORM) do
             form(action: "/import", method: "post", enctype: "multipart/form-data", id: FORM) do
               label(class: "field") do
                 plain "vcard file"
diff --git a/lib/pro_tacts/admin/record_card.rb b/lib/pro_tacts/admin/record_card.rb
index d50f983..ff638e8 100644
--- a/lib/pro_tacts/admin/record_card.rb
+++ b/lib/pro_tacts/admin/record_card.rb
@@ -16,13 +16,16 @@ module ProTacts
     #
     # `back` names the caption row's link, `[href, label]`, the label
     # a string or whatever `render` takes (GroupsEdit's is a
-    # GroupLabel). `nav`, when something is passed, is the row's one
-    # action at its right edge. The footer's Cancel goes where the
-    # back link goes — the editors' own rule — and the submit reaches
-    # the form by id rather than sitting in it, so the action bar is
-    # the card's and not the form's.
+    # GroupLabel); a card with neither back nor `nav` — the walk's
+    # editor, whose navigation is the list beside it — renders no
+    # caption row at all. `nav`, when something is passed, is the
+    # row's one action at its right edge. The footer's Cancel goes
+    # where the back link goes — the editors' own rule, and absent
+    # with it — and the submit reaches the form by id rather than
+    # sitting in it, so the action bar is the card's and not the
+    # form's.
     class RecordCard < Phlex::HTML
-      # @rbs @back: [String, (String | Phlex::HTML | Proc)]
+      # @rbs @back: ([String, (String | Phlex::HTML | Proc)])?
       # @rbs @nav: (Proc | Phlex::HTML)?
       # @rbs @heading: String?
       # @rbs @aside: Phlex::HTML?
@@ -30,8 +33,8 @@ module ProTacts
       # @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:, 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:, form:, nav: nil, heading: nil, aside: nil, sidebar: nil)
         @back = back
         @nav = nav
         @heading = heading
@@ -69,13 +72,17 @@ module ProTacts
       # row names the other thing you can do to the record from it.
       def record(&)
         div(class: "record") do
-          div(class: "record-nav") do
-            href, label = @back
-            a(href:, class: "type-label") do
-              plain "‹ "
-              render label
+          href, label = @back
+          if href || @nav
+            div(class: "record-nav") do
+              if href
+                a(href:, class: "type-label") do
+                  plain "‹ "
+                  render label
+                end
+              end
+              render @nav if @nav
             end
-            render @nav if @nav
           end
           paired(&)
         end
@@ -106,9 +113,11 @@ module ProTacts
           # 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
-          # what an anchor that acts like a button opts into.
+          # 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" }
+            a(href: @back&.fetch(0), class: "btn") { "Cancel" } if @back
             button(type: "submit", form: @form, data: {variant: "primary"}) { @submit }
           end
         end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index f318e70..ae07fd0 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -71,7 +71,7 @@ module ProTacts
           # not_found handler fills in, same as the page below.
           if contact
             response["Content-Type"] = "text/html; charset=utf-8"
-            Admin::ContactsEdit.call(contact:)
+            Admin::ContactsEdit.call(contact:, back: edit_back(contact))
           end
         end
 
@@ -234,7 +234,15 @@ module ProTacts
     #: (Contact contact, ?notice: String) -> String
     def edit_screen(contact, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ContactsEdit.call(contact:, notice:)
+      Admin::ContactsEdit.call(contact:, notice:, back: edit_back(contact))
+    end
+
+    # The edit's way back, named by the route rather than left to the
+    # view: the import's editor passes none, its navigation being the
+    # list beside it.
+    #: (Contact contact) -> [String, String]
+    def edit_back(contact)
+      ["/contacts/#{contact.id}", contact.name || contact.id]
     end
   end
 end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 9e0919d..325edef 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -2,7 +2,6 @@ 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_review"
 require "pro_tacts/admin/import_sidebar"
 require "pro_tacts/admin/import_upload"
 require "pro_tacts/birthday"
@@ -20,16 +19,19 @@ module ProTacts
     # .vcf is what every address book on earth already exports, and
     # the machine holding it is whichever one the browser is on.
     #
-    # Three screens, because an import is a walk rather than a
-    # submission: choose the file, read what the whole of it is
-    # losing, and open any of its contacts beside the card it arrived
-    # as. The Save on that last screen is the import of that one
-    # contact — not a note kept until a confirm at the end — so the
-    # walk's list says what is in the book and what is not yet, and
-    # what is left when the walk stops is only the part nobody looked
-    # at. That list is a sidebar rather than a screen of its own
-    # (Admin::ImportSidebar): it stands beside the two later screens
-    # both, so opening a row never costs your place in it.
+    # Two screens, because an import is a walk rather than a
+    # submission: choose the file, and open any of its contacts
+    # beside the card it arrived as. The Save on that screen is the
+    # import of that one contact — not a note kept until a confirm
+    # at the end — and then the walk is on the next row nobody has
+    # read, so what is left when the walk stops is only the part
+    # nobody looked at. The list is a sidebar rather than a screen
+    # of its own (Admin::ImportSidebar): it stands beside every
+    # card, so opening a row never costs your place in it, and what
+    # the file is losing is said on the rows (the dropped count) and
+    # on the cards (the struck lines) rather than on a screen about
+    # the file — a stop between saves that repeated what the list
+    # beside every card already said.
     #
     # Under the same identity gate as every other route (web.rb),
     # which is also where the arrivals' books come from: a card this
@@ -47,12 +49,6 @@ module ProTacts
       end
 
       r.on String do |upload|
-        r.is do
-          r.get do
-            review_screen(upload)
-          end
-        end
-
         # The contact's place in the file is its name here, until its
         # own Save mints one. The file's own order is the one thing
         # about a card that cannot change under the walk: the editor's
@@ -77,7 +73,7 @@ module ProTacts
     # anything is staged, the old `execute`'s rule that a source which
     # will not read brings in nothing. What it stages is both readings —
     # the file as it arrived, and the cards pared to what this book
-    # shows — and then it is the review screen's walk.
+    # shows — and then it is the walk's first card.
     #: (untyped r) -> untyped
     def stage_upload(r)
       upload = file_in(r.params["vcf"])
@@ -109,47 +105,23 @@ module ProTacts
         # and a contact at a time (#import_picker).
         group: Import::Write.default_group,
       )
-      # Straight to the first contact rather than to the screen about
+      # Straight to the first contact rather than to a screen about
       # the file: the walk is the point, and a list that has just been
       # built from a file nobody has looked at yet is a stop on the
-      # way to the same place. What the whole file is losing is on the
-      # screen that list links back to, and the list itself stands
-      # beside every card anyway (Admin::ImportSidebar). A file with
-      # no contacts in it has no first one to open.
+      # way to the same place — the list stands beside every card
+      # anyway (Admin::ImportSidebar).
       #
       # A 303, the other writes' answer, because what follows is a
       # walk: every screen of it is a GET a back button can revisit.
-      r.redirect(cards.empty? ? "/import/#{id}" : "/import/#{id}/0", 303)
-    end
-
-    # What the whole file is losing, what it is coming in under, and
-    # the way to stop. The contacts themselves are the sidebar beside
-    # it, which every screen of the walk carries.
-    #: (String upload, ?notice: String?) -> String
-    def review_screen(upload, notice: nil)
-      staged = staged_cards(upload)
-      return expired_screen if staged.nil?
-
-      originals, revised = staged
-      group, saved = Import::Staged.saved(upload)
-
-      response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ImportReview.call(
-        contacts: revised.length,
-        saved: saved.length,
-        unknown: Import::Vcf.unknown(originals),
-        group:,
-        sidebar: walk_sidebar(upload, originals, revised, saved),
-        notice:,
-      )
+      r.redirect "/import/#{id}/0", 303
     end
 
     # The walk's list of contacts, as every screen of it shows them:
     # the contact each card will be written as, how many of its
     # original's lines are not coming with it, and which rows are in
     # the book already. `current` is the row whose screen is open.
-    #: (String upload, Array[VCard] originals, Array[VCard] revised, Hash[String, String] saved, ?current: Integer?) -> Admin::ImportSidebar
-    def walk_sidebar(upload, originals, revised, saved, current: nil)
+    #: (String upload, Array[VCard] originals, Array[VCard] revised, Hash[String, String] saved, current: Integer) -> Admin::ImportSidebar
+    def walk_sidebar(upload, originals, revised, saved, current:)
       rows = revised.each_with_index.map { |card, index|
         # A literal of several elements is an Array until something
         # says otherwise, and an inline annotation needs its own line.
@@ -205,7 +177,6 @@ module ProTacts
         contact: import_contact(card, index),
         notice:,
         action: "/import/#{upload}/#{index}",
-        back: ["/import/#{upload}", "the import"],
         # 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
@@ -366,7 +337,15 @@ module ProTacts
       # thing.
       return close_walk(r, upload) if saved.length + 1 == revised.length
 
-      r.redirect "/import/#{upload}", 303
+      # Onto the next row nobody has read, in the file's own order —
+      # the walk steps from card to card, a screen between saves
+      # having only ever repeated what the list beside every card
+      # says. `saved` was read before this write, so the row just
+      # written is named here too; a `fetch` rather than a `first`
+      # because empty is the case the line above took, and reaching
+      # it is a broken assumption rather than a screen to render.
+      remaining = (0...revised.length).reject { |i| saved.key?(i.to_s) || i == index }
+      r.redirect "/import/#{upload}/#{remaining.fetch(0)}", 303
     end
 
     # The end of the walk: the file and the walk's own notes gone, and
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index c528025..8e94e41 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -45,10 +45,6 @@ module ProTacts
       def self.call: (?notice: String?) -> String
     end
 
-    class ImportReview
-      def self.call: (contacts: Integer, saved: Integer, unknown: Array[::ProTacts::Import::Vcf::Unknown], group: String, sidebar: untyped, ?notice: String?) -> String
-    end
-
     # Rendered into ContactsEdit rather than called, so these are the
     # views here whose `new` is what checked code reaches for.
     class ImportOriginal
@@ -60,7 +56,7 @@ module ProTacts
     end
 
     class ImportSidebar
-      def self.new: (upload: String, rows: Array[[Contact, Integer]], saved: Hash[String, String], ?current: Integer?) -> instance
+      def self.new: (upload: String, rows: Array[[Contact, Integer]], saved: Hash[String, String], current: Integer) -> instance
     end
 
     # Rendered by Dashboard, not called from checked code; only
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index b37aaaf..ba61fdf 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -80,20 +80,12 @@ class AdminImportPagesTest < Minitest::Test
     last_response.body.scan(/name="#{Regexp.escape(name)}" value="([^"]*)" checked/).flatten
   end
 
-  # The screen about the file rather than about a contact: what the
-  # whole of it is losing, and the way to stop. An upload goes
-  # straight past it into the first contact, so a test that is about
-  # it asks for it.
-  def summary(id)
-    get "/import/#{id}"
-  end
-
-  # The group this import files its arrivals under, read off the
-  # screen that states it — there is no form for it, and the name is
-  # the moment the upload happened.
+  # The group this import files its arrivals under, named when the
+  # upload happens: every Save ticks it, so the name is read off the
+  # staged files themselves — there is no form for it, and no screen
+  # that states it apart from the boxes beside each card.
   def import_group(id)
-    summary(id)
-    last_response.body[/Coming in as ([^.<]+)\./, 1].to_s
+    ProTacts::Import::Staged.saved(id).first
   end
 
   def test_the_screen_asks_for_a_vcf
@@ -123,20 +115,18 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  # And the file's own screen is a click back up the list.
-  def test_the_file_itself_is_a_screen_the_list_links_to
+  # A file with no contacts in it has no walk to walk: nothing is
+  # staged, and the screen that asked for the file says why it has it
+  # again — the reader's own message, the same one any file that will
+  # not read gets.
+  def test_a_file_with_no_contacts_in_it_comes_back_unstaged
     with_contacts({}) do |store|
-      id = upload(JANE + PLAIN)
-      follow_redirect!
-
-      assert_includes last_response.body, %(<a href="/import/#{id}" class="type-label">contacts</a>)
-
-      summary(id)
+      id = upload("")
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, "2 contacts"
-      assert_includes last_response.body, "Nothing has come in yet."
-      assert_empty store.contacts
+      assert_includes last_response.body, "this file holds no vCards"
+      assert_nil id
+      assert_empty store.changes
     end
   end
 
@@ -154,6 +144,9 @@ class AdminImportPagesTest < Minitest::Test
 
   # The contacts stand beside whatever is open, rather than on a page
   # of their own: opening a row never costs your place in the list.
+  # The list carries no caption — the rows are self-evidently
+  # contacts — and the editor's caption row only its action, there
+  # being no contact to go back to yet.
   def test_the_walk_stands_beside_the_card_being_read
     with_contacts({}) do |_store|
       id = upload(JANE + PLAIN)
@@ -161,10 +154,14 @@ class AdminImportPagesTest < Minitest::Test
       get "/import/#{id}/0"
 
       assert_includes last_response.body, %(<div class="walk">)
-      assert_includes last_response.body, %(<nav class="record walk-list">)
+      assert_includes last_response.body, %(<nav class="record walk-list"><ul)
       assert_includes last_response.body, "Sam Booles"
       assert_includes last_response.body, %(href="/import/#{id}/1")
-      # And says which row you are on.
+      # The editor's caption row carries only its action, and its
+      # footer only the submit: the contact this card will become
+      # does not exist to go back to.
+      refute_includes last_response.body, %(href="/contacts/0")
+      assert_includes last_response.body, %(<footer><button type="submit" form="contact-form")
       assert_includes last_response.body,
                       %(<a href="/import/#{id}/0" aria-current="page" data-state="unsaved">)
     end
@@ -185,37 +182,17 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  # The same fact for the file as a whole, read before deciding
-  # whether the list is worth working down at all.
-  def test_the_list_names_the_properties_the_file_is_losing
-    with_contacts({}) do |_store|
-      summary(upload(JANE))
-
-      assert_includes last_response.body, "X-ABRELATEDNAMES"
-      assert_includes last_response.body, "X-SOCIALPROFILE"
-      assert_includes last_response.body, "1 line"
-    end
-  end
-
-  def test_a_file_this_book_reads_whole_says_so
-    with_contacts({}) do |_store|
-      summary(upload(PLAIN))
-
-      assert_includes last_response.body, "Nothing in this file needs reading before it comes in."
-      refute_includes last_response.body, "diff-removed"
-    end
-  end
-
   # Every export names the program that wrote it, and nobody is
   # going to copy that into a card: it is dropped without being
-  # counted, so a file losing nothing else reads as losing nothing.
+  # counted, so a file losing nothing else reads as losing nothing —
+  # no row wears a mark, and the struck line on the card itself is
+  # the whole of what this file is losing.
   def test_a_file_losing_only_its_exporters_name_asks_for_nothing
     with_contacts({}) do |_store|
-      summary(upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n")))
+      upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n"))
+      follow_redirect!
 
-      assert_includes last_response.body, "Nothing in this file needs reading before it comes in."
       refute_includes last_response.body, "diff-removed"
-      refute_includes last_response.body, "PRODID"
     end
   end
 
@@ -256,7 +233,6 @@ class AdminImportPagesTest < Minitest::Test
       # And the editor, pre-filled and pointed at the import.
       assert_includes last_response.body, %(<form action="/import/#{id}/0" method="post")
       assert_includes last_response.body, %(value="+1 555 0100")
-      assert_includes last_response.body, %(href="/import/#{id}")
       # The one editor in the app whose Save creates the record.
       assert_includes last_response.body, "save to the book"
     end
@@ -286,16 +262,16 @@ class AdminImportPagesTest < Minitest::Test
       jane = store.contacts.fetch(0)
 
       assert_equal "Jane Booles", jane.name
-      # And the list it comes back to says so: the row leads to the
-      # contact now, and the one nobody has opened still leads into
-      # the walk.
+      # 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.
+      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, %(data-state="saved")
       assert_includes last_response.body, %(<span class="gl-visually-hidden">saved</span>)
-      assert_includes last_response.body, %(href="/import/#{id}/1")
-      assert_includes last_response.body, "1 of 2 contacts in the book."
     end
   end
 
@@ -370,10 +346,10 @@ class AdminImportPagesTest < Minitest::Test
 
       assert_includes last_response.body, %(href="/contacts/#{jane.id}")
 
-      # And the walk is gone with it.
+      # And the walk is gone with it: no screen at all answers the id.
       get "/import/#{id}"
 
-      assert_includes last_response.body, "That import is no longer here."
+      assert_equal 404, last_response.status
     end
   end
 
@@ -390,27 +366,6 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  # A walk is over when its last row is saved, and left when it is
-  # left: there is nothing to press, and nothing whose only power
-  # would be to throw away the rows nobody has read.
-  def test_the_walk_says_how_far_down_the_file_it_is_and_offers_no_button
-    with_contacts({}) do |store|
-      id = upload(JANE + PLAIN)
-      save(id, 0, first: "Jane", last: "Booles")
-      summary(id)
-
-      assert_includes last_response.body, "1 of 2 contacts in the book."
-      # Nothing to press at all: this screen only says things.
-      refute_includes last_response.body, %(type="submit")
-      refute_includes last_response.body, "/done"
-      assert_equal 1, store.contacts.length
-
-      post "/import/#{id}/done"
-
-      assert_equal 404, last_response.status
-    end
-  end
-
   # The editor's own guard, over the staged card: two tabs on one
   # import, and the second save would revert the first.
   def test_a_save_against_a_stale_card_is_refused
@@ -458,9 +413,9 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  # The whole file's group is named on the review screen; which of
-  # this book's own groups a contact joins is asked beside that
-  # contact's card, and written by the same Save as its fields.
+  # The group for the import is named when the file is uploaded;
+  # which of this book's own groups a contact joins is asked beside
+  # that contact's card, and written by the same Save as its fields.
   def test_a_contact_is_put_in_its_groups_beside_its_own_card
     with_contacts({}) do |store|
       school = store.create_group(name: "school")
@@ -539,9 +494,9 @@ class AdminImportPagesTest < Minitest::Test
   def test_the_group_for_the_import_cannot_be_renamed
     with_contacts({}) do |_store|
       id = upload(JANE)
-      summary(id)
 
-      assert_includes last_response.body, "Coming in as import-"
+      get "/import/#{id}/0"
+
       refute_includes last_response.body, %(name="group")
 
       post "/import/#{id}/group", "group" => "somewhere else"
@@ -731,7 +686,7 @@ class AdminImportPagesTest < Minitest::Test
       id = upload(JANE)
       save(id, 0, first: "Jane", last: "Booles")
 
-      summary(id)
+      get "/import/#{id}/0"
 
       assert_includes last_response.body, "That import is no longer here."
       assert_equal 1, store.contacts.length
@@ -744,7 +699,7 @@ class AdminImportPagesTest < Minitest::Test
     with_contacts({}) do |store|
       refute ProTacts::ChangeId.minted?("notminted")
 
-      get "/import/notminted"
+      get "/import/notminted/0"
 
       assert_includes last_response.body, "That import is no longer here."
       assert_empty store.changes