Implement Domus Capture C: homepage with document capture
Adds the capture-first homepage (Design C): a header-only top bar and a
centered card where the user can take a photo or browse for a file. After
selecting, an inline save form shows a preview and a name field before
posting to /documents.
- lib/views/capture.rb: new Phlex component with Utopia fluid type/space
scale, Alpine.js state machine (capture -> saved -> reset), drag-and-drop
support, and accessible SVG icons
- lib/views/layout.rb: adds Utopia CSS custom properties (type + space
scales, design tokens) and Google Fonts for Hanken Grotesk / JetBrains Mono
- lib/web.rb: renders capture page on GET /, saves uploaded file + DB row
on POST /documents
- db/migrate/002_add_name_to_documents.rb: adds nullable name column
- test/test_app.rb: updates root assertion to match the new capture page
diff --git a/db/migrate/002_add_name_to_documents.rb b/db/migrate/002_add_name_to_documents.rb
new file mode 100644
index 0000000..6074e76
--- /dev/null
+++ b/db/migrate/002_add_name_to_documents.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ alter_table(:documents) do
+ add_column :name, String
+ end
+ end
+end
diff --git a/lib/views/capture.rb b/lib/views/capture.rb
new file mode 100644
index 0000000..d6a30bc
--- /dev/null
+++ b/lib/views/capture.rb
@@ -0,0 +1,466 @@
+# frozen_string_literal: true
+
+require_relative "layout"
+
+module Domus
+ module Views
+ class Capture < Phlex::HTML
+ STYLES = <<~CSS
+ /* ---- page shell ---- */
+ .page {
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ }
+
+ /* ---- header ---- */
+ .topbar {
+ display: flex;
+ align-items: center;
+ padding: 0 var(--space-m);
+ height: 58px;
+ border-bottom: 1px solid var(--line);
+ background: color-mix(in oklch, var(--surface) 70%, var(--bg));
+ backdrop-filter: blur(4px);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ }
+
+ .logo {
+ font-size: var(--step-1);
+ font-weight: 700;
+ letter-spacing: -0.03em;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2xs);
+ color: var(--ink);
+ text-decoration: none;
+ }
+
+ .logo-mark {
+ width: 22px; height: 22px;
+ border: 1.5px solid var(--ink);
+ border-radius: 6px;
+ position: relative;
+ flex: none;
+ }
+ .logo-mark::after {
+ content: "";
+ position: absolute;
+ inset: 4px 4px auto 4px;
+ height: 1.5px;
+ background: var(--ink);
+ box-shadow: 0 4px 0 var(--ink), 0 8px 0 var(--accent);
+ }
+
+ .logo-dot { color: var(--accent); }
+
+ /* ---- main content ---- */
+ .content {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-xl) var(--space-m);
+ }
+
+ /* ---- capture card ---- */
+ .card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: 0 18px 50px -20px rgba(20,22,30,0.28), 0 2px 8px -4px rgba(20,22,30,0.12);
+ width: min(420px, 100%);
+ overflow: hidden;
+ }
+
+ .card-body {
+ padding: var(--space-l);
+ }
+
+ .card-title {
+ font-size: var(--step-1);
+ font-weight: 650;
+ letter-spacing: -0.02em;
+ line-height: 1.12;
+ margin: 0 0 var(--space-3xs) 0;
+ }
+
+ .card-lead {
+ font-size: var(--step--1);
+ color: var(--ink-2);
+ margin: 0 0 var(--space-m) 0;
+ line-height: 1.45;
+ }
+
+ /* ---- buttons ---- */
+ .btn-stack {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2xs);
+ }
+
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2xs);
+ padding: var(--space-xs) var(--space-m);
+ border: 1px solid var(--line-2);
+ border-radius: calc(var(--radius) - 2px);
+ background: var(--surface);
+ font-family: var(--font-ui);
+ font-size: var(--step-0);
+ font-weight: 550;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+ cursor: pointer;
+ white-space: nowrap;
+ width: 100%;
+ transition: background .12s ease, border-color .12s ease;
+ text-align: center;
+ }
+
+ .btn:hover { background: var(--fill); }
+
+ .btn-primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+ }
+ .btn-primary:hover { background: var(--accent-ink); }
+
+ .btn-ghost {
+ background: transparent;
+ border-color: transparent;
+ color: var(--ink-2);
+ }
+ .btn-ghost:hover { background: var(--fill); }
+
+ .drop-hint {
+ font-family: var(--font-mono);
+ font-size: var(--step--2);
+ color: var(--ink-3);
+ text-align: center;
+ margin-top: var(--space-s);
+ }
+
+ /* ---- saved state ---- */
+ .preview-zone {
+ height: 210px;
+ border-bottom: 1px solid var(--line);
+ background: var(--fill-2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ }
+
+ .preview-zone img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .preview-placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2xs);
+ color: var(--ink-3);
+ font-family: var(--font-mono);
+ font-size: var(--step--2);
+ }
+
+ .save-form {
+ padding: var(--space-m);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-m);
+ }
+
+ .field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3xs);
+ }
+
+ .field-label {
+ font-family: var(--font-mono);
+ font-size: var(--step--2);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-2);
+ }
+
+ .field-input {
+ border: 1px solid var(--line-2);
+ border-radius: calc(var(--radius) - 3px);
+ background: var(--surface);
+ padding: var(--space-xs) var(--space-s);
+ font-family: var(--font-ui);
+ font-size: var(--step-0);
+ color: var(--ink);
+ width: 100%;
+ transition: border-color .15s ease, box-shadow .15s ease;
+ outline: none;
+ }
+
+ .field-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+ }
+
+ .btn-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2xs);
+ }
+
+ .btn-row .btn-primary { flex: 1; }
+
+ /* ---- svg icons ---- */
+ .icon { flex: none; }
+
+ /* ---- dropzone drag feedback ---- */
+ .card[data-drag="over"] {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft), 0 18px 50px -20px rgba(20,22,30,0.28);
+ }
+
+ @media (max-width: 480px) {
+ .content {
+ padding: var(--space-m) var(--space-s);
+ align-items: flex-start;
+ padding-top: var(--space-l);
+ }
+ }
+ CSS
+
+ ALPINE_JS = <<~JS
+ function captureApp() {
+ return {
+ state: 'capture',
+ preview: null,
+ name: '',
+ dragging: false,
+
+ handleFile(file) {
+ if (!file) return;
+ this.preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
+ this.name = file.name.replace(/\\.[^.]+$/, '').replace(/[-_]/g, ' ');
+ this.state = 'saved';
+ },
+
+ onFileInput(e) {
+ this.handleFile(e.target.files[0]);
+ },
+
+ onDrop(e) {
+ this.dragging = false;
+ const file = e.dataTransfer?.files[0];
+ if (file) {
+ this.$refs.fileInput.files = e.dataTransfer.files;
+ this.handleFile(file);
+ }
+ },
+
+ reset() {
+ if (this.preview) URL.revokeObjectURL(this.preview);
+ this.state = 'capture';
+ this.preview = null;
+ this.name = '';
+ this.$refs.fileInput.value = '';
+ this.$refs.cameraInput.value = '';
+ }
+ }
+ }
+ JS
+
+ def view_template
+ doctype
+ html(lang: "en") do
+ head do
+ meta(charset: "utf-8")
+ meta(name: "viewport", content: "width=device-width, initial-scale=1")
+ title { "Domus - Add document" }
+ link(rel: "preconnect", href: "https://fonts.googleapis.com")
+ link(rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: true)
+ link(rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;550;600;650;700&family=JetBrains+Mono:wght@400;500&display=swap")
+ style { raw safe(Layout::UTOPIA_CSS) }
+ style { raw safe(STYLES) }
+ script(defer: true, src: "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js")
+ end
+ body do
+ script { raw safe(ALPINE_JS) }
+ render_page
+ end
+ end
+ end
+
+ private
+
+ def render_page
+ div(class: "page") do
+ render_header
+ render_main
+ end
+ end
+
+ def render_header
+ header(class: "topbar") do
+ a(href: "/", class: "logo") do
+ span(class: "logo-mark")
+ plain "domus"
+ span(class: "logo-dot") { plain "." }
+ end
+ end
+ end
+
+ def render_main
+ main(class: "content") do
+ div(
+ class: "card",
+ "x-data": "captureApp()",
+ "@dragover.prevent": "dragging = true",
+ "@dragleave.prevent": "dragging = false",
+ "@drop.prevent": "onDrop($event)",
+ ":data-drag": "dragging ? 'over' : null"
+ ) do
+ input(
+ type: "file",
+ name: "file",
+ accept: "image/*,application/pdf",
+ capture: "environment",
+ class: "sr-only",
+ "x-ref": "cameraInput",
+ "@change": "onFileInput($event)"
+ )
+ input(
+ type: "file",
+ name: "file",
+ accept: "image/*,application/pdf",
+ class: "sr-only",
+ "x-ref": "fileInput",
+ "@change": "onFileInput($event)"
+ )
+
+ div("x-show": "state === 'capture'", class: "card-body") do
+ h2(class: "card-title") { plain "Add a document" }
+ p(class: "card-lead") { plain "Take a photo or pick a file to keep." }
+
+ div(class: "btn-stack") do
+ button(
+ type: "button",
+ class: "btn btn-primary",
+ "@click": "$refs.cameraInput.click()"
+ ) do
+ camera_icon
+ plain "Take a photo"
+ end
+
+ button(
+ type: "button",
+ class: "btn",
+ "@click": "$refs.fileInput.click()"
+ ) do
+ folder_icon
+ plain "Browse files"
+ end
+ end
+
+ p(class: "drop-hint") { plain "or drop a file onto this card" }
+ end
+
+ form(
+ "x-show": "state === 'saved'",
+ method: "post",
+ action: "/documents",
+ enctype: "multipart/form-data",
+ "@submit.prevent": "false"
+ ) do
+ div(class: "preview-zone") do
+ img(
+ "x-show": "preview",
+ ":src": "preview",
+ alt: "Captured document"
+ )
+ div(
+ class: "preview-placeholder",
+ "x-show": "!preview"
+ ) do
+ image_icon
+ plain "captured file"
+ end
+ end
+
+ div(class: "save-form") do
+ div(class: "field") do
+ label(class: "field-label", for: "doc-name") { plain "name" }
+ input(
+ type: "text",
+ id: "doc-name",
+ name: "name",
+ class: "field-input",
+ "x-model": "name",
+ placeholder: "Document name",
+ required: true,
+ "x-effect": "if (state === 'saved') $nextTick(() => $el.focus())"
+ )
+ end
+
+ div(class: "btn-row") do
+ button(type: "submit", class: "btn btn-primary") do
+ check_icon
+ plain "Save document"
+ end
+ button(
+ type: "button",
+ class: "btn btn-ghost",
+ "@click": "reset()"
+ ) { plain "Discard" }
+ end
+ end
+ end
+ end
+ end
+ end
+
+ def camera_icon
+ raw safe(<<~SVG)
+ <svg class="icon" 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="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>
+ <circle cx="12" cy="13" r="3"/>
+ </svg>
+ SVG
+ end
+
+ def folder_icon
+ raw safe(<<~SVG)
+ <svg class="icon" 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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
+ </svg>
+ SVG
+ end
+
+ def image_icon
+ raw safe(<<~SVG)
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
+ <circle cx="8.5" cy="8.5" r="1.5"/>
+ <polyline points="21 15 16 10 5 21"/>
+ </svg>
+ SVG
+ end
+
+ def check_icon
+ raw safe(<<~SVG)
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+ <polyline points="20 6 9 17 4 12"/>
+ </svg>
+ SVG
+ end
+ end
+ end
+end
diff --git a/lib/views/layout.rb b/lib/views/layout.rb
index 127d737..49b517c 100644
--- a/lib/views/layout.rb
+++ b/lib/views/layout.rb
@@ -5,6 +5,62 @@ require "phlex"
module Domus
module Views
class Layout < Phlex::HTML
+ UTOPIA_CSS = <<~CSS
+ /* Utopia fluid type scale - min 320px/1rem, max 1280px/1.25rem, ratio 1.25 */
+ :root {
+ --step--2: clamp(0.64rem, calc(0.62rem + 0.11vw), 0.72rem);
+ --step--1: clamp(0.80rem, calc(0.76rem + 0.20vw), 0.94rem);
+ --step-0: clamp(1.00rem, calc(0.93rem + 0.33vw), 1.25rem);
+ --step-1: clamp(1.25rem, calc(1.14rem + 0.54vw), 1.56rem);
+ --step-2: clamp(1.56rem, calc(1.40rem + 0.82vw), 1.95rem);
+ --step-3: clamp(1.95rem, calc(1.72rem + 1.18vw), 2.44rem);
+
+ /* Utopia fluid space scale */
+ --space-3xs: clamp(0.25rem, calc(0.23rem + 0.11vw), 0.31rem);
+ --space-2xs: clamp(0.50rem, calc(0.46rem + 0.22vw), 0.63rem);
+ --space-xs: clamp(0.75rem, calc(0.70rem + 0.27vw), 0.94rem);
+ --space-s: clamp(1.00rem, calc(0.93rem + 0.33vw), 1.25rem);
+ --space-m: clamp(1.50rem, calc(1.40rem + 0.54vw), 1.88rem);
+ --space-l: clamp(2.00rem, calc(1.85rem + 0.76vw), 2.50rem);
+ --space-xl: clamp(3.00rem, calc(2.78rem + 1.09vw), 3.75rem);
+ --space-2xl: clamp(4.00rem, calc(3.70rem + 1.52vw), 5.00rem);
+ --space-3xl: clamp(6.00rem, calc(5.56rem + 2.17vw), 7.50rem);
+
+ /* Design tokens */
+ --bg: oklch(0.985 0.002 255);
+ --surface: #ffffff;
+ --ink: oklch(0.24 0.012 262);
+ --ink-2: oklch(0.50 0.010 262);
+ --ink-3: oklch(0.68 0.008 262);
+ --line: oklch(0.905 0.004 262);
+ --line-2: oklch(0.82 0.006 262);
+ --fill: oklch(0.967 0.003 262);
+ --fill-2: oklch(0.945 0.004 262);
+ --accent: oklch(0.50 0.17 290);
+ --accent-ink: oklch(0.42 0.17 290);
+ --accent-soft:oklch(0.96 0.03 290);
+ --radius: 10px;
+ --font-ui: "Hanken Grotesk", system-ui, -apple-system, sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
+ }
+
+ *, *::before, *::after { box-sizing: border-box; }
+
+ html, body {
+ margin: 0; padding: 0;
+ background: var(--bg);
+ font-family: var(--font-ui);
+ color: var(--ink);
+ -webkit-font-smoothing: antialiased;
+ }
+
+ .sr-only {
+ position: absolute; width: 1px; height: 1px;
+ padding: 0; margin: -1px; overflow: hidden;
+ clip: rect(0,0,0,0); white-space: nowrap; border: 0;
+ }
+ CSS
+
def initialize(title: "Domus", &content)
@title = title
@content = content
@@ -15,8 +71,14 @@ module Domus
html(lang: "en") do
head do
+ meta(charset: "utf-8")
+ meta(name: "viewport", content: "width=device-width, initial-scale=1")
title { @title }
- script defer: true, src: "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
+ link(rel: "preconnect", href: "https://fonts.googleapis.com")
+ link(rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: true)
+ link(rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;550;600;650;700&family=JetBrains+Mono:wght@400;500&display=swap")
+ style { raw safe(UTOPIA_CSS) }
+ script(defer: true, src: "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js")
end
body do
diff --git a/lib/web.rb b/lib/web.rb
index d4d1f86..789d176 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -1,22 +1,61 @@
# frozen_string_literal: true
require "roda"
+require "fileutils"
require_relative "views/layout"
+require_relative "views/capture"
module Domus
class Web < Roda
+ plugin :all_verbs
+
route do |r|
r.root do
- render_with_layout { "ok" }
+ r.get do
+ Views::Capture.new.call
+ end
+
+ r.post do
+ save_document(r.params)
+ r.redirect "/"
+ end
+ end
+
+ r.on "documents" do
+ r.post do
+ save_document(r.params)
+ r.redirect "/"
+ end
end
end
private
def db = opts[:db] || opts[:app].db
+ def storage_path = opts[:app]&.config&.storage_path || "storage"
+
+ def save_document(params)
+ upload = params["file"]
+ return unless upload.is_a?(Hash) && upload[:tempfile]
+
+ name = params["name"].to_s.strip
+ name = upload[:filename].to_s.gsub(/\.[^.]+$/, "").gsub(/[-_]/, " ").strip if name.empty?
+
+ ext = File.extname(upload[:filename].to_s)
+ filename = "#{Time.now.strftime("%Y%m%d%H%M%S%L")}#{ext}"
+ dest_dir = File.join(storage_path, "documents")
+ FileUtils.mkdir_p(dest_dir)
+ dest = File.join(dest_dir, filename)
+ FileUtils.cp(upload[:tempfile].path, dest)
- def render_with_layout(&block)
- Views::Layout.new(&block).call
+ now = Time.now
+ db[:documents].insert(
+ path: File.join("documents", filename),
+ kind: upload[:type].to_s,
+ name: name,
+ received_at: now,
+ created_at: now
+ )
end
end
end
diff --git a/test/test_app.rb b/test/test_app.rb
index 4359efc..084ad7b 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -13,7 +13,7 @@ class TestApp < Minitest::Test
def test_root
get "/"
assert_equal 200, last_response.status
- assert_includes last_response.body, "<title>Domus</title>"
- assert_includes last_response.body, "ok"
+ assert_includes last_response.body, "Domus"
+ assert_includes last_response.body, "Add a document"
end
end