{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "boxplot-ascii-whisker",
  "title": "Boxplot ASCII Whisker",
  "description": "ASCII-textured boxplot family with one shared fence handle: dragging or keying it re-cuts every box's whiskers live against the real sample, and outliers fade in or reclassify back into the whisker as the cut moves.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/boxplot-ascii-whisker/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BoxplotAsciiWhisker — the registry's first distribution instrument.\n// Nearest neighbours: histogram-live-grain tallies a rolling window into\n// fixed bins, stem-and-leaf-live keeps every raw digit in ascending stem\n// rows, chart-ridgeline-terrain renders many distributions as overlapping\n// density silhouettes — none of the three computes quartiles, a fence, or\n// classifies individual samples as outliers.\n//\n// The mechanic: ONE shared fence handle — a real <input type=\"range\">,\n// visually replaced by a custom track/thumb the way slider-range-shear\n// carries its a11y — controls k, the IQR multiplier that defines the\n// whisker fence (Q1 - k*IQR .. Q3 + k*IQR) for every box on the chart at\n// once. Dragging or keying it re-cuts the whisker caps live against the\n// REAL underlying sample (not a cached summary): the farthest sample still\n// inside the fence becomes the new whisker end, and every sample outside it\n// renders as an outlier glyph that fades in; a sample that re-enters the\n// fence as k grows fades back into the whisker instead of vanishing. Box\n// bodies are filled with the family's shared ASCII ramp ' .:-=+*#%@' tiled\n// at a constant mid density — box height already encodes the interquartile\n// spread, so ink here is texture, not a second value channel — pure\n// var(--foreground), var(--ns-accent) reserved for the focused/hovered group\n// and the fence handle itself.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\";\nconst BOX_CH = RAMP[6];\n\nexport interface BoxplotGroup {\n  label: string;\n  samples: number[];\n}\n\nexport interface BoxplotAsciiWhiskerProps {\n  /** the plotted groups, one boxplot each */\n  groups?: BoxplotGroup[];\n  /** heading above the chart */\n  title?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\n// deterministic synthetic samples — three clearly fabricated batches\nfunction defaultGroups(): BoxplotGroup[] {\n  let seed = 11;\n  const rand = () => {\n    seed = (seed * 48271) % 2147483647;\n    return (seed - 1) / 2147483646;\n  };\n  const gauss = (mean: number, sd: number) => {\n    let u = 0;\n    let v = 0;\n    while (u === 0) u = rand();\n    while (v === 0) v = rand();\n    return mean + sd * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);\n  };\n  const make = (mean: number, sd: number, n: number, outliers: number[]) => {\n    const out: number[] = [];\n    for (let i = 0; i < n; i++) out.push(Math.round(gauss(mean, sd) * 10) / 10);\n    return out.concat(outliers);\n  };\n  return [\n    { label: \"Batch A\", samples: make(40, 6, 34, [12, 68]) },\n    { label: \"Batch B\", samples: make(52, 9, 34, [18, 22, 96]) },\n    { label: \"Batch C\", samples: make(34, 4, 34, [55]) },\n  ];\n}\n\nfunction quantile(sorted: number[], p: number): number {\n  if (sorted.length === 0) return 0;\n  if (sorted.length === 1) return sorted[0];\n  const idx = p * (sorted.length - 1);\n  const lo = Math.floor(idx);\n  const hi = Math.ceil(idx);\n  if (lo === hi) return sorted[lo];\n  return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);\n}\n\nconst K_MIN = 0.5;\nconst K_MAX = 3;\nconst K_STEP = 0.1;\n\ninterface GroupStats {\n  label: string;\n  sorted: number[];\n  q1: number;\n  median: number;\n  q3: number;\n  iqr: number;\n  // samples that are outliers at the tightest possible fence (k = K_MIN) —\n  // the maximal candidate pool across the whole draggable range\n  candidates: number[];\n}\n\nfunction computeStats(g: BoxplotGroup): GroupStats {\n  const sorted = [...g.samples].sort((a, b) => a - b);\n  const q1 = quantile(sorted, 0.25);\n  const median = quantile(sorted, 0.5);\n  const q3 = quantile(sorted, 0.75);\n  const iqr = Math.max(1e-6, q3 - q1);\n  const lowAtMin = q1 - K_MIN * iqr;\n  const highAtMin = q3 + K_MIN * iqr;\n  const candidates = sorted.filter((v) => v < lowAtMin || v > highAtMin);\n  return { label: g.label, sorted, q1, median, q3, iqr, candidates };\n}\n\ninterface Tokens {\n  fg: string;\n  bg: string;\n  muted: string;\n  border: string;\n  accent: string;\n}\n\nfunction readTokens(): Tokens {\n  const cs = getComputedStyle(document.documentElement);\n  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;\n  return {\n    fg: get(\"--foreground\", \"#171717\"),\n    bg: get(\"--background\", \"#ffffff\"),\n    muted: get(\"--ns-muted\", \"#4d4d4d\"),\n    border: get(\"--border\", \"#ebebeb\"),\n    accent: get(\"--ns-accent\", \"#006bff\"),\n  };\n}\n\nfunction useTokens(): Tokens {\n  const [tokens, setTokens] = useState<Tokens>(() =>\n    typeof document === \"undefined\"\n      ? { fg: \"#171717\", bg: \"#ffffff\", muted: \"#4d4d4d\", border: \"#ebebeb\", accent: \"#006bff\" }\n      : readTokens()\n  );\n  useEffect(() => {\n    const sync = () => setTokens(readTokens());\n    sync();\n    const mo = new MutationObserver(sync);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n    return () => mo.disconnect();\n  }, []);\n  return tokens;\n}\n\nconst GROUP_W = 110;\nconst BOX_W = 46;\nconst PLOT_H = 220;\nconst TOP_PAD = 16;\nconst BOTTOM_PAD = 30;\nconst LEFT_PAD = 40;\nconst RIGHT_PAD = 16;\n\nexport function BoxplotAsciiWhisker({ groups, title = \"Chart\", className = \"\" }: BoxplotAsciiWhiskerProps) {\n  const data = useMemo(() => groups ?? defaultGroups(), [groups]);\n  const stats = useMemo(() => data.map(computeStats), [data]);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tokens = useTokens();\n  const [k, setK] = useState(1.5);\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const [activeIndex, setActiveIndex] = useState(0);\n  // useId, not Math.random(): the id is serialized into element ids on the\n  // server and must match what the client renders, or React discards the\n  // subtree as a hydration mismatch\n  const uid = `baw-${useId().replace(/:/g, \"\")}`;\n  const trackRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const reducedRef = useRef(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      reducedRef.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n\n  const globalMin = useMemo(() => Math.min(...stats.map((s) => s.sorted[0] ?? 0)), [stats]);\n  const globalMax = useMemo(() => Math.max(...stats.map((s) => s.sorted[s.sorted.length - 1] ?? 0)), [stats]);\n  const span = Math.max(1e-6, globalMax - globalMin);\n  const pad = span * 0.08;\n  const domainMin = globalMin - pad;\n  const domainMax = globalMax + pad;\n  const domainSpan = domainMax - domainMin;\n\n  const n = stats.length;\n  const viewW = LEFT_PAD + n * GROUP_W + RIGHT_PAD;\n  const viewH = TOP_PAD + PLOT_H + BOTTOM_PAD;\n  const valueToY = (v: number) => TOP_PAD + PLOT_H - ((v - domainMin) / domainSpan) * PLOT_H;\n\n  const cuts = useMemo(\n    () =>\n      stats.map((s) => {\n        const lowFence = s.q1 - k * s.iqr;\n        const highFence = s.q3 + k * s.iqr;\n        const within = s.sorted.filter((v) => v >= lowFence && v <= highFence);\n        const whiskerLow = within.length ? within[0] : s.q1;\n        const whiskerHigh = within.length ? within[within.length - 1] : s.q3;\n        const outliers = s.candidates.filter((v) => v < lowFence || v > highFence);\n        return { whiskerLow, whiskerHigh, outliers };\n      }),\n    [stats, k]\n  );\n\n  // animated display state: per-group whisker ends lerp toward target;\n  // per-candidate alpha lerps toward 1 (outlier) or 0 (reclassified inlier)\n  const displayWhiskerRef = useRef<{ lo: number; hi: number }[]>([]);\n  const alphaRef = useRef<number[][]>([]);\n  const rafRef = useRef(0);\n\n  useEffect(() => {\n    if (displayWhiskerRef.current.length !== n) {\n      displayWhiskerRef.current = cuts.map((c) => ({ lo: c.whiskerLow, hi: c.whiskerHigh }));\n    }\n    if (alphaRef.current.length !== n) {\n      alphaRef.current = stats.map((s) => s.candidates.map(() => 0));\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [n, stats]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    canvas.width = viewW * dpr;\n    canvas.height = viewH * dpr;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n    const draw = () => {\n      ctx.clearRect(0, 0, viewW, viewH);\n\n      ctx.strokeStyle = tokens.border;\n      ctx.lineWidth = 1;\n      [0, 0.25, 0.5, 0.75, 1].forEach((f) => {\n        const y = Math.round(TOP_PAD + PLOT_H * f) + 0.5;\n        ctx.beginPath();\n        ctx.moveTo(LEFT_PAD, y);\n        ctx.lineTo(viewW - RIGHT_PAD, y);\n        ctx.stroke();\n      });\n      ctx.fillStyle = tokens.muted;\n      ctx.font = `9px \"GeistMono\", ui-monospace, monospace`;\n      ctx.textAlign = \"right\";\n      ctx.fillText(domainMax.toFixed(0), LEFT_PAD - 6, TOP_PAD + 4);\n      ctx.fillText(domainMin.toFixed(0), LEFT_PAD - 6, TOP_PAD + PLOT_H);\n\n      let stillAnimating = false;\n\n      stats.forEach((s, i) => {\n        const cx = LEFT_PAD + i * GROUP_W + GROUP_W / 2;\n        const boxX = cx - BOX_W / 2;\n        // hoverIndex alone carries real interaction (pointer hover AND\n        // keyboard focus both set it) — activeIndex is only roving-tabindex\n        // bookkeeping and must never tint a resting, unfocused group\n        const isActive = hoverIndex === i;\n        const ink = isActive ? tokens.accent : tokens.fg;\n\n        const cut = cuts[i];\n        const dw = displayWhiskerRef.current[i] ?? { lo: cut.whiskerLow, hi: cut.whiskerHigh };\n        const nextLo = reducedRef.current ? cut.whiskerLow : dw.lo + (cut.whiskerLow - dw.lo) * 0.25;\n        const nextHi = reducedRef.current ? cut.whiskerHigh : dw.hi + (cut.whiskerHigh - dw.hi) * 0.25;\n        displayWhiskerRef.current[i] = {\n          lo: Math.abs(nextLo - cut.whiskerLow) < 0.05 ? cut.whiskerLow : nextLo,\n          hi: Math.abs(nextHi - cut.whiskerHigh) < 0.05 ? cut.whiskerHigh : nextHi,\n        };\n        if (\n          Math.abs(displayWhiskerRef.current[i].lo - cut.whiskerLow) >= 0.05 ||\n          Math.abs(displayWhiskerRef.current[i].hi - cut.whiskerHigh) >= 0.05\n        ) {\n          stillAnimating = true;\n        }\n\n        const yWhiskerLo = valueToY(displayWhiskerRef.current[i].lo);\n        const yWhiskerHi = valueToY(displayWhiskerRef.current[i].hi);\n        const yQ1 = valueToY(s.q1);\n        const yQ3 = valueToY(s.q3);\n        const yMed = valueToY(s.median);\n\n        // whisker stem + caps\n        ctx.strokeStyle = ink;\n        ctx.lineWidth = 1.25;\n        ctx.beginPath();\n        ctx.moveTo(cx, yWhiskerHi);\n        ctx.lineTo(cx, yQ3);\n        ctx.moveTo(cx, yQ1);\n        ctx.lineTo(cx, yWhiskerLo);\n        ctx.stroke();\n        ctx.beginPath();\n        ctx.moveTo(cx - 10, yWhiskerHi);\n        ctx.lineTo(cx + 10, yWhiskerHi);\n        ctx.moveTo(cx - 10, yWhiskerLo);\n        ctx.lineTo(cx + 10, yWhiskerLo);\n        ctx.stroke();\n\n        // box body — glyph texture fill, clipped, constant mid density\n        ctx.save();\n        ctx.beginPath();\n        ctx.rect(boxX, yQ3, BOX_W, Math.max(1, yQ1 - yQ3));\n        ctx.clip();\n        ctx.fillStyle = ink;\n        ctx.font = `9px \"GeistMono\", ui-monospace, monospace`;\n        ctx.textAlign = \"left\";\n        ctx.textBaseline = \"top\";\n        for (let y = yQ3; y < yQ1; y += 10) {\n          for (let x = boxX; x < boxX + BOX_W; x += 8) {\n            ctx.fillText(BOX_CH, x, y);\n          }\n        }\n        ctx.restore();\n        ctx.strokeStyle = ink;\n        ctx.lineWidth = 1.25;\n        ctx.strokeRect(boxX + 0.5, yQ3 + 0.5, BOX_W, Math.max(1, yQ1 - yQ3));\n\n        // median — heavier solid row\n        ctx.fillStyle = ink;\n        ctx.fillRect(boxX, yMed - 1, BOX_W, 2);\n\n        // outliers — animated alpha per candidate\n        const alphas = alphaRef.current[i] ?? [];\n        s.candidates.forEach((v, ci) => {\n          const target = cut.outliers.includes(v) ? 1 : 0;\n          const cur = alphas[ci] ?? 0;\n          const next = reducedRef.current ? target : cur + (target - cur) * 0.28;\n          alphas[ci] = Math.abs(next - target) < 0.01 ? target : next;\n          if (Math.abs(alphas[ci] - target) >= 0.01) stillAnimating = true;\n          if (alphas[ci] <= 0.01) return;\n          const y = valueToY(v);\n          ctx.globalAlpha = alphas[ci];\n          ctx.beginPath();\n          ctx.arc(cx, y, 3, 0, Math.PI * 2);\n          ctx.strokeStyle = ink;\n          ctx.lineWidth = 1.25;\n          ctx.stroke();\n          ctx.globalAlpha = 1;\n        });\n        alphaRef.current[i] = alphas;\n\n        ctx.fillStyle = tokens.muted;\n        ctx.font = `9.5px \"GeistMono\", ui-monospace, monospace`;\n        ctx.textAlign = \"center\";\n        ctx.fillText(s.label, cx, TOP_PAD + PLOT_H + BOTTOM_PAD - 12);\n      });\n\n      if (stillAnimating && !reducedRef.current) {\n        rafRef.current = requestAnimationFrame(draw);\n      }\n    };\n\n    draw();\n    return () => cancelAnimationFrame(rafRef.current);\n  }, [stats, cuts, tokens, viewW, viewH, domainMin, domainSpan, hoverIndex, activeIndex]);\n\n  const focusGroup = (i: number) => {\n    if (i < 0 || i >= n) return;\n    setActiveIndex(i);\n    document.getElementById(`${uid}-hit-${i}`)?.focus();\n  };\n\n  const kFromClientX = (clientX: number): number => {\n    const el = trackRef.current;\n    if (!el) return k;\n    const rect = el.getBoundingClientRect();\n    const frac = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n    const raw = K_MIN + frac * (K_MAX - K_MIN);\n    return Math.round(raw / K_STEP) * K_STEP;\n  };\n\n  const hovered = hoverIndex !== null ? stats[hoverIndex] : null;\n  const hoveredCut = hoverIndex !== null ? cuts[hoverIndex] : null;\n\n  return (\n    <figure className={`ns-baw inline-block ${className}`} aria-label={`${title}, box plot`}>\n      <style>{CSS}</style>\n      <div className=\"flex items-center justify-between gap-3 pb-2\">\n        <span className=\"font-mono text-xs tracking-widest text-ns-muted\">{title.toUpperCase()}</span>\n        <span className=\"font-mono text-[11px] text-ns-muted tabular-nums\">k = {k.toFixed(1)}× IQR</span>\n      </div>\n\n      <div className=\"relative\" style={{ width: viewW, maxWidth: \"100%\" }}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"ns-baw-canvas\"\n          style={{ width: viewW, height: viewH, maxWidth: \"100%\", display: \"block\" }}\n        />\n\n        {stats.map((s, i) => (\n          <button\n            key={s.label}\n            type=\"button\"\n            id={`${uid}-hit-${i}`}\n            tabIndex={activeIndex === i ? 0 : -1}\n            aria-label={`${s.label}: median ${s.median.toFixed(1)}, Q1 ${s.q1.toFixed(1)}, Q3 ${s.q3.toFixed(1)}, ${cuts[i].outliers.length} outlier${cuts[i].outliers.length === 1 ? \"\" : \"s\"}`}\n            className=\"ns-baw-hit absolute cursor-pointer border-0 bg-transparent p-0 outline-none\"\n            style={{ left: LEFT_PAD + i * GROUP_W, top: TOP_PAD - 10, width: GROUP_W, height: PLOT_H + 10 }}\n            onPointerEnter={() => setHoverIndex(i)}\n            onPointerLeave={() => setHoverIndex((c) => (c === i ? null : c))}\n            onFocus={() => {\n              setActiveIndex(i);\n              setHoverIndex(i);\n            }}\n            onBlur={() => setHoverIndex((c) => (c === i ? null : c))}\n            onKeyDown={(e) => {\n              if (e.key === \"ArrowLeft\") {\n                e.preventDefault();\n                focusGroup(i - 1);\n              } else if (e.key === \"ArrowRight\") {\n                e.preventDefault();\n                focusGroup(i + 1);\n              }\n            }}\n          />\n        ))}\n\n        {hovered && hoveredCut && (\n          <div\n            aria-hidden=\"true\"\n            className=\"ns-baw-tip pointer-events-none absolute z-10 rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] shadow-sm\"\n            style={{\n              left: `${((LEFT_PAD + hoverIndex! * GROUP_W + GROUP_W / 2) / viewW) * 100}%`,\n              top: `${(Math.max(0, valueToY(hovered.q3) - 46) / viewH) * 100}%`,\n              transform: \"translateX(-50%)\",\n            }}\n          >\n            <strong className=\"text-foreground\">{hovered.median.toFixed(1)}</strong>{\" \"}\n            <span className=\"text-ns-muted\">\n              med · Q1 {hovered.q1.toFixed(1)} · Q3 {hovered.q3.toFixed(1)} · {hoveredCut.outliers.length} out\n            </span>\n          </div>\n        )}\n      </div>\n\n      <div className=\"mt-4 flex items-center gap-3\">\n        <span className=\"font-mono text-[10px] tracking-widest text-ns-muted\">FENCE</span>\n        <div\n          ref={trackRef}\n          className=\"ns-baw-track relative h-4 flex-1 cursor-pointer rounded-full bg-border/60\"\n          onPointerDown={(e) => {\n            setK(kFromClientX(e.clientX));\n            inputRef.current?.focus();\n            const move = (ev: PointerEvent) => setK(kFromClientX(ev.clientX));\n            const up = () => {\n              window.removeEventListener(\"pointermove\", move);\n              window.removeEventListener(\"pointerup\", up);\n            };\n            window.addEventListener(\"pointermove\", move);\n            window.addEventListener(\"pointerup\", up);\n          }}\n        >\n          <input\n            ref={inputRef}\n            type=\"range\"\n            min={K_MIN}\n            max={K_MAX}\n            step={K_STEP}\n            value={k}\n            onChange={(e) => setK(Number(e.target.value))}\n            aria-label=\"Whisker fence, multiplier of interquartile range\"\n            className=\"ns-baw-range sr-only\"\n          />\n          <div\n            aria-hidden=\"true\"\n            className=\"ns-baw-thumb absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2 border-ns-accent bg-background\"\n            style={{ left: `${((k - K_MIN) / (K_MAX - K_MIN)) * 100}%`, transform: \"translate(-50%, -50%)\" }}\n          />\n        </div>\n      </div>\n    </figure>\n  );\n}\n\nconst CSS = `\n.ns-baw-hit { touch-action: manipulation; }\n.ns-baw-hit:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n.ns-baw-range:focus-visible ~ .ns-baw-thumb { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/boxplot-ascii-whisker.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "chart",
      "boxplot",
      "data-viz",
      "ascii",
      "canvas",
      "statistics"
    ],
    "instruction": "The registry's first distribution instrument: one box-and-whisker plot per group, quartiles computed by linear-interpolation quantile over the real sample (not a pre-aggregated summary). Box bodies are filled with the family's shared ASCII ramp ' .:-=+*#%@' tiled at a constant mid density purely as texture — box height already encodes the interquartile spread, so this is not a second value channel, matching the family's redundant-density convention elsewhere. The real mechanic is the single shared fence handle below the chart: a real <input type=\"range\"> (k, the IQR multiplier, 0.5 to 3.0 in 0.1 steps), visually replaced by a custom track and thumb the way slider-range-shear carries its accessibility, draggable by pointer or fully operable by the native input's own arrow/Home/End/PageUp/PageDown handling. Moving it recomputes, for every box on the chart at once, the low/high fence (Q1 - k*IQR .. Q3 + k*IQR) against the REAL underlying sample: the farthest sample still inside the fence becomes the new whisker cap (eased into position over roughly 300ms, not snapped), and every sample outside the fence renders as a small outlier ring that fades in — a sample that re-enters the fence as k grows fades back out of view instead of disappearing instantly, so the reclassification itself is visible motion, not a jump cut. Each group also has its own real hit button (roving tabindex, ArrowLeft/ArrowRight moving focus) whose hover or focus tints that box's whiskers and outline to var(--ns-accent) and opens a small tooltip with its median, Q1, Q3 and current outlier count; var(--ns-accent) is otherwise reserved for the fence thumb, matching the family's convention of accent for interaction only, never as a value channel. Tokens are read via getComputedStyle at mount and re-read through a MutationObserver on the document root's class attribute, so both themes repaint correctly on toggle. prefers-reduced-motion snaps whisker and outlier changes to their final state in one paint instead of lerping; the fence handle remains fully operable either way. Zero dependencies."
  },
  "type": "registry:ui"
}