# Semaphore — Animated Data Visualization for React

> The uptime bar, alive — labeled status-tracker rows that stack into an ops wall with a shared time axis. Boots with a sub-second left-to-right ignition cascade on first view, pulses the newest bucket, slides new buckets in on a live interval (suspended offscreen), and raises a hover chip with bucket detail. Statuses are shape-and-brightness coded, not just hue: dim operational, notched degraded, saturated glowing incident. Pure CSS animation, zero dependencies, visually-hidden textual summary per row, static wall under reduced motion.

- Page: https://crucibleui.com/components/data/semaphore
- Category: [data](https://crucibleui.com/components/data)
- Tier: free (full source)
- Engine: css

## Install

```bash
npx shadcn@latest add @crucible/semaphore
```

Registry JSON: https://crucibleui.com/r/semaphore.json

Import from `@/components/crucible/semaphore`.

## Props (`SemaphoreProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `segments?` | `SemaphoreSegment[]` | — | Single-row tracker: time buckets, oldest first. Ignored when `rows` is set. Omit both for a seeded sample row. |
| `rows?` | `SemaphoreRow[]` | — | Wall composition: multiple labeled tracker rows sharing one time axis. Takes precedence over `segments`. |
| `colors?` | `Record<string, string \| SemaphoreStatusStyle>` | — | Status → style map, merged over the built-in `operational` / `degraded` / `incident` / `empty` styles. A plain color string keeps that status's default shape/brightness coding. |
| `liveInterval?` | `number` | `0` | Live mode: advance every N milliseconds — the oldest bucket drops, a new one slides in from the right. `0` = static (no timers, zero JS work). Suspends offscreen and on hidden tabs. |
| `nextSegment?` | `(rowIndex: number, tick: number) => SemaphoreSegment` | `a deterministic mostly-operational feed` | Produces the incoming bucket in live mode. |
| `cascade?` | `boolean` | `true` | Boot cascade on first scroll into view (once): segments ignite left-to-right like a self-test. |
| `cascadeSpeed?` | `number` | `1` | Cascade speed multiplier — `2` boots twice as fast. |
| `pulse?` | `boolean` | `true` | Breathing pulse on each row's newest segment. |
| `segmentWidth?` | `number` | `6` | Segment width in px. |
| `segmentHeight?` | `number` | `26` | Segment height in px. |
| `gap?` | `number` | `3` | Gap between segments in px. |
| `radius?` | `number` | `2` | Segment corner radius in px. |
| `axis?` | `readonly [string, string]` | — | Shared time-axis labels rendered under the wall: `[oldest, newest]`. |
| `showChip?` | `boolean` | `true` | Show the hover chip with bucket detail. |
| `renderChip?` | `( segment: SemaphoreSegment, context: { rowIndex: number; segmentIndex: number; rowLabel?: string } ) => React.ReactNode` | — | Custom hover-chip contents; the default chip shows status, label, and detail. |
| `paused?` | `boolean` | `false` | Freeze everything at the fully-populated wall (also stops live mode). |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Semaphore, type SemaphoreRow } from "@/components/crucible/semaphore";

const COUNT = 44;

// Deterministic wall data — explicit incident/degraded buckets, no randomness.
function makeRow(label: string, degraded: number[], incident: number[]): SemaphoreRow {
  return {
    label,
    segments: Array.from({ length: COUNT }, (_, i) => {
      const status = incident.includes(i)
        ? "incident"
        : degraded.includes(i)
          ? "degraded"
          : "operational";
      return {
        status,
        label: i === COUNT - 1 ? "Just now" : `${COUNT - 1 - i} min ago`,
        detail:
          status === "incident"
            ? "Health check failed"
            : status === "degraded"
              ? "p99 latency above threshold"
              : "All checks passing",
      };
    }),
  };
}

const ROWS: SemaphoreRow[] = [
  makeRow("API", [12, 13, 30], [22, 23]),
  makeRow("Dashboard", [7, 27, 28], []),
  makeRow("CDN", [35], [36]),
  makeRow("Database", [18], []),
];

export default function SemaphoreDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.03),transparent_70%)]"
      />

      <div className="relative z-10 flex flex-col items-center gap-8 p-8">
        <Semaphore rows={ROWS} axis={["45 min ago", "now"]} liveInterval={2600} />

        <p className="max-w-sm text-center text-sm text-neutral-400">
          Hover a segment for its bucket detail — the wall boots when it scrolls
          into view, and the newest bucket pulses as live data arrives.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`semaphore.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

export interface SemaphoreSegment {
  /** Status key, resolved against the `colors` map (e.g. "operational"). */
  status: string;
  /** Short bucket label shown in the hover chip (e.g. "12 min ago"). */
  label?: string;
  /** Longer detail line for the hover chip (e.g. "p99 latency above threshold"). */
  detail?: string;
}

export interface SemaphoreRow {
  /** Row label rendered left of the track (e.g. a service name). */
  label?: string;
  /** Time buckets, oldest first — the last segment is "now" and carries the pulse. */
  segments: SemaphoreSegment[];
}

export interface SemaphoreStatusStyle {
  /** CSS color of segments with this status. */
  color: string;
  /** Resting opacity, `0`–`1` — the brightness half of the status coding. @default 1 */
  opacity?: number;
  /** Cut a hairline notch across the segment (the "degraded" shape cue). @default false */
  notch?: boolean;
  /** Saturated emphasis: soft glow + slightly taller (the "incident" treatment). @default false */
  emphasis?: boolean;
}

export interface SemaphoreProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /**
   * Single-row tracker: time buckets, oldest first. Ignored when `rows` is
   * set. Omit both for a seeded sample row.
   */
  segments?: SemaphoreSegment[];
  /**
   * Wall composition: multiple labeled tracker rows sharing one time axis.
   * Takes precedence over `segments`.
   */
  rows?: SemaphoreRow[];
  /**
   * Status → style map, merged over the built-in
   * `operational` / `degraded` / `incident` / `empty` styles. A plain color
   * string keeps that status's default shape/brightness coding.
   */
  colors?: Record<string, string | SemaphoreStatusStyle>;
  /**
   * Live mode: advance every N milliseconds — the oldest bucket drops, a new
   * one slides in from the right. `0` = static (no timers, zero JS work).
   * Suspends offscreen and on hidden tabs. @default 0
   */
  liveInterval?: number;
  /**
   * Produces the incoming bucket in live mode. Defaults to a deterministic
   * mostly-operational feed.
   */
  nextSegment?: (rowIndex: number, tick: number) => SemaphoreSegment;
  /**
   * Boot cascade on first scroll into view (once): segments ignite
   * left-to-right like a self-test. @default true
   */
  cascade?: boolean;
  /** Cascade speed multiplier — `2` boots twice as fast. @default 1 */
  cascadeSpeed?: number;
  /** Breathing pulse on each row's newest segment. @default true */
  pulse?: boolean;
  /** Segment width in px. @default 6 */
  segmentWidth?: number;
  /** Segment height in px. @default 26 */
  segmentHeight?: number;
  /** Gap between segments in px. @default 3 */
  gap?: number;
  /** Segment corner radius in px. @default 2 */
  radius?: number;
  /** Shared time-axis labels rendered under the wall: `[oldest, newest]`. */
  axis?: readonly [string, string];
  /** Show the hover chip with bucket detail. @default true */
  showChip?: boolean;
  /** Custom hover-chip contents; the default chip shows status, label, and detail. */
  renderChip?: (
    segment: SemaphoreSegment,
    context: { rowIndex: number; segmentIndex: number; rowLabel?: string }
  ) => React.ReactNode;
  /** Freeze everything at the fully-populated wall (also stops live mode). @default false */
  paused?: boolean;
}

/* Railway-semaphore discipline: statuses are shape-and-brightness coded, not
 * just hue. Operational sits dim and calm, degraded carries a hairline notch,
 * incident is the only saturated (and glowing, slightly taller) element. */
const DEFAULT_STATUS_STYLES: Record<string, Required<SemaphoreStatusStyle>> = {
  operational: { color: "#8b9bb4", opacity: 0.34, notch: false, emphasis: false },
  degraded: { color: "#c2913c", opacity: 0.85, notch: true, emphasis: false },
  incident: { color: "#e0552e", opacity: 1, notch: false, emphasis: true },
  empty: { color: "#57616f", opacity: 0.14, notch: false, emphasis: false },
};

const FALLBACK_STYLE = DEFAULT_STATUS_STYLES.operational;

/* Boot-cascade budget: full wall self-tests in under a second at speed 1. */
const SWEEP_S = 0.5; // left-to-right sweep across the widest row
const IGNITE_S = 0.35; // one segment's flare-and-settle
const ROW_OFFSET_S = 0.035; // slight per-row lag for a diagonal wavefront

const STYLE = `
[data-crucible="semaphore"] .sem-seg {
  position: relative;
  flex: none;
  width: var(--sem-w);
  height: var(--sem-h);
  border-radius: var(--sem-r);
  opacity: var(--seg-o, 1);
}
[data-crucible="semaphore"] .sem-notch {
  position: absolute;
  left: 0;
  right: 0;
  top: 30%;
  height: 2px;
  background: rgba(8, 10, 14, 0.9);
  pointer-events: none;
}
@keyframes crucible-sem-ignite {
  0%  { opacity: 0; filter: brightness(0.4); }
  45% { opacity: 1; filter: brightness(2.1); }
  /* no 100% frame: settles to the element's own status opacity/brightness */
}
@keyframes crucible-sem-pulse {
  0%, 100% { opacity: var(--seg-o, 1); filter: brightness(1); }
  50%      { opacity: calc(var(--seg-o, 1) + 0.45); filter: brightness(1.4); }
}
@keyframes crucible-sem-enter {
  0% { opacity: 0; transform: translateX(calc(var(--sem-w) + 4px)); }
}
@keyframes crucible-sem-chip {
  0% { opacity: 0; transform: translate(-50%, 3px); }
}
[data-crucible="semaphore"][data-boot="pending"] .sem-seg { opacity: 0; }
[data-crucible="semaphore"][data-boot="go"] .sem-seg {
  animation: crucible-sem-ignite var(--sem-ignite) cubic-bezier(0.2, 0.7, 0.3, 1) var(--seg-delay, 0s) backwards;
}
[data-crucible="semaphore"][data-boot="go"][data-pulse="true"] .sem-latest {
  animation:
    crucible-sem-ignite var(--sem-ignite) cubic-bezier(0.2, 0.7, 0.3, 1) var(--seg-delay, 0s) backwards,
    crucible-sem-pulse 2.4s ease-in-out calc(var(--seg-delay, 0s) + var(--sem-ignite)) infinite;
}
[data-crucible="semaphore"][data-boot="done"][data-pulse="true"] .sem-latest {
  animation: crucible-sem-pulse 2.4s ease-in-out infinite;
}
[data-crucible="semaphore"][data-boot="done"] .sem-new {
  animation: crucible-sem-enter 0.35s ease-out backwards;
}
[data-crucible="semaphore"][data-boot="done"][data-pulse="true"] .sem-latest.sem-new {
  animation:
    crucible-sem-enter 0.35s ease-out backwards,
    crucible-sem-pulse 2.4s ease-in-out 0.35s infinite;
}
[data-crucible="semaphore"] .sem-chip {
  position: absolute;
  bottom: calc(100% + 8px);
  transform: translateX(-50%);
  z-index: 20;
  pointer-events: none;
  white-space: nowrap;
  animation: crucible-sem-chip 0.16s ease-out;
}
/* Paused → the designed resting frame: fully-populated wall, no motion. */
[data-crucible="semaphore"][data-paused="true"] .sem-seg {
  animation: none !important;
  opacity: var(--seg-o, 1) !important;
}
/* Reduced motion → same static wall: no cascade, no pulse, no slide. */
@media (prefers-reduced-motion: reduce) {
  [data-crucible="semaphore"] .sem-seg {
    animation: none !important;
    opacity: var(--seg-o, 1) !important;
  }
  [data-crucible="semaphore"] .sem-chip { animation: none !important; }
}
`;

/** Deterministic PRNG — no Math.random(), so SSR and hydration always agree. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function seededSegments(seed: number, count: number): SemaphoreSegment[] {
  const rand = mulberry32(seed);
  return Array.from({ length: count }, (_, i) => {
    const v = rand();
    const status = v > 0.985 ? "incident" : v > 0.9 ? "degraded" : "operational";
    return {
      status,
      label: i === count - 1 ? "Just now" : `${count - 1 - i} min ago`,
      detail:
        status === "incident"
          ? "Health check failed"
          : status === "degraded"
            ? "Latency above threshold"
            : "All checks passing",
    };
  });
}

const DEFAULT_ROWS: SemaphoreRow[] = [{ segments: seededSegments(42, 48) }];

function defaultNextSegment(rowIndex: number, tick: number): SemaphoreSegment {
  const v = mulberry32(rowIndex * 92821 + tick * 68917)();
  const status = v > 0.94 ? "degraded" : "operational";
  return {
    status,
    label: "Just now",
    detail: status === "degraded" ? "Latency above threshold" : "All checks passing",
  };
}

function resolveStatusStyle(
  status: string,
  colors: SemaphoreProps["colors"]
): Required<SemaphoreStatusStyle> {
  const base = DEFAULT_STATUS_STYLES[status] ?? FALLBACK_STYLE;
  const user = colors?.[status];
  if (typeof user === "string") return { ...base, color: user };
  if (user) return { ...base, ...user };
  return base;
}

function summarizeRow(row: SemaphoreRow, rowIndex: number): string {
  const total = row.segments.length;
  const counts = new Map<string, number>();
  for (const seg of row.segments) counts.set(seg.status, (counts.get(seg.status) ?? 0) + 1);
  const parts = [...counts].map(([status, n]) => `${n} ${status}`);
  const latest = row.segments[total - 1];
  return `${row.label ?? `Row ${rowIndex + 1}`}: ${total} buckets — ${parts.join(", ")}. Latest: ${
    latest?.status ?? "none"
  }.`;
}

interface InternalSegment extends SemaphoreSegment {
  __id: string;
}
interface InternalRow {
  label?: string;
  segments: InternalSegment[];
}

/**
 * Semaphore — the uptime bar, alive. Rows of thin time-bucket segments colored
 * by status, stacking into a labeled status wall with a shared time axis. On
 * first scroll into view the wall boots: segments ignite left-to-right in a
 * sub-second self-test cascade. Each row's newest bucket carries a breathing
 * pulse, and in live mode new buckets slide in from the right on an interval
 * (suspended offscreen and on hidden tabs). Hovering a segment raises a chip
 * with its bucket detail.
 *
 * Statuses follow railway-semaphore discipline — shape-and-brightness coded,
 * not just hue: operational sits dim and calm, degraded carries a hairline
 * notch, and incident is the only saturated element (glowing, slightly
 * taller), so the wall reads without color vision. Segments are decorative
 * (`aria-hidden`) behind a visually-hidden textual summary per row. Reduced
 * motion: fully-populated wall, no cascade, no pulse. Pure CSS animation,
 * zero dependencies.
 */
export function Semaphore({
  segments,
  rows,
  colors,
  liveInterval = 0,
  nextSegment,
  cascade = true,
  cascadeSpeed = 1,
  pulse = true,
  segmentWidth = 6,
  segmentHeight = 26,
  gap = 3,
  radius = 2,
  axis,
  showChip = true,
  renderChip,
  paused = false,
  className,
  style,
  ...rest
}: SemaphoreProps) {
  const reducedMotion = useReducedMotion();
  const speed = Math.max(cascadeSpeed, 0.05);
  const live = liveInterval > 0;

  const baseRows = React.useMemo<InternalRow[]>(() => {
    const src = rows ?? (segments ? [{ segments }] : DEFAULT_ROWS);
    return src.map((row, ri) => ({
      label: row.label,
      segments: row.segments.map((seg, i) => ({ ...seg, __id: `s${ri}-${i}` })),
    }));
  }, [rows, segments]);

  // Live mode owns the data after its first tick; static mode never allocates.
  const [liveState, setLiveState] = React.useState<{ rows: InternalRow[]; tick: number } | null>(
    null
  );
  const shownRows = liveState?.rows ?? baseRows;

  const baseRowsRef = React.useRef(baseRows);
  const nextRef = React.useRef(nextSegment);
  React.useEffect(() => {
    baseRowsRef.current = baseRows;
    nextRef.current = nextSegment;
  });

  const advance = React.useCallback(() => {
    setLiveState((prev) => {
      const tick = (prev?.tick ?? 0) + 1;
      const current = prev?.rows ?? baseRowsRef.current;
      return {
        tick,
        rows: current.map((row, ri) => {
          const incoming = (nextRef.current ?? defaultNextSegment)(ri, tick);
          return {
            label: row.label,
            segments: [...row.segments.slice(1), { ...incoming, __id: `l${tick}-${ri}` }],
          };
        }),
      };
    });
  }, []);

  // The visibility-paused rAF is the live scheduler: it suspends offscreen and
  // on hidden tabs, never starts when not live, and tears down on unmount.
  const intervalS = live ? liveInterval / 1000 : Infinity;
  const lastAdvanceRef = React.useRef(0);
  const hostRef = useVisibilityPause<HTMLDivElement>(
    (elapsed) => {
      // The hook's clock restarts when `paused` toggles; re-anchor if it did.
      if (elapsed < lastAdvanceRef.current) lastAdvanceRef.current = elapsed;
      if (elapsed - lastAdvanceRef.current < intervalS) return;
      lastAdvanceRef.current = elapsed;
      advance();
    },
    { paused: paused || !live }
  );

  // Boot cascade — first in-view only.
  const maxCols = React.useMemo(
    () => Math.max(1, ...shownRows.map((r) => r.segments.length)),
    [shownRows]
  );
  const colStepS = SWEEP_S / speed / Math.max(1, maxCols - 1);
  const rowStepS = ROW_OFFSET_S / speed;
  const igniteS = IGNITE_S / speed;

  type Boot = "pending" | "go" | "done";
  const [boot, setBoot] = React.useState<Boot>("pending");
  const bootedRef = React.useRef(false);
  // No cascade (or reduced motion) → the wall renders fully populated at once.
  const effectiveBoot: Boot = !cascade || reducedMotion ? "done" : boot;

  React.useEffect(() => {
    if (bootedRef.current || !cascade || reducedMotion) return;
    const el = hostRef.current;
    if (!el) return;
    let timeout = 0;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        io.disconnect();
        bootedRef.current = true;
        setBoot("go");
        const totalMs =
          ((maxCols - 1) * colStepS + (shownRows.length - 1) * rowStepS + igniteS) * 1000 + 120;
        timeout = window.setTimeout(() => setBoot("done"), totalMs);
      },
      { threshold: 0.25 }
    );
    io.observe(el);
    return () => {
      io.disconnect();
      window.clearTimeout(timeout);
    };
    // Cascade timing is captured at ignition; data churn must not re-arm it.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cascade, reducedMotion]);

  const [hover, setHover] = React.useState<{ row: number; index: number } | null>(null);

  const hasLabels = shownRows.some((row) => row.label != null && row.label !== "");

  const vars = {
    "--sem-w": `${segmentWidth}px`,
    "--sem-h": `${segmentHeight}px`,
    "--sem-r": `${Math.max(0, radius)}px`,
    "--sem-ignite": `${igniteS.toFixed(3)}s`,
    ...style,
  } as React.CSSProperties;

  return (
    <div
      {...rest}
      ref={hostRef}
      data-crucible="semaphore"
      data-boot={effectiveBoot}
      data-pulse={pulse && !reducedMotion && !paused ? "true" : "false"}
      data-paused={paused ? "true" : undefined}
      className={cn(
        "inline-grid items-center gap-x-3 gap-y-2.5",
        hasLabels ? "grid-cols-[auto_1fr]" : "grid-cols-1",
        className
      )}
      style={vars}
    >
      <style>{STYLE}</style>

      {shownRows.map((row, ri) => {
        const chipSegment =
          showChip && hover?.row === ri ? row.segments[hover.index] : undefined;
        return (
          <React.Fragment key={ri}>
            {hasLabels && (
              <span className="text-xs font-medium leading-none text-neutral-400">
                {row.label}
              </span>
            )}
            <div className="relative">
              {/* The real status surface for assistive tech. */}
              <span className="sr-only">{summarizeRow(row, ri)}</span>

              <div
                aria-hidden
                className="flex items-center"
                style={{ gap: `${gap}px` }}
                onMouseOver={
                  showChip
                    ? (e) => {
                        const target = (e.target as HTMLElement).closest<HTMLElement>(
                          "[data-sem-idx]"
                        );
                        if (!target) return;
                        const index = Number(target.dataset.semIdx);
                        setHover((h) =>
                          h && h.row === ri && h.index === index ? h : { row: ri, index }
                        );
                      }
                    : undefined
                }
                onMouseLeave={showChip ? () => setHover(null) : undefined}
              >
                {row.segments.map((seg, i) => {
                  const st = resolveStatusStyle(seg.status, colors);
                  const isLatest = i === row.segments.length - 1;
                  const segStyle = {
                    backgroundColor: st.color,
                    "--seg-o": st.opacity,
                    "--seg-delay": `${(i * colStepS + ri * rowStepS).toFixed(3)}s`,
                    ...(st.emphasis
                      ? {
                          boxShadow: `0 0 9px 1px color-mix(in oklab, ${st.color} 55%, transparent)`,
                          transform: "scaleY(1.16)",
                        }
                      : null),
                  } as React.CSSProperties;
                  return (
                    <span
                      key={seg.__id}
                      data-sem-idx={i}
                      className={cn(
                        "sem-seg",
                        isLatest && "sem-latest",
                        isLatest && liveState !== null && "sem-new"
                      )}
                      style={segStyle}
                    >
                      {st.notch && <span className="sem-notch" />}
                    </span>
                  );
                })}
              </div>

              {chipSegment && hover && (
                <div
                  aria-hidden
                  className="sem-chip rounded-md border border-white/10 bg-neutral-900/95 px-2.5 py-1.5 text-[11px] leading-tight text-neutral-200 shadow-lg shadow-black/40"
                  style={{ left: hover.index * (segmentWidth + gap) + segmentWidth / 2 }}
                >
                  {renderChip ? (
                    renderChip(chipSegment, {
                      rowIndex: ri,
                      segmentIndex: hover.index,
                      rowLabel: row.label,
                    })
                  ) : (
                    <>
                      <span className="flex items-center gap-1.5">
                        <span
                          className="inline-block size-1.5 rounded-full"
                          style={{
                            backgroundColor: resolveStatusStyle(chipSegment.status, colors).color,
                          }}
                        />
                        <span className="font-medium capitalize text-neutral-100">
                          {chipSegment.status}
                        </span>
                        {chipSegment.label && (
                          <span className="text-neutral-500">{chipSegment.label}</span>
                        )}
                      </span>
                      {chipSegment.detail && (
                        <span className="mt-0.5 block text-neutral-400">{chipSegment.detail}</span>
                      )}
                    </>
                  )}
                </div>
              )}
            </div>
          </React.Fragment>
        );
      })}

      {axis && (
        <>
          {hasLabels && <span aria-hidden />}
          <div
            aria-hidden
            className="flex justify-between text-[10px] uppercase tracking-[0.18em] text-neutral-600"
          >
            <span>{axis[0]}</span>
            <span>{axis[1]}</span>
          </div>
        </>
      )}
    </div>
  );
}
```
