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