security/shield.ex
Shield
A shield with a check, and what it covers listed around it: roles, the audit log, encryption.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.security_shield animated />
</div>
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
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
/* 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); }