ai/thinking.ex
Thinking
A reasoning panel that lists its thinking steps as they complete, then settles into a one-line answer.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.ai_thinking animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Thought for 12 seconds" |
|
steps |
list | ["Read the stock ledger for SKU 4471", "Compare the counts with the WMS", "Find three receipts that were not posted", "Check who owns the receiving step"] |
Two to five steps of up to 42 characters. |
answer |
string | "Three receipts are missing from the ERP." |
One line of up to 42 characters. |
answer_label |
string | "Answer" |
|
thinking |
boolean | false |
Shows the last step still running and the answer not written yet. |
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/thinking.ex
defmodule EHVisuals.Ai.Thinking do
@moduledoc """
A reasoning panel: the steps the model went through, ticked off one by one,
and the one-line answer they settle into.
"""
use EHVisuals.Composition,
name: "ai/thinking",
title: "Thinking",
description:
"A reasoning panel that lists its thinking steps as they complete, then settles into a one-line answer."
@steps [
"Read the stock ledger for SKU 4471",
"Compare the counts with the WMS",
"Find three receipts that were not posted",
"Check who owns the receiving step"
]
attr(:title, :string, default: "Thought for 12 seconds")
attr(:steps, :list, default: @steps, doc: "Two to five steps of up to 42 characters.")
attr(:answer, :string,
default: "Three receipts are missing from the ERP.",
doc: "One line of up to 42 characters."
)
attr(:answer_label, :string, default: "Answer")
attr(:thinking, :boolean,
default: false,
doc: "Shows the last step still running and the answer not written yet."
)
attr(:label, :string,
default: "A reasoning panel with four completed thinking steps and a one-line answer"
)
frame_attrs()
@spec ai_thinking(map()) :: Phoenix.LiveView.Rendered.t()
def ai_thinking(assigns) do
rows = assigns.steps |> Enum.take(5) |> Enum.with_index()
count = max(length(rows), 1)
answer_y = 44 + 16 + count * 24 + 12
assigns =
assigns
|> assign(:rows, rows)
|> assign(:last, count - 1)
|> assign(:well_h, 16 + count * 24)
|> assign(:answer_y, answer_y)
|> assign(:height, answer_y + 48 + 20)
~H"""
<.frame name="ai/thinking" 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" />
<g class="ehv-a-fade ehv-i-1">
<path class="ehv-line-ink ehv-round" d="M22 23.5 l4 4 l4 -4" />
<text class="ehv-t-2" x="38" y="30" font-size="12.5">{@title}</text>
<text class="ehv-mono" x="320" y="29.5" font-size="8.5" text-anchor="end">
{length(@rows)} STEPS
</text>
</g>
<rect
class="ehv-well ehv-a-fade ehv-i-1"
x="20.5"
y="44.5"
width="299"
height={@well_h}
rx="8"
/>
<g :for={{step, index} <- @rows}>
<line
:if={index < @last}
class={"ehv-line-soft ehv-a-fade ehv-i-#{min(index * 2 + 3, 24)}"}
x1="37"
x2="37"
y1={72 + index * 24}
y2={80 + index * 24}
/>
<g class={"ehv-a-fade ehv-i-#{index * 2 + 2}"}>
<g :if={@thinking and index == @last}>
<circle class="ehv-ai-thinking__live ehv-a-ping" cx="37" cy={64 + index * 24} r="4" />
<circle class="ehv-ai-thinking__live ehv-a-pulse" cx="37" cy={64 + index * 24} r="3" />
</g>
<path
:if={!(@thinking and index == @last)}
class={"ehv-ai-thinking__tick ehv-round ehv-a-draw ehv-i-#{index * 2 + 3}"}
d={"M33 #{64 + index * 24} l2.8 2.8 l5 -5.6"}
pathLength="1"
/>
<text
class={[!(@thinking and index == @last) && "ehv-t-2"]}
x="52"
y={68 + index * 24}
font-size="11.5"
>
{step}
</text>
</g>
</g>
<g class={"ehv-a-rise ehv-i-#{min(length(@rows) * 2 + 3, 24)}"}>
<rect
class={if @thinking, do: "ehv-well ehv-dash", else: "ehv-card-lime"}
x="20.5"
y={@answer_y + 0.5}
width="299"
height="47"
rx="8"
/>
<text
class={["ehv-mono", if(@thinking, do: "ehv-t-4", else: "ehv-t-lime")]}
x="34"
y={@answer_y + 18}
font-size="8"
>
{String.upcase(@answer_label)}
</text>
<text :if={!@thinking} class="ehv-t-strong" x="34" y={@answer_y + 35.5} font-size="12.5">
{@answer}
</text>
<rect
:if={@thinking}
class="ehv-fill-soft ehv-a-pulse"
x="34"
y={@answer_y + 27}
width="196"
height="7"
rx="3.5"
/>
</g>
</g>
</svg>
</.frame>
"""
end
end
assets/css/ai/thinking.css
/* ai/thinking */
.ehv-ai-thinking { --ehv-max-w: 370px; --ehv-step: 150ms; }
.ehv-ai-thinking__tick { fill: none; stroke: var(--ehv-ink-2); stroke-width: 1.25; }
.ehv-ai-thinking__live { fill: var(--ehv-lime); }