Make and edit company cards in the web UI
The card is written in Contacts.app's own shape — ORG, the name in
N's family slot, X-ABShowAs:COMPANY — so storage needed nothing:
cards are stored as sent and clients already show them as companies.

Assisted-by: GLM-5.3 via pi
change swvopuznuxtoxpsqttmrvwpzyvkspylo
commit 16539c3ae4ffbc8a469158f0fb646e297def84eb
author Alpha Chen <alpha@kejadlen.dev>
date
parent xvrmlzqt
diff --git a/docs/plans/2026-09-24-company-cards.md b/docs/plans/2026-09-24-company-cards.md
new file mode 100644
index 0000000..168d1c7
--- /dev/null
+++ b/docs/plans/2026-09-24-company-cards.md
@@ -0,0 +1,51 @@
+# Company cards
+
+2026-09-24. The web UI can make an organization card and knows one
+when it edits one (task `qtw`, "Support non-person contacts like
+companies"). Storage needed nothing: cards are stored as sent, so the
+org cards Contacts.app writes already carry everything through —
+`ORG`, the name in N's family slot, and Apple's `X-ABShowAs:COMPANY`.
+
+## What a company card is
+
+`X-ABShowAs:COMPANY` is the flag, read by `Contact#company?` — the
+phone labels' own precedent for trusting an X- property. It is
+display's fact alone, and a different property from `ORG`: a person
+carries `ORG` without the flag, and nothing binds the two.
+`Contact#organization` reads `ORG`'s whole text (RFC 2426 section
+3.5.5) — what a search reads, no screen having asked for the
+components apart yet.
+
+## Creating
+
+The dashboard's new-contact dialog gained a Company checkbox. Checked,
+it hides the name pair and asks for one Organization box — Alpine
+showing whichever side the toggle chose, each required only while
+shown, so a hidden box can never block the submit. The route branches
+on the submitted `company` param, and `CardForm.new_org_card` writes
+the shape Contacts.app emits: the name in N's family slot, `FN` and
+`ORG` of the same, the flag. An organization is made here, not by
+converting a person later — the editor never flips the flag.
+
+## Editing
+
+A company card's editor shows the one Organization field where the
+pair stands. Its wire name is `last`, because that is the truth of
+where the name lives — N's family slot, the component `CardForm.n_line`
+splices — and the existing save machinery needed nothing: the field
+submits as the family half, `edited_fn` rewrites `FN` when it moves,
+and the nameless backstop refuses a blank.
+
+One policy, stated plainly: a save does not touch `ORG`. The editor
+writes the lines its fields own, and `ORG` is not one of them — a
+renamed company can drift from the `ORG` it arrived with, invisible
+here (no screen renders `ORG`) and editable on any client. The
+create writes it because a created card has no prior octets to
+preserve.
+
+## Search
+
+`ORG` joined the fields the dashboard's `matches?` scans, beside the
+nickname: "acme" finds both Acme the card and the person who works
+there. An organization card's own name is its `FN`, which the name
+match already read.
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 2d82f5e..5459963 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -33,6 +33,25 @@ module ProTacts
         )
       end
 
+      # A created organization's card, the shape Contacts.app emits
+      # for one: the name in N's family slot, FN of the same, ORG
+      # carrying it again, and Apple's X-ABShowAs:COMPANY flag — the
+      # spelling a synced client needs to show it as a company
+      # (Contact#company?). One name rather than a pair: an
+      # organization is known by one name, the mononym's own rule.
+      #: (String id, String name) -> VCard
+      def self.new_org_card(id, name)
+        VCard.new(
+          "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
+            "N:#{VCard.escape(name)};;;;\r\n" \
+            "FN:#{VCard.escape(name)}\r\n" \
+            "ORG:#{VCard.escape(name)}\r\n" \
+            "X-ABShowAs:COMPANY\r\n" \
+            "UID:#{id}\r\n" \
+            "END:VCARD\r\n",
+        )
+      end
+
       # The contact editor's save, spliced into the stored card.
       #: (Contact contact, String first, String middle, String last, Hash[String, untyped] params) -> VCard
       def self.contact_card(contact, first, middle, last, params)
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index 47aac8b..dd10ae3 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -41,6 +41,14 @@ module ProTacts
     # instead (see Web's POST handler).
     # `autofocus` fires when the popover is shown (it is scoped to the
     # popover, so the header search's own page-load autofocus stands).
+    #
+    # The Company toggle makes an organization instead of a person
+    # (docs/plans/2026-09-24-company-cards.md): one organization box
+    # replaces the pair, each side required only while shown, and the
+    # route branches on the submitted `company` param. An organization
+    # is created here rather than converted from a person later — the
+    # editor renders a company card's one name field, and nothing
+    # flips the flag.
     class ContactDialog < Phlex::HTML
       # @rbs @query: String
 
@@ -53,18 +61,36 @@ module ProTacts
         dialog(id: "new-contact", popover: "auto") do
           header { "New contact" }
           form(id: "new-contact-form", action: "/contacts", method: "post",
-               x_data: "{ #{NamePair.state(nil, nil)} }") do
+               x_data: "{ company: false, #{NamePair.state(nil, nil)} }") do
+            # The company toggle: an organization is made here, not by
+            # converting a person later — checking it hides the pair
+            # and asks for the one name an organization is known by
+            # (CardForm.new_org_card). x-model both ways, so the state
+            # the fields bind to is the checkbox's own, and the
+            # submitted `company` param is the browser's serialization
+            # of a checked box.
             label(class: "field") do
+              plain "Company"
+              input(type: "checkbox", name: "company", value: "1", x_model: "company")
+            end
+            label(class: "field", x_show: "!company") do
               plain "First"
-              input(**NamePair.first(nil, nil), autofocus: true)
+              input(**NamePair.first(nil, nil, guard: "!company"), autofocus: true)
             end
-            label(class: "field") do
+            label(class: "field", x_show: "!company") do
               plain "Middle"
               input(type: "text", name: "middle")
             end
-            label(class: "field") do
+            label(class: "field", x_show: "!company") do
               plain "Last"
-              input(**NamePair.last(nil, nil))
+              input(**NamePair.last(nil, nil, guard: "!company"))
+            end
+            # Required only while shown, the pair's own rule turned
+            # around: a hidden box demanding a name would block every
+            # person's create.
+            label(class: "field", x_show: "company") do
+              plain "Organization"
+              input(type: "text", name: "organization", ":required": "company")
             end
             # The search the dialog opened over — opening a popover
             # loads no page, so this survives only the failed-create
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index e480d7c..552ffa2 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -165,22 +165,39 @@ 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.
-                  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))
+                  # 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
                   end
                   # The whole-property rows wear the removal state only
                   # over a property the card carries: both render empty
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index c709494..777ddf4 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -87,6 +87,7 @@ module ProTacts
         q = query.downcase
         return true if row.contact.name&.downcase&.include?(q)
         return true if row.contact.nickname&.downcase&.include?(q)
+        return true if row.contact.organization&.downcase&.include?(q)
         return true if row.contact.phones.any? { it.value.downcase.include?(q) }
         return true if row.contact.emails.any? { it.value.downcase.include?(q) }
         return true if @labels.fetch(row.contact.id, []).any? { it.downcase.include?(q) }
diff --git a/lib/pro_tacts/admin/name_pair.rb b/lib/pro_tacts/admin/name_pair.rb
index e2e0a57..7604cef 100644
--- a/lib/pro_tacts/admin/name_pair.rb
+++ b/lib/pro_tacts/admin/name_pair.rb
@@ -22,19 +22,24 @@ module ProTacts
         "firstBlank: #{blank?(first)}, lastBlank: #{blank?(last)}"
       end
 
-      #: (String? first, String? last) -> Hash[Symbol, untyped]
-      def self.first(first, last)
-        box("first", first, required: blank?(last), own: "firstBlank", other: "lastBlank")
+      # `guard` names an Alpine state the pair stands behind — the
+      # create dialog's company toggle, which hides the pair and must
+      # stop it demanding a name while hidden: a required box the
+      # visitor cannot see blocks the submit the toggle asked for.
+      #: (String? first, String? last, ?guard: String?) -> Hash[Symbol, untyped]
+      def self.first(first, last, guard: nil)
+        box("first", first, required: blank?(last), own: "firstBlank", other: "lastBlank", guard:)
       end
 
-      #: (String? first, String? last) -> Hash[Symbol, untyped]
-      def self.last(first, last)
-        box("last", last, required: blank?(first), own: "lastBlank", other: "firstBlank")
+      #: (String? first, String? last, ?guard: String?) -> Hash[Symbol, untyped]
+      def self.last(first, last, guard: nil)
+        box("last", last, required: blank?(first), own: "lastBlank", other: "firstBlank", guard:)
       end
 
-      #: (String name, String? value, required: bool, own: String, other: String) -> Hash[Symbol, untyped]
-      def self.box(name, value, required:, own:, other:)
-        {type: "text", name:, value:, required:, ":required": other, "@input": "#{own} = !$el.value.trim()"}
+      #: (String name, String? value, required: bool, own: String, other: String, ?guard: String?) -> Hash[Symbol, untyped]
+      def self.box(name, value, required:, own:, other:, guard: nil)
+        {type: "text", name:, value:, required:, ":required": [guard, other].compact.join(" && "),
+         "@input": "#{own} = !$el.value.trim()"}
       end
 
       # The server's own test of a blank half (Web's contacts routes).
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index a86c4b2..149a4ac 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -295,6 +295,26 @@ module ProTacts
       text_of(vcard.property("NICKNAME"))
     end
 
+    # ORG's value (RFC 2426 section 3.5.5), in text form — the whole
+    # value as the card spells it, org name and units together, which
+    # is what a search reads; no screen has asked for the components
+    # apart yet.
+    #: () -> String?
+    def organization
+      text_of(vcard.property("ORG"))
+    end
+
+    # Whether the card shows as a company rather than a person:
+    # Apple's X-ABShowAs:COMPANY, the spelling Contacts.app writes and
+    # reads — the phone labels' own precedent for trusting an X-
+    # property (docs/plans/2026-09-18-phone-labels.md). Display's fact
+    # alone, and a different property from #organization's: a person
+    # carries ORG without the flag, and neither is bound to the other.
+    #: () -> bool
+    def company?
+      vcard.property("X-ABShowAs")&.text == "COMPANY"
+    end
+
     #: () -> Array[Phone]
     def phones
       labels = labels_by_group
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 44e1e3f..b2b1e4f 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -33,25 +33,40 @@ module ProTacts
         # names. Stored through Store#put like any client write, so
         # the change log a sync token counts on lands with the card.
         # A nameless create is a dashboard re-render with a toast: the
-        # browser cannot produce one (the dialog requires one name box
-        # or the other, Admin::NamePair), so this is the backstop, and
+        # browser cannot produce one — the dialog requires one name box
+        # or the other, Admin::NamePair, or the one organization box
+        # the Company toggle swaps in — so this is the backstop, and
         # a popover cannot be declared open in markup — the toast is
         # the refusal the
         # re-rendered page can actually show.
         r.post do
-          first = r.params["first"].to_s.strip
-          middle = r.params["middle"].to_s.strip
-          last = r.params["last"].to_s.strip
-          # A middle name is not one of the halves that make a name: the
-          # form asks for it beside a pair it stands outside of
-          # (Admin::NamePair), and nobody is known by their middle name
-          # alone.
-          if first.empty? && last.empty?
-            dashboard(query: r.params["q"], notice: "A contact needs a name.")
+          # The Company toggle's create: one name, the card
+          # CardForm.new_org_card writes. The same nameless backstop,
+          # for the same reason.
+          if r.params["company"]
+            organization = r.params["organization"].to_s.strip
+            if organization.empty?
+              dashboard(query: r.params["q"], notice: "A contact needs a name.")
+            else
+              id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
+              store.put(id, Admin::CardForm.new_org_card(id, organization))
+              r.redirect "/contacts/#{id}", 303
+            end
           else
-            id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
-            store.put(id, Admin::CardForm.new_card(id, first, middle, last))
-            r.redirect "/contacts/#{id}", 303
+            first = r.params["first"].to_s.strip
+            middle = r.params["middle"].to_s.strip
+            last = r.params["last"].to_s.strip
+            # A middle name is not one of the halves that make a name: the
+            # form asks for it beside a pair it stands outside of
+            # (Admin::NamePair), and nobody is known by their middle name
+            # alone.
+            if first.empty? && last.empty?
+              dashboard(query: r.params["q"], notice: "A contact needs a name.")
+            else
+              id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
+              store.put(id, Admin::CardForm.new_card(id, first, middle, last))
+              r.redirect "/contacts/#{id}", 303
+            end
           end
         end
       end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index dc70988..254fa4e 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -404,6 +404,19 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # ORG is a name a person works under: it finds them the way a
+  # nickname does. An organization card's own name is its FN, which
+  # the name match already reads.
+  def test_search_matches_the_organization
+    employed = ADA.sub("UID:ada", "UID:employed").sub("END:VCARD", "ORG:Acme Corp\r\nEND:VCARD")
+
+    with_contacts({"employed" => employed}) do
+      get "/", q: "acme"
+
+      assert_includes last_response.body, "Ada Lovelace"
+    end
+  end
+
   def test_search_with_no_matches_says_so
     with_contacts({"ada" => ADA}) do
       get "/", q: "nobody"
@@ -1082,15 +1095,21 @@ class AdminContactsPagesTest < Minitest::Test
       assert_includes body, '<button data-size="sm" popovertarget="new-contact">add contact</button>'
       assert_includes body, '<dialog id="new-contact" popover="auto">'
       assert_includes body, '<form id="new-contact-form" action="/contacts" method="post" ' \
-                            'x-data="{ firstBlank: true, lastBlank: true }">'
+                            'x-data="{ company: false, firstBlank: true, lastBlank: true }">'
       # The captions render — a bare string mid-block is void in
       # Phlex, so the labels carry their text through plain (the
       # assertion keeps a regression from rendering a silent label).
-      assert_includes body, '<label class="field">First<input type="text" name="first" required ' \
-                            ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus></label>'
-      assert_includes body, '<label class="field">Middle<input type="text" name="middle"></label>'
-      assert_includes body, '<label class="field">Last<input type="text" name="last" required ' \
-                            ':required="firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
+      # The Company toggle swaps the pair for the one organization
+      # box, and every field is required only while shown.
+      assert_includes body, '<label class="field">Company' \
+                            '<input type="checkbox" name="company" value="1" x-model="company"></label>'
+      assert_includes body, '<label class="field" x-show="!company">First<input type="text" name="first" required ' \
+                            ':required="!company && lastBlank" @input="firstBlank = !$el.value.trim()" autofocus></label>'
+      assert_includes body, '<label class="field" x-show="!company">Middle<input type="text" name="middle"></label>'
+      assert_includes body, '<label class="field" x-show="!company">Last<input type="text" name="last" required ' \
+                            ':required="!company && firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
+      assert_includes body, '<label class="field" x-show="company">Organization' \
+                            '<input type="text" name="organization" :required="company"></label>'
       assert_includes body, 'popovertargetaction="hide"'
     end
   end
@@ -1120,6 +1139,35 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The Company toggle's create: the card in Contacts.app's own shape
+  # for an organization, so a synced client shows it as one — the
+  # name in N's family slot, FN and ORG of the same, and
+  # X-ABShowAs:COMPANY.
+  def test_creating_an_organization_from_the_dialog
+    with_contacts({}) do |store|
+      post "/contacts", company: "1", organization: "Acme Corp"
+
+      assert_equal 303, last_response.status
+      id = last_response["Location"].delete_prefix("/contacts/")
+
+      card = store.contact(id).vcard.to_s
+      assert_includes card, "N:Acme Corp;;;;\r\n"
+      assert_includes card, "FN:Acme Corp\r\n"
+      assert_includes card, "ORG:Acme Corp\r\n"
+      assert_includes card, "X-ABShowAs:COMPANY\r\n"
+      assert_includes card, "UID:#{id}"
+    end
+  end
+
+  def test_a_nameless_organization_create_is_refused
+    with_contacts({}) do
+      post "/contacts", company: "1", organization: " "
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, '<div role="status" data-fixed><span>A contact needs a name.</span></div>'
+    end
+  end
+
   # The middle box writes N's additional component (RFC 2426 section
   # 3.1.2) and joins the display name the other two make.
   def test_creating_a_contact_with_a_middle_name
@@ -1254,6 +1302,41 @@ 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" \
+         "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.
+  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>'
+    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
+    with_contacts({"acme" => ACME}) do |store|
+      post "/contacts/acme", last: "Acme Corporation", 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, "X-ABShowAs:COMPANY\r\n"
+    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
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 601e592..0462c88 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -116,6 +116,25 @@ class ContactTest < Minitest::Test
     assert_nil contact(CARD).nickname
   end
 
+  def test_reads_the_organization
+    employed = CARD.sub("FN:Aiden\r\n", "FN:Aiden\r\nORG:Calculus Close Co\r\n")
+
+    assert_equal "Calculus Close Co", contact(employed).organization
+    assert_nil contact(CARD).organization
+  end
+
+  # X-ABShowAs:COMPANY is Apple's flag for a card shown as a company
+  # rather than a person, and it is display's fact alone: a person
+  # carries ORG without it.
+  def test_reads_whether_the_card_shows_as_a_company
+    company = CARD.sub("FN:Aiden\r\n", "FN:Aiden\r\nX-ABShowAs:COMPANY\r\n")
+    employed = CARD.sub("FN:Aiden\r\n", "FN:Aiden\r\nORG:Calculus Close Co\r\n")
+
+    assert contact(company).company?
+    refute contact(employed).company?
+    refute contact(CARD).company?
+  end
+
   def test_values_come_back_unescaped
     escaped = STRUCTURED.sub("FN:Ada Lovelace", "FN:Ada\\, Countess of Lovelace")