empowered.house ↗

hero/workflow-automation.ex

Workflow automation

A workflow canvas: a trigger runs through steps into a condition that posts automatically or asks a person.

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

<div class="h-96 rounded-xl border border-border">
  <.hero_workflow_automation animated />
</div>
default
isometric
asks a person · hover
custom copy · one step
three steps · light surface
fade out · no glow · still

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