empowered.house ↗

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.

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

<div class="h-96 rounded-xl border border-border">
  <.ai_prompt_box animated />
</div>
default
isometric
one line · no attachment
empty · hover · no glow
light surface
isometric · fade out · still

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
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
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; }
}