From 66c34f5b70ea9cddfe463676660ece20af476906 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 13:30:06 -0500 Subject: [PATCH] feat(ui): make components responsive across full breakpoint scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ui_* component suite was built desktop-first with no responsive breakpoints. Tablets and phones got truncated text, overflowing tables, and a sidebar that ate ~224px of viewport with no way to dismiss it. Component fixes: - Sidebar collapses to a daisyUI drawer + hamburger header below lg (1024px), so iPad portrait gets the drawer too. The drawer reuses the same nav slot via extracted sidebar_brand/sidebar_nav/sidebar_footer private components. - ui_table: action_bar wraps; pagination stacks flex-col sm:flex-row; numbered pages hidden on xs, replaced by "Page X of Y". - ui_tabs: overflow-x-auto whitespace-nowrap so 4+ tabs scroll instead of wrapping silently. - ui_page_header: stacks actions below title on xs; action buttons go full-width. - ui_card header: flex-col sm:flex-row so title and wide actions don't cramp on narrow. - ui_modal: max-h-[calc(100vh-2rem)] with overflow-y-auto body region, so tall content scrolls inside the panel instead of the viewport. - ui_kv_table: rows stack label-above-value on xs, side-by-side from sm. - ui_release_card: metric strip flex-col sm:flex-row. - ui_audit_row: timestamp stacks below body on xs, indented to align. - ui_card_title: rewritten to flex-wrap with ml-auto for the meta badge — the old flex-1 spacer cramped both title and meta when the card cell was narrow. - ui_chart_card + line_chart: added min-w-0 + overflow-hidden + a CSS-important w-full override on the canvas, plus a window.resize fallback in the hook. Chart.js was failing to shrink because the canvas's intrinsic width pushed the grid cell open, creating a circular ResizeObserver dependency. New dev-only showcase at /dev/ui: - DevUiLive renders every ui_* component grouped by family. - Viewport-width toggle: 375 / 414 / 768 / 1024 / 1440 / full. - Compiled out in :prod via Mix.env() guard in the router. Tests: - responsive_test.exs: 13 assertions for class hooks on each component. - dev_ui_live_test.exs: smoke test for /dev/ui route + width toggle. - 277/277 passing. Closes #19 --- lib/mast_web/components/layouts.ex | 52 ++- lib/mast_web/components/ui/charts.ex | 16 +- lib/mast_web/components/ui/containers.ex | 14 +- lib/mast_web/components/ui/data.ex | 14 +- lib/mast_web/components/ui/domain.ex | 36 +- lib/mast_web/components/ui/navigation.ex | 140 ++++-- lib/mast_web/components/ui/table.ex | 49 ++- lib/mast_web/live/dev_ui_live.ex | 40 ++ lib/mast_web/live/dev_ui_live/view.ex | 412 ++++++++++++++++++ lib/mast_web/router.ex | 17 +- .../components/ui/responsive_test.exs | 216 +++++++++ test/mast_web/live/dev_ui_live_test.exs | 40 ++ 12 files changed, 939 insertions(+), 107 deletions(-) create mode 100644 lib/mast_web/live/dev_ui_live.ex create mode 100644 lib/mast_web/live/dev_ui_live/view.ex create mode 100644 test/mast_web/components/ui/responsive_test.exs create mode 100644 test/mast_web/live/dev_ui_live_test.exs diff --git a/lib/mast_web/components/layouts.ex b/lib/mast_web/components/layouts.ex index 22207a3..bc2fd45 100644 --- a/lib/mast_web/components/layouts.ex +++ b/lib/mast_web/components/layouts.ex @@ -22,8 +22,48 @@ defmodule MastWeb.Layouts do def app(assigns) do ~H""" -
- <.ui_sidebar active={@active}> +
+ + +
+ <.ui_sidebar active={@active}> + <:nav key="dashboard" navigate={~p"/"} icon="hero-squares-2x2">Dashboard + <:nav key="servers" navigate={~p"/"} icon="hero-server-stack">Servers + <:nav key="alerts" navigate={~p"/alerts"} icon="hero-bell-alert">Alerts + <:nav key="audit" navigate={~p"/audit"} icon="hero-document-text">Audit + <:nav key="settings" navigate={~p"/settings"} icon="hero-cog-6-tooth">Settings + <:footer> + <.theme_toggle /> + + v{Mast.version()} + + + + +
+
+ +
+ + Mast +
+
+ +
+
+ {render_slot(@inner_block)} +
+
+
+
+ + <.ui_mobile_drawer toggle_id="app-drawer" active={@active}> <:nav key="dashboard" navigate={~p"/"} icon="hero-squares-2x2">Dashboard <:nav key="servers" navigate={~p"/"} icon="hero-server-stack">Servers <:nav key="alerts" navigate={~p"/alerts"} icon="hero-bell-alert">Alerts @@ -35,13 +75,7 @@ defmodule MastWeb.Layouts do v{Mast.version()} - - -
-
- {render_slot(@inner_block)} -
-
+ <.flash_group flash={@flash} />
diff --git a/lib/mast_web/components/ui/charts.ex b/lib/mast_web/components/ui/charts.ex index 46beac7..d154492 100644 --- a/lib/mast_web/components/ui/charts.ex +++ b/lib/mast_web/components/ui/charts.ex @@ -41,13 +41,13 @@ defmodule MastWeb.Components.UI.Charts do |> assign(:until_ms, to_unix_ms(assigns[:until])) ~H""" -
+
<%= if @empty? do %>
Waiting for more samples...
<% else %> -
+
@@ -86,9 +86,17 @@ defmodule MastWeb.Components.UI.Charts do mounted() { this.render() this.el.addEventListener("dblclick", () => this.chart?.resetZoom()) + // Chart.js's ResizeObserver can miss flex/grid resizes when the + // parent shrinks below the canvas's intrinsic width. Force a + // resize on window changes as a fallback. + this._onResize = () => this.chart?.resize() + window.addEventListener("resize", this._onResize) }, updated() { this.render() }, - destroyed() { this.chart?.destroy() }, + destroyed() { + window.removeEventListener("resize", this._onResize) + this.chart?.destroy() + }, render() { const series = JSON.parse(this.el.dataset.series) const unit = this.el.dataset.unit || "" diff --git a/lib/mast_web/components/ui/containers.ex b/lib/mast_web/components/ui/containers.ex index ec68525..d6364ae 100644 --- a/lib/mast_web/components/ui/containers.ex +++ b/lib/mast_web/components/ui/containers.ex @@ -42,7 +42,7 @@ defmodule MastWeb.Components.UI.Containers do ]}>

-
+
{render_slot(@inner_block)}
{render_slot(@footer)}
@@ -209,7 +209,7 @@ defmodule MastWeb.Components.UI.Containers do ~H"""
diff --git a/lib/mast_web/components/ui/data.ex b/lib/mast_web/components/ui/data.ex index 3557360..ff8c3cb 100644 --- a/lib/mast_web/components/ui/data.ex +++ b/lib/mast_web/components/ui/data.ex @@ -151,13 +151,15 @@ defmodule MastWeb.Components.UI.Data do def ui_card_title(assigns) do ~H""" - + - + {render_slot(@inner_block)} - - + {render_slot(@meta)} @@ -269,11 +271,11 @@ defmodule MastWeb.Components.UI.Data do
- + {row.label} diff --git a/lib/mast_web/components/ui/domain.ex b/lib/mast_web/components/ui/domain.ex index 95e3c2c..188fbef 100644 --- a/lib/mast_web/components/ui/domain.ex +++ b/lib/mast_web/components/ui/domain.ex @@ -182,7 +182,7 @@ defmodule MastWeb.Components.UI.Domain do
-
+
<.ui_metric_tile label="Memory" value={format_memory(@memory_mb)} /> <.ui_metric_tile label="Processes" value={format_count(@processes)} /> <.ui_metric_tile label="Msg Queue" value={format_count(@msg_queue)} /> @@ -297,26 +297,30 @@ defmodule MastWeb.Components.UI.Domain do def ui_audit_row(assigns) do ~H"""
-
- -
-
-
- {@actor} - <.ui_badge variant={audit_badge_variant(@variant)} dot={false}> - {audit_label(@variant)} - - {@verb} - {@target} +
+
+
-
- {@detail} +
+
+ {@actor} + <.ui_badge variant={audit_badge_variant(@variant)} dot={false}> + {audit_label(@variant)} + + {@verb} + {@target} +
+
+ {@detail} +
-
{@time}
+
+ {@time} +
""" end diff --git a/lib/mast_web/components/ui/navigation.ex b/lib/mast_web/components/ui/navigation.ex index a134255..42f3c7e 100644 --- a/lib/mast_web/components/ui/navigation.ex +++ b/lib/mast_web/components/ui/navigation.ex @@ -26,7 +26,11 @@ defmodule MastWeb.Components.UI.Navigation do def ui_tabs(assigns) do ~H""" -
+
<%= for tab <- @tab do %> <% active? = tab.key == @active %> <%= cond do %> @@ -113,45 +117,106 @@ defmodule MastWeb.Components.UI.Navigation do def ui_sidebar(assigns) do ~H""" + """ + end - + @doc """ + Mobile drawer-side companion to `ui_sidebar/1`. Renders the same nav + links inside a daisyUI `drawer-side` so the layout can expose them via + a hamburger toggle on screens below `md`. -
+ <:nav key="dashboard" navigate={~p"/"} icon="hero-squares-2x2">Dashboard + <:footer><.theme_toggle /> + + """ + attr :toggle_id, :string, required: true + attr :active, :string, default: "dashboard" + + slot :nav, required: true do + attr :key, :string, required: true + attr :navigate, :string + attr :icon, :string + end + + slot :footer + + def ui_mobile_drawer(assigns) do + ~H""" +
+
- + + +
+ """ + end + + defp sidebar_brand(assigns) do + ~H""" +
+ Mast + + Mast + +
+ """ + end + + attr :nav, :list, required: true + attr :active, :string, required: true + attr :drawer_toggle_id, :string, default: nil + + defp sidebar_nav(assigns) do + ~H""" + + """ + end + + slot :inner_block, required: true + + defp sidebar_footer(assigns) do + ~H""" +
+ {render_slot(@inner_block)} +
""" end @@ -175,7 +240,7 @@ defmodule MastWeb.Components.UI.Navigation do def ui_page_header(assigns) do ~H"""
@@ -186,7 +251,10 @@ defmodule MastWeb.Components.UI.Navigation do {@subtitle}

-
+
{render_slot(@actions)}
diff --git a/lib/mast_web/components/ui/table.ex b/lib/mast_web/components/ui/table.ex index 0090044..4f8f233 100644 --- a/lib/mast_web/components/ui/table.ex +++ b/lib/mast_web/components/ui/table.ex @@ -54,7 +54,7 @@ defmodule MastWeb.Components.UI.Table do >
{render_slot(@action_bar)}
@@ -125,11 +125,14 @@ defmodule MastWeb.Components.UI.Table do ~H"""
0} - class="px-4 py-3 border-t border-[var(--mast-border)] flex items-center justify-between gap-3 text-xs text-[var(--mast-font-secondary)]" + class="px-4 py-3 border-t border-[var(--mast-border)] flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs text-[var(--mast-font-secondary)]" > Showing {@from}-{@to} of {@total} -
1} class="flex items-center gap-1"> +
1} class="flex items-center gap-1 flex-wrap"> + 1} class="sm:hidden tabular-nums"> + Page {@page} of {@pages} + + +
+ """ + end + + attr :title, :string, required: true + attr :id, :string, required: true + slot :inner_block, required: true + + defp section(assigns) do + ~H""" +
+

+ {@title} +

+
+ {render_slot(@inner_block)} +
+
+ """ + end + + defp buttons_demo(assigns) do + ~H""" +
+ <.ui_button>Primary + <.ui_button variant="secondary">Secondary + <.ui_button variant="ghost">Ghost + <.ui_button variant="destructive">Destructive + <.ui_button icon="hero-plus">With icon + <.ui_button size="sm">Small + <.ui_button loading>Loading + <.ui_button disabled>Disabled +
+ """ + end + + defp feedback_demo(assigns) do + ~H""" +
+ <.ui_badge variant="online">online + <.ui_badge variant="offline">offline + <.ui_badge variant="warning">warning + <.ui_badge variant="accent">accent + <.ui_badge variant="neutral" dot={false}>neutral + <.ui_status_dot status="up" /> + <.ui_status_dot status="down" /> + <.ui_status_dot status="unknown" /> + <.ui_chip status="running">running + <.ui_chip status="stopped">stopped +
+ """ + end + + defp forms_demo(assigns) do + ~H""" +
+ <.ui_search name="q" placeholder="Search packages..." /> +
+ """ + end + + attr :show_modal?, :boolean, required: true + + defp containers_demo(assigns) do + ~H""" +
+ <.ui_card> + <:title>Card title + <:subtitle>With a subtitle line + <:actions> + <.ui_button size="sm" variant="ghost">Action one + <.ui_button size="sm">Action two + + Card body content goes here. Should sit below header on narrow widths. + + + <.ui_card> + <:title>Plain card + Body only, no actions. + + + <.ui_chart_card title="Memory over time"> + <:badge>Last 24h +
+ chart placeholder +
+ + + <.ui_empty + icon="hero-server" + title="No alerts yet" + body="When something needs attention, it'll show here." + > + <.ui_button size="sm">Add Server + +
+ +
+ <.ui_button phx-click="open-modal" variant="secondary">Open modal + + Modal body has its own scroll region. + +
+ + <.ui_modal :if={@show_modal?} id="dev-modal" on_cancel={JS.push("close-modal")}> + <:title>Modal title + <:subtitle>Use Esc or click outside to close. +
+

+ Paragraph {i}. Modal body content should scroll within the panel rather than + pushing the page below the fold on tall content. +

+
+ <:footer> + <.ui_button variant="secondary" phx-click="close-modal">Cancel + <.ui_button phx-click="close-modal">Confirm + + + """ + end + + defp navigation_demo(assigns) do + ~H""" + <.ui_tabs active="overview"> + <:tab key="overview" event="noop">Overview + <:tab key="apps" event="noop" count={3}>Apps + <:tab key="updates" event="noop" count={12}>Updates + <:tab key="audit" event="noop">Audit + <:tab key="settings" event="noop">Settings + <:tab key="releases" event="noop">Releases + + +

+ Six tabs should scroll horizontally on narrow widths, not wrap. +

+ +

+ Page header above is itself an example; sidebar lives in the app layout. +

+ """ + end + + defp data_demo(assigns) do + ~H""" +
+ <.ui_stat label="Total Servers" value={6} /> + <.ui_stat label="Online" value={5} tone="online" /> + <.ui_stat label="Updates Available" value={12} tone="warning" /> + <.ui_stat label="With usage bar" value={68} progress={68} sub="68% of capacity" /> +
+ +
+ <.ui_card> + <:title>Metrics + <.ui_metric label="CPU" value={68} /> + <.ui_metric label="Memory" value={9.4} max={16} suffix="GB" /> + <.ui_metric label="Disk" value={92} /> + + + <.ui_card padded={false}> + <:title> + <.ui_card_title icon="hero-cube" color="purple"> + Card title with icon + <:meta>3 running + + +
+ <.ui_metric_tile label="Memory" value="106 MB" /> + <.ui_metric_tile label="Procs" value="536" /> + <.ui_metric_tile label="Queue" value="0" /> +
+ +
+ +
+ <.ui_stat_tile icon="hero-cpu-chip" label="Memory" value="106 MB" sub="VM allocation" /> + <.ui_stat_tile label="Processes" value="536" sub="Active" tone="accent" /> + <.ui_stat_tile label="Msg Queue" value="0" sub="Backlog" /> +
+ + <.ui_kv_table title="Application Info"> + <:row label="Node">hermes@ip-10-0-0-42.us-west-2.compute.internal + <:row label="Status"> + <.ui_badge variant="online">running + + <:row label="Version">0.1.0 + <:row label="OTP Release">28 + + """ + end + + defp table_demo(assigns) do + rows = + for i <- 1..23 do + %{ + "package" => "openssl-#{i}", + "current" => "3.0.#{i}", + "next" => "3.0.#{i + 1}" + } + end + + assigns = assign(assigns, :rows, Enum.take(rows, 10)) |> assign(:total, length(rows)) + + ~H""" + <.ui_table id="dev-table" rows={@rows} size="sm" zebra> + <:col :let={row} label="Package">{row["package"]} + <:col :let={row} label="Current">{row["current"]} + <:col :let={row} label="Next">{row["next"]} + <:col :let={_row}> + <.ui_button size="sm" variant="ghost">Apply + + <:action_bar> + <.ui_search name="q" /> + <.ui_badge>{@total} packages + <.ui_badge variant="warning">3 outdated + + <:pagination page={1} page_size={10} total={@total} event="noop" /> + + """ + end + + defp domain_demo(assigns) do + server = %{ + id: "00000000-0000-0000-0000-000000000000", + name: "web-prod-1", + status: "up", + cpu: 42, + memory: 78, + disk: 60 + } + + assigns = assign(assigns, :server, server) + + ~H""" +
+ <.ui_server_card server={@server} /> + <.ui_server_card server={ + %{@server | name: "db-prod-1", status: "down", cpu: 0, memory: 0, disk: 0} + } /> + <.ui_server_card server={ + %{@server | name: "edge-eu-1", status: "unknown", cpu: 12, memory: 30, disk: 18} + } /> +
+ + <.ui_card> + <:title>Apps on this server +
+ <.ui_app_card name="mast_web" version="0.4.0" status="running" uptime="6d 4h" /> + <.ui_app_card name="hermes" version="0.1.0" status="stopped" /> + <.ui_app_card name="prometheus_exporter" status="pending" /> +
+ + + <.ui_card padded={false}> + <:title>App rows +
+ <.ui_app_row name="mast_web" meta="v0.4.0 · web-prod-1 · OTP 28" status="running" /> + <.ui_app_row name="hermes" meta="v0.1.0 · web-prod-1 · OTP 28" status="stopped" /> +
+ + + <.ui_release_card + name="hermes" + version="0.1.0" + node_name="hermes@ip-10-0-0-42.us-west-2.compute.internal" + status="running" + memory_mb={106.0} + processes={536} + msg_queue={0} + uptime_seconds={576_000} + otp_release="28" + /> + + <.ui_card padded={false}> + <:title>Audit log +
+ <.ui_audit_row variant="scan" actor="System" verb="scanned" target="web-prod-1" time="2m ago" /> + <.ui_audit_row + variant="create" + actor="pj" + verb="added server" + target="db-prod-1" + time="1h ago" + /> + <.ui_audit_row + variant="failure" + actor="System" + verb="failed to connect" + target="db-prod-1" + time="5m ago" + detail="nxdomain — DNS lookup failed" + /> + <.ui_audit_row + variant="key-create" + actor="pj" + verb="registered key" + target="hermes-key" + time="3h ago" + /> +
+ + + <.ui_card padded={false}> + <:title>Log stream +
+ <.ui_log_entry time="14:22:01" kind={:info}>Started apt update + <.ui_log_entry time="14:22:03" kind={:stdout}>Reading package lists... Done + <.ui_log_entry time="14:22:04" kind={:stderr}>W: Some warning from apt + <.ui_log_entry time="14:22:05" kind={:exit}>Process exited 0 + <.ui_log_entry time="14:22:06" kind={:error}>Job failed +
+ + """ + end +end diff --git a/lib/mast_web/router.ex b/lib/mast_web/router.ex index 01c8517..49b18df 100644 --- a/lib/mast_web/router.ex +++ b/lib/mast_web/router.ex @@ -32,15 +32,18 @@ defmodule MastWeb.Router do # pipe_through :api # end - # Enable LiveDashboard in development - if Application.compile_env(:mast, :dev_routes) do - # If you want to use the LiveDashboard in production, you should put - # it behind authentication and allow only admins to access it. - # If your application does not have an admins-only section yet, - # you can use Plug.BasicAuth to set up some basic authentication - # as long as you are also using SSL (which you should anyway). + # Dev-only routes: LiveDashboard, component showcase. Compiled out + # in :prod via Mix.env/0 — the showcase is unreachable in production + # without a redeploy. + if Mix.env() != :prod do import Phoenix.LiveDashboard.Router + scope "/dev", MastWeb do + pipe_through :browser + + live "/ui", DevUiLive, :index + end + scope "/dev" do pipe_through :browser diff --git a/test/mast_web/components/ui/responsive_test.exs b/test/mast_web/components/ui/responsive_test.exs new file mode 100644 index 0000000..d6d1cb0 --- /dev/null +++ b/test/mast_web/components/ui/responsive_test.exs @@ -0,0 +1,216 @@ +defmodule MastWeb.Components.UI.ResponsiveTest do + @moduledoc """ + Asserts mobile-friendly class hooks are present on the shared UI + components. We only check for the responsive prefixes (e.g. `sm:`, + `md:`, `flex-col`) — the visual outcome is validated by walking the + app at ~375px in the browser. + """ + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias MastWeb.Components.UI.Containers + alias MastWeb.Components.UI.Data + alias MastWeb.Components.UI.Domain + alias MastWeb.Components.UI.Navigation + alias MastWeb.Components.UI.Table + + describe "ui_table action_bar" do + test "wraps and search grows full-width on narrow screens" do + html = + render_component(&Table.ui_table/1, %{ + id: "t", + rows: [], + col: [%{label: "A", inner_block: fn _, _ -> "a" end}], + action_bar: [%{inner_block: fn _, _ -> "bar" end}] + }) + + assert html =~ "flex-wrap" + end + end + + describe "ui_table pagination" do + test "stacks counter and controls on narrow widths" do + html = + render_component(&Table.ui_table/1, %{ + id: "t", + rows: Enum.map(1..30, &%{n: &1}), + col: [%{label: "N", inner_block: fn _, row -> "#{row.n}" end}], + pagination: [%{page: 1, page_size: 10, total: 30, event: "go"}] + }) + + assert html =~ "flex-col sm:flex-row" + end + + test "hides numbered page buttons on xs in favor of Prev/Next" do + html = + render_component(&Table.ui_table/1, %{ + id: "t", + rows: Enum.map(1..30, &%{n: &1}), + col: [%{label: "N", inner_block: fn _, row -> "#{row.n}" end}], + pagination: [%{page: 1, page_size: 10, total: 30, event: "go"}] + }) + + # Numbered pages container must be hidden below sm + assert html =~ "hidden sm:flex" + end + end + + describe "ui_sidebar" do + test "hidden below lg, shown lg and up — iPad portrait gets the drawer" do + html = + render_component(&Navigation.ui_sidebar/1, %{ + active: "dashboard", + nav: [ + %{ + key: "dashboard", + navigate: "/", + icon: "hero-squares-2x2", + inner_block: fn _, _ -> "Dashboard" end + } + ] + }) + + assert html =~ "hidden lg:flex" + end + end + + describe "ui_mobile_drawer" do + test "renders daisyUI drawer-side that's lg:hidden" do + html = + render_component(&Navigation.ui_mobile_drawer/1, %{ + toggle_id: "app-drawer", + active: "dashboard", + nav: [ + %{ + key: "dashboard", + navigate: "/", + icon: "hero-squares-2x2", + inner_block: fn _, _ -> "Dashboard" end + } + ] + }) + + assert html =~ "drawer-side" + assert html =~ "drawer-overlay" + assert html =~ "lg:hidden" + # Label points at the toggle checkbox so clicking it closes the drawer + assert html =~ ~s(for="app-drawer") + end + end + + describe "ui_tabs" do + test "scrolls horizontally and prevents wrap" do + html = + render_component(&Navigation.ui_tabs/1, %{ + active: "a", + tab: [ + %{key: "a", event: "set", inner_block: fn _, _ -> "A" end}, + %{key: "b", event: "set", inner_block: fn _, _ -> "B" end} + ] + }) + + assert html =~ "overflow-x-auto" + assert html =~ "whitespace-nowrap" + end + end + + describe "ui_page_header" do + test "stacks actions on narrow, side-by-side on sm+" do + html = + render_component(&Navigation.ui_page_header/1, %{ + title: "Hi", + actions: [%{inner_block: fn _, _ -> "x" end}] + }) + + assert html =~ "flex-col sm:flex-row" + end + end + + describe "ui_card header" do + test "stacks title above actions on narrow" do + html = + render_component(&Containers.ui_card/1, %{ + title: [%{inner_block: fn _, _ -> "T" end}], + actions: [%{inner_block: fn _, _ -> "A" end}], + inner_block: [%{inner_block: fn _, _ -> "body" end}] + }) + + assert html =~ "flex-col sm:flex-row" + end + end + + describe "ui_modal" do + test "body has internal scroll region" do + html = + render_component(&Containers.ui_modal/1, %{ + id: "m", + inner_block: [%{inner_block: fn _, _ -> "body" end}] + }) + + assert html =~ "overflow-y-auto" + assert html =~ "max-h" + end + end + + describe "ui_kv_table" do + test "rows stack label above value on narrow" do + html = + render_component(&Data.ui_kv_table/1, %{ + title: "Info", + row: [%{label: "Node", inner_block: fn _, _ -> "n@h" end}] + }) + + assert html =~ "flex-col sm:flex-row" + end + end + + describe "ui_release_card" do + test "metric strip stacks on narrow, row on sm+" do + html = + render_component(&Domain.ui_release_card/1, %{ + name: "app", + version: "0.1.0", + status: "running", + memory_mb: 100, + processes: 10, + msg_queue: 0 + }) + + assert html =~ "flex-col sm:flex-row" + end + end + + describe "ui_card_title" do + test "wraps on narrow widths instead of cramping with a flex-1 spacer" do + html = + render_component(&Data.ui_card_title/1, %{ + icon: "hero-cube", + color: "purple", + inner_block: [%{inner_block: fn _, _ -> "Card title with icon" end}], + meta: [%{inner_block: fn _, _ -> "3 running" end}] + }) + + # No flex-1 spacer that forces title + meta to share width. + refute html =~ "flex-1" + # Wraps when out of room. + assert html =~ "flex-wrap" + # Meta is pushed right via margin, not a spacer. + assert html =~ "ml-auto" + end + end + + describe "ui_audit_row" do + test "stacks time under body on narrow" do + html = + render_component(&Domain.ui_audit_row/1, %{ + actor: "System", + verb: "scanned", + target: "web-prod-1", + time: "2m ago" + }) + + assert html =~ "flex-col sm:flex-row" + end + end +end diff --git a/test/mast_web/live/dev_ui_live_test.exs b/test/mast_web/live/dev_ui_live_test.exs new file mode 100644 index 0000000..33c8491 --- /dev/null +++ b/test/mast_web/live/dev_ui_live_test.exs @@ -0,0 +1,40 @@ +defmodule MastWeb.DevUiLiveTest do + @moduledoc """ + Smoke test for the dev-only component showcase at `/dev/ui`. The page + renders a sample of every `ui_*` component inside a width-frame so we + can eyeball responsive behavior without resizing the OS window. + """ + use MastWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "/dev/ui" do + test "renders with section headers for each component family", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/dev/ui") + + # Each component family gets a section so engineers can scan the page. + assert html =~ "Buttons" + assert html =~ "Feedback" + assert html =~ "Forms" + assert html =~ "Containers" + assert html =~ "Navigation" + assert html =~ "Data" + assert html =~ "Table" + assert html =~ "Domain" + + # Width toggle covers phone (375), large phone (414), tablet (768), + # small laptop (1024), and large desktop (1440). + for w <- ~w(375 414 768 1024 1440) do + assert html =~ ~s(phx-value-width="#{w}") + end + + # Default frame is full-width — switching to mobile applies the max-w class. + html_375 = + view + |> element("button[phx-value-width='375']") + |> render_click() + + assert html_375 =~ "max-w-[375px]" + end + end +end