ai/model-picker.ex
Model picker
A short list of model tiers with one selected and a response streaming underneath.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.ai_model_picker animated />
</div>
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
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
/* 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; }