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
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")