ai/writer.ex
Writer
A document where a selected passage is being rewritten, with ghost text and a popover to accept or discard it.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.ai_writer animated />
</div>
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
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
/* 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; }
}