import: the walk opens on a contact, and the rail carries the state
Four things Alpha found reading the screen:

The two columns started a rung apart. The sidebar was a section-head
over a card and the thing beside it a record-nav over a card, which are
not the same height. The sidebar is a .record block now, the same shape
as its neighbor, and its caption is the link back to the screen about
the file -- which has no row of its own to be reached from.

The card as exported and the editor stacked instead of standing side by
side. Squeezed into a third of the ordinary wide page, .paired's
auto-fit was always going to take the fallback: the walk gets a page
wide enough for three columns, and the pair inside it is two or one and
never the auto-fit in between.

"(not) saved" on every row was a column of the same sentence, and it
took the slot the row's other fact wanted. The rail carries it now --
worn by the rows still to do, because what is worth marking is what is
left -- and the diff mark moves back to the trailing edge. The word
stays in the row for a screen reader, which cannot see a rail. The rail
was invisible before this because it was hung on the current row, and
the screen an upload landed on had no current row at all.

And an upload goes straight into the first contact. 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
change
commit 074680d93d7de26ea292c7413426bc728b1c6765
author Claude <noreply@anthropic.com>
date
parent da5f909b
diff --git a/AGENTS.md b/AGENTS.md
index 9a1aa5b..0b92878 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,8 +44,8 @@ the file wrote it beside the card that is coming in, with the lines no
 screen here shows struck, and this book's groups as a filtered list of
 boxes to tick — the group made for the import and everyone's book among
 them, ticked. That contact's own Save is what writes it through the
-store, so each row reads "saved" or "not saved" and stopping halfway
-keeps everything already in (docs/plans/2026-09-21-import-a-vcf.md).
+store, so every row still to do wears a rail and stopping halfway keeps
+everything already in (docs/plans/2026-09-21-import-a-vcf.md).
 The `import:*` tasks, the Swift reader they drove Contacts.app with,
 and the plan directories they wrote are gone.
 
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index aedfb24..ad59c7f 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -97,25 +97,25 @@ importer actually wants is to look at a contact and fix it.
 
 So the review is a walk, three screens rather than a submission:
 
-1. **Choose the file.**
-2. **What the whole file is losing**, and beside it the contacts it
-   holds, one row each.
-3. **One contact, two cards.** 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 editor, pre-filled with what was read and
-   pointed at the import, with this book's groups under its fields as
-   a filtered list of boxes to tick, the filter doubling as the way to
-   name one it does not have yet. Read the spouse's name off the left
-   card and type it into the note on the right, in your own words, and
-   put the contact where it belongs while you are 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 reads "not saved" until it has
-   been pressed.
+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
+   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
+   editor, pre-filled with what was read and pointed at the import, with
+   this book's groups under its fields as a filtered list of boxes to tick,
+   the filter doubling as the way to name one it does not have yet. Read the
+   spouse's name off the left card and type it into the note on the right,
+   in your own words, and put the contact where it belongs while you are
+   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.
 
 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
@@ -147,21 +147,36 @@ 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.
+
 Two facts per row, and the walk turns on both.
 
-**Whether the contact is in the book**, said in words. 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; a state
-nobody notices is the silence this screen exists to undo, so the
-unsaved rows stand in the page's own text color and the saved ones go
-quiet and green. The colors are the second telling: the words are
-"not saved" and "saved".
-
-**What the card is losing**, as a diff's own mark — `-3` against the
-contact's name, 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** — 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.
+
+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
+the editor. Those last two are two columns or one and never the
+auto-fit in between — squeezed to a third of the ordinary wide page
+the fallback was always the stack, and a card read beside its own
+source is the whole point of the screen.
 
 ## Nothing waits for the end
 
diff --git a/lib/pro_tacts/admin/import_sidebar.rb b/lib/pro_tacts/admin/import_sidebar.rb
index 6e5b3e6..661968c 100644
--- a/lib/pro_tacts/admin/import_sidebar.rb
+++ b/lib/pro_tacts/admin/import_sidebar.rb
@@ -16,15 +16,22 @@ module ProTacts
     # is open.
     #
     # Each row carries the two facts the walk turns on. Whether the
-    # contact is in the book, said in words rather than left to be
-    # inferred from where the row leads: everything here is unsaved
-    # until its own screen says otherwise, and a walk broken off
-    # overnight has to say which rows those are. And what the card is
-    # losing, as a diff's own mark — `-3` against the contact's name,
-    # 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.
+    # 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).
+    #
+    # 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.
     class ImportSidebar < Phlex::HTML
       # @rbs @upload: String
       # @rbs @rows: Array[[::ProTacts::Contact, Integer]]
@@ -44,10 +51,16 @@ module ProTacts
         @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.
       def view_template
-        nav(class: "walk-list") do
-          div(class: "section-head") do
-            h2(class: "type-label") { "contacts" }
+        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|
@@ -70,14 +83,11 @@ module ProTacts
           # 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}",
-            aria_current: ("page" if index == @current)) do
-            div(style: "flex: 1; min-width: 0; font-weight: 550;") do
-              div { Format.name_label(contact) }
-              span(class: "type-mono diff-removed") { "-#{dropped}" } if dropped.positive?
-            end
-            span(class: "type-label", data: {state: stored ? "saved" : "unsaved"}) do
-              stored ? "saved" : "not saved"
-            end
+            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) }
+            span(class: "gl-visually-hidden") { stored ? "saved" : "not saved" }
+            span(class: "type-label diff-removed") { "-#{dropped}" } if dropped.positive?
           end
         end
       end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 18443cd..1f1de21 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -116,9 +116,17 @@ 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
+      # 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.
+      #
       # 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 "/import/#{id}", 303
+      r.redirect(cards.empty? ? "/import/#{id}" : "/import/#{id}/0", 303)
     end
 
     # What the whole file is losing, what it is coming in under, and
@@ -209,7 +217,7 @@ module ProTacts
         # 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 reads "not saved" until it has been pressed
+        # beside it wears the unsaved rail until it has been pressed
         # (Admin::ImportSidebar).
         save: "save to the book",
         aside: Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped),
diff --git a/public/admin.css b/public/admin.css
index 496a6ee..59b6b71 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -866,44 +866,68 @@ dialog > .field-stack > input[type="search"] {
    so it names a width for the first and gives the rest to the second,
    and collapses to one column where the two cannot both stand. The
    sidebar goes on top there rather than under: it is how you got to
-   what is below it. */
+   what is below it.
+
+   Both columns are .record blocks, caption row over card, so their
+   cards start at the same height -- two columns beginning a rung
+   apart read as a mistake before they read as anything else. */
 .walk {
   display: grid;
-  grid-template-columns: minmax(0, 17rem) minmax(0, 1fr);
+  grid-template-columns: minmax(0, 15rem) minmax(0, 1fr);
   gap: var(--gl-space-m);
   align-items: start;
 }
 
-@media (max-width: 56rem) {
-  .walk {
+/* Wider than the wide column, because three things stand in it: the
+   list, the card as exported, and the editor. The editor's own widest
+   row is sized against the value column the reading width names (see
+   .admin-main below), so squeezing it to a third of 68rem is what put
+   the pair one under the other in the first place. */
+.admin-main:has(.walk) {
+  max-width: 92rem;
+}
+
+/* The pair inside the walk is two columns or one, never the auto-fit
+   in between: at this width the fallback was always the stack, and a
+   card read beside its own source is the whole point of the screen.
+   It collapses with the walk itself, on the walk's own breakpoint. */
+.walk .paired {
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+@media (max-width: 64rem) {
+  .walk,
+  .walk .paired {
     grid-template-columns: minmax(0, 1fr);
   }
 }
 
-/* The row being read: the hover fill it already knows, plus a rail in
-   the accent down its inside edge. A rail rather than a filled row,
-   because the fill is the hover gesture and a row wearing it at rest
-   reads as hovered; drawn as an inset shadow rather than a border so
-   the text of one row stays in line with the others. accent-soft
-   would have said it in one declaration and is not an option here --
-   tokens.css never derives it again in dark mode (see the accent
-   block above). */
-.walk-list a[aria-current] {
-  background: var(--gl-color-surface-fill-2);
+/* 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. */
+.walk-list a[data-state="unsaved"] {
   box-shadow: inset 3px 0 0 var(--gl-color-accent);
 }
 
-/* Whether a row is in the book yet, which is the one thing the walk
-   turns on. The unsaved state is the page's own text color rather
-   than the muted grey a type-label takes: everything here is unsaved
-   until its own screen says otherwise, and a state nobody notices is
-   the silence this list exists to undo. Saved goes quiet, and green,
-   the status color being one of the two tokens that never swap with
-   the accent. The words carry it either way and the color is the
-   second telling, the rule the diff marks beside them follow. */
-.walk-list [data-state="unsaved"] {
-  color: var(--gl-color-text-primary);
+/* The row being read. The fill is the hover gesture too, so the name
+   takes the weight that says this one is not merely under a pointer.
+   accent-soft would have said it in one declaration and is not an
+   option here: tokens.css never derives it again in dark mode (see
+   the accent block above). */
+.walk-list a[aria-current] {
+  background: var(--gl-color-surface-fill-2);
 }
-.walk-list [data-state="saved"] {
-  color: var(--gl-color-success);
+.walk-list a[aria-current] > div {
+  font-weight: 700;
+}
+
+/* The count a row is losing, at its trailing edge: a type-label in
+   the danger color, the pairing a struck line on the card as exported
+   already wears (.card-lines above). */
+.walk-list .diff-removed {
+  white-space: nowrap;
 }
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 98c50c9..22ab50b 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -82,11 +82,19 @@ class AdminImportPagesTest < Minitest::Test
     last_response.body.scan(/name="#{Regexp.escape(name)}" value="([^"]*)" checked(?! disabled)/).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.
   def import_group(id)
-    get "/import/#{id}"
+    summary(id)
     last_response.body[/Coming in as ([^.<]+)\./, 1].to_s
   end
 
@@ -98,23 +106,41 @@ class AdminImportPagesTest < Minitest::Test
     assert_includes last_response.body, %(<input type="file" name="vcf" accept=".vcf,text/vcard">)
   end
 
-  def test_an_upload_is_looked_over_before_anything_is_written
+  # Straight into the walk: 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.
+  def test_an_upload_opens_on_the_first_contact_having_written_nothing
     with_contacts({}) do |store|
       id = upload(JANE + PLAIN)
 
       assert_equal 303, last_response.status
+      assert_equal "/import/#{id}/0", last_response.headers["location"]
       follow_redirect!
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, "2 contacts"
       assert_includes last_response.body, "Jane Booles"
       assert_includes last_response.body, "Sam Booles"
-      assert_includes last_response.body, %(href="/import/#{id}/0")
-      assert_includes last_response.body, "Nothing has come in yet."
+      assert_includes last_response.body, %(href="/import/#{id}/1")
       assert_empty store.changes
     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
+    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)
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "2 contacts"
+      assert_includes last_response.body, "Nothing has come in yet."
+    end
+  end
+
   # The row says what opening it would be for, in a diff's own mark: a
   # contact losing nothing does not need a visit, and wears nothing.
   def test_the_walk_marks_what_each_contact_is_losing
@@ -122,7 +148,7 @@ class AdminImportPagesTest < Minitest::Test
       upload(JANE + PLAIN)
       follow_redirect!
 
-      assert_includes last_response.body, %(<span class="type-mono diff-removed">-3</span>)
+      assert_includes last_response.body, %(<span class="type-label diff-removed">-3</span>)
       assert_equal 1, last_response.body.scan("diff-removed").length
     end
   end
@@ -136,22 +162,27 @@ class AdminImportPagesTest < Minitest::Test
       get "/import/#{id}/0"
 
       assert_includes last_response.body, %(<div class="walk">)
-      assert_includes last_response.body, %(<nav class="walk-list">)
+      assert_includes last_response.body, %(<nav class="record walk-list">)
       assert_includes last_response.body, "Sam Booles"
       assert_includes last_response.body, %(href="/import/#{id}/1")
       # And says which row you are on.
-      assert_includes last_response.body, %(<a href="/import/#{id}/0" aria-current="page">)
+      assert_includes last_response.body,
+                      %(<a href="/import/#{id}/0" aria-current="page" data-state="unsaved">)
     end
   end
 
   # Everything is unsaved until its own screen says otherwise, and a
-  # walk broken off overnight has to say which rows those are.
+  # walk broken off overnight has to say which rows those are. The
+  # rail says it on screen; the word is there for a reader that
+  # cannot see one.
   def test_a_row_that_is_not_in_the_book_says_so
     with_contacts({}) do |_store|
       upload(JANE + PLAIN)
       follow_redirect!
 
-      assert_equal 2, last_response.body.scan(%(data-state="unsaved">not saved)).length
+      assert_equal 2, last_response.body.scan(%(data-state="unsaved")).length
+      assert_equal 2, last_response.body.scan(%(<span class="gl-visually-hidden">not saved</span>)).length
+      refute_includes last_response.body, %(<span class="type-label">not saved</span>)
     end
   end
 
@@ -159,8 +190,7 @@ class AdminImportPagesTest < Minitest::Test
   # whether the list is worth working down at all.
   def test_the_list_names_the_properties_the_file_is_losing
     with_contacts({}) do |_store|
-      upload(JANE)
-      follow_redirect!
+      summary(upload(JANE))
 
       assert_includes last_response.body, "X-ABRELATEDNAMES"
       assert_includes last_response.body, "X-SOCIALPROFILE"
@@ -170,8 +200,7 @@ class AdminImportPagesTest < Minitest::Test
 
   def test_a_file_this_book_reads_whole_says_so
     with_contacts({}) do |_store|
-      upload(PLAIN)
-      follow_redirect!
+      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"
@@ -183,8 +212,7 @@ class AdminImportPagesTest < Minitest::Test
   # counted, so a file losing nothing else reads as losing nothing.
   def test_a_file_losing_only_its_exporters_name_asks_for_nothing
     with_contacts({}) do |_store|
-      upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n"))
-      follow_redirect!
+      summary(upload(PLAIN.sub("VERSION:3.0\r\n", "VERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 15.0//EN\r\n")))
 
       assert_includes last_response.body, "Nothing in this file needs reading before it comes in."
       refute_includes last_response.body, "diff-removed"
@@ -265,7 +293,8 @@ class AdminImportPagesTest < Minitest::Test
       follow_redirect!
 
       assert_includes last_response.body, %(href="/contacts/#{jane.id}")
-      assert_includes last_response.body, %(data-state="saved">saved</span>)
+      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 contact in the book."
     end
@@ -488,7 +517,7 @@ class AdminImportPagesTest < Minitest::Test
   def test_the_group_for_the_import_cannot_be_renamed
     with_contacts({}) do |_store|
       id = upload(JANE)
-      follow_redirect!
+      summary(id)
 
       assert_includes last_response.body, "Coming in as import-"
       refute_includes last_response.body, %(name="group")