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.
change
commit cfcedc8724c1cf60f541c90df325917bd98dbe41
author Alpha Chen <alpha@kejadlen.dev>
date
parent 71390935
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);
+  }
+}