ai/prompt-box.ex
Prompt box
A wide prompt composer with a typed prompt, an attachment, a model chip, a token count and a send button.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.ai_prompt_box animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
prompt |
list | ["Compare the October close with September and lis" <> ..., "the accounts that moved by more than 5%"] |
Up to three typed lines of about 56 characters. The caret follows the last one. An empty list shows the placeholder. |
placeholder |
string | "Ask about your operations" |
|
attachment |
any | "close-october.xlsx" |
A file name of up to 40 characters, or `nil` for no attachment. |
model |
string | "Balanced" |
A neutral tier name. |
tokens |
string | "182 / 8k tokens" |
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/prompt-box.ex
defmodule EHVisuals.Ai.PromptBox do
@moduledoc """
A prompt composer: what is being typed, the file attached to it, the model
it goes to, how many tokens it takes and the button that sends it.
"""
use EHVisuals.Composition,
name: "ai/prompt-box",
title: "Prompt box",
description:
"A wide prompt composer with a typed prompt, an attachment, a model chip, a token count and a send button."
@prompt [
"Compare the October close with September and list",
"the accounts that moved by more than 5%"
]
attr(:prompt, :list,
default: @prompt,
doc:
"Up to three typed lines of about 56 characters. The caret follows the last one. An empty list shows the placeholder."
)
attr(:placeholder, :string, default: "Ask about your operations")
attr(:attachment, :any,
default: "close-october.xlsx",
doc: "A file name of up to 40 characters, or `nil` for no attachment."
)
attr(:model, :string, default: "Balanced", doc: "A neutral tier name.")
attr(:tokens, :string, default: "182 / 8k tokens")
attr(:label, :string,
default: "A prompt composer with a typed question, an attached spreadsheet and a send button"
)
frame_attrs()
@spec ai_prompt_box(map()) :: Phoenix.LiveView.Rendered.t()
def ai_prompt_box(assigns) do
lines = assigns.prompt |> Enum.take(3) |> Enum.with_index()
first = if assigns.attachment, do: 68, else: 38
bar = first + max(length(lines) - 1, 0) * 20 + 20
assigns =
assigns
|> assign(:lines, lines)
|> assign(:last_line, length(lines) - 1)
|> assign(:first, first)
|> assign(:chip_w, Float.round(52 + String.length(assigns.attachment || "") * 5.7, 1))
|> assign(:model_w, Float.round(58 + String.length(assigns.model) * 5.8, 1))
|> assign(:bar, bar)
|> assign(:height, bar + 28 + 16)
~H"""
<.frame name="ai/prompt-box" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 460 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="459" height={@height - 1} rx="14" />
<g :if={@attachment} class="ehv-a-pop ehv-i-2">
<rect class="ehv-well" x="20.5" y="18.5" width={@chip_w} height="26" rx="7" />
<path
class="ehv-line-ink ehv-round"
d="M30.5 25 h5.5 l3.5 3.5 v9.5 h-9 z M36 25 v3.5 h3.5"
/>
<text class="ehv-t-2" x="47" y="35.5" font-size="11">{@attachment}</text>
<path
class="ehv-line ehv-round"
d={"M#{20.5 + @chip_w - 15} 29 l5 5 m0 -5 l-5 5"}
/>
</g>
<text
:for={{line, index} <- @lines}
class={"ehv-a-fade ehv-i-#{index * 2 + 3}"}
x="22"
y={@first + index * 20}
font-size="13.5"
>
{line}<tspan :if={index == @last_line} class="ehv-ai-prompt-box__caret" dx="2">|</tspan>
</text>
<text
:if={@lines == []}
class="ehv-t-3 ehv-a-fade ehv-i-3"
x="22"
y={@first}
font-size="13.5"
>
<tspan class="ehv-ai-prompt-box__caret">|</tspan>
<tspan dx="3">{@placeholder}</tspan>
</text>
<g class="ehv-a-fade ehv-i-5">
<rect class="ehv-well" x="20.5" y={@bar + 0.5} width="27" height="27" rx="8" />
<path class="ehv-line-ink ehv-round" d={"M34 #{@bar + 9} v10 m-5 -5 h10"} />
<rect class="ehv-well" x="56.5" y={@bar + 0.5} width={@model_w} height="27" rx="8" />
<rect
:for={step <- 0..2}
class={if step < 2, do: "ehv-fill-2", else: "ehv-fill-4"}
x={67 + step * 5}
y={@bar + 16 - step * 3}
width="3"
height={4 + step * 3}
rx="1"
/>
<text class="ehv-mono ehv-t-2" x="87" y={@bar + 17.5} font-size="8.5">
{String.upcase(@model)}
</text>
<path
class="ehv-line-ink ehv-round"
d={"M#{56.5 + @model_w - 17} #{@bar + 12.5} l3 3 l3 -3"}
/>
<text class="ehv-mono" x="398" y={@bar + 17.5} font-size="8.5" text-anchor="end">
{String.upcase(@tokens)}
</text>
</g>
<g class="ehv-a-pop ehv-i-7">
<g class="ehv-ai-prompt-box__send">
<rect class="ehv-fill-lime" x="412" y={@bar} width="28" height="28" rx="8" />
<path
class="ehv-ai-prompt-box__arrow ehv-round"
d={"M426 #{@bar + 20} v-12 m-5 5 l5 -5 l5 5"}
/>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
end
assets/css/ai/prompt-box.css
/* ai/prompt-box */
.ehv-ai-prompt-box { --ehv-max-w: 500px; --ehv-step: 110ms; }
.ehv-ai-prompt-box__arrow { fill: none; stroke: var(--ehv-on-lime); stroke-width: 1.5; }
/* The caret is a glyph in the prompt, so it always sits right after the last word. */
.ehv svg .ehv-ai-prompt-box__caret { fill: var(--ehv-lime); }
@keyframes ehv-ai-prompt-box-blink { 50% { fill-opacity: 0; } }
.ehv--animated .ehv-ai-prompt-box__caret { animation: ehv-ai-prompt-box-blink 1.1s steps(1) infinite; }
.ehv-ai-prompt-box__send { transform-box: fill-box; transform-origin: center; }
.ehv--hover .ehv-ai-prompt-box__send { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-ai-prompt-box__send { transform: translateY(-3px); }
@media (prefers-reduced-motion: reduce) {
.ehv--hover .ehv-ai-prompt-box__send { transition: none; }
}