empowered.house ↗

states/error.ex

Error state

An error state: a request travels into a failed service, and the services that depend on it are dimmed.

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

<div class="h-96 rounded-xl border border-border">
  <.states_error animated />
</div>
default
isometric
one dependent · custom copy
four dependents · hover
light surface
fade out · no glow · still

Props

Prop Type Default What it does
caption string "Incident · 14:02" The line in the top left corner. Empty hides it.
request string "POST /orders" The incoming call, 14 characters at most.
service string "Orders API" The failed service, 12 characters at most.
status string "503 · Timeout" 14 characters at most.
message string "Retry in 30 s" The quiet last line of the card.
dependents list ["Invoicing", "Stock sync", "Notifications"] One to four services that wait on the failed one, 13 characters at most.
waiting string "Waiting" The status under every dependent.
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/states/error.ex
error.ex
defmodule EHVisuals.States.Error do
  @moduledoc """
  An error state: a request travels into a service that has failed, and the
  services that depend on it are dimmed.
  """

  use EHVisuals.Composition,
    name: "states/error",
    title: "Error state",
    description:
      "An error state: a request travels into a failed service, and the services that depend on it are dimmed."

  attr(:caption, :string,
    default: "Incident · 14:02",
    doc: "The line in the top left corner. Empty hides it."
  )

  attr(:request, :string,
    default: "POST /orders",
    doc: "The incoming call, 14 characters at most."
  )

  attr(:service, :string,
    default: "Orders API",
    doc: "The failed service, 12 characters at most."
  )

  attr(:status, :string, default: "503 · Timeout", doc: "14 characters at most.")
  attr(:message, :string, default: "Retry in 30 s", doc: "The quiet last line of the card.")

  attr(:dependents, :list,
    default: ["Invoicing", "Stock sync", "Notifications"],
    doc: "One to four services that wait on the failed one, 13 characters at most."
  )

  attr(:waiting, :string, default: "Waiting", doc: "The status under every dependent.")

  attr(:label, :string,
    default: "A request reaching a failed orders service while three dependent services wait"
  )

  frame_attrs()

  @spec states_error(map()) :: Phoenix.LiveView.Rendered.t()
  def states_error(assigns) do
    names = Enum.take(assigns.dependents, 4)
    count = length(names)
    pill_w = Float.round(22 + String.length(assigns.request) * 5.8, 1)

    dependents =
      for {name, index} <- Enum.with_index(names) do
        %{name: name, index: index, y: Float.round(126 + (index - (count - 1) / 2) * 48, 1)}
      end

    assigns =
      assigns
      |> assign(:items, dependents)
      |> assign(:pill_w, pill_w)
      |> assign(:line_x, 12 + pill_w)
      |> assign(:still_x, Float.round(12 + pill_w + (176 - 12 - pill_w) * 0.62, 1))

    ~H"""
    <.frame name="states/error" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 460 240">
        <g class="ehv-h-lift">
          <g :if={@caption != ""} class="ehv-a-fade">
            <circle class="ehv-fill-coral ehv-a-pulse" cx="16" cy="17" r="3" />
            <text class="ehv-mono ehv-t-2" x="26" y="20" font-size="8.5">
              {String.upcase(@caption)}
            </text>
          </g>

          <line
            class="ehv-line ehv-a-draw ehv-i-2"
            x1={@line_x}
            x2="170"
            y1="126"
            y2="126"
            pathLength="1"
          />
          <path class="ehv-fill-3 ehv-a-fade ehv-i-4" d="M169 122 l7 4 l-7 4 z" />
          <circle
            class="ehv-fill-lime ehv-states-error__packet"
            cx={@still_x}
            cy="126"
            r="3"
          />
          <g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-6">
            <circle :for={delay <- [0, 1.2]} class="ehv-fill-lime" cy="126" r="3">
              <animate
                attributeName="cx"
                from={@line_x}
                to="172"
                dur="2.4s"
                begin={"#{delay}s"}
                repeatCount="indefinite"
              />
              <animate
                attributeName="opacity"
                values="0;1;1;0"
                keyTimes="0;0.15;0.85;1"
                dur="2.4s"
                begin={"#{delay}s"}
                repeatCount="indefinite"
              />
            </circle>
          </g>
          <g class="ehv-a-rise ehv-i-1">
            <rect class="ehv-card" x="12.5" y="111.5" width={@pill_w} height="29" rx="8" />
            <text
              class="ehv-mono ehv-t-2"
              x={12.5 + @pill_w / 2}
              y="129"
              font-size="8.5"
              text-anchor="middle"
            >
              {String.upcase(@request)}
            </text>
          </g>

          <g :for={item <- @items} class={"ehv-a-fade ehv-i-#{item.index + 5}"}>
            <g class="ehv-states-error__dependent">
              <path class="ehv-line ehv-dash" d={"M304 126 H324 V#{item.y} H344"} />
              <rect class="ehv-card" x="344.5" y={item.y - 18.5} width="103" height="37" rx="8" />
              <text class="ehv-t-2" x="356" y={item.y - 1.5} font-size="11.5">{item.name}</text>
              <text class="ehv-mono" x="356" y={item.y + 10.5} font-size="7">
                {String.upcase(@waiting)}
              </text>
            </g>
          </g>

          <rect
            class="ehv-states-error__alarm"
            x="176.5"
            y="86.5"
            width="127"
            height="79"
            rx="10"
          />
          <g class="ehv-a-rise ehv-i-3">
            <rect
              class="ehv-states-error__failed"
              x="176.5"
              y="86.5"
              width="127"
              height="79"
              rx="10"
            />
            <circle class="ehv-fill-coral-soft" cx="285" cy="105" r="8.5" />
            <path class="ehv-line-coral ehv-round" d="M282 102 l6 6 M288 102 l-6 6" />
            <text class="ehv-t-strong" x="190" y="110" font-size="12.5">{@service}</text>
            <text class="ehv-mono ehv-t-coral" x="190" y="128" font-size="8">
              {String.upcase(@status)}
            </text>
            <line class="ehv-line-faint" x1="190" x2="290" y1="138.5" y2="138.5" />
            <text class="ehv-mono" x="190" y="153" font-size="7.5">{String.upcase(@message)}</text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end
end
assets/css/states/error.css
error.css
/* states/error */
.ehv-states-error { --ehv-max-w: 520px; --ehv-iso-scale: 0.82; }

.ehv-states-error__failed { fill: var(--ehv-surface-1); stroke: var(--ehv-coral); stroke-width: 1.25; }
.ehv-states-error__dependent { opacity: 0.7; }

/* A coral ring leaves the failed card every few seconds. */
@keyframes ehv-states-error-alarm {
  from { opacity: 0.7; transform: scale(1); }
  60%, to { opacity: 0; transform: scale(1.16, 1.26); }
}
.ehv-states-error__alarm {
  fill: none;
  stroke: var(--ehv-coral);
  stroke-width: 1;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.ehv--animated .ehv-states-error__alarm {
  animation: ehv-states-error-alarm 2.4s var(--ehv-ease) infinite;
  animation-delay: calc(var(--ehv-delay, 0ms) + 4 * var(--ehv-step) + var(--ehv-dur));
}

/* The still packet stands in for the travelling ones when nothing moves. */
.ehv--animated .ehv-states-error__packet { display: none; }
@media (prefers-reduced-motion: reduce) {
  .ehv--animated .ehv-states-error__packet { display: inline; }
}

.ehv--hover .ehv-states-error__dependent { transition: opacity 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-states-error__dependent { opacity: 0.85; }