hero/workflow-automation.ex
Workflow automation
A workflow canvas: a trigger runs through steps into a condition that posts automatically or asks a person.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.hero_workflow_automation animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Workflow · invoice intake" |
|
start |
map | %{meta: "AP inbox", title: "Invoice arrives"} |
`%{title, meta}` of the trigger, the first card. Titles hold up to 16 characters. |
steps |
list | [%{meta: "12 fields found", title: "Read the invoice"}, %{meta: "PO 7731 · 3 lines", title: "Match to PO"}] |
One to three `%{title, meta}`. Titles hold up to 16 characters. |
condition |
string | "Under 5,000" |
The test in the diamond, up to 12 characters. |
auto |
map | %{meta: "No one touches it", title: "Post to ERP"} |
`%{title, meta}` of the outcome when the condition holds. |
person |
map | %{meta: "Approval asked", title: "Marta Nowak", initials: "MN"} |
`%{title, meta, initials}` of the outcome when it does not. Titles hold up to 12 characters. |
taken |
"auto" | "person" | "auto" |
The branch this run takes, drawn in lime. |
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/hero/workflow-automation.ex
defmodule EHVisuals.Hero.WorkflowAutomation do
@moduledoc """
A workflow on a canvas: a trigger, a few steps, then a condition that
either posts the result automatically or asks a person.
"""
use EHVisuals.Composition,
name: "hero/workflow-automation",
title: "Workflow automation",
description:
"A workflow canvas: a trigger runs through steps into a condition that posts automatically or asks a person."
# Cards sit on one row and are joined by short arrows.
@card_w 118
@card_h 60
@gap 24
@row 165
@out_w 134
@auto_y 76
@person_y 254
@steps [
%{title: "Read the invoice", meta: "12 fields found"},
%{title: "Match to PO", meta: "PO 7731 · 3 lines"}
]
attr(:caption, :string, default: "Workflow · invoice intake")
attr(:start, :map,
default: %{title: "Invoice arrives", meta: "AP inbox"},
doc: "`%{title, meta}` of the trigger, the first card. Titles hold up to 16 characters."
)
attr(:steps, :list,
default: @steps,
doc: "One to three `%{title, meta}`. Titles hold up to 16 characters."
)
attr(:condition, :string,
default: "Under 5,000",
doc: "The test in the diamond, up to 12 characters."
)
attr(:auto, :map,
default: %{title: "Post to ERP", meta: "No one touches it"},
doc: "`%{title, meta}` of the outcome when the condition holds."
)
attr(:person, :map,
default: %{title: "Marta Nowak", meta: "Approval asked", initials: "MN"},
doc:
"`%{title, meta, initials}` of the outcome when it does not. Titles hold up to 12 characters."
)
attr(:taken, :string,
default: "auto",
values: ~w(auto person),
doc: "The branch this run takes, drawn in lime."
)
attr(:label, :string,
default:
"A workflow from an incoming invoice through two steps to a condition that posts it automatically or asks a person"
)
frame_attrs()
@spec hero_workflow_automation(map()) :: Phoenix.LiveView.Rendered.t()
def hero_workflow_automation(assigns) do
steps = Enum.take(assigns.steps, 3)
cards =
for {card, index} <- Enum.with_index([assigns.start | steps]) do
%{
title: card.title,
meta: Map.get(card, :meta, ""),
index: index,
kicker: if(index == 0, do: "Trigger", else: "Step #{pad(index)}"),
x: 10 + index * (@card_w + @gap)
}
end
last = List.last(cards)
diamond = last.x + @card_w + @gap + 46
bend = diamond + 46 + 18
out_x = bend + 22
width = out_x + @out_w + 10
auto? = assigns.taken == "auto"
arrows =
for card <- cards do
from = card.x + @card_w
%{
index: card.index,
d: "M#{from} #{@row} H#{from + @gap - 5}",
head: head(from + @gap, @row)
}
end
assigns =
assigns
|> assign(:cards, cards)
|> assign(:arrows, arrows)
|> assign(:width, width)
|> assign(:dots, for(x <- 0..div(width, 36), y <- 1..8, do: {x * 36 + 8, y * 36 + 2}))
|> assign(:diamond, %{
x: diamond,
points:
"#{diamond - 46},#{@row} #{diamond},#{@row - 40} #{diamond + 46},#{@row} #{diamond},#{@row + 40}"
})
|> assign(:bend, bend)
|> assign(:stub, "M#{diamond + 46} #{@row} H#{bend}")
|> assign(:branches, [
branch("auto", auto?, "Yes", bend, out_x, @auto_y, 8),
branch("person", !auto?, "No", bend, out_x, @person_y, -8)
])
|> assign(:out_x, out_x)
|> assign(:auto_on, auto?)
|> assign(:route, route(List.first(cards).x + @card_w, bend, out_x, auto?))
|> assign(:size, %{w: @card_w, h: @card_h, top: @row - div(@card_h, 2), row: @row})
|> assign(:out, %{w: @out_w, auto: @auto_y - 30, person: @person_y - 30})
|> assign(:stage, length(cards) * 2)
|> assign(:taken_y, if(auto?, do: @auto_y - 30, else: @person_y - 30))
~H"""
<.frame name="hero/workflow-automation" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 #{@width} 304"}>
<g class="ehv-a-fade">
<circle
:for={{x, y} <- @dots}
class="ehv-hero-workflow-automation__dot"
cx={x}
cy={y}
r="0.9"
/>
</g>
<text class="ehv-mono ehv-a-fade" x="10" y="18" font-size="9">
{String.upcase(@caption)}
</text>
<g class="ehv-a-fade ehv-i-2">
<circle class="ehv-fill-lime ehv-a-pulse" cx={@width - 42} cy="15" r="3" />
<text class="ehv-mono ehv-t-2" x={@width - 10} y="18" font-size="9" text-anchor="end">
LIVE
</text>
</g>
<g class="ehv-h-lift">
<g :for={arrow <- @arrows} class={"ehv-a-fade ehv-i-#{arrow.index * 2 + 2}"}>
<path class="ehv-line-ink" d={arrow.d} />
<path class="ehv-fill-2" d={arrow.head} />
</g>
<path class={"ehv-line-lime ehv-a-fade ehv-i-#{@stage + 1}"} d={@stub} />
<g :for={branch <- @branches} class={"ehv-a-fade ehv-i-#{@stage + 2}"}>
<path
class={[
"ehv-round",
if(branch.on, do: "ehv-line-lime", else: "ehv-line ehv-dash")
]}
d={branch.d}
/>
<path class={if branch.on, do: "ehv-fill-lime", else: "ehv-fill-3"} d={branch.head} />
<rect
class="ehv-fill-canvas"
x={@bend - 13}
y={branch.tag_y - 8}
width="26"
height="15"
rx="4"
/>
<text
class={["ehv-mono", branch.on && "ehv-t-lime"]}
x={@bend}
y={branch.tag_y + 3}
font-size="7.5"
text-anchor="middle"
>
{String.upcase(branch.tag)}
</text>
</g>
<g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-14">
<circle class="ehv-fill-lime" r="3.5">
<animateMotion path={@route} dur="4.4s" repeatCount="indefinite" />
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.08;0.92;1"
dur="4.4s"
repeatCount="indefinite"
/>
</circle>
</g>
<g :for={card <- @cards} class={"ehv-a-rise ehv-i-#{card.index * 2 + 1}"}>
<rect
class="ehv-card ehv-hero-workflow-automation__card"
x={card.x + 0.5}
y={@size.top + 0.5}
width={@size.w - 1}
height={@size.h - 1}
rx="9"
/>
<text class="ehv-mono" x={card.x + 12} y={@size.top + 18} font-size="7.5">
{String.upcase(card.kicker)}
</text>
<path
class="ehv-line-ink ehv-round"
d={"M#{card.x + @size.w - 21} #{@size.top + 15} l2.5 2.5 l5 -5.5"}
/>
<text class="ehv-t-strong" x={card.x + 12} y={@size.top + 36} font-size="12.5">
{card.title}
</text>
<text class="ehv-mono" x={card.x + 12} y={@size.top + 50} font-size="7.5">
{String.upcase(card.meta)}
</text>
</g>
<g class={"ehv-a-pop ehv-i-#{@stage + 1}"}>
<polygon class="ehv-card ehv-hero-workflow-automation__card" points={@diamond.points} />
<text
class="ehv-mono"
x={@diamond.x}
y={@size.row - 8}
font-size="7.5"
text-anchor="middle"
>
IF
</text>
<text
class="ehv-t-strong"
x={@diamond.x}
y={@size.row + 8}
font-size="11"
text-anchor="middle"
>
{@condition}
</text>
</g>
<g class={"ehv-a-rise ehv-i-#{@stage + 3}"}>
<rect
class={[
if(@auto_on, do: "ehv-card-lime", else: "ehv-card"),
"ehv-hero-workflow-automation__card"
]}
x={@out_x + 0.5}
y={@out.auto + 0.5}
width={@out.w - 1}
height={@size.h - 1}
rx="9"
/>
<text
class={["ehv-mono", @auto_on && "ehv-t-lime"]}
x={@out_x + 12}
y={@out.auto + 18}
font-size="7.5"
>
POST AUTOMATICALLY
</text>
<text
class={["ehv-t-strong", !@auto_on && "ehv-t-3"]}
x={@out_x + 12}
y={@out.auto + 36}
font-size="12.5"
>
{@auto.title}
</text>
<text class="ehv-mono" x={@out_x + 12} y={@out.auto + 50} font-size="7.5">
{String.upcase(Map.get(@auto, :meta, ""))}
</text>
</g>
<g class={"ehv-a-rise ehv-i-#{@stage + 4}"}>
<rect
class={[
if(@auto_on, do: "ehv-card", else: "ehv-card-lime"),
"ehv-hero-workflow-automation__card"
]}
x={@out_x + 0.5}
y={@out.person + 0.5}
width={@out.w - 1}
height={@size.h - 1}
rx="9"
/>
<circle class="ehv-card-3" cx={@out_x + 24} cy={@out.person + 30} r="12.5" />
<text
class="ehv-mono ehv-t-ink"
x={@out_x + 24}
y={@out.person + 33}
font-size="8"
text-anchor="middle"
>
{String.upcase(Map.get(@person, :initials, ""))}
</text>
<text
class={["ehv-mono", !@auto_on && "ehv-t-lime"]}
x={@out_x + 45}
y={@out.person + 18}
font-size="7.5"
>
ASK A PERSON
</text>
<text
class={["ehv-t-strong", @auto_on && "ehv-t-3"]}
x={@out_x + 45}
y={@out.person + 36}
font-size="12.5"
>
{@person.title}
</text>
<text class="ehv-mono" x={@out_x + 45} y={@out.person + 50} font-size="7.5">
{String.upcase(Map.get(@person, :meta, ""))}
</text>
</g>
<g class={"ehv-a-fade ehv-i-#{@stage + 5}"}>
<circle
class="ehv-fill-lime ehv-a-ping"
cx={@out_x + @out.w - 15}
cy={@taken_y + 15}
r="3"
/>
<circle class="ehv-fill-lime" cx={@out_x + @out.w - 15} cy={@taken_y + 15} r="3" />
</g>
</g>
</svg>
</.frame>
"""
end
# An elbow from the bend to an outcome card, with a rounded corner.
# `turn` is the corner radius, signed by the way the branch leaves the row.
defp branch(name, on, tag, bend, out_x, y, turn) do
%{
name: name,
on: on,
tag: tag,
tag_y: div(@row + y, 2),
d: "M#{bend} #{@row} V#{y + turn} Q#{bend} #{y} #{bend + 8} #{y} H#{out_x - 5}",
head: head(out_x, y)
}
end
defp head(x, y), do: "M#{x} #{y} l-6 -3.5 v7 z"
# The way a packet travels: along the row, then up or down to the outcome.
defp route(from, bend, out_x, auto?) do
y = if auto?, do: @auto_y, else: @person_y
"M#{from} #{@row} H#{bend} V#{y} H#{out_x}"
end
defp pad(number), do: number |> Integer.to_string() |> String.pad_leading(2, "0")
end
assets/css/hero/workflow-automation.css
/* hero/workflow-automation */
.ehv-hero-workflow-automation { --ehv-max-w: 800px; --ehv-step: 100ms; --ehv-iso-scale: 0.74; }
/* The dotted grid of the canvas. */
.ehv-hero-workflow-automation__dot { fill: var(--ehv-border-soft); }
.ehv--hover .ehv-hero-workflow-automation__card { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-hero-workflow-automation__card:hover { stroke: var(--ehv-lime); }