contacts: switch a card between company and person
A company's name sat in N, but macOS writes it to ORG and FN;
the Company box now flips the kind and saves it the way that PUT had been.
A person's ORG, the place they work, gets a field and reads beneath their name;
a company's N is emptied, so no hidden name stays in the frame.

Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change yluszmzwztpzwvskvwmxlwynmwoymtyq
commit 9e7074f890e5005efad80b5237c23de5ad01a7da
author Alpha Chen <alpha@kejadlen.dev>
date
parent knnvvokz
diff --git a/docs/plans/2026-10-07-switching-company-and-person.md b/docs/plans/2026-10-07-switching-company-and-person.md
new file mode 100644
index 0000000..32f810b
--- /dev/null
+++ b/docs/plans/2026-10-07-switching-company-and-person.md
@@ -0,0 +1,106 @@
+# Switching a card between company and person
+
+2026-10-07. The contact editor has a Company box, Contacts.app's own
+checkbox: ticked, the card is a company and its name is the one
+Organization field; unticked, a person and the First/Middle/Last pair.
+Switching moves the name to where macOS keeps it for that kind, which
+reverses one policy of `2026-09-24-company-cards.md`: a company's save
+now writes `ORG`.
+
+## What macOS does
+
+Recorded, not remembered: the fixture company card switched in
+Contacts.app against `rake dev`, two PUTs in `log/dev.log`.
+
+- **Company to person** (exchange `b5ce7eaebe0e`). `X-ABShowAs` is
+  dropped — a person is a card without the line, not one saying
+  `PERSON` — `ORG` stays as it was, and `N` and `FN` carry the names.
+  Unticking alone sent nothing: Contacts would not save a person
+  without a name until one was typed.
+- **Person to company** (exchange `ec5ce1d8dd03`, the names cleared by
+  hand in the same edit). `X-ABShowAs:COMPANY` comes back, `FN` is
+  `ORG`'s name, and `ORG` is unchanged.
+
+So macOS keeps a company's name in `ORG`, with `FN` following it. The
+editor kept it in `N`'s family slot and left `ORG` alone, which that
+plan already named as drift ("a renamed company can drift from the
+`ORG` it arrived with"); the recording shows it as a divergence from
+the client, so it goes.
+
+## The save
+
+`CardForm.contact_card` asks `CardForm.company?` which kind the save
+writes, and writes the name that kind keeps:
+
+- **A company** (`company_name`): the Organization field into `ORG`'s
+  first component, the units after it rejoined byte for byte as
+  `n_line` rejoins N's; the same name into `FN`; and
+  `X-ABShowAs:COMPANY`. `N` is emptied, `N:;;;;` as macOS wrote it: a
+  company has no person's name, and one kept in `N` would be a name no
+  screen shows, the editor hiding the pair for a company. A person
+  ticked a company leaves their names behind, prefixes and suffixes
+  too, and a switch back types them afresh, which Contacts.app asked
+  for as well. Each line is rewritten only
+  where it says something else, so an untouched save of the card macOS
+  wrote keeps every byte. A company whose `FN` disagrees with its
+  `ORG` (an imported "Bletchley Park" beside "Bletchley Park Trust")
+  is the exception: the field shows `ORG`'s name, and the save brings
+  `FN` into line with it, as macOS's own PUT did.
+- **A person** (`person_name`): `N` and `FN` as before, and
+  `X-ABShowAs` removed. `FN` is rewritten whenever the card was a
+  company, since the `FN` it had was the organization's name. `ORG` is
+  the person's employer, written from the same Organization field
+  (`edited_organization`), whose blank removes it — the whole-property
+  rule, department and all, those units having no row of their own. A
+  POST without the field leaves `ORG` alone, as one without the
+  birthday group leaves the birthday.
+
+`CardForm.nameless?` is the refusal both saves share, kind by kind:
+the Organization field for a company, either half of the pair for a
+person. "A contact needs a name." stands for both, as macOS's own
+refusal to save a nameless person did.
+
+The box submits `1` ticked, and a hidden `0` before it otherwise. A
+POST with neither keeps the card's kind, so the walk's and any other
+form's save cannot switch one by leaving the field out.
+
+## The form
+
+The rows read First, Middle, Last, Organization, then the Company box.
+Organization is one row for both kinds, since both keep it in `ORG`:
+required while the box is ticked, being the company's whole name, and
+otherwise an optional row like the nickname, wearing the editor's
+"removed on save" state over an `ORG` the card carries. That state is
+bound to the box, so a company's blank, which cannot save, never
+claims a removal. A company shows only that row and the box; the
+pair is rendered hidden and disabled (`x-show`, `:disabled`), so it
+neither submits nor holds the form back with a `required`, and a
+switch back mid-edit finds it as it was. The server's render sets all
+of it from the card, so the page is right before Alpine runs. The
+field's wire name is `organization`: it was `last` because the save
+wrote a company's name into N's family slot, which it no longer does.
+
+The field prefills from `ORG`'s first component. A company card
+without `ORG` is the older shape, which kept the name in `N` and `FN`,
+so it reads `FN`; a person without one gets an empty row, their own
+name being no organization's.
+
+## The details page
+
+`ORG` had no screen ("no screen renders `ORG`", the company-cards
+plan), which an editable row cannot leave it with. A person's
+organization reads under the name and nickname in the header, where
+Contacts.app puts it, in body type rather than the nickname's heading
+type because it is not a name of theirs. A company's is not repeated:
+it is the name above.
+
+The box is a Gloss Checkbox, not a Switch: Gloss reserves Switch for
+settings, and this is a fact about the record. Its caption is a
+`label for` beside it rather than one wrapping it, because Gloss sets
+a wrapping label in the checkbox's own type, over the editor's
+caption type.
+
+It sits on the edit page and not the create dialog, which this does
+not reopen: the dialog's reasons stand (`2026-09-24-company-cards.md`,
+"Creating"), and the page has none of them — no popover to resize, and
+a full form to swap rows in.
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 2d82f5e..5a4ff89 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -33,7 +33,35 @@ module ProTacts
         )
       end
 
-      # The contact editor's save, spliced into the stored card.
+      # Whether the save writes a company card: the editor's Company
+      # box, which submits "1" checked and the hidden "0" before it
+      # otherwise. A POST carrying neither keeps what the card is
+      # (Contact#company?), so no other form's save can switch one.
+      #: (Contact contact, Hash[String, untyped] params) -> bool
+      def self.company?(contact, params)
+        case params["company"]
+        when "1" then true
+        when "0" then false
+        else contact.company?
+        end
+      end
+
+      # Whether the save is refused for want of a name, each kind
+      # asking its own: a company its organization field, a person
+      # either half of the pair (Admin::NamePair). The server's half
+      # of the browser's `required`.
+      #: (Contact contact, Hash[String, untyped] params) -> bool
+      def self.nameless?(contact, params)
+        if company?(contact, params)
+          params["organization"].to_s.strip.empty?
+        else
+          params["first"].to_s.strip.empty? && params["last"].to_s.strip.empty?
+        end
+      end
+
+      # The contact editor's save, spliced into the stored card. Where
+      # each kind keeps its name, and the switch between the two, is
+      # docs/plans/2026-10-07-switching-company-and-person.md.
       #: (Contact contact, String first, String middle, String last, Hash[String, untyped] params) -> VCard
       def self.contact_card(contact, first, middle, last, params)
         nickname = params["nickname"].to_s.strip
@@ -47,8 +75,12 @@ module ProTacts
         own = contact.own
 
         vcard = contact.stored
-        vcard = vcard.replace("N", [n_line(vcard, first, middle, last)])
-        vcard = edited_fn(contact, vcard, first, middle, last)
+        vcard =
+          if company?(contact, params)
+            company_name(vcard, params["organization"].to_s.strip)
+          else
+            person_name(contact, vcard, first, middle, last, params["organization"]&.to_s&.strip)
+          end
         vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
         vcard = vcard.replace("NOTE", text_lines("NOTE", note))
         vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
@@ -236,6 +268,65 @@ module ProTacts
         ["#{name}:#{VCard.escape(value)}\r\n"]
       end
 
+      # A company's name, written where macOS writes one (exchange
+      # ec5ce1d8dd03 in log/dev.log, 2026-10-07): ORG's first component
+      # and FN both, under X-ABShowAs:COMPANY, with N empty. A company
+      # has no person's name, and one left in N would be a name no
+      # screen here shows (the editor hides the pair for a company), so
+      # a person made a company leaves theirs behind, prefixes and
+      # suffixes too, and a switch back is typed afresh, as Contacts.app
+      # asked for it. Each line is rewritten only where it says
+      # something else, so an untouched save of the card macOS wrote
+      # keeps its bytes.
+      #: (VCard vcard, String organization) -> VCard
+      def self.company_name(vcard, organization)
+        vcard = vcard.replace("N", ["N:;;;;\r\n"]) unless vcard.property("N")&.components&.all?(&:empty?)
+        vcard = vcard.replace("FN", ["FN:#{VCard.escape(organization)}\r\n"]) unless vcard.property("FN")&.text == organization
+        vcard = edited_organization(vcard, organization)
+        return vcard if vcard.property("X-ABShowAs")&.text == "COMPANY"
+
+        vcard.replace("X-ABShowAs", ["X-ABShowAs:COMPANY\r\n"])
+      end
+
+      # A person's name, into N and FN, and the flag gone: a person is
+      # a card without X-ABShowAs, which is how macOS unticks Company
+      # (exchange b5ce7eaebe0e). ORG is the person's employer, written
+      # from the same field as a company's name; a POST without that
+      # field leaves it be, the birthday group's posture.
+      #: (Contact contact, VCard vcard, String first, String middle, String last, String? organization) -> VCard
+      def self.person_name(contact, vcard, first, middle, last, organization)
+        vcard = vcard.replace("N", [n_line(vcard, first, middle, last)])
+        vcard = edited_fn(contact, vcard, first, middle, last)
+        vcard = edited_organization(vcard, organization) if organization
+        vcard.replace("X-ABShowAs", [])
+      end
+
+      # An organization's own name: ORG's first component (RFC 2426
+      # section 3.5.5), the units after it being no part of it. Public
+      # for the editor's prefill (Admin::ContactsEdit), which reads the
+      # name this save writes.
+      #: (VCard vcard) -> String?
+      def self.organization_of(vcard)
+        vcard.property("ORG")&.components&.first
+      end
+
+      # ORG with the organization field's name in it, or the card
+      # untouched where the name already is that. The name is the first
+      # component, the units after it rejoined byte for byte — n_line's
+      # splice, for n_line's reason. Blank removes the property, the
+      # whole-property rule, units and all: they are no row of their own
+      # here, and a department of no organization is not one to keep.
+      #: (VCard vcard, String organization) -> VCard
+      def self.edited_organization(vcard, organization)
+        return vcard if organization_of(vcard).to_s == organization
+        return vcard.replace("ORG", []) if organization.empty?
+
+        property = vcard.property("ORG")
+        units = [] #: Array[String]
+        units = VCard.split_raw_components(property.value).drop(1) if property
+        vcard.replace("ORG", ["ORG:#{[VCard.escape(organization), *units].join(";")}\r\n"])
+      end
+
       # FN's replacement, or the card untouched: the display name is the
       # card's own text (RFC 2426 section 3.1.1), not a rendering of N,
       # and "Dr. Ada B. Lovelace, Jr." holds parts no field on this form
@@ -250,7 +341,9 @@ module ProTacts
       def self.edited_fn(contact, vcard, first, middle, last)
         family, given, additional = contact.name_components || []
         named = !vcard.property("FN").nil?
-        return vcard if named && first == given.to_s && middle == additional.to_s && last == family.to_s
+        # A company's FN is its organization's name (#company_name), so
+        # a card leaving that kind takes its display name from the pair.
+        return vcard if named && !contact.company? && first == given.to_s && middle == additional.to_s && last == family.to_s
 
         vcard.replace("FN", ["FN:#{VCard.escape(display_name(first, middle, last))}\r\n"])
       end
@@ -296,7 +389,7 @@ module ProTacts
 
       private_class_method :edited_values, :edited_addresses, :address_unchanged?,
                            :address_line, :birthday_component, :text_lines, :edited_fn, :display_name,
-                           :n_line
+                           :n_line, :company_name, :person_name, :edited_organization
     end
   end
 end
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index ec32def..5bdc672 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -1,6 +1,7 @@
 require "pro_tacts/admin/phlex"
 
 require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/card_form"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/group_dialog"
 require "pro_tacts/admin/group_label"
@@ -63,6 +64,13 @@ module ProTacts
             if @contact.nickname
               div(class: "type-h3 gl-muted", style: "margin-top: var(--gl-space-2xs);") { @contact.nickname }
             end
+            # A person's organization, where Contacts.app shows it:
+            # under the name, in body type because it is no name of
+            # theirs. A company's is the name above it already.
+            organization = CardForm.organization_of(@contact.vcard).to_s
+            if !@contact.company? && !organization.empty?
+              div(class: "type-body gl-muted", style: "margin-top: var(--gl-space-2xs);") { organization }
+            end
           end
           # Only a picture earns this slot: an initials circle
           # beside the name in type-h2 would repeat what the name
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 236c282..1220949 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -80,6 +80,7 @@ module ProTacts
       # @rbs @first: String?
       # @rbs @middle: String?
       # @rbs @last: String?
+      # @rbs @organization: String?
       # @rbs @action: String
       # @rbs @back: ([String, String])?
       # @rbs @aside: Phlex::HTML?
@@ -128,7 +129,8 @@ module ProTacts
         family, given, additional = contact.name_components || []
         @first = given
         @middle = additional
-        @last = family || organization_name
+        @last = family
+        @organization = organization_name
       end
 
       def view_template
@@ -142,8 +144,9 @@ module ProTacts
           # row for it, so the two have to share state, and Alpine
           # scopes by ancestry. `added` is the list of types added
           # this pass, `type` is the radio's binding, and the rest is
-          # the name boxes' (NamePair).
-          div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}', #{NamePair.state(@first, @last)} }") do
+          # the name boxes' (NamePair), `company` the Company box's.
+          div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}', company: #{@contact.company?}, " \
+                      "#{NamePair.state(@first, @last)} }") do
             render RecordCard.new(
               back: @back,
               nav: -> {
@@ -165,39 +168,64 @@ module ProTacts
                   # would still land in the type column as an
                   # anonymous grid item, but nothing could then reach
                   # it, and the note's caption needs reaching.
-                  # The name, as an organization spells it or a person:
-                  # one field for a company card (X-ABShowAs:COMPANY,
-                  # Contact#company?), the pair and the middle box for
-                  # everyone else. The organization field's wire name
-                  # is `last` because that is where the name lives —
-                  # N's family slot, the component the save splices
-                  # (CardForm.n_line) — and the label says what the
-                  # visitor knows it by. An unchecked company card
-                  # submits no first or middle, and the save reads
-                  # their absence as the empties they are.
-                  if @contact.company?
-                    label(class: "field") do
-                      span { "Organization" }
-                      input(type: "text", name: "last", value: @last, required: true, autofocus: true)
-                    end
-                  else
-                    label(class: "field") do
-                      span { "First" }
-                      input(**NamePair.first(@first, @last), autofocus: true)
-                    end
-                    # Outside the required pair, and outside its removal
-                    # state with it: a blanked middle name rewrites N's
-                    # additional component like any other name box, and
-                    # the row it would strike through is the name row,
-                    # which no save can remove.
-                    label(class: "field") do
-                      span { "Middle" }
-                      input(type: "text", name: "middle", value: @middle)
-                    end
-                    label(class: "field") do
-                      span { "Last" }
-                      input(**NamePair.last(@first, @last))
-                    end
+                  # The name, as a person spells it: the pair and the
+                  # middle box, hidden for a company, whose name is the
+                  # organization row below. Disabled rather than only
+                  # hidden, so they neither submit nor stand in the way
+                  # of a `required`, and a switch back before the save
+                  # finds them as they were; a saved company has none
+                  # (CardForm.company_name). Shown and enabled from the
+                  # server's own render, so the page is right before
+                  # Alpine runs.
+                  company = @contact.company?
+                  label(class: "field", x_show: "!company", style: ("display: none" if company)) do
+                    span { "First" }
+                    input(**NamePair.first(@first, @last), disabled: company, ":disabled": "company",
+                                                           autofocus: !company)
+                  end
+                  # Outside the required pair, and outside its removal
+                  # state with it: a blanked middle name rewrites N's
+                  # additional component like any other name box, and
+                  # the row it would strike through is the name row,
+                  # which no save can remove.
+                  label(class: "field", x_show: "!company", style: ("display: none" if company)) do
+                    span { "Middle" }
+                    input(type: "text", name: "middle", value: @middle, disabled: company, ":disabled": "company")
+                  end
+                  label(class: "field", x_show: "!company", style: ("display: none" if company)) do
+                    span { "Last" }
+                    input(**NamePair.last(@first, @last), disabled: company, ":disabled": "company")
+                  end
+                  # ORG, one row for both kinds: a company's name, and
+                  # so required while the box is ticked, or a person's
+                  # employer, and so a whole-property row like the
+                  # nickname, whose blank removes it. The removal state
+                  # is bound to the box so a company's blank, which
+                  # cannot save, never claims a removal.
+                  org = !@contact.stored.property("ORG").nil?
+                  label(class: "field", data: {blank_removes: org && !company},
+                                        **(org ? {":data-blank-removes": "!company"} : {})) do
+                    span { "Organization" }
+                    input(type: "text", name: "organization", value: @organization,
+                          required: company, ":required": "company", autofocus: company,
+                          placeholder: org && !company ? "removed on save" : nil,
+                          **(org ? {":placeholder": "company ? '' : 'removed on save'"} : {}))
+                  end
+                  # Company or person, Contacts.app's own checkbox
+                  # (docs/plans/2026-10-07-switching-company-and-
+                  # person.md), under the name it decides the shape of.
+                  # The hidden "0" goes first so an unticked box still
+                  # says so — Rack keeps the last of a repeated name —
+                  # and CardForm.company? reads a POST with neither as
+                  # the card's kind unchanged. The caption is a label
+                  # beside the box rather than one wrapping it: Gloss
+                  # sets a wrapping label in its checkbox type, over
+                  # the caption's.
+                  div(class: "field") do
+                    label(for: "company") { "Company" }
+                    input(type: "hidden", name: "company", value: "0")
+                    input(type: "checkbox", id: "company", name: "company", value: "1",
+                          checked: company, x_model: "company")
                   end
                   # The whole-property rows wear the removal state only
                   # over a property the card carries: both render empty
@@ -248,18 +276,16 @@ module ProTacts
 
       private
 
-      # A company card's name, from wherever the card holds it. macOS
-      # writes N empty and the name in FN and ORG both (the PUT a real
-      # client sent, log/dev.log 2026-09-24), so the organization
-      # field cannot prefill from the family slot alone; the older
-      # family-slot shape is already read by then, and ORG's own name
-      # is its first component — the semicolons after it are units.
-      # nil for a person, whose family slot is the field.
+      # The organization field's value: ORG's own name, where the save
+      # writes it (CardForm.edited_organization). A company card without
+      # one is the older shape, which kept a company's name in N and
+      # FN, so it reads FN; a person without one has no employer.
       #: () -> String?
       def organization_name
-        return nil unless @contact.company?
+        own = CardForm.organization_of(@contact.stored).to_s
+        return own unless own.empty?
 
-        @contact.name || @contact.organization&.split(";", 2)&.first
+        @contact.name if @contact.company?
       end
 
       # The rows added this pass, rendered by Alpine from `added` —
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 359119a..c6f53f4 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -158,11 +158,12 @@ module ProTacts
       # N and FN are mandatory (RFC 2426 section 4), so a save blank
       # throughout is refused — the toast is the backstop, the form's
       # name pair (Admin::NamePair) being the browser's own refusal of
-      # the same.
+      # the same, and a company's organization field the one name it
+      # has (Admin::CardForm.nameless?).
       first = r.params["first"].to_s.strip
       middle = r.params["middle"].to_s.strip
       last = r.params["last"].to_s.strip
-      return refuse.call(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
+      return refuse.call(contact, notice: "A contact needs a name.") if Admin::CardForm.nameless?(contact, r.params)
 
       # Request validity, standing with the name check rather than the
       # stored-state conditionals below (write_card's ordering rule). A
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index b741266..15bdbc3 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -428,11 +428,12 @@ module ProTacts
       # N and FN are mandatory (RFC 2426 section 4), so a save blank
       # throughout is refused — the toast is the backstop, the form's
       # name pair (Admin::NamePair) being the browser's own refusal of
-      # the same.
+      # the same, and a company's organization field the one name it
+      # has (Admin::CardForm.nameless?).
       first = r.params["first"].to_s.strip
       middle = r.params["middle"].to_s.strip
       last = r.params["last"].to_s.strip
-      if first.empty? && last.empty?
+      if Admin::CardForm.nameless?(contact, r.params)
         return card_screen(upload, index, into:, notice: "A contact needs a name.", joined: ticked, named: wanted)
       end
 
diff --git a/public/admin.css b/public/admin.css
index 30a518e..f8edb76 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -183,6 +183,12 @@
   column-gap: var(--gl-space-s);
   align-items: baseline;
 }
+/* The Company row's box has no text to share a baseline with, so the
+   baseline rule above floats it over its caption; centred, the two
+   read as one row like every other. */
+.card-body form.field-stack > .field:has(> input[type="checkbox"]) {
+  align-items: center;
+}
 /* A row whose control is a stack of inputs — an address's component
    fields — holds the multi-line value's own lines in the value
    column (docs/DESIGN.md), so the stack closes to the record's own
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 77e0007..f0f7261 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1350,10 +1350,10 @@ class AdminContactsPagesTest < Minitest::Test
       assert_includes body, '<form action="/contacts/red" method="post" class="field-stack" id="contact-form">'
       assert_includes body, '<button type="submit" form="contact-form" data-variant="primary">Save</button>'
       assert_includes body, '<input type="text" name="first" value="Ada" ' \
-                            ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
-      assert_includes body, '<span>Middle</span><input type="text" name="middle">'
+                            ':required="lastBlank" @input="firstBlank = !$el.value.trim()" :disabled="company" autofocus>'
+      assert_includes body, '<span>Middle</span><input type="text" name="middle" :disabled="company">'
       assert_includes body, '<input type="text" name="last" value="Lovelace" ' \
-                            ':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
+                            ':required="firstBlank" @input="lastBlank = !$el.value.trim()" :disabled="company">'
       assert_includes body,
         '<label class="field" data-blank-removes><span>Nickname</span>' \
         '<input type="text" name="nickname" value="Red" placeholder="removed on save"></label>'
@@ -1373,7 +1373,7 @@ class AdminContactsPagesTest < Minitest::Test
       get "/contacts/ada/edit"
 
       assert_equal 200, last_response.status
-      assert_includes last_response.body, '<input type="text" name="middle" value="Byron, the poet">'
+      assert_includes last_response.body, '<input type="text" name="middle" value="Byron, the poet" :disabled="company">'
     end
   end
 
@@ -1388,9 +1388,9 @@ class AdminContactsPagesTest < Minitest::Test
       body = last_response.body
       assert_includes body, "firstBlank: true, lastBlank: false"
       assert_includes body, '<input type="text" name="first" ' \
-                            ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
+                            ':required="lastBlank" @input="firstBlank = !$el.value.trim()" :disabled="company" autofocus>'
       assert_includes body, '<input type="text" name="last" value="Prince" required ' \
-                            ':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
+                            ':required="firstBlank" @input="lastBlank = !$el.value.trim()" :disabled="company">'
 
       post "/contacts/prince", first: "", last: "Prince", etag: store.contact("prince").etag
 
@@ -1399,71 +1399,195 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  ACME = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Acme Corp;;;;\r\nFN:Acme Corp\r\nORG:Acme Corp\r\n" \
+  ACME = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Acme Corp;;;;\r\nFN:Acme Corp\r\nORG:Acme Corp;Sales\r\n" \
          "X-ABShowAs:COMPANY\r\nUID:acme\r\nEND:VCARD\r\n"
 
-  # A company card's name is one field — the organization's own — in
-  # place of the pair, keyed off X-ABShowAs:COMPANY; the wire name is
-  # `last`, the family slot the save splices.
+  # The two cards macOS PUT as a card was switched in Contacts.app
+  # (log/dev.log, 2026-10-07): a person with an employer (exchange
+  # b5ce7eaebe0e) and that card made a company again (ec5ce1d8dd03).
+  # Their X-ADDRESSING-GRAMMAR blob is left out, being Apple's opaque
+  # bytes that no read here touches.
+  SWITCHED_PERSON = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 27.0//EN\r\nN:B;A;;;\r\n" \
+                    "FN:A B\r\nORG:Arbitrary Definitions;\r\nREV:2026-10-07T13:34:15Z\r\nUID:company\r\n" \
+                    "END:VCARD\r\n"
+  SWITCHED_COMPANY = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//macOS 27.0//EN\r\nN:;;;;\r\n" \
+                     "FN:Arbitrary Definitions\r\nORG:Arbitrary Definitions;\r\nX-ABShowAs:COMPANY\r\n" \
+                     "REV:2026-10-07T13:34:40Z\r\nUID:company\r\nEND:VCARD\r\n"
+
+  # A company card's name is the organization row, required and
+  # focused; the pair is rendered hidden and disabled, so the Company
+  # box can switch to it and it neither submits nor blocks a save.
   def test_the_edit_screen_shows_one_organization_field_for_a_company_card
     with_contacts({"acme" => ACME}) do
       get "/contacts/acme/edit"
 
       body = last_response.body
       assert_equal 200, last_response.status
-      assert_includes body, '<span>Organization</span>' \
-                            '<input type="text" name="last" value="Acme Corp" required autofocus>'
-      refute_includes body, '<span>First</span>'
-      refute_includes body, '<span>Last</span>'
+      assert_includes body, '<div class="field"><label for="company">Company</label>' \
+                            '<input type="hidden" name="company" value="0">' \
+                            '<input type="checkbox" id="company" name="company" value="1" checked x-model="company">'
+      assert_includes body, %(<label class="field" :data-blank-removes="!company"><span>Organization</span>) +
+                            %(<input type="text" name="organization" value="Acme Corp" required :required="company" ) +
+                            %(autofocus :placeholder="company ? '' : 'removed on save'">)
+      assert_includes body, '<label class="field" x-show="!company" style="display: none"><span>First</span>'
+      assert_includes body, '<input type="text" name="last" value="Acme Corp" required ' \
+                            ':required="firstBlank" @input="lastBlank = !$el.value.trim()" disabled :disabled="company">'
+      # The organization, then the box, under the name fields.
+      assert_operator body.index('name="organization"'), :>, body.index('name="last"')
+      assert_operator body.index('id="company"'), :>, body.index('name="organization"')
+    end
+  end
+
+  # A person's organization is their employer: shown, optional, and a
+  # whole-property row whose blank removes it, like the nickname's.
+  def test_the_edit_screen_shows_a_persons_organization
+    with_contacts({"company" => SWITCHED_PERSON}) do
+      get "/contacts/company/edit"
+
+      body = last_response.body
+      assert_includes body, '<input type="checkbox" id="company" name="company" value="1" x-model="company">'
+      assert_includes body, %(<label class="field" data-blank-removes :data-blank-removes="!company">) +
+                            %(<span>Organization</span><input type="text" name="organization" ) +
+                            %(value="Arbitrary Definitions" :required="company" placeholder="removed on save" ) +
+                            %(:placeholder="company ? '' : 'removed on save'">)
+    end
+  end
+
+  # A person without one has an empty row and nothing to lose, and
+  # their own name is no organization's to prefill.
+  def test_a_person_without_an_organization_gets_an_empty_row
+    with_contacts({"ada" => ADA}) do
+      get "/contacts/ada/edit"
+
+      assert_includes last_response.body,
+        '<label class="field"><span>Organization</span><input type="text" name="organization" :required="company">'
+    end
+  end
+
+  # The person's save writes the employer into ORG's first component,
+  # the units after it kept; blank removes the property; a POST
+  # without the field leaves it be.
+  def test_saving_a_persons_organization
+    with_contacts({"company" => SWITCHED_PERSON.sub("ORG:Arbitrary Definitions;", "ORG:Arbitrary Definitions;Ops")}) do |store|
+      post "/contacts/company", first: "A", last: "B", organization: "Gloss", etag: store.contact("company").etag
+      assert_includes store.contact("company").vcard.to_s, "ORG:Gloss;Ops\r\n"
+
+      post "/contacts/company", first: "A", last: "B", etag: store.contact("company").etag
+      assert_includes store.contact("company").vcard.to_s, "ORG:Gloss;Ops\r\n"
+
+      post "/contacts/company", first: "A", last: "B", organization: "", etag: store.contact("company").etag
+      refute_includes store.contact("company").vcard.to_s, "ORG"
+      refute store.contact("company").company?
+    end
+  end
+
+  # The details page shows a person's organization under their name,
+  # and a company's not twice.
+  def test_show_puts_a_persons_organization_under_the_name
+    with_contacts({"company" => SWITCHED_PERSON, "acme" => ACME}) do
+      get "/contacts/company"
+      header = last_response.body.split("<dl").first
+      assert_includes header, '<div class="type-body gl-muted" style="margin-top: var(--gl-space-2xs);">' \
+                              "Arbitrary Definitions</div>"
+
+      get "/contacts/acme"
+      refute_includes last_response.body.split("<dl").first, "type-body gl-muted"
     end
   end
 
-  # The save splices the organization's name into N's family slot and
-  # rewrites FN, and touches nothing else: ORG is a property no field
-  # on this form owns, so it keeps the bytes it arrived with.
-  def test_saving_a_company_cards_edit_moves_the_name_and_leaves_its_org_alone
+  # A company's name goes into ORG's first component and FN, where
+  # macOS keeps it; the units after it keep their bytes, and N is
+  # emptied, the older shape's name in it included.
+  def test_saving_a_company_writes_its_name_into_org_and_fn
     with_contacts({"acme" => ACME}) do |store|
-      post "/contacts/acme", last: "Acme Corporation", etag: store.contact("acme").etag
+      post "/contacts/acme", company: "1", organization: "Acme, Inc.", etag: store.contact("acme").etag
 
       assert_equal 303, last_response.status
       card = store.contact("acme").vcard.to_s
-      assert_includes card, "N:Acme Corporation;;;;\r\n"
-      assert_includes card, "FN:Acme Corporation\r\n"
-      assert_includes card, "ORG:Acme Corp\r\n"
+      assert_includes card, "N:;;;;\r\n"
+      assert_includes card, "FN:Acme\\, Inc.\r\n"
+      assert_includes card, "ORG:Acme\\, Inc.;Sales\r\n"
       assert_includes card, "X-ABShowAs:COMPANY\r\n"
     end
   end
 
-  # The card a real client sends: macOS writes a company's N empty
-  # and its name in FN and ORG both (the PUT of 2026-09-24 in
-  # log/dev.log), so the organization field prefills from FN — not
-  # the family slot the older shape carried it in — and an untouched
-  # save writes the name into N while ORG keeps its own bytes.
-  def test_a_company_card_whose_name_sits_in_fn_prefills_and_saves
-    client =
-      "BEGIN:VCARD\r\nVERSION:3.0\r\nN:;;;;\r\nFN:Arbitrary Definitions\r\n" \
-        "ORG:Arbitrary Definitions;\r\nX-ABShowAs:COMPANY\r\n" \
-        "UID:client-org\r\nEND:VCARD\r\n"
+  # An untouched save of the card macOS wrote keeps every byte.
+  def test_an_untouched_company_save_keeps_the_card
+    with_contacts({"company" => SWITCHED_COMPANY}) do |store|
+      get "/contacts/company/edit"
+      assert_includes last_response.body, 'name="organization" value="Arbitrary Definitions"'
 
-    with_contacts({"client-org" => client}) do |store|
-      get "/contacts/client-org/edit"
+      post "/contacts/company", company: "1", organization: "Arbitrary Definitions",
+                                etag: store.contact("company").etag
 
-      body = last_response.body
-      assert_equal 200, last_response.status
-      assert_includes body, '<span>Organization</span>' \
-                            '<input type="text" name="last" value="Arbitrary Definitions" required autofocus>'
+      assert_equal 303, last_response.status
+      assert_equal SWITCHED_COMPANY, store.contact("company").vcard.to_s
+    end
+  end
 
-      post "/contacts/client-org", last: "Arbitrary Definitions",
-                                    etag: store.contact("client-org").etag
+  # Ticking Company on a person: the flag goes on and FN takes the
+  # organization's name, and N is emptied, as in macOS's second PUT:
+  # the person's names would otherwise stay on a card that no longer
+  # shows them.
+  def test_ticking_company_makes_a_person_a_company
+    with_contacts({"company" => SWITCHED_PERSON}) do |store|
+      post "/contacts/company", company: "1", organization: "Arbitrary Definitions",
+                                etag: store.contact("company").etag
 
       assert_equal 303, last_response.status
-      card = store.contact("client-org").vcard.to_s
-      assert_includes card, "N:Arbitrary Definitions;;;;\r\n"
+      contact = store.contact("company")
+      assert contact.company?
+      card = contact.vcard.to_s
+      assert_includes card, "N:;;;;\r\n"
       assert_includes card, "FN:Arbitrary Definitions\r\n"
       assert_includes card, "ORG:Arbitrary Definitions;\r\n"
     end
   end
 
+  # Unticking it: the flag comes off and the pair writes N and FN, as
+  # in macOS's first PUT; ORG stays, the person's employer now.
+  def test_unticking_company_makes_a_company_a_person
+    with_contacts({"company" => SWITCHED_COMPANY}) do |store|
+      post "/contacts/company", company: "0", first: "A", last: "B", etag: store.contact("company").etag
+
+      assert_equal 303, last_response.status
+      contact = store.contact("company")
+      refute contact.company?
+      card = contact.vcard.to_s
+      assert_includes card, "N:B;A;;;\r\n"
+      assert_includes card, "FN:A B\r\n"
+      assert_includes card, "ORG:Arbitrary Definitions;\r\n"
+      refute_includes card, "X-ABShowAs"
+    end
+  end
+
+  # A company switched to a person whose N already holds names still
+  # gets its FN from them: the FN it had was the organization's.
+  def test_a_switch_back_to_a_person_rewrites_the_display_name
+    names_kept = SWITCHED_COMPANY.sub("N:;;;;", "N:B;A;;;")
+
+    with_contacts({"company" => names_kept}) do |store|
+      post "/contacts/company", company: "0", first: "A", last: "B", etag: store.contact("company").etag
+
+      assert_includes store.contact("company").vcard.to_s, "FN:A B\r\n"
+    end
+  end
+
+  # Neither kind saves without its name: macOS would not save the
+  # person it unticked until one was typed, and a company's one field
+  # is the whole of its name.
+  def test_a_switch_without_the_new_kinds_name_is_refused
+    with_contacts({"company" => SWITCHED_COMPANY, "person" => SWITCHED_PERSON.sub("UID:company", "UID:person")}) do |store|
+      post "/contacts/company", company: "0", first: "", last: "", etag: store.contact("company").etag
+      assert_includes last_response.body, "A contact needs a name."
+      assert store.contact("company").company?
+
+      post "/contacts/person", company: "1", organization: " ", etag: store.contact("person").etag
+      assert_includes last_response.body, "A contact needs a name."
+      refute store.contact("person").company?
+    end
+  end
+
   # The editor renders the contact's own rows and none a group lends:
   # a row here is a row the save may rewrite or remove, and neither is
   # this screen's to do to a value the household owns. The details
@@ -2341,7 +2465,7 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => ADA}) do
       body = (get("/contacts/ada/edit") && last_response.body)
 
-      assert_includes body, %(<div x-data="{ added: [], type: 'phone', firstBlank: false, lastBlank: false }">)
+      assert_includes body, %(<div x-data="{ added: [], type: 'phone', company: false, firstBlank: false, lastBlank: false }">)
       # One template serves every kind: the single-value kinds get an
       # input whose type and name bind to the kind, the address kind
       # the same six component fields a standing row gets — named by
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 3d19452..3a2f60f 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -280,7 +280,9 @@ class AdminImportPagesTest < Minitest::Test
 
   # The shape macOS writes a company in (test/fixtures/cards/
   # company.vcf): N empty, the name in FN and ORG, and the flag. It
-  # opens in the company editor and comes in as a company.
+  # opens in the company editor and comes in as a company, its name
+  # ORG's own and FN following it the way macOS writes FN
+  # (docs/plans/2026-10-07-switching-company-and-person.md).
   def test_a_company_card_comes_in_as_a_company
     company = <<~CARD.gsub("\n", "\r\n")
       BEGIN:VCARD
@@ -299,15 +301,16 @@ class AdminImportPagesTest < Minitest::Test
 
       refute_includes last_response.body, "not imported"
       assert_includes last_response.body, "<span>Organization</span>"
-      assert_includes last_response.body, %(name="last" value="Bletchley Park")
+      assert_includes last_response.body, %(name="organization" value="Bletchley Park Trust")
 
-      save(id, 0, first: "", last: "Bletchley Park")
+      save(id, 0, first: "", last: "", company: "1", organization: "Bletchley Park Trust")
 
       assert_equal 303, last_response.status
       park = store.contacts.fetch(0)
 
       assert_predicate park, :company?
       assert_equal "Bletchley Park Trust;", park.organization
+      assert_equal "Bletchley Park Trust", park.name
     end
   end