+ <%= for n <- @numbers do %>
+ <%= if n == :gap do %>
+ …
+ <% else %>
+
+ {n}
+
+ <% end %>
<% end %>
- <% end %>
+
assign(:page_title, "UI Showcase")
+ |> assign(:width, "full")
+ |> assign(:widths, @widths)
+ |> assign(:show_modal?, false)}
+ end
+
+ @impl true
+ def handle_event("set-width", %{"width" => w}, socket) when w in @widths do
+ {:noreply, assign(socket, :width, w)}
+ end
+
+ @impl true
+ def handle_event("open-modal", _, socket), do: {:noreply, assign(socket, :show_modal?, true)}
+
+ @impl true
+ def handle_event("close-modal", _, socket), do: {:noreply, assign(socket, :show_modal?, false)}
+
+ @impl true
+ def render(assigns), do: View.render(assigns)
+end
diff --git a/lib/mast_web/live/dev_ui_live/view.ex b/lib/mast_web/live/dev_ui_live/view.ex
new file mode 100644
index 0000000..6f7009e
--- /dev/null
+++ b/lib/mast_web/live/dev_ui_live/view.ex
@@ -0,0 +1,412 @@
+defmodule MastWeb.DevUiLive.View do
+ @moduledoc """
+ Render template for the dev-only `/dev/ui` showcase. Kept separate
+ from `MastWeb.DevUiLive` so the LiveView module stays focused on
+ state and event handling.
+ """
+ use MastWeb, :html
+
+ @doc """
+ Top-level showcase page. Expects `:width`, `:widths`, `:show_modal?`
+ assigns.
+ """
+ attr :width, :string, required: true
+ attr :widths, :list, required: true
+ attr :show_modal?, :boolean, default: false
+
+ def render(assigns) do
+ ~H"""
+
+ <.ui_page_header
+ title="Component showcase"
+ subtitle="Dev-only. Pick a width to preview responsive behavior."
+ >
+ <:actions>
+ <.width_toggle width={@width} widths={@widths} />
+
+
+
+
+
+ <.section title="Buttons" id="section-buttons">
+ <.buttons_demo />
+
+
+ <.section title="Feedback" id="section-feedback">
+ <.feedback_demo />
+
+
+ <.section title="Forms" id="section-forms">
+ <.forms_demo />
+
+
+ <.section title="Containers" id="section-containers">
+ <.containers_demo show_modal?={@show_modal?} />
+
+
+ <.section title="Navigation" id="section-navigation">
+ <.navigation_demo />
+
+
+ <.section title="Data" id="section-data">
+ <.data_demo />
+
+
+ <.section title="Table" id="section-table">
+ <.table_demo />
+
+
+ <.section title="Domain" id="section-domain">
+ <.domain_demo />
+
+
+
+
+ """
+ end
+
+ defp frame_style("full"), do: nil
+ defp frame_style(w), do: "max-width: #{w}px;"
+
+ defp frame_classes("full"), do: "w-full"
+
+ defp frame_classes(w),
+ do: "w-full max-w-[#{w}px] mx-auto border-x border-dashed border-[var(--mast-border)]"
+
+ attr :width, :string, required: true
+ attr :widths, :list, required: true
+
+ defp width_toggle(assigns) do
+ ~H"""
+
+
+ {if w == "full", do: "full", else: w}
+
+
+ """
+ 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"""
+
+ """
+ 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