Let organizations arrive by client and import, not the web UI
Every web-side create affordance cost the person dialog more than the
rare case was worth, and the client path is the real one: the recorded
macOS 27 PUT writes a finished company card in two steps. The real wire
shape leaves N empty with the name in FN and ORG both, so the editor
now prefills its Organization field from whichever holds it, and
test/fixtures/cards/company.vcf seeds those bytes.
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
index e8617a5..5c6973b 100644
--- a/docs/plans/2026-09-24-company-cards.md
+++ b/docs/plans/2026-09-24-company-cards.md
@@ -16,37 +16,50 @@ carries `ORG` without the flag, and nothing binds the two.
3.5.5) — what a search reads, no screen having asked for the
components apart yet.
+The wire shape is recorded, not remembered: the PUT a real client
+sent (log/dev.log, 2026-09-24; macOS 27.0) writes a company card
+with `N` empty — `N:;;;;` — the name in `FN` and `ORG` both, and the
+flag. `test/fixtures/cards/company.vcf` carries those bytes. The
+older shape this task's description recalled, the name in N's family
+slot, is handled too — the editor reads whichever holds it — but no
+client here has been seen sending it.
+
## Creating
-The dashboard's new-contact dialog carries a quiet "New company"
-link at the foot of its actions, opening a second popover of its own:
-one Organization field, the same Create. The first cut was a Company
-checkbox swapping the name fields inside the one dialog, and it was
-wrong twice — a mode switch inside a three-field form, and a popover
-resizing around its own content every time it flipped. The second
-dialog is the honest shape: the person's dialog is exactly what it
-was, and the swap is whole popovers (`popover: "auto"` closes the
-first when the second opens), not fields inside one. The org form
-posts `company` with the one name, 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.
+There is no web-side create for an organization, and not for want
+of trying: a Company checkbox inside the dialog rendered wrong
+(a checkbox is no Gloss Field control), rendered right it made the
+popover lurch (a popover sizes to its content, and the toggle
+swapped three fields for one), and a second popover of its own
+doubled the dialog for a rare case. Each shape cost the common
+path more than the rare one was worth. What made the cut easy is
+that the other paths are the real ones: a client's own Company
+flow PUTs a finished card — proven on the wire, including the
+two-step create where the first PUT is an empty shell and the name
+lands with the second — and import is the bulk path, once `KNOWN`
+lets `ORG` and the flag through.
## 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.
+pair stands. Its wire name is `last`, because that is where the save
+writes the name — N's family slot, the component `CardForm.n_line`
+splices — and the existing save machinery needed nothing beyond the
+prefill: the field submits as the family half, `edited_fn` rewrites
+`FN` when it moves, and the nameless backstop refuses a blank.
+
+The prefill reads the name from wherever the card holds it — the
+family slot, then `FN`, then `ORG`'s own name — because the real
+wire shape (above) leaves `N` empty, and a field prefilled from the
+family slot alone would have opened blank and refused its own save.
+An untouched save on such a card writes the name into `N` as its
+side effect, which is the editor's contract kept: the field owns
+the name, and the name now lives where the field writes it.
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.
+here (no screen renders `ORG`) and editable on any client.
## Search
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 5459963..2d82f5e 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -33,25 +33,6 @@ 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 ab1ee7e..350640b 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -42,13 +42,12 @@ module ProTacts
# `autofocus` fires when the popover is shown (it is scoped to the
# popover, so the header search's own page-load autofocus stands).
#
- # An organization is made by its own popover beside this one
- # (docs/plans/2026-09-24-company-cards.md), opened from the
- # footer's link — not by a toggle swapping the name fields, which
- # made the popover lurch around its own content-sized box. Created
- # here rather than converted from a person later: the editor
- # renders a company card's one name field, and nothing flips the
- # flag.
+ # No company variant of this dialog: organizations are created by
+ # the clients (Contacts.app's own Company flow PUTs a finished
+ # card) and by import, and each web-side affordance tried for one
+ # cost the common path chrome it could not pay for
+ # (docs/plans/2026-09-24-company-cards.md, "Creating"). The editor
+ # knows a company card when it edits one (Contact#company?).
class ContactDialog < Phlex::HTML
# @rbs @query: String
@@ -80,46 +79,12 @@ module ProTacts
input(type: "hidden", name: "q", value: @query)
end
footer do
- # The company create's way in, on the footer's left (Gloss
- # right-justifies a dialog's actions; this is the one
- # thing on the other side). Opening the second popover
- # closes this one, `popover: "auto"` being the many-open-
- # at-once refusal, so the swap is whole dialogs rather
- # than fields inside one — a popover sizes to its content,
- # and fields swapping inside it made the box lurch.
- button(type: "button", style: "margin-right: auto",
- popovertarget: "new-company") { "New company" }
button(type: "button", popovertarget: "new-contact",
popovertargetaction: "hide") { "Cancel" }
button(type: "submit", form: "new-contact-form",
data: {variant: "primary"}) { "Create" }
end
end
-
- # The company create's own popover, the person dialog's footer
- # link the way in. One field — an organization is known by one
- # name, the mononym's own rule — and the hidden `company` the
- # route branches on (CardForm.new_org_card). The same popover
- # machinery as the person's: Popover API open and close, no
- # script, values surviving a light dismiss, autofocus scoped
- # to the popover once shown.
- dialog(id: "new-company", popover: "auto") do
- header { "New company" }
- form(id: "new-company-form", action: "/contacts", method: "post") do
- input(type: "hidden", name: "company", value: "1")
- label(class: "field") do
- plain "Organization"
- input(type: "text", name: "organization", required: true, autofocus: true)
- end
- input(type: "hidden", name: "q", value: @query)
- end
- footer do
- button(type: "button", popovertarget: "new-company",
- popovertargetaction: "hide") { "Cancel" }
- button(type: "submit", form: "new-company-form",
- data: {variant: "primary"}) { "Create" }
- end
- end
end
end
end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 552ffa2..65e8c79 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -128,7 +128,7 @@ module ProTacts
family, given, additional = contact.name_components || []
@first = given
@middle = additional
- @last = family
+ @last = family || organization_name
end
def view_template
@@ -248,6 +248,20 @@ 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.
+ #: () -> String?
+ def organization_name
+ return nil unless @contact.company?
+
+ @contact.name || @contact.organization&.split(";", 2)&.first
+ end
+
# The rows added this pass, rendered by Alpine from `added` —
# one per type named in the dialog, in the order they were
# asked for. Nothing stands here until something is added and
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 718b4cb..44e1e3f 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -33,40 +33,25 @@ 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, or the company dialog's one
- # organization box — so this is the backstop, and
+ # browser cannot produce one (the dialog requires one name box
+ # or the other, Admin::NamePair), 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
- # The company dialog'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
+ 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
- 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
+ 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
diff --git a/test/fixtures/cards/company.vcf b/test/fixtures/cards/company.vcf
new file mode 100644
index 0000000..eb9d200
--- /dev/null
+++ b/test/fixtures/cards/company.vcf
@@ -0,0 +1,8 @@
+BEGIN:VCARD
+VERSION:3.0
+N:;;;;
+FN:Arbitrary Definitions
+ORG:Arbitrary Definitions;
+X-ABShowAs:COMPANY
+UID:company
+END:VCARD
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 098b4af..5eb32d0 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
</d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+ <cs:getctag>23</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 0ea83f5..161899b 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
</d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+ <cs:getctag>23</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
@@ -137,6 +137,15 @@ Content-Type: text/xml
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
+ <d:response>
+ <d:href>/dav/addressbook/company.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"9619ae633bcbe86ad4a6e177be72a8bf364dde6d5022abb26fcd0e68954c9388"</d:getetag>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
<d:response>
<d:href>/dav/addressbook/emoji.vcf</d:href>
<d:propstat>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 098b4af..5eb32d0 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
</d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+ <cs:getctag>23</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index c43aeeb..efe46ff 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -102,6 +102,15 @@ Content-Type: text/xml
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
+ <d:response>
+ <d:href>/dav/addressbook/company.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"9619ae633bcbe86ad4a6e177be72a8bf364dde6d5022abb26fcd0e68954c9388"</d:getetag>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
<d:response>
<d:href>/dav/addressbook/emoji.vcf</d:href>
<d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 0ea83f5..161899b 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -18,8 +18,8 @@ Content-Type: text/xml
</d:report>
</d:supported-report>
</d:supported-report-set>
- <cs:getctag>22</cs:getctag>
- <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
+ <cs:getctag>23</cs:getctag>
+ <d:sync-token>http://pro-tacts/sync/23/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
<d:current-user-privilege-set>
<d:privilege>
<d:read/>
@@ -137,6 +137,15 @@ Content-Type: text/xml
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
+ <d:response>
+ <d:href>/dav/addressbook/company.vcf</d:href>
+ <d:propstat>
+ <d:prop>
+ <d:getetag>"9619ae633bcbe86ad4a6e177be72a8bf364dde6d5022abb26fcd0e68954c9388"</d:getetag>
+ </d:prop>
+ <d:status>HTTP/1.1 200 OK</d:status>
+ </d:propstat>
+ </d:response>
<d:response>
<d:href>/dav/addressbook/emoji.vcf</d:href>
<d:propstat>
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 780bfdc..2526854 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1105,27 +1105,12 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes body, '<label class="field">Last<input type="text" name="last" required ' \
':required="firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
assert_includes body, 'popovertargetaction="hide"'
+ # No company create rides along: organizations arrive by client
+ # and by import, and the dialog is exactly the person's.
+ refute_includes body, "new-company"
end
end
- # The company create is its own popover, opened from the person
- # dialog's footer link — not a toggle swapping the name fields,
- # which made the popover lurch around its own content-sized box.
- def test_the_dashboard_carries_the_new_company_dialog
- get "/"
-
- body = last_response.body
- assert_includes body, '<button type="button" style="margin-right: auto" ' \
- 'popovertarget="new-company">New company</button>'
- assert_includes body, '<dialog id="new-company" popover="auto">'
- assert_includes body, "<header>New company</header>"
- assert_includes body, '<form id="new-company-form" action="/contacts" method="post">'
- assert_includes body, '<input type="hidden" name="company" value="1">'
- assert_includes body, '<label class="field">Organization' \
- '<input type="text" name="organization" required autofocus></label>'
- assert_includes body, '<button type="submit" form="new-company-form" data-variant="primary">Create</button>'
- end
-
# A create lands through Store#put — change log, index, and all —
# and the answer is a redirect to the record, the details page
# where further editing belongs.
@@ -1151,35 +1136,6 @@ 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
@@ -1349,6 +1305,36 @@ class AdminContactsPagesTest < Minitest::Test
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"
+
+ with_contacts({"client-org" => client}) do |store|
+ get "/contacts/client-org/edit"
+
+ 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>'
+
+ post "/contacts/client-org", last: "Arbitrary Definitions",
+ etag: store.contact("client-org").etag
+
+ assert_equal 303, last_response.status
+ card = store.contact("client-org").vcard.to_s
+ assert_includes card, "N:Arbitrary Definitions;;;;\r\n"
+ assert_includes card, "FN:Arbitrary Definitions\r\n"
+ assert_includes card, "ORG:Arbitrary Definitions;\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