empowered.house ↗

ai/model-picker.ex

Model picker

A short list of model tiers with one selected and a response streaming underneath.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.ai_model_picker animated />
</div>
default
isometric
two tiers · the deep one selected
four tiers · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Model"
models list [%{meta: "0.4 s", name: "Fast", note: "Lookups and short answers"}, %{meta: "1.2 s", ...}, ...] Two to four `%{name, note, meta}`, lightest first. Use neutral tier names, a note of up to 30 characters and a short meta such as the time to first answer.
selected integer 1 Index of the chosen tier.
status string "Streaming"
response list ["Invoice 4471 matches PO-2291. The amount and", "the supplier are the same, the due date"] One or two lines of up to 44 characters. The caret follows the last one.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/ai/model-picker.ex
model-picker.ex
defmodule EHVisuals.Ai.ModelPicker do
  @moduledoc """
  A model switcher: a short list of tiers with one chosen, and the answer of
  that tier streaming underneath.
  """

  use EHVisuals.Composition,
    name: "ai/model-picker",
    title: "Model picker",
    description:
      "A short list of model tiers with one selected and a response streaming underneath."

  @models [
    %{name: "Fast", note: "Lookups and short answers", meta: "0.4 s"},
    %{name: "Balanced", note: "Everyday work", meta: "1.2 s"},
    %{name: "Deep", note: "Long reasoning and plans", meta: "9 s"}
  ]

  @response [
    "Invoice 4471 matches PO-2291. The amount and",
    "the supplier are the same, the due date"
  ]

  attr(:eyebrow, :string, default: "Model")

  attr(:models, :list,
    default: @models,
    doc:
      "Two to four `%{name, note, meta}`, lightest first. Use neutral tier names, a note of up to 30 characters and a short meta such as the time to first answer."
  )

  attr(:selected, :integer, default: 1, doc: "Index of the chosen tier.")
  attr(:status, :string, default: "Streaming")

  attr(:response, :list,
    default: @response,
    doc: "One or two lines of up to 44 characters. The caret follows the last one."
  )

  attr(:label, :string,
    default: "Three model tiers with the middle one selected and its answer streaming below"
  )

  frame_attrs()

  @spec ai_model_picker(map()) :: Phoenix.LiveView.Rendered.t()
  def ai_model_picker(assigns) do
    models = Enum.take(assigns.models, 4)
    count = max(length(models), 1)
    selected = assigns.selected |> max(0) |> min(count - 1)
    chosen = Enum.at(models, selected, %{name: "", meta: ""})
    lines = assigns.response |> Enum.take(2) |> Enum.with_index()
    divider = 44 + count * 46 + 4

    tiers =
      for {model, index} <- Enum.with_index(models) do
        %{
          model: model,
          index: index,
          y: 44 + index * 46,
          on: index == selected,
          level: max(round((index + 1) * 3 / count), 1)
        }
      end

    assigns =
      assigns
      |> assign(:tiers, tiers)
      |> assign(:chosen, "#{chosen.name} · #{chosen.meta}")
      |> assign(:lines, lines)
      |> assign(:last_line, length(lines) - 1)
      |> assign(:divider, divider)
      |> assign(:height, divider + 44 + max(length(lines) - 1, 0) * 18 + 20)

    ~H"""
    <.frame name="ai/model-picker" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 340 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height={@height - 1} rx="12" />
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="29" font-size="9">
            {String.upcase(@eyebrow)}
          </text>
          <g
            :for={tier <- @tiers}
            class={"ehv-ai-model-picker__tier ehv-a-rise ehv-i-#{tier.index + 2}"}
          >
            <rect
              class={["ehv-ai-model-picker__row", tier.on && "ehv-ai-model-picker__row--on"]}
              x="20.5"
              y={tier.y + 0.5}
              width="299"
              height="40"
              rx="8"
            />
            <circle
              class={if tier.on, do: "ehv-node-lime", else: "ehv-node-soft"}
              cx="39"
              cy={tier.y + 20.5}
              r="6.5"
            />
            <circle
              :if={tier.on}
              class="ehv-ai-model-picker__on ehv-a-pop ehv-i-6"
              cx="39"
              cy={tier.y + 20.5}
              r="3"
            />
            <text
              class={["ehv-t-strong", !tier.on && "ehv-t-2"]}
              x="56"
              y={tier.y + 18}
              font-size="12.5"
            >
              {tier.model.name}
            </text>
            <text class="ehv-t-3" x="56" y={tier.y + 31.5} font-size="10">{tier.model.note}</text>
            <text
              class={["ehv-mono", tier.on && "ehv-t-lime"]}
              x="286"
              y={tier.y + 24}
              font-size="8.5"
              text-anchor="end"
            >
              {String.upcase(tier.model.meta)}
            </text>
            <rect
              :for={bar <- 0..2}
              class={meter(bar < tier.level, tier.on)}
              x={296 + bar * 5}
              y={tier.y + 21 - bar * 3}
              width="3"
              height={5 + bar * 3}
              rx="1"
            />
          </g>
          <line
            class="ehv-line-faint ehv-a-fade ehv-i-6"
            x1="20"
            x2="320"
            y1={@divider + 0.5}
            y2={@divider + 0.5}
          />
          <g class="ehv-a-fade ehv-i-7">
            <circle class="ehv-fill-lime ehv-a-pulse" cx="23.5" cy={@divider + 19} r="2.5" />
            <text class="ehv-mono ehv-t-2" x="33" y={@divider + 22} font-size="8.5">
              {String.upcase(@status)}
            </text>
            <text class="ehv-mono" x="320" y={@divider + 22} font-size="8.5" text-anchor="end">
              {String.upcase(@chosen)}
            </text>
          </g>
          <text
            :for={{line, index} <- @lines}
            class={"ehv-a-fade ehv-i-#{index * 2 + 8}"}
            x="20"
            y={@divider + 44 + index * 18}
            font-size="12.5"
          >
            {line}<tspan :if={index == @last_line} class="ehv-ai-model-picker__caret" dx="2">|</tspan>
          </text>
        </g>
      </svg>
    </.frame>
    """
  end

  defp meter(true, true), do: "ehv-ai-model-picker__on"
  defp meter(true, false), do: "ehv-fill-2"
  defp meter(false, _on), do: "ehv-fill-4"
end
assets/css/ai/model-picker.css
model-picker.css
/* ai/model-picker */
.ehv-ai-model-picker { --ehv-max-w: 370px; --ehv-step: 110ms; }

.ehv-ai-model-picker__row { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-ai-model-picker__row--on { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); }

.ehv--hover .ehv-ai-model-picker__row { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-ai-model-picker__tier:hover .ehv-ai-model-picker__row { stroke: var(--ehv-border-strong); }
.ehv--hover .ehv-ai-model-picker__tier:hover .ehv-ai-model-picker__row--on { stroke: var(--ehv-lime); }

/* The radio dot and the meter of the chosen tier: the line lime, so they hold on cream. */
.ehv-ai-model-picker__on { fill: var(--ehv-lime); }

/* The caret is a glyph in the text, so it always sits right after the last word. */
.ehv svg .ehv-ai-model-picker__caret { fill: var(--ehv-lime); }

@keyframes ehv-ai-model-picker-blink { 50% { fill-opacity: 0; } }
.ehv--animated .ehv-ai-model-picker__caret { animation: ehv-ai-model-picker-blink 1.1s steps(1) infinite; }