fix: add data attributes and Google Fonts to enable theme visual changes

- Add Google Fonts link to root layout for typography presets
- Add data-mood, data-typography, data-shape, data-density attributes to preview-frame
- Create theme-layer2-attributes.css with attribute-based CSS from demo
- Move theme CSS files from priv/static/css to assets/css for proper compilation
- Update CSS import order (primitives → layer2 → semantic)
- Add 'css' to static_paths to serve theme CSS files

This fixes the issue where theme controls updated the database but didn't
visually affect the preview. The demo's attribute-based CSS system is now
properly integrated with the Phoenix LiveView implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2025-12-31 00:24:53 +00:00
parent 6a3069f854
commit 476ec9667a
9 changed files with 470 additions and 34 deletions

View File

@ -103,9 +103,12 @@
[data-phx-session], [data-phx-teleported-src] { display: contents } [data-phx-session], [data-phx-teleported-src] { display: contents }
/* Theme CSS - Layer 1: Primitives (fixed CSS variables) */ /* Theme CSS - Layer 1: Primitives (fixed CSS variables) */
@import url("/css/theme-primitives.css"); @import "./theme-primitives.css";
/* Theme CSS - Layer 2: Attribute-based theme tokens */
@import "./theme-layer2-attributes.css";
/* Theme CSS - Layer 3: Semantic aliases */ /* Theme CSS - Layer 3: Semantic aliases */
@import url("/css/theme-semantic.css"); @import "./theme-semantic.css";
/* This file is for your main application CSS */ /* This file is for your main application CSS */

View File

@ -0,0 +1,157 @@
/* ========================================
LAYER 2: THEME TOKENS (Attribute-based)
======================================== */
/* Mood - Default (Neutral) */
.preview-frame {
--t-surface-base: #ffffff;
--t-surface-raised: #ffffff;
--t-surface-sunken: #f5f5f5;
--t-surface-overlay: rgba(255, 255, 255, 0.95);
--t-text-primary: #171717;
--t-text-secondary: #525252;
--t-text-tertiary: #a3a3a3;
--t-text-inverse: #ffffff;
--t-border-default: #e5e5e5;
--t-border-subtle: #f0f0f0;
}
.preview-frame[data-mood="warm"] {
--t-surface-base: #fdf8f3;
--t-surface-raised: #fffcf8;
--t-surface-sunken: #f5ebe0;
--t-text-primary: #1c1917;
--t-text-secondary: #57534e;
--t-text-tertiary: #a8a29e;
--t-border-default: #e7e0d8;
--t-border-subtle: #f0ebe4;
}
.preview-frame[data-mood="cool"] {
--t-surface-base: #f4f7fb;
--t-surface-raised: #f8fafc;
--t-surface-sunken: #e8eff7;
--t-text-primary: #0f172a;
--t-text-secondary: #475569;
--t-text-tertiary: #94a3b8;
--t-border-default: #d4dce8;
--t-border-subtle: #e8eff5;
}
.preview-frame[data-mood="dark"] {
--t-surface-base: #0a0a0a;
--t-surface-raised: #171717;
--t-surface-sunken: #000000;
--t-surface-overlay: rgba(23, 23, 23, 0.95);
--t-text-primary: #fafafa;
--t-text-secondary: #a3a3a3;
--t-text-tertiary: #737373;
--t-text-inverse: #171717;
--t-border-default: #262626;
--t-border-subtle: #1c1c1c;
--p-shadow-strength: 0.25;
}
/* Typography - Default (Clean/Inter) */
.preview-frame {
--t-font-heading: var(--p-font-inter);
--t-font-body: var(--p-font-inter);
--t-heading-weight: 600;
--t-heading-tracking: -0.025em;
}
.preview-frame[data-typography="editorial"] {
--t-font-heading: var(--p-font-fraunces);
--t-font-body: var(--p-font-source);
--t-heading-weight: 600;
--t-heading-tracking: -0.02em;
}
.preview-frame[data-typography="modern"] {
--t-font-heading: var(--p-font-space);
--t-font-body: var(--p-font-space);
--t-heading-weight: 500;
--t-heading-tracking: -0.03em;
}
.preview-frame[data-typography="classic"] {
--t-font-heading: var(--p-font-baskerville);
--t-font-body: var(--p-font-source);
--t-heading-weight: 400;
--t-heading-tracking: 0;
}
.preview-frame[data-typography="friendly"] {
--t-font-heading: var(--p-font-nunito);
--t-font-body: var(--p-font-nunito);
--t-heading-weight: 700;
--t-heading-tracking: -0.01em;
}
.preview-frame[data-typography="minimal"] {
--t-font-heading: var(--p-font-outfit);
--t-font-body: var(--p-font-outfit);
--t-heading-weight: 300;
--t-heading-tracking: 0;
}
.preview-frame[data-typography="impulse"] {
--t-font-heading: var(--p-font-avenir);
--t-font-body: var(--p-font-avenir);
--t-heading-weight: 300;
--t-heading-tracking: 0.02em;
}
/* Shape - Default (Soft) */
.preview-frame {
--t-radius-sm: var(--p-radius-sm);
--t-radius-md: var(--p-radius-md);
--t-radius-lg: var(--p-radius-lg);
--t-radius-button: var(--p-radius-md);
--t-radius-card: var(--p-radius-lg);
--t-radius-input: var(--p-radius-md);
--t-radius-image: var(--p-radius-md);
}
.preview-frame[data-shape="sharp"] {
--t-radius-sm: 0;
--t-radius-md: 0;
--t-radius-lg: 0;
--t-radius-button: 0;
--t-radius-card: 0;
--t-radius-input: 0;
--t-radius-image: 0;
}
.preview-frame[data-shape="round"] {
--t-radius-sm: var(--p-radius-md);
--t-radius-md: var(--p-radius-lg);
--t-radius-lg: var(--p-radius-xl);
--t-radius-button: var(--p-radius-lg);
--t-radius-card: var(--p-radius-xl);
--t-radius-input: var(--p-radius-lg);
--t-radius-image: var(--p-radius-lg);
}
.preview-frame[data-shape="pill"] {
--t-radius-sm: var(--p-radius-full);
--t-radius-md: var(--p-radius-full);
--t-radius-lg: var(--p-radius-xl);
--t-radius-button: var(--p-radius-full);
--t-radius-card: var(--p-radius-xl);
--t-radius-input: var(--p-radius-full);
--t-radius-image: var(--p-radius-lg);
}
/* Density - Default (Balanced) */
.preview-frame {
--t-density: 1;
}
.preview-frame[data-density="spacious"] {
--t-density: 1.25;
}
.preview-frame[data-density="compact"] {
--t-density: 0.85;
}

View File

@ -17,7 +17,7 @@ defmodule SimpleshopThemeWeb do
those modules here. those modules here.
""" """
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) def static_paths, do: ~w(assets css fonts images favicon.ico robots.txt)
def router do def router do
quote do quote do

View File

@ -8,6 +8,9 @@
{assigns[:page_title]} {assigns[:page_title]}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:wght@400;700&family=Nunito:wght@400;600;700&family=Nunito+Sans:opsz,wght@6..12,300;6..12,400;6..12,500;6..12,600&family=Outfit:wght@300;400;500;600&family=Source+Sans+3:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script> </script>
<script> <script>

View File

@ -53,6 +53,76 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
{:noreply, assign(socket, :preview_page, page_atom)} {:noreply, assign(socket, :preview_page, page_atom)}
end end
@impl true
def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("update_setting", %{"field" => field} = params, socket) do
# For phx-change events from select/input elements, the value comes from the name attribute
value = params[field] || params["#{field}_text"] || params["value"]
if value do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, socket}
end
end
@impl true
def handle_event("update_color", %{"field" => field, "value" => value}, socket) do
field_atom = String.to_existing_atom(field)
attrs = %{field_atom => value}
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
@impl true @impl true
def handle_event("save_theme", _params, socket) do def handle_event("save_theme", _params, socket) do
socket = put_flash(socket, :info, "Theme saved successfully") socket = put_flash(socket, :info, "Theme saved successfully")

View File

@ -35,39 +35,138 @@
</div> </div>
</div> </div>
<!-- Current Settings Display --> <!-- Customization Controls -->
<div class="divider"></div> <div class="divider"></div>
<!-- Mood -->
<div> <div>
<h2 class="text-lg font-semibold mb-4">Current Settings</h2> <h3 class="font-semibold mb-3">Mood</h3>
<div class="space-y-2 text-sm"> <div class="grid grid-cols-2 gap-2">
<div class="flex justify-between"> <%= for mood <- ["neutral", "warm", "cool", "dark"] do %>
<span class="font-medium">Mood:</span> <button
<span class="capitalize"><%= @theme_settings.mood %></span> type="button"
</div> phx-click="update_setting"
<div class="flex justify-between"> phx-value-field="mood"
<span class="font-medium">Typography:</span> phx-value-setting_value={mood}
<span class="capitalize"><%= @theme_settings.typography %></span> class={"btn btn-sm #{if @theme_settings.mood == mood, do: "btn-primary", else: "btn-outline"} capitalize"}
</div>
<div class="flex justify-between">
<span class="font-medium">Shape:</span>
<span class="capitalize"><%= @theme_settings.shape %></span>
</div>
<div class="flex justify-between">
<span class="font-medium">Density:</span>
<span class="capitalize"><%= @theme_settings.density %></span>
</div>
<div class="flex justify-between">
<span class="font-medium">Accent Color:</span>
<span>
<span
class="inline-block w-4 h-4 rounded-sm border border-base-300"
style={"background-color: #{@theme_settings.accent_color}"}
> >
</span> <%= mood %>
<%= @theme_settings.accent_color %> </button>
</span> <% end %>
</div> </div>
</div> </div>
<!-- Typography -->
<div>
<h3 class="font-semibold mb-3">Typography</h3>
<form phx-change="update_setting" phx-value-field="typography">
<select
name="typography"
class="select select-bordered select-sm w-full capitalize"
>
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal", "impulse"] do %>
<option value={typo} selected={@theme_settings.typography == typo}>
<%= typo %>
</option>
<% end %>
</select>
</form>
</div>
<!-- Shape -->
<div>
<h3 class="font-semibold mb-3">Shape</h3>
<div class="grid grid-cols-2 gap-2">
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="shape"
phx-value-setting_value={shape}
class={"btn btn-sm #{if @theme_settings.shape == shape, do: "btn-primary", else: "btn-outline"} capitalize"}
>
<%= shape %>
</button>
<% end %>
</div>
</div>
<!-- Density -->
<div>
<h3 class="font-semibold mb-3">Density</h3>
<div class="grid grid-cols-3 gap-2">
<%= for density <- ["spacious", "balanced", "compact"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="density"
phx-value-setting_value={density}
class={"btn btn-sm #{if @theme_settings.density == density, do: "btn-primary", else: "btn-outline"} capitalize text-xs"}
>
<%= density %>
</button>
<% end %>
</div>
</div>
<!-- Grid Columns -->
<div>
<h3 class="font-semibold mb-3">Grid Columns</h3>
<div class="grid grid-cols-3 gap-2">
<%= for cols <- ["2", "3", "4"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="grid_columns"
phx-value-setting_value={cols}
class={"btn btn-sm #{if @theme_settings.grid_columns == cols, do: "btn-primary", else: "btn-outline"}"}
>
<%= cols %>
</button>
<% end %>
</div>
</div>
<!-- Colors -->
<div>
<h3 class="font-semibold mb-3">Accent Color</h3>
<div class="flex gap-2 items-center">
<input
type="color"
phx-change="update_color"
phx-value-field="accent_color"
value={@theme_settings.accent_color}
class="w-12 h-10 rounded cursor-pointer"
phx-debounce="300"
/>
<input
type="text"
phx-change="update_color"
phx-value-field="accent_color"
value={@theme_settings.accent_color}
class="input input-bordered input-sm flex-1 font-mono text-xs"
placeholder="#000000"
phx-debounce="blur"
/>
</div>
</div>
<!-- Header Layout -->
<div>
<h3 class="font-semibold mb-3">Header Layout</h3>
<div class="grid grid-cols-3 gap-2">
<%= for layout <- ["standard", "centered", "minimal"] do %>
<button
type="button"
phx-click="update_setting"
phx-value-field="header_layout"
phx-value-setting_value={layout}
class={"btn btn-sm #{if @theme_settings.header_layout == layout, do: "btn-primary", else: "btn-outline"} capitalize text-xs"}
>
<%= layout %>
</button>
<% end %>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -103,7 +202,14 @@
</div> </div>
<!-- Preview Frame --> <!-- Preview Frame -->
<div class="preview-frame bg-white overflow-auto" style="min-height: 600px; max-height: calc(100vh - 200px);"> <div class="preview-frame bg-white overflow-auto"
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
style="min-height: 600px; max-height: calc(100vh - 200px);">
<style> <style>
<%= Phoenix.HTML.raw(@generated_css) %> <%= Phoenix.HTML.raw(@generated_css) %>
</style> </style>

View File

@ -114,5 +114,102 @@ defmodule SimpleshopThemeWeb.ThemeLiveTest do
assert html =~ "Contact" assert html =~ "Contact"
assert html =~ "404" assert html =~ "404"
end end
test "mood customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "dark" mood button
html =
view
|> element("button", "dark")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.mood == "dark"
assert html =~ "dark"
end
test "shape customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "round" shape button
view
|> element("button", "round")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.shape == "round"
end
test "density customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "compact" density button
view
|> element("button", "compact")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.density == "compact"
end
test "grid columns customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "2" grid columns button
view
|> element("button", "2")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.grid_columns == "2"
end
test "typography customization select works", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Change typography via the form
view
|> element("form[phx-value-field='typography']")
|> render_change(%{"typography" => "modern"})
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.typography == "modern"
end
test "header layout customization buttons work", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/theme")
# Click the "centered" header layout button
view
|> element("button", "centered")
|> render_click()
# Verify the setting was updated
theme_settings = Settings.get_theme_settings()
assert theme_settings.header_layout == "centered"
end
test "CSS regenerates when settings change", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/admin/theme")
# Capture initial CSS
initial_css = html
# Change a setting
new_html =
view
|> element("button", "dark")
|> render_click()
# Verify CSS has changed (dark mode should have different surface colors)
refute initial_css == new_html
assert new_html =~ "--t-surface-base:"
end
end end
end end