empowered.house ↗

ai/writer.ex

Writer

A document where a selected passage is being rewritten, with ghost text and a popover to accept or discard it.

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

<div class="h-96 rounded-xl border border-border">
  <.ai_writer animated />
</div>
default
isometric
custom document · one line selected
three lines selected · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Reply to Northwind"
title string "Delay on PO-2291"
before list ["Hi Piotr,", "thanks for flagging the delay on your order."] Up to three lines of the document above the selection, about 50 characters each.
selection list ["We are very sorry for any inconvenience this may", "have caused and are looking into it urgently."] One to three whole lines, the passage being rewritten.
ghost string "The truck left a day late. It arrives on Friday." The suggestion, one line of up to 50 characters.
instruction string "Shorter, and say what happened" Up to 32 characters.
accept string "Accept"
discard string "Discard"
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/writer.ex
writer.ex
defmodule EHVisuals.Ai.Writer do
  @moduledoc """
  A document with one passage selected and being rewritten: the selection,
  the suggestion written underneath as ghost text, and the popover that
  accepts or discards it.
  """

  use EHVisuals.Composition,
    name: "ai/writer",
    title: "Writer",
    description:
      "A document where a selected passage is being rewritten, with ghost text and a popover to accept or discard it."

  attr(:eyebrow, :string, default: "Reply to Northwind")
  attr(:title, :string, default: "Delay on PO-2291")

  attr(:before, :list,
    default: ["Hi Piotr,", "thanks for flagging the delay on your order."],
    doc: "Up to three lines of the document above the selection, about 50 characters each."
  )

  attr(:selection, :list,
    default: [
      "We are very sorry for any inconvenience this may",
      "have caused and are looking into it urgently."
    ],
    doc: "One to three whole lines, the passage being rewritten."
  )

  attr(:ghost, :string,
    default: "The truck left a day late. It arrives on Friday.",
    doc: "The suggestion, one line of up to 50 characters."
  )

  attr(:instruction, :string,
    default: "Shorter, and say what happened",
    doc: "Up to 32 characters."
  )

  attr(:accept, :string, default: "Accept")
  attr(:discard, :string, default: "Discard")

  attr(:label, :string,
    default: "A reply where two selected lines are being rewritten into one shorter line"
  )

  frame_attrs()

  @spec ai_writer(map()) :: Phoenix.LiveView.Rendered.t()
  def ai_writer(assigns) do
    before = assigns.before |> Enum.take(3) |> Enum.with_index()
    first = 82 + length(before) * 19

    selection =
      for {line, index} <- assigns.selection |> Enum.take(3) |> Enum.with_index() do
        %{line: line, index: index, y: first + index * 19, w: round(String.length(line) * 5.75)}
      end

    ghost_y = first + length(selection) * 19 + 2
    pop_y = ghost_y + 13
    accept_w = Float.round(22 + String.length(assigns.accept) * 5.8, 1)
    discard_w = Float.round(22 + String.length(assigns.discard) * 5.8, 1)

    assigns =
      assigns
      |> assign(:before, before)
      |> assign(:selection, selection)
      |> assign(:shown, length(before) + length(selection))
      |> assign(:ghost_y, ghost_y)
      |> assign(:pop_y, pop_y)
      |> assign(:accept_w, accept_w)
      |> assign(:discard_w, discard_w)
      |> assign(:height, pop_y + 70 + 18)

    ~H"""
    <.frame name="ai/writer" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 380 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="379" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-mono" x="24" y="30" font-size="8.5">{String.upcase(@eyebrow)}</text>
            <text class="ehv-mono ehv-t-4" x="356" y="30" font-size="8.5" text-anchor="end">
              DRAFT
            </text>
            <text class="ehv-t-strong" x="24" y="56" font-size="16">{@title}</text>
          </g>
          <text
            :for={{line, index} <- @before}
            class={"ehv-t-2 ehv-a-fade ehv-i-#{index + 2}"}
            x="24"
            y={82 + index * 19}
            font-size="12"
          >
            {line}
          </text>
          <g :for={row <- @selection} class={"ehv-a-grow-x ehv-i-#{row.index + 5}"}>
            <rect class="ehv-fill-lime-soft" x="21" y={row.y - 13.5} width={row.w + 6} height="19" />
          </g>
          <text
            :for={row <- @selection}
            class={"ehv-a-fade ehv-i-#{row.index + 3}"}
            x="24"
            y={row.y}
            font-size="12"
            textLength={row.w}
          >
            {row.line}
          </text>
          <g class={"ehv-a-fade ehv-i-#{@shown + 4}"}>
            <rect class="ehv-fill-soft" x="24" y={@ghost_y + 13} width="332" height="5" rx="2.5" />
            <rect class="ehv-fill-soft" x="24" y={@ghost_y + 25} width="332" height="5" rx="2.5" />
            <rect class="ehv-fill-soft" x="24" y={@ghost_y + 37} width="180" height="5" rx="2.5" />
          </g>
          <text class={"ehv-t-3 ehv-a-fade ehv-i-#{@shown + 6}"} x="24" y={@ghost_y} font-size="12">
            {@ghost}<tspan class="ehv-ai-writer__caret" dx="2">|</tspan>
          </text>
          <g class={"ehv-a-rise ehv-i-#{@shown + 5}"}>
            <rect class="ehv-card-3" x="112.5" y={@pop_y + 0.5} width="247" height="69" rx="10" />
            <rect class="ehv-fill-canvas" x="124" y={@pop_y + 11} width="18" height="18" rx="5.5" />
            <rect class="ehv-ai-writer__spark" x="130" y={@pop_y + 17} width="6" height="6" rx="2" />
            <text x="150" y={@pop_y + 24.5} font-size="11.5">{@instruction}</text>
            <g class="ehv-ai-writer__accept">
              <rect
                class="ehv-fill-lime"
                x="124"
                y={@pop_y + 37}
                width={@accept_w}
                height="22"
                rx="6"
              />
              <text
                class="ehv-t-strong ehv-t-on-lime"
                x={124 + @accept_w / 2}
                y={@pop_y + 52}
                font-size="10.5"
                text-anchor="middle"
              >
                {@accept}
              </text>
            </g>
            <rect
              class="ehv-well"
              x={124 + @accept_w + 6.5}
              y={@pop_y + 37.5}
              width={@discard_w}
              height="21"
              rx="6"
            />
            <text
              class="ehv-t-2"
              x={124 + @accept_w + 6.5 + @discard_w / 2}
              y={@pop_y + 52}
              font-size="10.5"
              text-anchor="middle"
            >
              {@discard}
            </text>
            <text class="ehv-mono" x="348" y={@pop_y + 51.5} font-size="8" text-anchor="end">
              1 / 3
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end
end
assets/css/ai/writer.css
writer.css
/* ai/writer */
.ehv-ai-writer { --ehv-max-w: 410px; --ehv-step: 110ms; }

.ehv-ai-writer__spark { fill: var(--ehv-ink-2); }

/* The caret is a glyph in the ghost line, right after its last word. */
.ehv svg .ehv-ai-writer__caret { fill: var(--ehv-lime); }

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

.ehv-ai-writer__accept { transform-box: fill-box; transform-origin: center; }
.ehv--hover .ehv-ai-writer__accept { transition: transform 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-ai-writer__accept { transform: translateY(-2px); }

@media (prefers-reduced-motion: reduce) {
  .ehv--hover .ehv-ai-writer__accept { transition: none; }
}