empowered.house ↗

integrations/plugin-slot.ex

Plugin slot

An isometric board of integration cubes with one open slot and a new cube dropping into it.

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

<div class="h-96 rounded-xl border border-border">
  <.integrations_plugin_slot animated />
</div>
default
slot in the middle · custom copy
five systems · hover
back corner · no caption
light surface
fade out · no glow · still

Props

Prop Type Default What it does
systems list ["ERP", "CRM", "Mail", "Sheets", "WMS", "HR", ...] Up to eight system names already on the board, six characters at most.
incoming string "Bank" The cube being added, six characters at most.
slot integer 8 The open cell, 0 to 8, counted row by row from the back corner. 8 is the front corner.
board string "Integrations" The name on the edge of the board.
caption string "Adding the bank feed" The status line in the top left corner. Empty hides it.
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.
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/integrations/plugin-slot.ex
plugin-slot.ex
defmodule EHVisuals.Integrations.PluginSlot do
  @moduledoc """
  An isometric board of integration cubes with one open slot and a new cube
  dropping into it.

  The scene is drawn in isometric projection. The board is a 200 by 200
  square in plan, with nine cells. A cube is two side faces in screen
  coordinates and a top face in the plane of the board, lifted by its height.
  """

  use EHVisuals.Composition,
    name: "integrations/plugin-slot",
    title: "Plugin slot",
    description:
      "An isometric board of integration cubes with one open slot and a new cube dropping into it."

  # Screen position of the top vertex of the board.
  @cx 240
  @top 72
  @cells [22, 78, 134]
  @size 44
  @depth 26
  # How far above its slot the new cube hovers. The stylesheet drops it by
  # the same distance.
  @lift 56

  attr(:systems, :list,
    default: ~w(ERP CRM Mail Sheets WMS HR BI Chat),
    doc: "Up to eight system names already on the board, six characters at most."
  )

  attr(:incoming, :string, default: "Bank", doc: "The cube being added, six characters at most.")

  attr(:slot, :integer,
    default: 8,
    doc: "The open cell, 0 to 8, counted row by row from the back corner. 8 is the front corner."
  )

  attr(:board, :string, default: "Integrations", doc: "The name on the edge of the board.")

  attr(:caption, :string,
    default: "Adding the bank feed",
    doc: "The status line in the top left corner. Empty hides it."
  )

  attr(:label, :string,
    default: "A board of eight integration cubes with a ninth dropping into the open slot"
  )

  frame_attrs(isometric: false)

  @spec integrations_plugin_slot(map()) :: Phoenix.LiveView.Rendered.t()
  def integrations_plugin_slot(assigns) do
    slot = assigns.slot |> max(0) |> min(8)
    free = Enum.reject(0..8, &(&1 == slot))
    installed = assigns.systems |> Enum.take(8) |> Enum.zip(free)

    cubes =
      [{assigns.incoming, slot} | installed]
      |> Enum.map(fn {name, cell} -> cube(name, cell, cell == slot) end)
      |> Enum.sort_by(&(&1.x + &1.y))
      |> Enum.with_index()

    {slot_x, slot_y} = plan(slot)

    assigns =
      assigns
      |> assign(:cubes, cubes)
      |> assign(:sockets, for(cell <- 0..8, do: {plan(cell), cell == slot}))
      |> assign(:guides, guides(slot_x, slot_y))
      |> assign(:count, "#{length(installed)} of 9")
      |> assign(:size, @size)
      |> assign(:top, @top)

    ~H"""
    <.frame name="integrations/plugin-slot" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 480 318">
        <g :if={@caption != ""} class="ehv-a-fade ehv-i-10">
          <circle class="ehv-fill-lime ehv-a-pulse" cx="13" cy="16" r="3" />
          <text class="ehv-mono ehv-t-2" x="23" y="19" font-size="8.5">
            {String.upcase(@caption)}
          </text>
        </g>
        <g class="ehv-h-lift">
          <g class="ehv-a-rise ehv-i-0">
            <polygon class="ehv-integrations-plugin-slot__edge" points={left_edge()} />
            <polygon class="ehv-integrations-plugin-slot__edge" points={right_edge()} />
            <g class="ehv-integrations-plugin-slot__plane" transform={matrix(0)}>
              <rect class="ehv-integrations-plugin-slot__board" width="200" height="200" rx="5" />
              <rect
                :for={{{x, y}, open} <- @sockets}
                class={[
                  "ehv-integrations-plugin-slot__socket",
                  open && "ehv-integrations-plugin-slot__socket--open"
                ]}
                x={x}
                y={y}
                width={@size}
                height={@size}
                rx="4"
              />
              <text class="ehv-mono ehv-t-2" x="22" y="193" font-size="7">
                {String.upcase(@board)}
              </text>
              <text class="ehv-mono" x="178" y="193" font-size="7" text-anchor="end">
                {String.upcase(@count)}
              </text>
            </g>
          </g>
          <g :for={{cube, index} <- @cubes}>
            <g :if={!cube.incoming} class={"ehv-a-rise ehv-i-#{index + 2}"}>
              <polygon class="ehv-integrations-plugin-slot__side" points={cube.left} />
              <polygon
                class="ehv-integrations-plugin-slot__side ehv-integrations-plugin-slot__side--dark"
                points={cube.right}
              />
              <g class="ehv-integrations-plugin-slot__plane" transform={matrix(cube.z)}>
                <rect
                  class="ehv-integrations-plugin-slot__lid"
                  x={cube.x}
                  y={cube.y}
                  width={@size}
                  height={@size}
                  rx="3"
                />
                <text
                  class="ehv-mono ehv-t-2"
                  x={cube.x + 22}
                  y={cube.y + 24.5}
                  font-size="7.5"
                  text-anchor="middle"
                >
                  {String.upcase(cube.name)}
                </text>
              </g>
            </g>
            <g :if={cube.incoming}>
              <g class="ehv-integrations-plugin-slot__guides">
                <line
                  :for={{x, y} <- @guides}
                  class="ehv-line-lime ehv-dash ehv-a-fade ehv-i-12"
                  x1={x}
                  x2={x}
                  y1={y}
                  y2={y - cube.z + 26}
                />
              </g>
              <g class="ehv-integrations-plugin-slot__incoming">
                <g class="ehv-a-drop ehv-i-12">
                  <polygon
                    class="ehv-integrations-plugin-slot__side ehv-integrations-plugin-slot__side--new"
                    points={cube.left}
                  />
                  <polygon
                    class="ehv-integrations-plugin-slot__side ehv-integrations-plugin-slot__side--dark ehv-integrations-plugin-slot__side--new"
                    points={cube.right}
                  />
                  <g class="ehv-integrations-plugin-slot__plane" transform={matrix(cube.z)}>
                    <rect
                      class="ehv-integrations-plugin-slot__lid ehv-integrations-plugin-slot__lid--new"
                      x={cube.x}
                      y={cube.y}
                      width={@size}
                      height={@size}
                      rx="3"
                    />
                    <text
                      class="ehv-mono ehv-t-lime"
                      x={cube.x + 22}
                      y={cube.y + 24.5}
                      font-size="7.5"
                      text-anchor="middle"
                    >
                      {String.upcase(cube.name)}
                    </text>
                  </g>
                </g>
              </g>
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Plan {x, y} of a cell, counted row by row from the back corner.
  defp plan(cell), do: {Enum.at(@cells, rem(cell, 3)), Enum.at(@cells, div(cell, 3))}

  # Plan {x, y} at height z to screen.
  defp screen(x, y, z),
    do: {Float.round(@cx + 1.1 * (x - y), 1), Float.round(@top + 0.55 * (x + y) - z, 1)}

  # The plane of the board, lifted by z.
  defp matrix(z), do: "matrix(1.1 0.55 -1.1 0.55 #{@cx} #{@top - z})"

  defp cube(name, cell, incoming) do
    {x, y} = plan(cell)
    base = if incoming, do: @lift, else: 0

    %{
      name: name,
      incoming: incoming,
      x: x,
      y: y,
      z: base + @depth,
      left: face({x, y + @size}, {x + @size, y + @size}, base),
      right: face({x + @size, y + @size}, {x + @size, y}, base)
    }
  end

  # A side face: the plan edge from `from` to `to`, standing on height `base`.
  defp face({x1, y1}, {x2, y2}, base) do
    [
      screen(x1, y1, base),
      screen(x2, y2, base),
      screen(x2, y2, base + @depth),
      screen(x1, y1, base + @depth)
    ]
    |> Enum.map_join(" ", fn {x, y} -> "#{x},#{y}" end)
  end

  # The three near corners of the open slot, on the board.
  defp guides(x, y) do
    [screen(x, y + @size, 0), screen(x + @size, y + @size, 0), screen(x + @size, y, 0)]
  end

  # The two visible edges of the board, ten units thick.
  defp left_edge,
    do:
      "#{@cx - 220},#{@top + 110} #{@cx},#{@top + 220} #{@cx},#{@top + 230} #{@cx - 220},#{@top + 120}"

  defp right_edge,
    do:
      "#{@cx},#{@top + 220} #{@cx + 220},#{@top + 110} #{@cx + 220},#{@top + 120} #{@cx},#{@top + 230}"
end
assets/css/integrations/plugin-slot.css
plugin-slot.css
/* integrations/plugin-slot */
.ehv-integrations-plugin-slot { --ehv-max-w: 540px; --ehv-step: 70ms; }
.ehv-integrations-plugin-slot .ehv__glow { background: radial-gradient(46% 40% at 50% 56%, var(--ehv-glow), transparent 72%); }

/* Hairlines stay one pixel wide in the skewed plane of the board. */
.ehv-integrations-plugin-slot__plane * { vector-effect: non-scaling-stroke; }

.ehv-integrations-plugin-slot__board { fill: var(--ehv-surface-1); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-integrations-plugin-slot__edge { fill: var(--ehv-surface-2); stroke: var(--ehv-border-soft); stroke-width: 1; stroke-linejoin: round; }

.ehv-integrations-plugin-slot__socket { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-integrations-plugin-slot__socket--open { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-dasharray: 3 3; }

/* A cube: the lid is the lightest face, the right side the darkest. */
.ehv-integrations-plugin-slot__lid { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-integrations-plugin-slot__side { fill: var(--ehv-surface-2); stroke: var(--ehv-border-strong); stroke-width: 1; stroke-linejoin: round; }
.ehv-integrations-plugin-slot__side--dark { fill: var(--ehv-surface-1); }
.ehv-integrations-plugin-slot__lid--new,
.ehv-integrations-plugin-slot__side--new { stroke: var(--ehv-lime); }

/* On the cream surface the tones run the other way. */
.ehv--light .ehv-integrations-plugin-slot__lid { fill: var(--ehv-surface-1); }
.ehv--light .ehv-integrations-plugin-slot__side--dark { fill: var(--ehv-surface-3); }

/* The new cube hovers 56 above its slot, drops in, sits, and starts over. */
@keyframes ehv-integrations-plugin-slot-drop {
  0%, 18% { transform: translateY(0); opacity: 1; }
  44%, 86% { transform: translateY(56px); opacity: 1; }
  92% { transform: translateY(56px); opacity: 0; }
  93% { transform: translateY(0); opacity: 0; }
  100% { transform: translateY(0); opacity: 1; }
}
@keyframes ehv-integrations-plugin-slot-guide {
  0%, 18% { opacity: 1; }
  42%, 93% { opacity: 0; }
  100% { opacity: 1; }
}

.ehv--animated .ehv-integrations-plugin-slot__incoming {
  animation: ehv-integrations-plugin-slot-drop 5.4s var(--ehv-ease) infinite;
  animation-delay: calc(var(--ehv-delay, 0ms) + 12 * var(--ehv-step) + var(--ehv-dur));
}
.ehv--animated .ehv-integrations-plugin-slot__guides {
  animation: ehv-integrations-plugin-slot-guide 5.4s var(--ehv-ease) infinite;
  animation-delay: calc(var(--ehv-delay, 0ms) + 12 * var(--ehv-step) + var(--ehv-dur));
}

.ehv--hover .ehv-integrations-plugin-slot__lid { transition: fill 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-integrations-plugin-slot__lid--new { fill: var(--ehv-lime-soft); }