Make home capture actions open the picker in place
The "Take a photo" / "Upload a file" buttons no longer navigate to the
capture page — the whole home page is now one captureApp() Alpine
component, so the buttons trigger the hidden camera/file inputs and
choosing a file swaps the recent-asset list for the save form.

Extract the shared icon helper into an Icons mixin and the save step
into a CaptureForm component, reused by both the capture and home views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147qRctyhELxJgGs3nHizEM
change
commit 0bf3ad1461ba6fbc0b1b55b30eefbaa4c1599685
author Claude <noreply@anthropic.com>
date
parent d51e9f57
diff --git a/lib/views/capture.rb b/lib/views/capture.rb
index a0df4b9..c7f6e97 100644
--- a/lib/views/capture.rb
+++ b/lib/views/capture.rb
@@ -1,11 +1,13 @@
 # frozen_string_literal: true
 
 require_relative "layout"
+require_relative "icons"
+require_relative "capture_form"
 
 module Domus
   module Views
     class Capture < Phlex::HTML
-      ICONS_DIR = File.expand_path("../../public/icons", __dir__)
+      include Icons
 
       def view_template
         doctype
@@ -79,95 +81,10 @@ module Domus
               p(class: "drop-hint") { plain "or drop a file onto this card" }
             end
 
-            form(
-              "x-show": "state === 'saved'",
-              method: "post",
-              action: "/files",
-              enctype: "multipart/form-data",
-              "@submit": "onSubmit()"
-            ) do
-              input(
-                type: "file",
-                name: "file",
-                accept: "image/*",
-                capture: "environment",
-                class: "sr-only",
-                "x-ref": "cameraInput",
-                "@change": "onCameraInput($event)"
-              )
-              input(
-                type: "file",
-                name: "file",
-                accept: "image/*",
-                class: "sr-only",
-                "x-ref": "fileInput",
-                "@change": "onFileInput($event)"
-              )
-              div(class: "preview-zone") do
-                img(
-                  "x-show": "preview",
-                  ":src": "preview",
-                  alt: "Captured image"
-                )
-                div(
-                  class: "preview-placeholder",
-                  "x-show": "!preview"
-                ) do
-                  icon("image")
-                  plain "captured file"
-                end
-              end
-
-              div(class: "save-form") do
-                div(class: "asset-inputs") do
-                  p(class: "asset-inputs-label") { plain "Assets" }
-                  template("x-for": "(_, i) in assetNames", ":key": "i") do
-                    div(class: "asset-input-row") do
-                      input(
-                        type: "text",
-                        name: "asset_names[]",
-                        placeholder: "Asset name",
-                        "x-model": "assetNames[i]",
-                        "@keydown.enter.prevent": "addAsset()"
-                      )
-                      button(
-                        type: "button",
-                        class: "btn-remove-asset",
-                        "@click": "removeAsset(i)"
-                      ) { icon("trash") }
-                    end
-                  end
-                  button(
-                    type: "button",
-                    class: "asset-add-btn",
-                    "@click": "addAsset()"
-                  ) { plain "Add another" }
-                end
-
-                div(class: "btn-row") do
-                  button(type: "submit", class: "btn btn-primary") do
-                    icon("check")
-                    plain "Save image"
-                  end
-                  button(
-                    type: "button",
-                    class: "btn",
-                    "@click": "reset()"
-                  ) { plain "Discard" }
-                end
-              end
-            end
+            render CaptureForm.new
           end
         end
       end
-
-      ICONS = Hash.new do |cache, name|
-        cache[name] = File.read(File.join(ICONS_DIR, "#{name}.svg")).freeze
-      end
-
-      def icon(name)
-        raw safe(ICONS[name])
-      end
     end
   end
 end
diff --git a/lib/views/capture_form.rb b/lib/views/capture_form.rb
new file mode 100644
index 0000000..6cf14c3
--- /dev/null
+++ b/lib/views/capture_form.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require "phlex"
+require_relative "icons"
+
+module Domus
+  module Views
+    # The save step of a capture: the hidden camera/file inputs plus the
+    # preview and asset-naming form. Driven by the captureApp() Alpine data,
+    # and shared by both the capture page and the home page so the capture
+    # actions can open the picker in place.
+    class CaptureForm < Phlex::HTML
+      include Icons
+
+      def view_template
+        form(
+          "x-show": "state === 'saved'",
+          method: "post",
+          action: "/files",
+          enctype: "multipart/form-data",
+          "@submit": "onSubmit()"
+        ) do
+          input(
+            type: "file",
+            name: "file",
+            accept: "image/*",
+            capture: "environment",
+            class: "sr-only",
+            "x-ref": "cameraInput",
+            "@change": "onCameraInput($event)"
+          )
+          input(
+            type: "file",
+            name: "file",
+            accept: "image/*",
+            class: "sr-only",
+            "x-ref": "fileInput",
+            "@change": "onFileInput($event)"
+          )
+
+          div(class: "preview-zone") do
+            img("x-show": "preview", ":src": "preview", alt: "Captured image")
+            div(class: "preview-placeholder", "x-show": "!preview") do
+              icon("image")
+              plain "captured file"
+            end
+          end
+
+          div(class: "save-form") do
+            div(class: "asset-inputs") do
+              p(class: "asset-inputs-label") { plain "Assets" }
+              template("x-for": "(_, i) in assetNames", ":key": "i") do
+                div(class: "asset-input-row") do
+                  input(
+                    type: "text",
+                    name: "asset_names[]",
+                    placeholder: "Asset name",
+                    "x-model": "assetNames[i]",
+                    "@keydown.enter.prevent": "addAsset()"
+                  )
+                  button(
+                    type: "button",
+                    class: "btn-remove-asset",
+                    "@click": "removeAsset(i)"
+                  ) { icon("trash") }
+                end
+              end
+              button(
+                type: "button",
+                class: "asset-add-btn",
+                "@click": "addAsset()"
+              ) { plain "Add another" }
+            end
+
+            div(class: "btn-row") do
+              button(type: "submit", class: "btn btn-primary") do
+                icon("check")
+                plain "Save image"
+              end
+              button(
+                type: "button",
+                class: "btn",
+                "@click": "reset()"
+              ) { plain "Discard" }
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/views/home.rb b/lib/views/home.rb
index a61a719..968e9da 100644
--- a/lib/views/home.rb
+++ b/lib/views/home.rb
@@ -1,14 +1,17 @@
 # frozen_string_literal: true
 
 require "phlex"
+require_relative "icons"
+require_relative "capture_form"
 
 module Domus
   module Views
     # The home page — the archive's front door. Capture actions live in the
-    # header (and a thumb-reachable dock on small screens); the recent assets
-    # run as a single-column list below. Calm Archive language throughout.
+    # header (and a thumb-reachable dock on small screens) and open the
+    # picker in place: the whole page is one captureApp() Alpine component,
+    # so choosing a file swaps the recent-asset list for the save form.
     class Home < Phlex::HTML
-      ICONS_DIR = File.expand_path("../../public/icons", __dir__)
+      include Icons
 
       def initialize(assets:, total:)
         @assets = assets
@@ -23,11 +26,14 @@ module Domus
             meta(name: "viewport", content: "width=device-width, initial-scale=1")
             title { "Domus" }
             link(rel: "stylesheet", href: "/app.css")
+            script(defer: true, src: "/capture.js")
+            script(defer: true, src: "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js")
           end
           body do
-            div(class: "page") do
+            div(class: "page", "x-data": "captureApp()") do
               render_header
               render_main
+              render_capture
               render_footer
               render_dock
             end
@@ -43,16 +49,25 @@ module Domus
             span(class: "logo-mark")
             plain "domus"
           end
-          render_actions(class: "actions")
+          render_actions(class: "actions", "x-show": "state === 'capture'")
         end
       end
 
-      # The capture entry points. Both lead to the capture flow, where the
-      # camera and file pickers live.
+      # 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
-          a(href: "/capture", class: "browse") { plain "Upload a file" }
-          a(href: "/capture", class: "add-btn") do
+          button(
+            type: "button",
+            class: "browse",
+            "@click": "$refs.fileInput.click()"
+          ) { plain "Upload a file" }
+          button(
+            type: "button",
+            class: "add-btn",
+            "@click": "$refs.cameraInput.click()"
+          ) do
             icon("camera")
             plain "Take a photo"
           end
@@ -60,7 +75,7 @@ module Domus
       end
 
       def render_main
-        main(class: "wrap") do
+        main(class: "wrap", "x-show": "state === 'capture'") do
           section do
             div(class: "sec-h") do
               h3 { plain "Recent assets" }
@@ -95,8 +110,17 @@ module Domus
         end
       end
 
+      # The capture save step, shown once a photo or file has been chosen.
+      def render_capture
+        div(class: "content", "x-show": "state === 'saved'", "x-cloak": true) do
+          div(class: "card") do
+            render CaptureForm.new
+          end
+        end
+      end
+
       def render_footer
-        footer(class: "foot") do
+        footer(class: "foot", "x-show": "state === 'capture'") do
           span(class: "fm") { plain "v1.0" }
         end
       end
@@ -104,7 +128,7 @@ module Domus
       # On small screens the capture actions move to a thumb-reachable dock
       # fixed to the bottom of the viewport; it's hidden on wider screens.
       def render_dock
-        render_actions(class: "dock")
+        render_actions(class: "dock", "x-show": "state === 'capture'")
       end
 
       # Compact, archival relative time — "now", "5m", "3h", "2d", "1w", "4mo".
@@ -131,14 +155,6 @@ module Domus
 
         "#{days / 365}y"
       end
-
-      ICONS = Hash.new do |cache, name|
-        cache[name] = File.read(File.join(ICONS_DIR, "#{name}.svg")).freeze
-      end
-
-      def icon(name)
-        raw safe(ICONS[name])
-      end
     end
   end
 end
diff --git a/lib/views/icons.rb b/lib/views/icons.rb
new file mode 100644
index 0000000..7d86983
--- /dev/null
+++ b/lib/views/icons.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Domus
+  module Views
+    # Inlines the SVG files from public/icons so they inherit currentColor
+    # and can be sized and recoloured with CSS. Mixed into the Phlex views
+    # that draw icons.
+    module Icons
+      ICONS_DIR = File.expand_path("../../public/icons", __dir__)
+
+      ICONS = Hash.new do |cache, name|
+        cache[name] = File.read(File.join(ICONS_DIR, "#{name}.svg")).freeze
+      end
+
+      def icon(name)
+        raw safe(ICONS[name])
+      end
+    end
+  end
+end
diff --git a/public/app.css b/public/app.css
index 7f54531..3b7db60 100644
--- a/public/app.css
+++ b/public/app.css
@@ -75,6 +75,9 @@ body {
   clip: rect(0,0,0,0); white-space: nowrap; border: 0;
 }
 
+/* Hide Alpine-managed nodes until the component initializes. */
+[x-cloak] { display: none !important; }
+
 /* ---- page shell ---- */
 .page {
   min-height: 100dvh;
diff --git a/test/test_app.rb b/test/test_app.rb
index 3108256..9fa82b2 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -45,6 +45,17 @@ class TestApp < Minitest::Test
     assert_includes last_response.body, "Nothing tracked yet."
   end
 
+  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.
+    assert_includes body, "captureApp()"
+    assert_includes body, "$refs.cameraInput.click()"
+    assert_includes body, "$refs.fileInput.click()"
+    refute_includes body, 'href="/capture"'
+  end
+
   def test_capture_page
     get "/capture"
     assert_equal 200, last_response.status