ai/presence.ex
Presence
A shared document with the live cursors and name tags of people and an agent, one of them yours.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.ai_presence animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "Month-end close" |
|
heading |
string | "October close checklist" |
|
items |
list | ["Reconcile the bank statement", "Post the accruals", "Review open invoices", "Lock the period"] |
One to four checklist lines of up to 30 characters. |
done |
integer | 1 |
How many lines are ticked, from the top. |
cursors |
list | [%{name: "Marta"}, %{name: "Jan"}, %{name: "Close agent", ...}] |
One to four `%{name}` with a name of up to 12 characters. `agent: true` marks an agent. |
you |
integer | 1 |
Index of your own cursor, the lime one. |
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/presence.ex
defmodule EHVisuals.Ai.Presence do
@moduledoc """
A shared document with the live cursors of the people and the agent working
in it, one of them yours.
"""
use EHVisuals.Composition,
name: "ai/presence",
title: "Presence",
description:
"A shared document with the live cursors and name tags of people and an agent, one of them yours."
@items [
"Reconcile the bank statement",
"Post the accruals",
"Review open invoices",
"Lock the period"
]
@cursors [%{name: "Marta"}, %{name: "Jan"}, %{name: "Close agent", agent: true}]
attr(:title, :string, default: "Month-end close")
attr(:heading, :string, default: "October close checklist")
attr(:items, :list, default: @items, doc: "One to four checklist lines of up to 30 characters.")
attr(:done, :integer, default: 1, doc: "How many lines are ticked, from the top.")
attr(:cursors, :list,
default: @cursors,
doc: "One to four `%{name}` with a name of up to 12 characters. `agent: true` marks an agent."
)
attr(:you, :integer, default: 1, doc: "Index of your own cursor, the lime one.")
attr(:label, :string,
default: "A shared checklist with the cursors of Marta, Jan and an agent working in it"
)
frame_attrs()
@spec ai_presence(map()) :: Phoenix.LiveView.Rendered.t()
def ai_presence(assigns) do
items = assigns.items |> Enum.take(4) |> Enum.with_index()
count = max(length(items), 1)
cursors = place(Enum.take(assigns.cursors, 4), assigns.you, count)
bottom = 136 + (count - 1) * 26 + 18
assigns =
assigns
|> assign(:rows, items)
|> assign(:cursors, cursors)
|> assign(:people, length(cursors))
|> assign(:selecting, Enum.find(cursors, &(&1.slot == 0)))
|> assign(:typing, Enum.find(cursors, &(&1.slot == 1)))
|> assign(:working, Enum.find(cursors, &(&1.slot == 2)))
|> assign(:bottom, bottom)
|> assign(:height, bottom + 20)
~H"""
<.frame name="ai/presence" 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">
<path class="ehv-line-ink ehv-round" d="M21.5 17.5 h7 l4 4 v12 h-11 z M28.5 17.5 v4 h4" />
<text class="ehv-t-strong" x="42" y="30.5" font-size="13">{@title}</text>
<text
class="ehv-mono"
x={350 - (@people - 1) * 23 - 17}
y="30"
font-size="8"
text-anchor="end"
>
{@people} EDITING
</text>
<g :for={cursor <- Enum.reverse(@cursors)}>
<rect
:if={cursor.agent}
class={["ehv-ai-presence__avatar", cursor.you && "ehv-ai-presence__avatar--you"]}
x={340 - cursor.index * 23}
y="17"
width="20"
height="20"
rx="6"
/>
<circle
:if={!cursor.agent}
class={["ehv-ai-presence__avatar", cursor.you && "ehv-ai-presence__avatar--you"]}
cx={350 - cursor.index * 23}
cy="27"
r="10"
/>
<text
class={["ehv-mono", if(cursor.you, do: "ehv-t-lime", else: "ehv-t-2")]}
x={350 - cursor.index * 23}
y="29.8"
font-size="7.5"
text-anchor="middle"
>
{cursor.initials}
</text>
</g>
</g>
<g class="ehv-a-rise ehv-i-2">
<rect class="ehv-well" x="20.5" y="50.5" width="339" height={@bottom - 50} rx="8" />
<text class="ehv-t-strong" x="36" y="80" font-size="14">{@heading}</text>
<rect
:if={@selecting}
class={if @selecting.you, do: "ehv-fill-lime-soft", else: "ehv-ai-presence__selection"}
x="150"
y="88"
width="132"
height="13"
rx="2"
/>
<rect class="ehv-fill-soft" x="36" y="92" width="300" height="5" rx="2.5" />
<rect class="ehv-fill-soft" x="36" y="103" width="212" height="5" rx="2.5" />
</g>
<g :for={{item, index} <- @rows} class={"ehv-a-rise ehv-i-#{index + 3}"}>
<rect
:if={@working && @working.row == index}
class="ehv-fill-surface-2"
x="29"
y={121 + index * 26}
width="322"
height="22"
rx="6"
/>
<rect
class={if index < @done, do: "ehv-ai-presence__box--done", else: "ehv-node-soft"}
x="36.5"
y={126.5 + index * 26}
width="11"
height="11"
rx="3"
/>
<path
:if={index < @done}
class="ehv-ai-presence__tick ehv-round"
d={"M39 #{132 + index * 26} l2.2 2.2 l3.8 -4.4"}
/>
<text class={[index < @done && "ehv-t-3"]} x="58" y={136 + index * 26} font-size="12">
{item}<tspan
:if={@typing && @typing.row == index}
class={[
"ehv-ai-presence__caret",
@typing.you && "ehv-ai-presence__caret--you"
]}
dx="1.5"
>|</tspan>
</text>
</g>
<g
:for={cursor <- @cursors}
class={[
"ehv-ai-presence__cursor ehv-a-pop ehv-i-#{cursor.index * 2 + 7}",
cursor.you && "ehv-ai-presence__cursor--you"
]}
>
<g class={"ehv-ai-presence__drift ehv-i-#{cursor.index * 3}"}>
<path
class={["ehv-ai-presence__arrow", cursor.you && "ehv-ai-presence__arrow--you"]}
d={"M#{cursor.x} #{cursor.y} l0 13 l3.6 -3.2 l2.7 5.6 l2.1 -1 l-2.6 -5.5 l4.6 0 z"}
/>
<rect
class={if cursor.you, do: "ehv-fill-lime", else: "ehv-card-3"}
x={cursor.tag_x}
y={cursor.y + 12}
width={cursor.tag_w}
height="16"
rx="5"
/>
<rect
:if={cursor.agent}
class={
if cursor.you, do: "ehv-ai-presence__glyph--you", else: "ehv-ai-presence__glyph"
}
x={cursor.tag_x + 6}
y={cursor.y + 17}
width="6"
height="6"
rx="2"
/>
<text
class={["ehv-mono", if(cursor.you, do: "ehv-t-on-lime", else: "ehv-t-ink")]}
x={cursor.tag_x + if(cursor.agent, do: 16, else: 7)}
y={cursor.y + 23}
font-size="8"
>
{cursor.tag}
</text>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
# Each cursor takes one slot of the page: the first selects in the paragraph,
# the second types at the end of a checklist line, the third works on the
# last line, the fourth rests in the top right corner of the page.
defp place(cursors, you, count) do
for {cursor, index} <- Enum.with_index(cursors) do
agent = Map.get(cursor, :agent, false)
mine = index == you
tag = String.upcase(if mine, do: "#{cursor.name} · you", else: cursor.name)
tag_w = Float.round(14 + String.length(tag) * 5.5 + if(agent, do: 9, else: 0), 1)
{x, y, row} = spot(index, count)
tag_x = if x + 9 + tag_w > 372, do: x - 4 - tag_w, else: x + 9
%{
index: index,
slot: index,
row: row,
you: mine,
agent: agent,
initials: initials(cursor.name),
tag: tag,
tag_w: tag_w,
tag_x: tag_x,
x: x,
y: y
}
end
end
defp spot(0, _count), do: {283, 97, nil}
defp spot(1, count) do
row = max(count - 3, 0)
{238, 140 + row * 26, row}
end
# Next to the typing cursor's line it moves left, so the two tags stay apart.
defp spot(2, count) do
row = count - 1
{if(count >= 3, do: 204, else: 112), 124 + row * 26, row}
end
defp spot(_index, _count), do: {332, 58, nil}
defp initials(name) do
name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
end
end
assets/css/ai/presence.css
/* ai/presence */
.ehv-ai-presence { --ehv-max-w: 410px; --ehv-step: 100ms; }
.ehv-ai-presence__avatar { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-ai-presence__avatar--you { fill: var(--ehv-surface-1); stroke: var(--ehv-lime); stroke-width: 1.25; }
.ehv-ai-presence__selection { fill: var(--ehv-border-strong); }
.ehv-ai-presence__box--done { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-ai-presence__tick { fill: none; stroke: var(--ehv-ink-2); stroke-width: 1.25; }
.ehv-ai-presence__arrow { fill: var(--ehv-ink); stroke: var(--ehv-canvas); stroke-width: 1; stroke-linejoin: round; }
.ehv-ai-presence__arrow--you { fill: var(--ehv-lime-fill); stroke: var(--ehv-on-lime); }
.ehv-ai-presence__glyph { fill: var(--ehv-ink-2); }
.ehv-ai-presence__glyph--you { fill: var(--ehv-on-lime); }
/* The caret is a glyph in the line it types into, right after the last word. */
.ehv svg .ehv-ai-presence__caret { fill: var(--ehv-ink-2); }
.ehv svg .ehv-ai-presence__caret--you { fill: var(--ehv-lime); }
@keyframes ehv-ai-presence-blink { 50% { fill-opacity: 0; } }
.ehv--animated .ehv-ai-presence__caret { animation: ehv-ai-presence-blink 1.1s steps(1) infinite; }
/* Live cursors never sit still: each drifts a few pixels on its own phase. */
@keyframes ehv-ai-presence-drift {
35% { transform: translate(5px, -3px); }
70% { transform: translate(-3px, 3px); }
}
.ehv--animated .ehv-ai-presence__drift {
animation: ehv-ai-presence-drift 6s ease-in-out infinite;
animation-delay: calc(var(--ehv-i, 0) * -600ms);
}
.ehv--hover .ehv-ai-presence__cursor { transition: transform 300ms var(--ehv-ease); }
.ehv--hover:hover .ehv-ai-presence__cursor--you { transform: translate(8px, 5px); }
@media (prefers-reduced-motion: reduce) {
.ehv--hover .ehv-ai-presence__cursor { transition: none; }
}