empowered.house ↗

ai/presence.ex

Presence

A shared document with the live cursors and name tags of people and an agent, one of them yours.

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

<div class="h-96 rounded-xl border border-border">
  <.ai_presence animated />
</div>
default
isometric
custom document · the agent is yours
four cursors · hover · no glow
light surface
isometric · fade out · still

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