# Log Security

A security overview with a computed score ring, alert counts by severity, and resolvable alerts.

Log Security is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/log-security

**React**

**Example**

```tsx title="components/blocks/log-security.tsx"
"use client"

import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  GlobeIcon,
  KeyIcon,
  LockSimpleIcon,
  ShieldIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

type Severity = "High" | "Medium" | "Low"

const severity: Record<
  Severity,
  { tint: string; text: string; weight: number }
> = {
  High: {
    tint: "bg-red-500",
    text: "text-[color-mix(in_oklab,var(--color-red-500),var(--foreground)_25%)] dark:text-red-500",
    weight: 18,
  },
  Medium: {
    tint: "bg-orange-500",
    text: "text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] dark:text-orange-500",
    weight: 8,
  },
  Low: {
    tint: "bg-yellow-500",
    text: "text-[color-mix(in_oklab,var(--color-yellow-500),var(--foreground)_55%)] dark:text-yellow-500",
    weight: 3,
  },
}

const initialAlerts: {
  severity: Severity
  title: string
  detail: string
  icon: React.ReactNode
}[] = [
  {
    severity: "High",
    title: "Sign-in from an unusual location",
    detail: "morgan@example.com · Lagos, NG · 3:12 AM",
    icon: (
      <GlobeIcon className="size-4" />
    ),
  },
  {
    severity: "Medium",
    title: "5 failed sign-in attempts",
    detail: "85.104.x.x · last 10 minutes",
    icon: (
      <LockSimpleIcon className="size-4" />
    ),
  },
  {
    severity: "Medium",
    title: "API key expires in 7 days",
    detail: "Payments integration",
    icon: (
      <KeyIcon className="size-4" />
    ),
  },
  {
    severity: "Low",
    title: "Two-factor authentication is off",
    detail: "2 users",
    icon: (
      <ShieldIcon className="size-4" />
    ),
  },
]

function scoreTone(score: number) {
  if (score > 85) return "text-green-500"
  if (score > 65) return "text-orange-500"
  return "text-red-500"
}

export function LogSecurity() {
  const [alerts, setAlerts] = React.useState(() =>
    initialAlerts.map((alert) => ({ ...alert, resolved: false }))
  )
  const [filter, setFilter] = React.useState("Open")
  const open = alerts.filter((alert) => !alert.resolved)
  const count = (level: Severity) =>
    open.filter((alert) => alert.severity === level).length
  const score =
    100 -
    (Object.keys(severity) as Severity[]).reduce(
      (sum, level) => sum + count(level) * severity[level].weight,
      0
    )
  const tone = scoreTone(score)
  const list =
    filter === "All"
      ? alerts
      : alerts.filter((alert) => alert.resolved === (filter === "Resolved"))

  const setResolved = (title: string, resolved: boolean) =>
    setAlerts((current) =>
      current.map((alert) =>
        alert.title === title ? { ...alert, resolved } : alert
      )
    )

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="log"
        header={
          <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
            <span className="font-bold">suiss</span> Admin
          </p>
        }
      />
      <SidebarInset>
        <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
          <div className="flex items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <div className="flex flex-1 flex-col">
              <h1 className="text-3xl font-semibold tracking-tight">
                Security
              </h1>
              <p className="text-sm text-muted-foreground" aria-live="polite">
                {open.length
                  ? `${open.length} open ${open.length === 1 ? "alert" : "alerts"}`
                  : "No open alerts"}
              </p>
            </div>
          </div>
          <div className="flex flex-wrap items-center gap-4 rounded-xl bg-muted px-4 py-3.5">
            <svg
              aria-hidden
              viewBox="0 0 54 54"
              className={`size-13.5 -rotate-90 transition-colors duration-400 ${tone}`}
            >
              <circle
                cx="27"
                cy="27"
                r="22"
                fill="none"
                strokeWidth="6"
                className="stroke-secondary"
              />
              <circle
                cx="27"
                cy="27"
                r="22"
                fill="none"
                strokeWidth="6"
                strokeLinecap="round"
                pathLength={100}
                strokeDasharray={`${score} 100`}
                className="stroke-current transition-[stroke-dasharray] duration-600 ease-[cubic-bezier(0.32,0.72,0,1)]"
              />
            </svg>
            <div className="flex flex-1 flex-col">
              <span className="text-sm text-muted-foreground">
                Security score
              </span>
              <span className="text-3xl font-bold tabular-nums">{score}</span>
            </div>
            <dl className="flex gap-4.5">
              {(Object.keys(severity) as Severity[]).map((level) => (
                <div key={level} className="flex flex-col items-center">
                  <dt className="text-xs text-muted-foreground">{level}</dt>
                  <dd
                    className={`text-xl font-semibold tabular-nums ${count(level) ? severity[level].text : "text-muted-foreground"}`}
                  >
                    {count(level)}
                  </dd>
                </div>
              ))}
            </dl>
          </div>
          <ToggleGroup
            spacing={0}
            value={[filter]}
            onValueChange={(value: string[]) => {
              if (value[0]) setFilter(value[0])
            }}
            aria-label="Filter"
          >
            {["Open", "Resolved", "All"].map((item) => (
              <ToggleGroupItem
                key={item}
                value={item}
                size="sm"
                className="px-4"
              >
                {item}
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
          <div key={filter}>
            {list.length > 0 ? (
              <ul className="overflow-hidden rounded-2xl bg-muted">
                {list.map((alert) => (
                  <li
                    key={alert.title}
                    className="relative flex min-h-13 items-center gap-3 px-4 py-2.5 not-first:before:absolute not-first:before:inset-e-0 not-first:before:inset-s-14.5 not-first:before:top-0 not-first:before:h-px not-first:before:bg-border"
                  >
                    <span
                      className={`flex size-7.5 shrink-0 items-center justify-center rounded-[7px] text-white transition-colors duration-300 ${alert.resolved ? "bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_20%)] dark:bg-green-500" : severity[alert.severity].tint}`}
                    >
                      {alert.resolved ? (
                        <CheckIcon className="size-4" />
                      ) : (
                        alert.icon
                      )}
                    </span>
                    <span className="flex min-w-0 flex-1 flex-col">
                      <span className="truncate text-sm font-semibold">
                        {alert.title}
                      </span>
                      <span className="truncate text-xs text-muted-foreground">
                        {alert.resolved
                          ? "Resolved"
                          : `${alert.severity} severity`}{" "}
                        · {alert.detail}
                      </span>
                    </span>
                    {alert.resolved ? (
                      <Button
                        size="sm"
                        variant="secondary"
                        onClick={() => setResolved(alert.title, false)}
                      >
                        Reopen
                      </Button>
                    ) : (
                      <Button
                        size="sm"
                        onClick={() => setResolved(alert.title, true)}
                      >
                        Resolve
                      </Button>
                    )}
                  </li>
                ))}
              </ul>
            ) : (
              <p className="p-7.5 text-center text-sm text-muted-foreground">
                {filter === "Open"
                  ? "No open alerts. All good."
                  : "Nothing here yet."}
              </p>
            )}
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

A security overview with a score ring that drops for each open alert and shifts from green to orange to red, next to open alert counts by severity. Below, alerts can be filtered by Open, Resolved or All and resolved or reopened one by one. It sits in a `Sidebar` with admin navigation that opens as a sheet from the trigger on mobile. Use it for security centers, compliance checklists or system health checks.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/log-security
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks log-security
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Sidebar](https://ui.suiss.app/docs/components/sidebar), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/blocks/log-security.tsx`:

```tsx title="components/blocks/log-security.tsx"
"use client"

import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  GlobeIcon,
  KeyIcon,
  LockSimpleIcon,
  ShieldIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

type Severity = "High" | "Medium" | "Low"

const severity: Record<
  Severity,
  { tint: string; text: string; weight: number }
> = {
  High: {
    tint: "bg-red-500",
    text: "text-[color-mix(in_oklab,var(--color-red-500),var(--foreground)_25%)] dark:text-red-500",
    weight: 18,
  },
  Medium: {
    tint: "bg-orange-500",
    text: "text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] dark:text-orange-500",
    weight: 8,
  },
  Low: {
    tint: "bg-yellow-500",
    text: "text-[color-mix(in_oklab,var(--color-yellow-500),var(--foreground)_55%)] dark:text-yellow-500",
    weight: 3,
  },
}

const initialAlerts: {
  severity: Severity
  title: string
  detail: string
  icon: React.ReactNode
}[] = [
  {
    severity: "High",
    title: "Sign-in from an unusual location",
    detail: "morgan@example.com · Lagos, NG · 3:12 AM",
    icon: (
      <GlobeIcon className="size-4" />
    ),
  },
  {
    severity: "Medium",
    title: "5 failed sign-in attempts",
    detail: "85.104.x.x · last 10 minutes",
    icon: (
      <LockSimpleIcon className="size-4" />
    ),
  },
  {
    severity: "Medium",
    title: "API key expires in 7 days",
    detail: "Payments integration",
    icon: (
      <KeyIcon className="size-4" />
    ),
  },
  {
    severity: "Low",
    title: "Two-factor authentication is off",
    detail: "2 users",
    icon: (
      <ShieldIcon className="size-4" />
    ),
  },
]

function scoreTone(score: number) {
  if (score > 85) return "text-green-500"
  if (score > 65) return "text-orange-500"
  return "text-red-500"
}

export function LogSecurity() {
  const [alerts, setAlerts] = React.useState(() =>
    initialAlerts.map((alert) => ({ ...alert, resolved: false }))
  )
  const [filter, setFilter] = React.useState("Open")
  const open = alerts.filter((alert) => !alert.resolved)
  const count = (level: Severity) =>
    open.filter((alert) => alert.severity === level).length
  const score =
    100 -
    (Object.keys(severity) as Severity[]).reduce(
      (sum, level) => sum + count(level) * severity[level].weight,
      0
    )
  const tone = scoreTone(score)
  const list =
    filter === "All"
      ? alerts
      : alerts.filter((alert) => alert.resolved === (filter === "Resolved"))

  const setResolved = (title: string, resolved: boolean) =>
    setAlerts((current) =>
      current.map((alert) =>
        alert.title === title ? { ...alert, resolved } : alert
      )
    )

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="log"
        header={
          <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
            <span className="font-bold">suiss</span> Admin
          </p>
        }
      />
      <SidebarInset>
        <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
          <div className="flex items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <div className="flex flex-1 flex-col">
              <h1 className="text-3xl font-semibold tracking-tight">
                Security
              </h1>
              <p className="text-sm text-muted-foreground" aria-live="polite">
                {open.length
                  ? `${open.length} open ${open.length === 1 ? "alert" : "alerts"}`
                  : "No open alerts"}
              </p>
            </div>
          </div>
          <div className="flex flex-wrap items-center gap-4 rounded-xl bg-muted px-4 py-3.5">
            <svg
              aria-hidden
              viewBox="0 0 54 54"
              className={`size-13.5 -rotate-90 transition-colors duration-400 ${tone}`}
            >
              <circle
                cx="27"
                cy="27"
                r="22"
                fill="none"
                strokeWidth="6"
                className="stroke-secondary"
              />
              <circle
                cx="27"
                cy="27"
                r="22"
                fill="none"
                strokeWidth="6"
                strokeLinecap="round"
                pathLength={100}
                strokeDasharray={`${score} 100`}
                className="stroke-current transition-[stroke-dasharray] duration-600 ease-[cubic-bezier(0.32,0.72,0,1)]"
              />
            </svg>
            <div className="flex flex-1 flex-col">
              <span className="text-sm text-muted-foreground">
                Security score
              </span>
              <span className="text-3xl font-bold tabular-nums">{score}</span>
            </div>
            <dl className="flex gap-4.5">
              {(Object.keys(severity) as Severity[]).map((level) => (
                <div key={level} className="flex flex-col items-center">
                  <dt className="text-xs text-muted-foreground">{level}</dt>
                  <dd
                    className={`text-xl font-semibold tabular-nums ${count(level) ? severity[level].text : "text-muted-foreground"}`}
                  >
                    {count(level)}
                  </dd>
                </div>
              ))}
            </dl>
          </div>
          <ToggleGroup
            spacing={0}
            value={[filter]}
            onValueChange={(value: string[]) => {
              if (value[0]) setFilter(value[0])
            }}
            aria-label="Filter"
          >
            {["Open", "Resolved", "All"].map((item) => (
              <ToggleGroupItem
                key={item}
                value={item}
                size="sm"
                className="px-4"
              >
                {item}
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
          <div key={filter}>
            {list.length > 0 ? (
              <ul className="overflow-hidden rounded-2xl bg-muted">
                {list.map((alert) => (
                  <li
                    key={alert.title}
                    className="relative flex min-h-13 items-center gap-3 px-4 py-2.5 not-first:before:absolute not-first:before:inset-e-0 not-first:before:inset-s-14.5 not-first:before:top-0 not-first:before:h-px not-first:before:bg-border"
                  >
                    <span
                      className={`flex size-7.5 shrink-0 items-center justify-center rounded-[7px] text-white transition-colors duration-300 ${alert.resolved ? "bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_20%)] dark:bg-green-500" : severity[alert.severity].tint}`}
                    >
                      {alert.resolved ? (
                        <CheckIcon className="size-4" />
                      ) : (
                        alert.icon
                      )}
                    </span>
                    <span className="flex min-w-0 flex-1 flex-col">
                      <span className="truncate text-sm font-semibold">
                        {alert.title}
                      </span>
                      <span className="truncate text-xs text-muted-foreground">
                        {alert.resolved
                          ? "Resolved"
                          : `${alert.severity} severity`}{" "}
                        · {alert.detail}
                      </span>
                    </span>
                    {alert.resolved ? (
                      <Button
                        size="sm"
                        variant="secondary"
                        onClick={() => setResolved(alert.title, false)}
                      >
                        Reopen
                      </Button>
                    ) : (
                      <Button
                        size="sm"
                        onClick={() => setResolved(alert.title, true)}
                      >
                        Resolve
                      </Button>
                    )}
                  </li>
                ))}
              </ul>
            ) : (
              <p className="p-7.5 text-center text-sm text-muted-foreground">
                {filter === "Open"
                  ? "No open alerts. All good."
                  : "Nothing here yet."}
              </p>
            )}
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { LogSecurity } from "@/components/blocks/log-security"
```

```tsx
<LogSecurity />
```

Replace `initialAlerts` with alerts from your security backend; each has a severity, title, detail and icon. `severity` sets each level's colors and how much it lowers the score. Alerts are matched by title when resolving, so switch to an id for real data, and connect Resolve and Reopen to your API.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-log-security--default.light.webp)

```tsx title="uniwind/components/blocks/log-security.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  GlobeIcon,
  KeyIcon,
  LockSimpleIcon,
  type Icon as PhosphorIcon,
  type IconProps as PhosphorIconProps,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import Svg, { Circle } from "react-native-svg"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

type Severity = "High" | "Medium" | "Low"

const LEVELS: Severity[] = ["High", "Medium", "Low"]

const SEVERITY: Record<
  Severity,
  { tint: string; text: string; weight: number }
> = {
  High: { tint: "bg-red-500", text: "text-destructive", weight: 18 },
  Medium: {
    tint: "bg-orange-500",
    text: "text-amber-600 dark:text-amber-400",
    weight: 8,
  },
  Low: { tint: "bg-yellow-500", text: "text-foreground", weight: 3 },
}

const FILTERS = ["Open", "Resolved", "All"]

type Alert = {
  severity: Severity
  title: string
  detail: string
  icon: PhosphorIcon
  resolved: boolean
}

const INITIAL_ALERTS: Alert[] = [
  {
    severity: "High",
    title: "Sign-in from an unusual location",
    detail: "morgan@example.com · Lagos, NG · 3:12 AM",
    icon: GlobeIcon,
    resolved: false,
  },
  {
    severity: "Medium",
    title: "5 failed sign-in attempts",
    detail: "85.104.x.x · last 10 minutes",
    icon: LockSimpleIcon,
    resolved: false,
  },
  {
    severity: "Medium",
    title: "API key expires in 7 days",
    detail: "Payments integration",
    icon: KeyIcon,
    resolved: false,
  },
  {
    severity: "Low",
    title: "Two-factor authentication is off",
    detail: "2 users",
    icon: ShieldIcon,
    resolved: false,
  },
]

const scoreTone = (score: number) => {
  if (score > 85) return "text-green-500"
  if (score > 65) return "text-orange-500"
  return "text-red-500"
}

const ScoreContext = React.createContext(0)

function ScoreTrack({ color }: PhosphorIconProps) {
  return (
    <Svg width={54} height={54} viewBox="0 0 54 54">
      <Circle
        cx={27}
        cy={27}
        r={22}
        fill="none"
        strokeWidth={6}
        stroke={color}
      />
    </Svg>
  )
}

function ScoreArc({ color }: PhosphorIconProps) {
  const score = React.useContext(ScoreContext)
  const length = 2 * Math.PI * 22
  return (
    <Svg width={54} height={54} viewBox="0 0 54 54">
      <Circle
        cx={27}
        cy={27}
        r={22}
        fill="none"
        strokeWidth={6}
        strokeLinecap="round"
        stroke={color}
        strokeDasharray={`${(Math.max(0, score) / 100) * length} ${length}`}
      />
    </Svg>
  )
}

function ScoreRing({ score }: { score: number }) {
  return (
    <View
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      className="size-13.5 -rotate-90"
    >
      <Icon as={ScoreTrack} className="absolute size-13.5 text-secondary" />
      <ScoreContext.Provider value={score}>
        <Icon
          as={ScoreArc}
          className={cn("absolute size-13.5", scoreTone(score))}
        />
      </ScoreContext.Provider>
    </View>
  )
}

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-13">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function LogSecurity({
  onFilterChange,
  onResolve,
  onNavigate,
}: {
  onFilterChange?: (filter: string) => void
  onResolve?: (title: string, resolved: boolean) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("log")
  const [alerts, setAlerts] = React.useState(INITIAL_ALERTS)
  const [filter, setFilter] = React.useState("Open")
  const scroll = useHeaderScroll()
  const open = alerts.filter((alert) => !alert.resolved)
  const count = (level: Severity) =>
    open.filter((alert) => alert.severity === level).length
  const score =
    100 -
    LEVELS.reduce(
      (sum, level) => sum + count(level) * SEVERITY[level].weight,
      0
    )
  const list =
    filter === "All"
      ? alerts
      : alerts.filter((alert) => alert.resolved === (filter === "Resolved"))
  const summary = open.length ? `${open.length} open alerts` : "No open alerts"

  const setResolved = (title: string, resolved: boolean) => {
    onResolve?.(title, resolved)
    setAlerts((current) =>
      current.map((alert) =>
        alert.title === title ? { ...alert, resolved } : alert
      )
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Security</HeaderTitle>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={
                <Text
                  accessibilityLiveRegion="polite"
                  className="text-sm text-muted-foreground"
                >
                  {summary}
                </Text>
              }
            >
              Security
            </HeaderLargeTitle>
            <View className="gap-5 px-4 pt-3 pb-8">
              <View className="flex-row flex-wrap items-center gap-4 rounded-xl bg-muted px-4 py-3.5">
                <ScoreRing score={score} />
                <View accessible className="flex-1">
                  <Text className="text-sm text-muted-foreground">
                    Security score
                  </Text>
                  <Text className="text-3xl font-bold tabular-nums">
                    {String(score)}
                  </Text>
                </View>
                <View className="flex-row gap-4.5">
                  {LEVELS.map((level) => (
                    <View key={level} accessible className="items-center">
                      <Text className="text-xs text-muted-foreground">
                        {level}
                      </Text>
                      <Text
                        className={cn(
                          "text-xl font-semibold tabular-nums",
                          count(level)
                            ? SEVERITY[level].text
                            : "text-muted-foreground"
                        )}
                      >
                        {String(count(level))}
                      </Text>
                    </View>
                  ))}
                </View>
              </View>
              <ChipGroup
                value={[filter]}
                onValueChange={(value) => {
                  if (!value[0]) return
                  setFilter(value[0])
                  onFilterChange?.(value[0])
                }}
                accessibilityLabel="Filter"
              >
                {FILTERS.map((item) => (
                  <Chip key={item} value={item}>
                    {item}
                  </Chip>
                ))}
              </ChipGroup>
              <AnimatedView
                key={filter}
                entering={FadeInDown.duration(700).reduceMotion(
                  ReduceMotion.System
                )}
              >
                {list.length > 0 ? (
                  <View className="overflow-hidden rounded-2xl bg-muted">
                    {list.map((alert, index) => (
                      <View key={alert.title}>
                        {index > 0 ? (
                          <View className="ms-14.5 h-px bg-border" />
                        ) : null}
                        <View className="min-h-13 flex-row items-center gap-3 px-4 py-2.5">
                          <View
                            className={cn(
                              "size-7.5 shrink-0 items-center justify-center rounded-[7px]",
                              alert.resolved
                                ? "bg-green-500"
                                : SEVERITY[alert.severity].tint
                            )}
                          >
                            <Icon
                              as={alert.resolved ? CheckIcon : alert.icon}
                              weight="bold"
                              className="size-4 text-primary-foreground"
                            />
                          </View>
                          <View className="min-w-0 flex-1">
                            <Text
                              numberOfLines={2}
                              className="text-sm font-semibold"
                            >
                              {alert.title}
                            </Text>
                            <Text
                              numberOfLines={1}
                              className="text-xs text-muted-foreground"
                            >
                              {`${alert.resolved ? "Resolved" : `${alert.severity} severity`} · ${alert.detail}`}
                            </Text>
                          </View>
                          {alert.resolved ? (
                            <Button
                              size="sm"
                              variant="secondary"
                              onPress={() => setResolved(alert.title, false)}
                            >
                              <Text>Reopen</Text>
                            </Button>
                          ) : (
                            <Button
                              size="sm"
                              onPress={() => setResolved(alert.title, true)}
                            >
                              <Text>Resolve</Text>
                            </Button>
                          )}
                        </View>
                      </View>
                    ))}
                  </View>
                ) : (
                  <Text className="p-7.5 text-center text-sm text-muted-foreground">
                    {filter === "Open"
                      ? "No open alerts. All good."
                      : "Nothing here yet."}
                  </Text>
                )}
              </AnimatedView>
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogSecurity }
```

```swift title="swift/Blocks/LogSecurity.swift"
import PhosphorSwift
import SwiftUI

public struct LogSecurity: View {
  enum Severity: String, CaseIterable {
    case high = "High"
    case medium = "Medium"
    case low = "Low"

    var tint: Color {
      switch self {
      case .high: EsuissColor.destructive
      case .medium: Color.adaptive(light: Palette.amber600, dark: Palette.amber400)
      case .low: EsuissColor.foreground
      }
    }

    var tile: Color {
      switch self {
      case .high: EsuissColor.destructive
      case .medium: Palette.amber500
      case .low: Palette.zinc500
      }
    }

    var weight: Int {
      switch self {
      case .high: 18
      case .medium: 8
      case .low: 3
      }
    }
  }

  struct Alert: Identifiable {
    let severity: Severity
    let title: String
    let detail: String
    let icon: Image
    var resolved = false

    var id: String { title }
  }

  static var initialAlerts: [Alert] {
    [
      Alert(severity: .high, title: "Sign-in from an unusual location", detail: "morgan@example.com · Lagos, NG · 3:12 AM", icon: Ph.globe.bold),
      Alert(severity: .medium, title: "5 failed sign-in attempts", detail: "85.104.x.x · last 10 minutes", icon: Ph.lockSimple.bold),
      Alert(severity: .medium, title: "API key expires in 7 days", detail: "Payments integration", icon: Ph.key.bold),
      Alert(severity: .low, title: "Two-factor authentication is off", detail: "2 users", icon: Ph.shield.bold),
    ]
  }

  static let filters = ["Open", "Resolved", "All"]

  static func score(_ alerts: [Alert]) -> Int {
    100 - alerts.filter { !$0.resolved }.reduce(0) { $0 + $1.severity.weight }
  }

  static func tone(_ score: Int) -> Color {
    if score > 85 { return Palette.green500 }
    if score > 65 { return Palette.orange500 }
    return Palette.red500
  }

  private var onNavigate: ((String) -> Void)?
  private var onResolve: ((String, Bool) -> Void)?
  private var onFilterChange: ((String) -> Void)?
  @State private var alerts: [Alert]
  @State private var filter: String?
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onFilterChange: ((_ filter: String) -> Void)? = nil,
    onResolve: ((_ title: String, _ resolved: Bool) -> Void)? = nil,
    onNavigate: ((_ section: String) -> Void)? = nil
  ) {
    self.init(filter: "Open")
    self.onFilterChange = onFilterChange
    self.onResolve = onResolve
    self.onNavigate = onNavigate
  }

  init(filter: String = "Open", resolved: Set<String> = []) {
    _alerts = State(initialValue: Self.initialAlerts.map { alert in
      var alert = alert
      alert.resolved = resolved.contains(alert.title)
      return alert
    })
    _filter = State(initialValue: filter)
  }

  private var animation: Animation? {
    reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.5)
  }

  private var filterSelection: Binding<String?> {
    Binding(
      get: { filter },
      set: { value in
        let next = value ?? "Open"
        guard next != filter else { return }
        withAnimation(animation) { filter = next }
        onFilterChange?(next)
      }
    )
  }

  public var body: some View {
    LogSecurityShell(home: "log", onNavigate: onNavigate) {
      page
    }
  }

  @ViewBuilder private var page: some View {
    let open = alerts.filter { !$0.resolved }
    let current = filter ?? "Open"
    let list = current == "All" ? alerts : alerts.filter { $0.resolved == (current == "Resolved") }
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Security", subtitle: open.isEmpty ? "No open alerts" : "\(open.count) open alerts")
        VStack(alignment: .leading, spacing: 20) {
          scoreCard(open)
          ChipGroup(selection: filterSelection) {
            ForEach(Self.filters, id: \.self) { item in
              Chip(LocalizedStringKey(item), value: item)
            }
          }
          .accessibilityLabel(Text("Filter"))
          Group {
            if list.isEmpty {
              Text(current == "Open" ? "No open alerts. All good." : "Nothing here yet.")
                .scaledFont(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
                .frame(maxWidth: .infinity)
                .padding(30)
            } else {
              VStack(spacing: 0) {
                ForEach(Array(list.enumerated()), id: \.element.id) { index, alert in
                  row(alert)
                    .overlay(alignment: .top) {
                      if index > 0 {
                        EsuissColor.border.frame(height: 1).padding(.leading, 58)
                      }
                    }
                    .transition(.opacity)
                }
              }
              .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
              .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
            }
          }
          .id(current)
          .transition(.opacity.combined(with: .offset(y: 6)))
        }
        .padding(.horizontal, 16)
        .padding(.top, 8)
        .padding(.bottom, 24)
      } header: {
        Header {
          HeaderTitle("Security")
        }
      }
    }
  }

  private func scoreCard(_ open: [Alert]) -> some View {
    let score = Self.score(alerts)
    let tone = Self.tone(score)
    return HStack(spacing: 16) {
      ZStack {
        Circle()
          .stroke(EsuissColor.secondary, lineWidth: 6)
        Circle()
          .trim(from: 0, to: CGFloat(max(score, 0)) / 100)
          .stroke(tone, style: StrokeStyle(lineWidth: 6, lineCap: .round))
          .rotationEffect(.degrees(-90))
      }
      .padding(3)
      .frame(width: 54, height: 54)
      .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.6), value: score)
      .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 0) {
        Text("Security score")
          .scaledFont(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
        Text(verbatim: "\(score)")
          .scaledFont(.xl3, weight: .bold)
          .monospacedDigit()
          .contentTransition(.numericText())
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      .accessibilityElement(children: .combine)
      HStack(spacing: 14) {
        ForEach(Severity.allCases, id: \.self) { level in
          let count = open.filter { $0.severity == level }.count
          VStack(spacing: 0) {
            Text(LocalizedStringKey(level.rawValue))
              .scaledFont(.xs)
              .fixedSize()
              .foregroundStyle(EsuissColor.mutedForeground)
            Text(verbatim: "\(count)")
              .scaledFont(.xl, weight: .semibold)
              .monospacedDigit()
              .foregroundStyle(count > 0 ? level.tint : EsuissColor.mutedForeground)
              .contentTransition(.numericText())
          }
          .accessibilityElement(children: .combine)
        }
      }
    }
    .padding(.horizontal, 16)
    .padding(.vertical, 14)
    .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
  }

  private func row(_ alert: Alert) -> some View {
    HStack(spacing: 12) {
      (alert.resolved ? Ph.check.bold : alert.icon)
        .frame(width: 16, height: 16)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 30, height: 30)
        .background(alert.resolved ? Palette.green500 : alert.severity.tile, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
        .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 1) {
        Text(verbatim: alert.title)
          .scaledFont(.sm, weight: .semibold)
          .lineLimit(1)
        Text("\(Text(alert.resolved ? "Resolved" : "\(alert.severity.rawValue) severity"))\(Text(verbatim: " · \(alert.detail)"))")
          .scaledFont(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
          .lineLimit(1)
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      .accessibilityElement(children: .combine)
      if alert.resolved {
        Button("Reopen") { set(alert.title, resolved: false) }
          .buttonStyle(.variant(.secondary, size: .sm))
      } else {
        Button("Resolve") { set(alert.title, resolved: true) }
          .buttonStyle(.variant(.default, size: .sm))
      }
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.horizontal, 16)
    .padding(.vertical, 10)
    .frame(minHeight: 52)
  }

  private func set(_ title: String, resolved: Bool) {
    guard let index = alerts.firstIndex(where: { $0.title == title }) else { return }
    onResolve?(title, resolved)
    withAnimation(animation) { alerts[index].resolved = resolved }
  }
}

private struct LogSecurityShell<Content: View>: View {
  let home: String
  let onNavigate: ((String) -> Void)?
  let content: Content

  @State private var tab: String

  init(home: String, onNavigate: ((String) -> Void)?, @ViewBuilder content: () -> Content) {
    self.home = home
    self.onNavigate = onNavigate
    self.content = content()
    _tab = State(initialValue: home)
  }

  private static var sections: [(value: String, label: LocalizedStringKey, icon: Image, active: Image)] {
    [
      ("users", "Users", Ph.users.regular, Ph.users.fill),
      ("roles", "Roles", Ph.shield.regular, Ph.shield.fill),
      ("log", "Activity", Ph.chartBar.regular, Ph.chartBar.fill),
      ("settings", "Settings", Ph.gear.regular, Ph.gear.fill),
    ]
  }

  var body: some View {
    let selection = Binding<String>(
      get: { tab },
      set: { value in
        guard value != tab else { return }
        onNavigate?(value)
        tab = value
      }
    )
    Screen {
      ZStack {
        content
          .opacity(tab == home ? 1 : 0)
          .allowsHitTesting(tab == home)
          .accessibilityHidden(tab != home)
        if tab != home {
          Empty {
            EmptyHeader {
              EmptyDescription("This section isn't part of the sample. Choose \(Text(Self.sections.first { $0.value == home }?.label ?? "")).")
            }
          }
          .background(EsuissColor.background)
        }
      }
      .frame(maxHeight: .infinity)
      TabBar(selection: selection) {
        ForEach(Self.sections, id: \.value) { section in
          TabBarItem(section.label, value: section.value, icon: section.icon, activeIcon: section.active)
        }
      }
    }
  }
}
```

```kt title="kotlin/blocks/LogSecurity.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.bold.Globe
import com.adamglin.phosphoricons.bold.Key
import com.adamglin.phosphoricons.bold.LockSimple
import com.adamglin.phosphoricons.bold.Shield
import com.adamglin.phosphoricons.fill.ChartBar as FillChartBar
import com.adamglin.phosphoricons.fill.Gear as FillGear
import com.adamglin.phosphoricons.fill.Shield as FillShield
import com.adamglin.phosphoricons.fill.Users as FillUsers
import com.adamglin.phosphoricons.regular.ChartBar
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.Shield
import com.adamglin.phosphoricons.regular.Users
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemActions
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemGroup
import com.esuiss.ui.components.ItemGroupVariant
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemSeparator
import com.esuiss.ui.components.ItemSize
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.rememberHeaderState
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

private val SecurityEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private enum class AlertSeverity(val weight: Int) { High(18), Medium(8), Low(3) }

private class SecurityAlert(val severity: AlertSeverity, val title: String, val detail: String, val icon: ImageVector) {
    var resolved by mutableStateOf(false)
}

private fun securityAlerts() = listOf(
    SecurityAlert(AlertSeverity.High, "Sign-in from an unusual location", "morgan@example.com · Lagos, NG · 3:12 AM", PhosphorIcons.Bold.Globe),
    SecurityAlert(AlertSeverity.Medium, "5 failed sign-in attempts", "85.104.x.x · last 10 minutes", PhosphorIcons.Bold.LockSimple),
    SecurityAlert(AlertSeverity.Medium, "API key expires in 7 days", "Payments integration", PhosphorIcons.Bold.Key),
    SecurityAlert(AlertSeverity.Low, "Two-factor authentication is off", "2 users", PhosphorIcons.Bold.Shield),
)

private fun severityTint(colors: Colors, severity: AlertSeverity): Color = when (severity) {
    AlertSeverity.High -> colors.destructive
    AlertSeverity.Medium -> if (colors.isDark) Palette.amber400 else Palette.amber600
    AlertSeverity.Low -> colors.foreground
}

private fun severityText(colors: Colors, severity: AlertSeverity): Color = severityTint(colors, severity)

private fun scoreTone(colors: Colors, score: Int): Color = when {
    score > 85 -> Palette.green500
    score > 65 -> Palette.orange500
    else -> Palette.red500
}

@Composable
fun LogSecurity(
    modifier: Modifier = Modifier,
    onFilterChange: (filter: String) -> Unit = {},
    onResolve: (title: String, resolved: Boolean) -> Unit = { _, _ -> },
    onNavigate: (section: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val header = rememberHeaderState()
    val alerts = remember { mutableStateListOf<SecurityAlert>().apply { addAll(securityAlerts()) } }
    var filter by rememberSaveable { mutableStateOf("Open") }
    val open = alerts.filter { !it.resolved }
    fun count(level: AlertSeverity) = open.count { it.severity == level }
    val score = 100 - AlertSeverity.entries.sumOf { count(it) * it.weight }
    val list = when (filter) {
        "All" -> alerts.toList()
        "Resolved" -> alerts.filter { it.resolved }
        else -> open
    }
    Screen(modifier, edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        AdminTabs("log", onNavigate) {
            Column(
                Modifier
                    .fillMaxSize()
                    .nestedScroll(header.nestedScrollConnection),
            ) {
                Header(state = header) {
                    HeaderTitle("Security")
                    HeaderLargeTitle(
                        "Security",
                        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                        subtitle = if (open.isNotEmpty()) "${open.size} open alerts" else "No open alerts",
                    )
                }
                Column(
                    Modifier
                        .weight(1f)
                        .fillMaxWidth()
                        .verticalScroll(rememberScrollState())
                        .padding(top = 8.dp, bottom = 32.dp),
                    verticalArrangement = Arrangement.spacedBy(20.dp),
                ) {
                    ScoreCard(score, ::count, Modifier.padding(horizontal = 16.dp))
                    ChipGroup(
                        value = filter,
                        onValueChange = { value ->
                            if (value != null && value != filter) {
                                onFilterChange(value)
                                filter = value
                            }
                        },
                        scrollable = true,
                        contentPadding = PaddingValues(horizontal = 16.dp),
                    ) {
                        listOf("Open", "Resolved", "All").forEach { Chip(it, it) }
                    }
                    Box(Modifier.padding(horizontal = 16.dp)) {
                        if (list.isEmpty()) {
                            BasicText(
                                if (filter == "Open") "No open alerts. All good." else "Nothing here yet.",
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(30.dp),
                                style = Typography.sm.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                            )
                        } else {
                            ItemGroup(variant = ItemGroupVariant.Inset) {
                                list.forEachIndexed { index, alert ->
                                    if (index > 0) ItemSeparator(Modifier.padding(start = 40.dp))
                                    AlertRow(
                                        alert = alert,
                                        onResolve = {
                                            onResolve(alert.title, true)
                                            alert.resolved = true
                                        },
                                        onReopen = {
                                            onResolve(alert.title, false)
                                            alert.resolved = false
                                        },
                                    )
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun ScoreCard(score: Int, count: (AlertSeverity) -> Int, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val tone by animateColorAsState(scoreTone(colors, score), tween(400), label = "tone")
    val sweep by animateFloatAsState(score / 100f, tween(600, easing = SecurityEasing), label = "sweep")
    val track = colors.secondary
    FlowRow(
        modifier = modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(Radius.xl))
            .background(colors.muted)
            .padding(horizontal = 16.dp, vertical = 14.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
        itemVerticalAlignment = Alignment.CenterVertically,
    ) {
        Row(
            modifier = Modifier
                .weight(1f)
                .semantics(mergeDescendants = true) { contentDescription = "Security score $score" },
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Canvas(
                Modifier
                    .size(54.dp)
                    .clearAndSetSemantics {},
            ) {
                val stroke = 6.dp.toPx()
                val radius = 22.dp.toPx()
                val topLeft = Offset(center.x - radius, center.y - radius)
                val arc = Size(radius * 2, radius * 2)
                drawArc(track, 0f, 360f, false, topLeft, arc, style = Stroke(stroke))
                drawArc(tone, -90f, 360f * sweep, false, topLeft, arc, style = Stroke(stroke, cap = StrokeCap.Round))
            }
            Column(Modifier.clearAndSetSemantics {}) {
                BasicText("Security score", style = Typography.sm.copy(color = colors.mutedForeground), maxLines = 1)
                BasicText("$score", style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.Bold, fontFeatureSettings = "tnum"))
            }
        }
        Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
            AlertSeverity.entries.forEach { level ->
                val value = count(level)
                Column(
                    modifier = Modifier.semantics(mergeDescendants = true) {},
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    BasicText(level.name, style = Typography.xs.copy(color = colors.mutedForeground))
                    BasicText(
                        "$value",
                        style = Typography.xl.copy(
                            color = if (value > 0) severityText(colors, level) else colors.mutedForeground,
                            fontWeight = FontWeight.SemiBold,
                            fontFeatureSettings = "tnum",
                        ),
                    )
                }
            }
        }
    }
}

@Composable
private fun AlertRow(alert: SecurityAlert, onResolve: () -> Unit, onReopen: () -> Unit) {
    val colors = EsuissTheme.colors
    val resolvedTint = if (colors.isDark) Palette.green500 else lerp(Palette.green500, colors.foreground, 0.2f)
    val tint by animateColorAsState(if (alert.resolved) resolvedTint else severityTint(colors, alert.severity), tween(300), label = "tint")
    Item(size = ItemSize.Sm, role = null) {
        ItemMedia {
            Box(
                Modifier
                    .size(30.dp)
                    .background(tint, RoundedCornerShape(7.dp)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(
                    if (alert.resolved) PhosphorIcons.Bold.Check else alert.icon,
                    contentDescription = null,
                    size = 16.dp,
                    tint = if (!alert.resolved && alert.severity == AlertSeverity.Low) colors.background else Palette.white,
                )
            }
        }
        ItemContent {
            BasicText(
                alert.title,
                style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            BasicText(
                "${if (alert.resolved) "Resolved" else "${alert.severity.name} severity"} · ${alert.detail}",
                style = Typography.xs.copy(color = colors.mutedForeground),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
        ItemActions {
            if (alert.resolved) {
                Button(text = "Reopen", onClick = onReopen, size = ButtonSize.Sm, variant = ButtonVariant.Secondary)
            } else {
                Button(text = "Resolve", onClick = onResolve, size = ButtonSize.Sm)
            }
        }
    }
}

private val AdminSections = listOf("users" to "Users", "roles" to "Roles", "log" to "Activity", "settings" to "Settings")

@Composable
private fun AdminTabs(home: String, onNavigate: (String) -> Unit, content: @Composable () -> Unit) {
    val colors = EsuissTheme.colors
    var tab by rememberSaveable { mutableStateOf(home) }
    Column(Modifier.fillMaxSize()) {
        Box(
            Modifier
                .weight(1f)
                .fillMaxWidth(),
        ) {
            if (tab == home) {
                content()
            } else {
                BasicText(
                    "This section isn't part of the sample. Choose ${AdminSections.first { it.first == home }.second}.",
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(32.dp),
                    style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
            }
        }
        TabBar(
            value = tab,
            onValueChange = {
                onNavigate(it)
                tab = it
            },
        ) {
            TabBarItem("users", "Users", PhosphorIcons.Regular.Users, selectedIcon = PhosphorIcons.Fill.FillUsers)
            TabBarItem("roles", "Roles", PhosphorIcons.Regular.Shield, selectedIcon = PhosphorIcons.Fill.FillShield)
            TabBarItem("log", "Activity", PhosphorIcons.Regular.ChartBar, selectedIcon = PhosphorIcons.Fill.FillChartBar)
            TabBarItem("settings", "Settings", PhosphorIcons.Regular.Gear, selectedIcon = PhosphorIcons.Fill.FillGear)
        }
    }
}
```

An admin screen with a collapsing `Header`, a ring that shows a security score, and counts of open High, Medium and Low alerts. A `ChipGroup` filters the list by Open, Resolved or All, and each alert has a Resolve or Reopen `Button`; the score and counts update as alerts change. A `TabBar` at the bottom switches between admin sections; only Activity has content in the sample.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/log-security
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add log-security
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add log-security --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add log-security --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/log-security.tsx`:

- `uniwind/components/blocks/log-security.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/log-security.tsx`:

```tsx title="nativewind/components/blocks/log-security.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  GlobeIcon,
  KeyIcon,
  LockSimpleIcon,
  type Icon as PhosphorIcon,
  type IconProps as PhosphorIconProps,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import Svg, { Circle } from "react-native-svg"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

type Severity = "High" | "Medium" | "Low"

const LEVELS: Severity[] = ["High", "Medium", "Low"]

const SEVERITY: Record<
  Severity,
  { tint: string; text: string; weight: number }
> = {
  High: { tint: "bg-red-500", text: "text-destructive", weight: 18 },
  Medium: {
    tint: "bg-orange-500",
    text: "text-amber-600 dark:text-amber-400",
    weight: 8,
  },
  Low: { tint: "bg-yellow-500", text: "text-foreground", weight: 3 },
}

const FILTERS = ["Open", "Resolved", "All"]

type Alert = {
  severity: Severity
  title: string
  detail: string
  icon: PhosphorIcon
  resolved: boolean
}

const INITIAL_ALERTS: Alert[] = [
  {
    severity: "High",
    title: "Sign-in from an unusual location",
    detail: "morgan@example.com · Lagos, NG · 3:12 AM",
    icon: GlobeIcon,
    resolved: false,
  },
  {
    severity: "Medium",
    title: "5 failed sign-in attempts",
    detail: "85.104.x.x · last 10 minutes",
    icon: LockSimpleIcon,
    resolved: false,
  },
  {
    severity: "Medium",
    title: "API key expires in 7 days",
    detail: "Payments integration",
    icon: KeyIcon,
    resolved: false,
  },
  {
    severity: "Low",
    title: "Two-factor authentication is off",
    detail: "2 users",
    icon: ShieldIcon,
    resolved: false,
  },
]

const scoreTone = (score: number) => {
  if (score > 85) return "text-green-500"
  if (score > 65) return "text-orange-500"
  return "text-red-500"
}

const ScoreContext = React.createContext(0)

function ScoreTrack({ color }: PhosphorIconProps) {
  return (
    <Svg width={54} height={54} viewBox="0 0 54 54">
      <Circle
        cx={27}
        cy={27}
        r={22}
        fill="none"
        strokeWidth={6}
        stroke={color}
      />
    </Svg>
  )
}

function ScoreArc({ color }: PhosphorIconProps) {
  const score = React.useContext(ScoreContext)
  const length = 2 * Math.PI * 22
  return (
    <Svg width={54} height={54} viewBox="0 0 54 54">
      <Circle
        cx={27}
        cy={27}
        r={22}
        fill="none"
        strokeWidth={6}
        strokeLinecap="round"
        stroke={color}
        strokeDasharray={`${(Math.max(0, score) / 100) * length} ${length}`}
      />
    </Svg>
  )
}

function ScoreRing({ score }: { score: number }) {
  return (
    <View
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      className="size-[3.375rem] -rotate-90"
    >
      <Icon
        as={ScoreTrack}
        className="absolute size-[3.375rem] text-secondary"
      />
      <ScoreContext.Provider value={score}>
        <Icon
          as={ScoreArc}
          className={cn("absolute size-[3.375rem]", scoreTone(score))}
        />
      </ScoreContext.Provider>
    </View>
  )
}

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-[3.25rem]">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function LogSecurity({
  onFilterChange,
  onResolve,
  onNavigate,
}: {
  onFilterChange?: (filter: string) => void
  onResolve?: (title: string, resolved: boolean) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("log")
  const [alerts, setAlerts] = React.useState(INITIAL_ALERTS)
  const [filter, setFilter] = React.useState("Open")
  const scroll = useHeaderScroll()
  const open = alerts.filter((alert) => !alert.resolved)
  const count = (level: Severity) =>
    open.filter((alert) => alert.severity === level).length
  const score =
    100 -
    LEVELS.reduce(
      (sum, level) => sum + count(level) * SEVERITY[level].weight,
      0
    )
  const list =
    filter === "All"
      ? alerts
      : alerts.filter((alert) => alert.resolved === (filter === "Resolved"))
  const summary = open.length ? `${open.length} open alerts` : "No open alerts"

  const setResolved = (title: string, resolved: boolean) => {
    onResolve?.(title, resolved)
    setAlerts((current) =>
      current.map((alert) =>
        alert.title === title ? { ...alert, resolved } : alert
      )
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Security</HeaderTitle>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={
                <Text
                  accessibilityLiveRegion="polite"
                  className="text-sm text-muted-foreground"
                >
                  {summary}
                </Text>
              }
            >
              Security
            </HeaderLargeTitle>
            <View className="gap-5 px-4 pt-3 pb-8">
              <View className="flex-row flex-wrap items-center gap-4 rounded-xl bg-muted px-4 py-3.5">
                <ScoreRing score={score} />
                <View accessible className="flex-1">
                  <Text className="text-sm text-muted-foreground">
                    Security score
                  </Text>
                  <Text className="text-3xl font-bold tabular-nums">
                    {String(score)}
                  </Text>
                </View>
                <View className="flex-row gap-[1.125rem]">
                  {LEVELS.map((level) => (
                    <View key={level} accessible className="items-center">
                      <Text className="text-xs text-muted-foreground">
                        {level}
                      </Text>
                      <Text
                        className={cn(
                          "text-xl font-semibold tabular-nums",
                          count(level)
                            ? SEVERITY[level].text
                            : "text-muted-foreground"
                        )}
                      >
                        {String(count(level))}
                      </Text>
                    </View>
                  ))}
                </View>
              </View>
              <ChipGroup
                value={[filter]}
                onValueChange={(value) => {
                  if (!value[0]) return
                  setFilter(value[0])
                  onFilterChange?.(value[0])
                }}
                accessibilityLabel="Filter"
              >
                {FILTERS.map((item) => (
                  <Chip key={item} value={item}>
                    {item}
                  </Chip>
                ))}
              </ChipGroup>
              <AnimatedView
                key={filter}
                entering={FadeInDown.duration(700).reduceMotion(
                  ReduceMotion.System
                )}
              >
                {list.length > 0 ? (
                  <View className="overflow-hidden rounded-2xl bg-muted">
                    {list.map((alert, index) => (
                      <View key={alert.title}>
                        {index > 0 ? (
                          <View className="ms-[3.625rem] h-px bg-border" />
                        ) : null}
                        <View className="min-h-[3.25rem] flex-row items-center gap-3 px-4 py-2.5">
                          <View
                            className={cn(
                              "size-[1.875rem] shrink-0 items-center justify-center rounded-[7px]",
                              alert.resolved
                                ? "bg-green-500"
                                : SEVERITY[alert.severity].tint
                            )}
                          >
                            <Icon
                              as={alert.resolved ? CheckIcon : alert.icon}
                              weight="bold"
                              className="size-4 text-primary-foreground"
                            />
                          </View>
                          <View className="min-w-0 flex-1">
                            <Text
                              numberOfLines={2}
                              className="text-sm font-semibold"
                            >
                              {alert.title}
                            </Text>
                            <Text
                              numberOfLines={1}
                              className="text-xs text-muted-foreground"
                            >
                              {`${alert.resolved ? "Resolved" : `${alert.severity} severity`} · ${alert.detail}`}
                            </Text>
                          </View>
                          {alert.resolved ? (
                            <Button
                              size="sm"
                              variant="secondary"
                              onPress={() => setResolved(alert.title, false)}
                            >
                              <Text>Reopen</Text>
                            </Button>
                          ) : (
                            <Button
                              size="sm"
                              onPress={() => setResolved(alert.title, true)}
                            >
                              <Text>Resolve</Text>
                            </Button>
                          )}
                        </View>
                      </View>
                    ))}
                  </View>
                ) : (
                  <Text className="p-[1.875rem] text-center text-sm text-muted-foreground">
                    {filter === "Open"
                      ? "No open alerts. All good."
                      : "Nothing here yet."}
                  </Text>
                )}
              </AnimatedView>
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogSecurity }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/log-security.tsx`:

```tsx title="stylesheet/components/blocks/log-security.tsx"
import {
  ChartBarIcon,
  CheckIcon,
  GearIcon,
  GlobeIcon,
  KeyIcon,
  LockSimpleIcon,
  type Icon as PhosphorIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { StyleSheet, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import Svg, { Circle } from "react-native-svg"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Header,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

type Tint = Tone

type Severity = "High" | "Medium" | "Low"

const LEVELS: Severity[] = ["High", "Medium", "Low"]

const SEVERITY: Record<Severity, { tint: Tint; text: Tint; weight: number }> = {
  High: { tint: "red500", text: "destructive", weight: 18 },
  Medium: { tint: "orange500", text: "amber500", weight: 8 },
  Low: { tint: "yellow500", text: "foreground", weight: 3 },
}

const FILTERS = ["Open", "Resolved", "All"]

type Alert = {
  severity: Severity
  title: string
  detail: string
  icon: PhosphorIcon
  resolved: boolean
}

const INITIAL_ALERTS: Alert[] = [
  {
    severity: "High",
    title: "Sign-in from an unusual location",
    detail: "morgan@example.com · Lagos, NG · 3:12 AM",
    icon: GlobeIcon,
    resolved: false,
  },
  {
    severity: "Medium",
    title: "5 failed sign-in attempts",
    detail: "85.104.x.x · last 10 minutes",
    icon: LockSimpleIcon,
    resolved: false,
  },
  {
    severity: "Medium",
    title: "API key expires in 7 days",
    detail: "Payments integration",
    icon: KeyIcon,
    resolved: false,
  },
  {
    severity: "Low",
    title: "Two-factor authentication is off",
    detail: "2 users",
    icon: ShieldIcon,
    resolved: false,
  },
]

const RING_LENGTH = 2 * Math.PI * 22

const styles = StyleSheet.create({
  other: { flex: 1, gap: 8, paddingHorizontal: 16, paddingTop: 52 },
  otherTitle: { ...fontSize["3xl"], fontWeight: "700" },
  ring: { width: 54, height: 54, transform: [{ rotate: "-90deg" }] },
  ringLayer: { position: "absolute", top: 0, left: 0 },
  body: { gap: 20, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 32 },
  card: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "center",
    gap: 16,
    borderRadius: radius.xl,
    paddingHorizontal: 16,
    paddingVertical: 14,
  },
  score: { flex: 1 },
  scoreValue: {
    ...fontSize["3xl"],
    fontWeight: "700",
    fontVariant: ["tabular-nums"],
  },
  levels: { flexDirection: "row", gap: 18 },
  level: { alignItems: "center" },
  levelValue: {
    ...fontSize.xl,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  group: { overflow: "hidden", borderRadius: radius["2xl"] },
  separator: { marginStart: 58, height: 1 },
  row: {
    minHeight: 52,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 10,
  },
  tile: {
    width: 30,
    height: 30,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 7,
  },
  text: { minWidth: 0, flex: 1 },
  title: { ...fontSize.sm, fontWeight: "600" },
  empty: { ...fontSize.sm, padding: 30, textAlign: "center" },
})

const scoreTone = (score: number): Tint => {
  if (score > 85) return "green500"
  if (score > 65) return "orange500"
  return "red500"
}

function ScoreRing({ score }: { score: number }) {
  const { colors } = useTheme()
  return (
    <View
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      style={styles.ring}
    >
      <Svg width={54} height={54} viewBox="0 0 54 54" style={styles.ringLayer}>
        <Circle
          cx={27}
          cy={27}
          r={22}
          fill="none"
          strokeWidth={6}
          stroke={colors.secondary}
        />
      </Svg>
      <Svg width={54} height={54} viewBox="0 0 54 54" style={styles.ringLayer}>
        <Circle
          cx={27}
          cy={27}
          r={22}
          fill="none"
          strokeWidth={6}
          strokeLinecap="round"
          stroke={tone(colors, scoreTone(score))}
          strokeDasharray={`${(Math.max(0, score) / 100) * RING_LENGTH} ${RING_LENGTH}`}
        />
      </Svg>
    </View>
  )
}

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const { colors } = useTheme()
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View style={styles.other}>
      <Text role="heading" style={styles.otherTitle}>
        {label}
      </Text>
      <Text style={[fontSize.lg, { color: colors.mutedForeground }]}>
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function LogSecurity({
  onFilterChange,
  onResolve,
  onNavigate,
}: {
  onFilterChange?: (filter: string) => void
  onResolve?: (title: string, resolved: boolean) => void
  onNavigate?: (section: string) => void
}) {
  const { colors } = useTheme()
  const [section, setSection] = React.useState("log")
  const [alerts, setAlerts] = React.useState(INITIAL_ALERTS)
  const [filter, setFilter] = React.useState("Open")
  const scroll = useHeaderScroll()
  const open = alerts.filter((alert) => !alert.resolved)
  const count = (level: Severity) =>
    open.filter((alert) => alert.severity === level).length
  const score =
    100 -
    LEVELS.reduce(
      (sum, level) => sum + count(level) * SEVERITY[level].weight,
      0
    )
  const list =
    filter === "All"
      ? alerts
      : alerts.filter((alert) => alert.resolved === (filter === "Resolved"))
  const summary = open.length ? `${open.length} open alerts` : "No open alerts"

  const setResolved = (title: string, resolved: boolean) => {
    onResolve?.(title, resolved)
    setAlerts((current) =>
      current.map((alert) =>
        alert.title === title ? { ...alert, resolved } : alert
      )
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      {section === "log" ? (
        <>
          <Header scroll={scroll}>
            <HeaderTitle>Security</HeaderTitle>
          </Header>
          <HeaderScrollView scroll={scroll}>
            <HeaderLargeTitle
              subtitle={
                <Text
                  accessibilityLiveRegion="polite"
                  style={[fontSize.sm, { color: colors.mutedForeground }]}
                >
                  {summary}
                </Text>
              }
            >
              Security
            </HeaderLargeTitle>
            <View style={styles.body}>
              <View style={[styles.card, { backgroundColor: colors.muted }]}>
                <ScoreRing score={score} />
                <View accessible style={styles.score}>
                  <Text
                    style={[fontSize.sm, { color: colors.mutedForeground }]}
                  >
                    Security score
                  </Text>
                  <Text style={styles.scoreValue}>{String(score)}</Text>
                </View>
                <View style={styles.levels}>
                  {LEVELS.map((level) => (
                    <View key={level} accessible style={styles.level}>
                      <Text
                        style={[fontSize.xs, { color: colors.mutedForeground }]}
                      >
                        {level}
                      </Text>
                      <Text
                        style={[
                          styles.levelValue,
                          {
                            color: count(level)
                              ? tone(colors, SEVERITY[level].text)
                              : colors.mutedForeground,
                          },
                        ]}
                      >
                        {String(count(level))}
                      </Text>
                    </View>
                  ))}
                </View>
              </View>
              <ChipGroup
                value={[filter]}
                onValueChange={(value) => {
                  if (!value[0]) return
                  setFilter(value[0])
                  onFilterChange?.(value[0])
                }}
                accessibilityLabel="Filter"
              >
                {FILTERS.map((item) => (
                  <Chip key={item} value={item}>
                    {item}
                  </Chip>
                ))}
              </ChipGroup>
              <AnimatedView
                key={filter}
                entering={FadeInDown.duration(700).reduceMotion(
                  ReduceMotion.System
                )}
              >
                {list.length > 0 ? (
                  <View
                    style={[styles.group, { backgroundColor: colors.muted }]}
                  >
                    {list.map((alert, index) => (
                      <View key={alert.title}>
                        {index > 0 ? (
                          <View
                            style={[
                              styles.separator,
                              { backgroundColor: colors.border },
                            ]}
                          />
                        ) : null}
                        <View style={styles.row}>
                          <View
                            style={[
                              styles.tile,
                              {
                                backgroundColor: alert.resolved
                                  ? palette.green500
                                  : tone(colors, SEVERITY[alert.severity].tint),
                              },
                            ]}
                          >
                            <Icon
                              as={alert.resolved ? CheckIcon : alert.icon}
                              weight="bold"
                              size={16}
                              color={colors.primaryForeground}
                            />
                          </View>
                          <View style={styles.text}>
                            <Text numberOfLines={2} style={styles.title}>
                              {alert.title}
                            </Text>
                            <Text
                              numberOfLines={1}
                              style={[
                                fontSize.xs,
                                { color: colors.mutedForeground },
                              ]}
                            >
                              {`${alert.resolved ? "Resolved" : `${alert.severity} severity`} · ${alert.detail}`}
                            </Text>
                          </View>
                          {alert.resolved ? (
                            <Button
                              size="sm"
                              variant="secondary"
                              onPress={() => setResolved(alert.title, false)}
                            >
                              <Text>Reopen</Text>
                            </Button>
                          ) : (
                            <Button
                              size="sm"
                              onPress={() => setResolved(alert.title, true)}
                            >
                              <Text>Resolve</Text>
                            </Button>
                          )}
                        </View>
                      </View>
                    ))}
                  </View>
                ) : (
                  <Text
                    style={[styles.empty, { color: colors.mutedForeground }]}
                  >
                    {filter === "Open"
                      ? "No open alerts. All good."
                      : "Nothing here yet."}
                  </Text>
                )}
              </AnimatedView>
            </View>
          </HeaderScrollView>
        </>
      ) : (
        <OtherSection value={section} home="Activity" />
      )}
      <AdminTabBar
        value={section}
        onValueChange={(value) => {
          setSection(value)
          onNavigate?.(value)
        }}
      />
    </Screen>
  )
}

export { LogSecurity }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/LogSecurity.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Chip.swift`: see [Chip](https://ui.suiss.app/docs/components/chip.md)

- `swift/Components/Combobox.swift`: see [Combobox](https://ui.suiss.app/docs/components/combobox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Empty.swift`: see [Empty](https://ui.suiss.app/docs/components/empty.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TabBar.swift`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/LogSecurity.kt`: shown above

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Chip.kt`: see [Chip](https://ui.suiss.app/docs/components/chip.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Item.kt`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/TabBar.kt`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { LogSecurity } from "@/components/blocks/log-security"
```

```tsx
<LogSecurity />
```

Replace `INITIAL_ALERTS` with your alerts and tune the weights in `SEVERITY` to change how the score is computed. Wire `onFilterChange`, `onResolve` and `onNavigate` to your app.

