Consolidate capture actions into a single split button
Replace the two separate dock actions with one split button: a primary
action plus a caret that opens a drop-up menu with the alternate. The
primary defaults to "Upload a file" on desktop and "Take a photo" on
mobile (captureApp picks the input by viewport); both labels are
rendered and swapped by media query. Adds menuOpen state and a chevron
icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147qRctyhELxJgGs3nHizEM
change
commit 780a46c4c160285cf6222f67af013ec9b43dc55f
author Claude <noreply@anthropic.com>
date
parent 1d00cdc9
diff --git a/lib/views/home.rb b/lib/views/home.rb
index 4c1599a..2d0374d 100644
--- a/lib/views/home.rb
+++ b/lib/views/home.rb
@@ -51,23 +51,48 @@ module Domus
         end
       end
 
-      # The capture entry points. Rather than navigating, they trigger the
-      # hidden inputs that captureApp() drives, opening the camera or file
-      # picker in place.
-      def render_actions(**attrs)
-        div(**attrs) do
-          button(
-            type: "button",
-            class: "browse",
-            "@click": "$refs.fileInput.click()"
-          ) { plain "Upload a file" }
-          button(
-            type: "button",
-            class: "add-btn",
-            "@click": "$refs.cameraInput.click()"
+      # A single split button: the primary action opens the picker
+      # captureApp() chooses for the viewport (upload on desktop, camera on
+      # mobile), and the caret opens a drop-up menu with the alternate.
+      # Both labels are rendered and toggled by media query.
+      def render_capture_control
+        div(class: "capture", "@click.outside": "menuOpen = false") do
+          div(class: "split") do
+            button(
+              type: "button",
+              class: "add-btn split-main",
+              "@click": "capturePrimary()"
+            ) do
+              span(class: "when-wide") { icon("folder"); plain "Upload a file" }
+              span(class: "when-narrow") { icon("camera"); plain "Take a photo" }
+            end
+            button(
+              type: "button",
+              class: "add-btn split-toggle",
+              "aria-label": "More capture options",
+              ":aria-expanded": "menuOpen",
+              ":class": "{ 'is-open': menuOpen }",
+              "@click": "menuOpen = !menuOpen"
+            ) { icon("chevron") }
+          end
+
+          div(
+            class: "menu",
+            role: "menu",
+            "x-show": "menuOpen",
+            "x-cloak": true,
+            "x-transition.opacity.duration.120ms": true,
+            "@keydown.escape.window": "menuOpen = false"
           ) do
-            icon("camera")
-            plain "Take a photo"
+            button(
+              type: "button",
+              class: "menu-item",
+              role: "menuitem",
+              "@click": "captureAlternate()"
+            ) do
+              span(class: "when-wide") { icon("camera"); plain "Take a photo" }
+              span(class: "when-narrow") { icon("folder"); plain "Upload a file" }
+            end
           end
         end
       end
@@ -121,7 +146,9 @@ module Domus
       # every screen, so the primary action stays reachable and out of the
       # header.
       def render_dock
-        render_actions(class: "dock", "x-show": "state === 'capture'")
+        div(class: "dock", "x-show": "state === 'capture'") do
+          render_capture_control
+        end
       end
 
       # Compact, archival relative time — "now", "5m", "3h", "2d", "1w", "4mo".
diff --git a/public/app.css b/public/app.css
index bcd2c24..09fd335 100644
--- a/public/app.css
+++ b/public/app.css
@@ -315,43 +315,79 @@ body {
    Home — the archive front door
    ════════════════════════════════════════════════════════════════════════ */
 
-/* ---- capture actions (shown in the bottom dock) ---- */
+/* ---- capture split button (shown in the bottom dock) ---- */
 .add-btn {
   display: inline-flex;
   align-items: center;
+  justify-content: center;
   gap: var(--space-2xs);
   padding: var(--space-2xs) var(--space-s);
-  border: 1px solid var(--w-accent);
-  border-radius: calc(var(--radius) - 6px);
+  border: 0;
   background: var(--w-accent);
   color: #fff;
   cursor: pointer;
-  text-decoration: none;
   font-family: var(--font-ui);
   font-weight: 600;
   font-size: var(--step--1);
   letter-spacing: -0.01em;
   white-space: nowrap;
-  transition: background .14s ease, border-color .14s ease;
+  transition: background .14s ease;
 }
-.add-btn:hover { background: var(--w-accent-ink); border-color: var(--w-accent-ink); }
+.add-btn:hover { background: var(--w-accent-ink); }
 .add-btn svg { width: 17px; height: 17px; }
 
-.browse {
+/* labels/icons swapped by viewport so one control serves both defaults */
+.when-wide   { display: inline-flex; align-items: center; gap: var(--space-2xs); }
+.when-narrow { display: none; align-items: center; gap: var(--space-2xs); }
+
+/* the split: primary action joined to a caret that opens the alternate */
+.capture { position: relative; }
+.split {
+  display: inline-flex;
+  border-radius: calc(var(--radius) - 6px);
+  overflow: hidden;
+  box-shadow: 0 1px 2px rgba(60,52,36,.10);
+}
+.split-toggle {
+  padding-left: var(--space-2xs);
+  padding-right: var(--space-2xs);
+  box-shadow: inset 1px 0 0 rgba(255,255,255,.22);
+}
+.split-toggle svg { width: 16px; height: 16px; transition: transform .14s ease; }
+.split-toggle.is-open svg { transform: rotate(180deg); }
+
+/* drop-up menu holding the alternate action */
+.menu {
+  position: absolute;
+  bottom: calc(100% + var(--space-2xs));
+  right: 0;
+  min-width: 190px;
+  padding: var(--space-3xs);
+  background: var(--w-surface);
+  border: 1px solid var(--w-line-2);
+  border-radius: calc(var(--radius) - 4px);
+  box-shadow: var(--shadow-float);
+  z-index: 30;
+}
+.menu-item {
+  display: flex;
+  align-items: center;
+  gap: var(--space-2xs);
+  width: 100%;
+  padding: var(--space-2xs) var(--space-xs);
+  border: 0;
+  background: none;
+  border-radius: calc(var(--radius) - 8px);
   font-family: var(--font-ui);
   font-size: var(--step--1);
   font-weight: 550;
-  color: var(--w-ink-2);
-  background: none;
-  border: 0;
+  letter-spacing: -0.01em;
+  color: var(--w-ink);
+  text-align: left;
   cursor: pointer;
-  padding: 2px 0;
-  text-decoration: none;
-  white-space: nowrap;
-  border-bottom: 1.5px solid var(--w-line-2);
-  transition: color .14s ease, border-color .14s ease;
 }
-.browse:hover { color: var(--w-accent-ink); border-color: var(--w-accent); }
+.menu-item:hover { background: var(--w-fill); }
+.menu-item svg { width: 17px; height: 17px; color: var(--w-ink-2); }
 
 /* ---- page shell ---- */
 .wrap {
@@ -462,23 +498,33 @@ body {
   background: linear-gradient(to top, var(--w-bg) 55%, transparent);
 }
 
-/* On small screens the dock fills the width and the primary action grows
-   into a thumb-reachable button. */
+/* On small screens the dock fills the width, the primary action grows into
+   a thumb-reachable button, and the viewport-default flips to the camera. */
 @media (max-width: 560px) {
   .wrap { padding: var(--space-l) var(--space-s) 150px; }
 
+  .when-wide   { display: none; }
+  .when-narrow { display: inline-flex; }
+
   .dock {
-    flex-direction: column-reverse;
-    gap: var(--space-xs);
     padding: var(--space-s) var(--space-s) calc(var(--space-s) + env(safe-area-inset-bottom, 8px));
     background: linear-gradient(to top, var(--w-bg) 72%, transparent);
   }
-  .dock .add-btn {
-    width: 100%;
-    height: 52px;
-    justify-content: center;
+  .dock .capture,
+  .dock .split { width: 100%; }
+  .split {
     border-radius: calc(var(--radius) - 2px);
-    font-weight: 650;
     box-shadow: 0 1px 2px rgba(60,52,36,.10), 0 10px 24px -12px rgba(154,90,60,.6);
   }
+  .split-main {
+    flex: 1;
+    height: 52px;
+    font-weight: 650;
+  }
+  .split-toggle {
+    height: 52px;
+    padding-left: var(--space-s);
+    padding-right: var(--space-s);
+  }
+  .menu { left: 0; right: 0; min-width: 0; }
 }
diff --git a/public/capture.js b/public/capture.js
index 2bb3172..87798e0 100644
--- a/public/capture.js
+++ b/public/capture.js
@@ -5,6 +5,16 @@ function captureApp() {
     dragging: false,
     activeRef: null,
     assetNames: [''],
+    menuOpen: false,
+
+    // The primary capture action defaults to uploading a file on desktop
+    // and taking a photo on mobile; the dropdown exposes the other one.
+    get onMobile() { return window.matchMedia('(max-width: 560px)').matches; },
+    capturePrimary() { (this.onMobile ? this.$refs.cameraInput : this.$refs.fileInput).click(); },
+    captureAlternate() {
+      this.menuOpen = false;
+      (this.onMobile ? this.$refs.fileInput : this.$refs.cameraInput).click();
+    },
 
     handleFile(file, ref) {
       if (!file) return;
diff --git a/public/icons/chevron.svg b/public/icons/chevron.svg
new file mode 100644
index 0000000..9c37029
--- /dev/null
+++ b/public/icons/chevron.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+  <path d="M6 15l6-6 6 6"/>
+</svg>
diff --git a/test/test_app.rb b/test/test_app.rb
index dc49d05..40d8f8d 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -48,11 +48,11 @@ class TestApp < Minitest::Test
   def test_root_capture_actions_open_picker_in_place
     get "/"
     body = last_response.body
-    # The capture flow is embedded, so the actions trigger the file inputs
-    # rather than navigating away.
+    # The capture flow is embedded, so the split button opens the picker in
+    # place (primary + alternate) rather than navigating away.
     assert_includes body, "captureApp()"
-    assert_includes body, "$refs.cameraInput.click()"
-    assert_includes body, "$refs.fileInput.click()"
+    assert_includes body, "capturePrimary()"
+    assert_includes body, "captureAlternate()"
     refute_includes body, 'href="/capture"'
   end