Give the company create its own popover, not a toggle
A checkbox swapping the name fields made the popover lurch around its
own content-sized box, and a mode switch inside a three-field form was
the wrong shape anyway: the person dialog is exactly what it was, and
the footer link swaps whole popovers.

Assisted-by: GLM-5.3 via pi
change vlwzvkzwmxutvzopmqzxovrkwkxmmvku
commit 92dab599ed245aad64be7461d789be141b551bf5
author Alpha Chen <alpha@kejadlen.dev>
date
parent lktoyzks
diff --git a/docs/plans/2026-09-24-company-cards.md b/docs/plans/2026-09-24-company-cards.md
index 168d1c7..e8617a5 100644
--- a/docs/plans/2026-09-24-company-cards.md
+++ b/docs/plans/2026-09-24-company-cards.md
@@ -18,11 +18,16 @@ 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 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.
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index e464fb4..ab1ee7e 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -42,13 +42,13 @@ 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).
     #
-    # 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.
+    # 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.
     class ContactDialog < Phlex::HTML
       # @rbs @query: String
 
@@ -61,39 +61,18 @@ module ProTacts
         dialog(id: "new-contact", popover: "auto") do
           header { "New contact" }
           form(id: "new-contact-form", action: "/contacts", method: "post",
-               x_data: "{ company: false, #{NamePair.state(nil, nil)} }") do
-            # The company toggle: a Gloss checkbox row, not a field —
-            # a checkbox is no Field control (Gloss's Checkbox contract
-            # styles the bare label wrapping one as an inline row), and
-            # the quiet option reads as what the name fields stand
-            # behind. 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 do
-              input(type: "checkbox", name: "company", value: "1", x_model: "company")
-              plain "Company"
-            end
-            label(class: "field", x_show: "!company") do
+               x_data: "{ #{NamePair.state(nil, nil)} }") do
+            label(class: "field") do
               plain "First"
-              input(**NamePair.first(nil, nil, guard: "!company"), autofocus: true)
+              input(**NamePair.first(nil, nil), autofocus: true)
             end
-            label(class: "field", x_show: "!company") do
+            label(class: "field") do
               plain "Middle"
               input(type: "text", name: "middle")
             end
-            label(class: "field", x_show: "!company") do
+            label(class: "field") do
               plain "Last"
-              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")
+              input(**NamePair.last(nil, nil))
             end
             # The search the dialog opened over — opening a popover
             # loads no page, so this survives only the failed-create
@@ -101,12 +80,46 @@ 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/name_pair.rb b/lib/pro_tacts/admin/name_pair.rb
index 7604cef..e2e0a57 100644
--- a/lib/pro_tacts/admin/name_pair.rb
+++ b/lib/pro_tacts/admin/name_pair.rb
@@ -22,24 +22,19 @@ module ProTacts
         "firstBlank: #{blank?(first)}, lastBlank: #{blank?(last)}"
       end
 
-      # `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:)
+      #: (String? first, String? last) -> Hash[Symbol, untyped]
+      def self.first(first, last)
+        box("first", first, required: blank?(last), own: "firstBlank", other: "lastBlank")
       end
 
-      #: (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:)
+      #: (String? first, String? last) -> Hash[Symbol, untyped]
+      def self.last(first, last)
+        box("last", last, required: blank?(first), own: "lastBlank", other: "firstBlank")
       end
 
-      #: (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()"}
+      #: (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()"}
       end
 
       # The server's own test of a blank half (Web's contacts routes).
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index b2b1e4f..718b4cb 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -34,13 +34,13 @@ module ProTacts
         # 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 one organization box
-        # the Company toggle swaps in — so this is the backstop, and
+        # or the other, Admin::NamePair, or the company dialog's one
+        # organization box — 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 toggle's create: one name, the card
+          # 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"]
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index ee01aa8..780bfdc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1095,24 +1095,37 @@ 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="{ company: false, firstBlank: true, lastBlank: true }">'
+                            'x-data="{ 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).
-      # The Company toggle swaps the pair for the one organization
-      # box, and every field is required only while shown.
-      assert_includes body, '<label><input type="checkbox" name="company" value="1" x-model="company">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, '<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>'
       assert_includes body, 'popovertargetaction="hide"'
     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.