Move CSS to public/app.css, served via Roda :public plugin
Extracts all styles (Utopia scale, design tokens, component CSS) from
inline Ruby string constants into public/app.css. Views now link to
/app.css instead of embedding <style> blocks.
diff --git a/lib/views/capture.rb b/lib/views/capture.rb
index d6a30bc..eebe99f 100644
--- a/lib/views/capture.rb
+++ b/lib/views/capture.rb
@@ -5,239 +5,6 @@ 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 {
@@ -288,8 +55,7 @@ module Domus
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) }
+ link(rel: "stylesheet", href: "/app.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/views/layout.rb b/lib/views/layout.rb
index 49b517c..8160698 100644
--- a/lib/views/layout.rb
+++ b/lib/views/layout.rb
@@ -5,62 +5,6 @@ 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
@@ -77,7 +21,7 @@ module Domus
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) }
+ link(rel: "stylesheet", href: "/app.css")
script(defer: true, src: "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js")
end
diff --git a/lib/web.rb b/lib/web.rb
index 789d176..ab8a782 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -7,9 +7,12 @@ require_relative "views/capture"
module Domus
class Web < Roda
+ plugin :public
plugin :all_verbs
route do |r|
+ r.public
+
r.root do
r.get do
Views::Capture.new.call
diff --git a/public/app.css b/public/app.css
new file mode 100644
index 0000000..b4813d8
--- /dev/null
+++ b/public/app.css
@@ -0,0 +1,284 @@
+/* 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;
+}
+
+/* ---- 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);
+ }
+}