empowered.house ↗

security/shield.ex

Shield

A shield with a check, and what it covers listed around it: roles, the audit log, encryption.

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

<div class="h-96 rounded-xl border border-border">
  <.security_shield animated />
</div>
default
isometric
six items · custom copy
one gap · hover · no glow
light surface
three items · fade out · still

Props

Prop Type Default What it does
caption string "Security"
scope string "Order desk · production" What is protected, on the right of the header.
items list [%{title: "Roles", detail: "12 roles · 48 people"}, %{title: "Audit log", detail: "Every change kept"}, ...] Two to six `%{title, detail}`, placed left and right in turn. Titles take 18 characters, details 22. An item with `state: "off"` is a gap: it turns coral and so does the shield.
status any nil The word under the shield. Left as `nil`, it is written from the items.
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/security/shield.ex
shield.ex
defmodule EHVisuals.Security.Shield do
  @moduledoc """
  A shield with a check in the middle and what it covers around it.
  """

  use EHVisuals.Composition,
    name: "security/shield",
    title: "Shield",
    description:
      "A shield with a check, and what it covers listed around it: roles, the audit log, encryption."

  @items [
    %{title: "Roles", detail: "12 roles · 48 people"},
    %{title: "Audit log", detail: "Every change kept"},
    %{title: "Encryption", detail: "At rest · in transit"},
    %{title: "Single sign-on", detail: "Company accounts"}
  ]

  # The middle of the shield, and the item cards on either side of it.
  @cx 220
  @cy 134
  @card_w 128
  @card_h 42
  @pitch 54

  attr(:caption, :string, default: "Security")

  attr(:scope, :string,
    default: "Order desk · production",
    doc: "What is protected, on the right of the header."
  )

  attr(:items, :list,
    default: @items,
    doc:
      "Two to six `%{title, detail}`, placed left and right in turn. Titles take 18 characters, details 22. An item with `state: \"off\"` is a gap: it turns coral and so does the shield."
  )

  attr(:status, :any,
    default: nil,
    doc: "The word under the shield. Left as `nil`, it is written from the items."
  )

  attr(:label, :string,
    default: "A shield with a check, covering roles, the audit log, encryption and single sign-on"
  )

  frame_attrs()

  @spec security_shield(map()) :: Phoenix.LiveView.Rendered.t()
  def security_shield(assigns) do
    items = assigns.items |> Enum.take(6) |> Enum.with_index()
    {left, right} = Enum.split_with(items, fn {_item, index} -> rem(index, 2) == 0 end)
    cards = column(left, :left) ++ column(right, :right)
    gaps = Enum.count(cards, & &1.off)
    status = assigns.status || status(gaps)

    assigns =
      assigns
      |> assign(:cx, @cx)
      |> assign(:cards, cards)
      |> assign(:card_w, @card_w)
      |> assign(:card_h, @card_h)
      |> assign(:safe, gaps == 0)
      |> assign(:status, status)
      |> assign(:status_w, Float.round(14 + String.length(status) * 5.9, 1))

    ~H"""
    <.frame name="security/shield" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 440 268">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="439" height="267" rx="12" />
          <text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
            {String.upcase(@caption)}
          </text>
          <text
            class="ehv-mono ehv-t-4 ehv-a-fade ehv-i-1"
            x="420"
            y="31"
            font-size="9"
            text-anchor="end"
          >
            {String.upcase(@scope)}
          </text>
          <path
            :for={card <- @cards}
            class={[
              "ehv-a-draw ehv-i-#{card.index + 3}",
              if(card.off, do: "ehv-line-coral ehv-security-shield__wire--off", else: "ehv-line")
            ]}
            d={card.wire}
            pathLength="1"
          />
          <g class="ehv-a-pop">
            <path
              class="ehv-line-faint"
              d="M220 62l56 19v46c0 43 -29 67 -56 80c-27 -13 -56 -37 -56 -80v-46z"
            />
          </g>
          <g class="ehv-a-pop ehv-i-1">
            <path
              class="ehv-security-shield__plate"
              d="M220 78l40 14v35c0 31 -21 49 -40 59c-19 -10 -40 -28 -40 -59v-35z"
            />
            <path
              :if={@safe}
              class="ehv-security-shield__check ehv-round ehv-a-draw ehv-i-5"
              d="M204.5 131.5l11 11l20.5 -23"
              pathLength="1"
            />
            <g :if={!@safe}>
              <path
                class="ehv-security-shield__check ehv-security-shield__check--off ehv-round"
                d="M220 112v24"
              />
              <circle class="ehv-fill-coral" cx="220" cy="148" r="2.6" />
            </g>
          </g>
          <g class="ehv-a-fade ehv-i-6">
            <rect
              class={if @safe, do: "ehv-fill-lime-soft", else: "ehv-fill-coral-soft"}
              x={@cx - @status_w / 2}
              y="216"
              width={@status_w}
              height="18"
              rx="6"
            />
            <text
              class={["ehv-mono", if(@safe, do: "ehv-t-lime", else: "ehv-t-coral")]}
              x={@cx}
              y="228.2"
              font-size="8.5"
              text-anchor="middle"
            >
              {String.upcase(@status)}
            </text>
          </g>
          <g :for={card <- @cards} class={"ehv-a-rise ehv-i-#{card.index + 4}"}>
            <rect
              class={[
                "ehv-security-shield__card",
                if(card.off, do: "ehv-security-shield__card--off", else: "ehv-card-2")
              ]}
              x={card.x}
              y={card.y}
              width={@card_w}
              height={@card_h}
              rx="9"
            />
            <text class="ehv-t-strong" x={card.x + 12} y={card.y + 18} font-size="11.5">
              {card.title}
            </text>
            <text
              class={["ehv-mono", card.off && "ehv-t-coral"]}
              x={card.x + 12}
              y={card.y + 31.5}
              font-size="7.5"
            >
              {String.upcase(card.detail)}
            </text>
            <circle
              class={if card.off, do: "ehv-fill-coral", else: "ehv-fill-2"}
              cx={card.dot_x}
              cy={card.y + @card_h / 2}
              r="2.5"
            />
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # The cards of one side, spread evenly around the height of the shield,
  # each with the wire that ties it to the shield's edge.
  defp column(items, side) do
    count = length(items)

    for {{item, index}, row} <- Enum.with_index(items) do
      mid = @cy + (row - (count - 1) / 2) * @pitch
      x = if side == :left, do: 20, else: 420 - @card_w
      edge = if side == :left, do: 20 + @card_w, else: 420 - @card_w
      bend = if side == :left, do: edge + 14, else: edge - 14
      rim = if side == :left, do: @cx - 41, else: @cx + 41
      rim_y = Float.round(@cy - 6 + (mid - @cy) * 0.3, 1)

      %{
        index: index,
        title: item.title,
        detail: item.detail,
        off: Map.get(item, :state) == "off",
        x: x,
        y: Float.round(mid - @card_h / 2, 1),
        dot_x: edge,
        wire: "M#{edge} #{Float.round(mid * 1.0, 1)}H#{bend}L#{rim} #{rim_y}"
      }
    end
  end

  defp status(0), do: "Protected"
  defp status(1), do: "1 gap"
  defp status(count), do: "#{count} gaps"
end
assets/css/security/shield.css
shield.css
/* security/shield */
.ehv-security-shield { --ehv-max-w: 480px; }

.ehv-security-shield__plate { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); stroke-width: 1; stroke-linejoin: round; }
.ehv-security-shield__check { fill: none; stroke: var(--ehv-lime); stroke-width: 3.5; }
.ehv-security-shield__check--off { stroke: var(--ehv-coral); }

.ehv-security-shield__card--off { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1; }
.ehv-security-shield__wire--off { stroke-width: 1; }

.ehv--hover .ehv-security-shield__card { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-security-shield__card:hover { stroke: var(--ehv-lime); }