{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tour",
  "title": "Tour",
  "type": "registry:ui",
  "description": "Multi-step guided overlay walkthrough. Highlights a target element with a dim mask cutout and shows a card next to it. Steps support targets by selector, ref, or function; centered (no-target) steps work as modal-style intros.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/tour/tour.ts",
      "content": "import { LitElement, css, html, isServer, nothing, svg } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { buttonVariants } from '../button/button.variants'\n\nexport interface TourStep {\n  target?: string | HTMLElement | null\n  title: string\n  description?: string\n  cover?: string\n  mask?: boolean\n  nextButtonText?: string\n  prevButtonText?: string\n  finishButtonText?: string\n  /** Optional link button shown in the card, e.g. { label: 'Star on GitHub', href }. */\n  action?: { label: string; href: string }\n}\n\nexport interface TargetRect {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nlet tourMaskUid = 0\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\n/**\n * <uip-tour> — Guided product tour overlay with spotlight cutout and anchored step cards.\n *\n * Each step's target is scrolled into view (instantly under reduced motion)\n * before it is measured; scroll/resize re-measure as it moves. The card sits\n * below the target (above when there's no room); targets hugging the left\n * edge (sidebar items) get it beside them instead. `step.action`\n * ({ label, href }) renders an outline link button in the card. The mask dims\n * 50% in light and 75% in dark, with a primary ring around the spotlight.\n */\nexport class UipTour extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    open: { type: Boolean, reflect: true },\n    current: { type: Number, reflect: true },\n    steps: { type: Array },\n    mask: { converter: trueByDefault },\n    type: { type: String },\n    zIndex: { type: Number, attribute: 'z-index' },\n    targetRect: { state: true },\n  }\n\n  open = false\n  current = 0\n  steps: TourStep[] = []\n  mask = true\n  type: 'default' | 'primary' = 'default'\n  zIndex = 1000\n\n  private targetRect: TargetRect | null = null\n  private maskId = `uip-tour-mask-${++tourMaskUid}`\n  private targetEl: HTMLElement | null = null\n  private ro?: ResizeObserver\n  private scrollHandler?: () => void\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'tour')\n    this.scrollHandler = () => this.measure()\n    window.addEventListener('scroll', this.scrollHandler, { passive: true, capture: true })\n    window.addEventListener('resize', this.scrollHandler, { passive: true })\n    window.addEventListener('keydown', this.onKeyDown.bind(this))\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    if (this.scrollHandler) {\n      window.removeEventListener('scroll', this.scrollHandler, true)\n      window.removeEventListener('resize', this.scrollHandler)\n    }\n    this.ro?.disconnect()\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (isServer) return\n    if (changed.has('open') || changed.has('current') || changed.has('steps')) {\n      if (this.open) {\n        this.attachTarget()\n      } else {\n        this.targetRect = null\n      }\n    }\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    if (!this.open) return\n    if (e.key === 'Escape') {\n      e.preventDefault()\n      this.close()\n    }\n  }\n\n  private attachTarget() {\n    this.ro?.disconnect()\n    const step = this.steps[this.current]\n    if (!step || !step.target) {\n      this.targetEl = null\n      this.targetRect = null\n      return\n    }\n\n    let el: HTMLElement | null = null\n    if (typeof step.target === 'string') {\n      el = document.querySelector(step.target) as HTMLElement | null\n    } else if (step.target instanceof HTMLElement) {\n      el = step.target\n    }\n\n    this.targetEl = el\n    if (el) {\n      const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches\n      el.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'center' })\n      this.measure()\n      if (typeof ResizeObserver !== 'undefined') {\n        this.ro = new ResizeObserver(() => this.measure())\n        this.ro.observe(el)\n      }\n    } else {\n      this.targetRect = null\n    }\n  }\n\n  private measure() {\n    if (!this.targetEl) {\n      this.targetRect = null\n      return\n    }\n    const r = this.targetEl.getBoundingClientRect()\n    this.targetRect = { x: r.left, y: r.top, width: r.width, height: r.height }\n  }\n\n  next() {\n    if (this.current < this.steps.length - 1) {\n      this.current++\n      this.dispatchEvent(new CustomEvent('current-change', { detail: { current: this.current }, bubbles: true, composed: true }))\n    }\n  }\n\n  prev() {\n    if (this.current > 0) {\n      this.current--\n      this.dispatchEvent(new CustomEvent('current-change', { detail: { current: this.current }, bubbles: true, composed: true }))\n    }\n  }\n\n  finish() {\n    this.open = false\n    this.dispatchEvent(new CustomEvent('finish', { detail: { current: this.current }, bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open: false }, bubbles: true, composed: true }))\n  }\n\n  close() {\n    this.open = false\n    this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open: false }, bubbles: true, composed: true }))\n  }\n\n  render() {\n    if (!this.open || this.steps.length === 0) return nothing\n    const step = this.steps[this.current]\n    if (!step) return nothing\n\n    const showMask = step.mask !== undefined ? step.mask : this.mask\n    const rect = this.targetRect\n    const padding = 4\n    const radius = 6\n\n    const cutout = rect\n      ? {\n          x: rect.x - padding,\n          y: rect.y - padding,\n          w: rect.width + padding * 2,\n          h: rect.height + padding * 2,\n        }\n      : null\n\n    const hitClipPath = cutout\n      ? `polygon(evenodd, 0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ${cutout.x}px ${cutout.y}px, ${cutout.x}px ${cutout.y + cutout.h}px, ${cutout.x + cutout.w}px ${cutout.y + cutout.h}px, ${cutout.x + cutout.w}px ${cutout.y}px, ${cutout.x}px ${cutout.y}px)`\n      : undefined\n\n    const cardWidth = 320\n    const margin = 12\n    const edgePadding = 8\n    const cardHeight = step.cover ? 320 : 200\n\n    let cardTop = 0\n    let cardLeft = 0\n    let cardTransform = ''\n\n    if (!rect) {\n      cardTop = 50\n      cardLeft = 50\n      cardTransform = 'translate(-50%, -50%)'\n    } else {\n      const vh = typeof window !== 'undefined' ? window.innerHeight : 768\n      const vw = typeof window !== 'undefined' ? window.innerWidth : 1024\n      const clampTop = (v: number) => Math.min(Math.max(edgePadding, v), vh - cardHeight - edgePadding)\n      // Targets hugging the left edge (sidebar items) get the card beside them,\n      // so it doesn't cover the neighbouring items.\n      const right = rect.x + rect.width\n      const placeRight = right < vw * 0.35 && right + margin + cardWidth <= vw - edgePadding\n      if (placeRight) {\n        cardTop = clampTop(rect.y + rect.height / 2 - cardHeight / 2)\n        cardLeft = right + margin\n      } else {\n        const placeBelow = rect.y + rect.height + margin + cardHeight < vh\n        cardTop = clampTop(placeBelow ? rect.y + rect.height + margin : rect.y - margin - cardHeight)\n        cardLeft = rect.x\n        if (cardLeft + cardWidth > vw - edgePadding) {\n          cardLeft = vw - cardWidth - edgePadding\n        }\n        if (cardLeft < edgePadding) cardLeft = edgePadding\n      }\n    }\n\n    const cardStyle = {\n      position: 'fixed',\n      top: rect ? `${cardTop}px` : `${cardTop}%`,\n      left: rect ? `${cardLeft}px` : `${cardLeft}%`,\n      width: `${cardWidth}px`,\n      transform: cardTransform || undefined,\n      zIndex: String(this.zIndex + 1),\n    }\n\n    const isFirst = this.current === 0\n    const isLast = this.current === this.steps.length - 1\n\n    return html`\n      <!-- Mask -->\n      ${showMask\n        ? html`\n            <svg\n              class=\"pointer-events-none fixed inset-0\"\n              style=${styleMap({ zIndex: String(this.zIndex) })}\n              width=\"100%\"\n              height=\"100%\"\n              aria-hidden=\"true\"\n            >\n              <defs>\n                <mask id=${this.maskId}>\n                  <rect width=\"100%\" height=\"100%\" fill=\"white\" />\n                  <!-- Nested SVG children need the svg tag: an html-tagged <rect> is an HTML element and never paints. -->\n                  ${cutout\n                    ? svg`<rect\n                        x=${cutout.x}\n                        y=${cutout.y}\n                        width=${cutout.w}\n                        height=${cutout.h}\n                        rx=${radius}\n                        fill=\"black\"\n                      />`\n                    : nothing}\n                </mask>\n              </defs>\n              <!-- 50% black barely changes an already-dark UI, so dark dims 75%. -->\n              <rect\n                width=\"100%\"\n                height=\"100%\"\n                class=\"fill-black/50 dark:fill-black/75\"\n                mask=${`url(#${this.maskId})`}\n                style=\"transition: all 200ms ease;\"\n              />\n              <!-- Ring around the spotlight so the focus reads in both themes. -->\n              ${cutout\n                ? svg`<rect\n                    x=${cutout.x}\n                    y=${cutout.y}\n                    width=${cutout.w}\n                    height=${cutout.h}\n                    rx=${radius}\n                    class=\"stroke-primary/70 fill-none transition-all duration-200 motion-reduce:transition-none\"\n                    stroke-width=\"2\"\n                  />`\n                : nothing}\n            </svg>\n            <div\n              class=\"fixed inset-0\"\n              aria-hidden=\"true\"\n              style=${styleMap({\n                zIndex: String(this.zIndex),\n                clipPath: hitClipPath,\n                background: 'transparent',\n              })}\n            ></div>\n          `\n        : nothing}\n\n      <!-- Card -->\n      <div\n        part=\"card\"\n        data-slot=\"tour-card\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        tabindex=\"-1\"\n        class=${cn(\n          'relative space-y-3 rounded-lg border p-4 shadow-lg outline-none',\n          this.type === 'primary'\n            ? 'bg-primary text-primary-foreground border-primary'\n            : 'bg-popover text-popover-foreground',\n        )}\n        style=${styleMap(cardStyle)}\n      >\n        <button\n          type=\"button\"\n          class=\"hover:bg-foreground/10 focus-visible:ring-ring absolute top-2 right-2 inline-flex size-6 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none\"\n          aria-label=\"Close tour\"\n          @click=${() => this.close()}\n        >\n          ${icon(X, 'x', 'size-4')}\n        </button>\n\n        ${step.cover ? html`<img src=${step.cover} alt=\"\" class=\"w-full rounded-md\" />` : nothing}\n\n        <div>\n          <div class=\"pr-6 font-semibold\">${step.title}</div>\n          ${step.description\n            ? html`<div class=\"mt-1 text-sm opacity-90\">${step.description}</div>`\n            : nothing}\n          ${step.action\n            ? html`<a\n                href=${step.action.href}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                data-slot=\"tour-action\"\n                class=${cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'text-foreground mt-3 w-full')}\n                >${step.action.label}</a\n              >`\n            : nothing}\n        </div>\n\n        <div class=\"flex items-center justify-between gap-2 pt-2\">\n          <div class=\"text-xs tabular-nums opacity-70\" aria-live=\"polite\">\n            ${this.current + 1} / ${this.steps.length}\n          </div>\n          <div class=\"flex gap-2\">\n            ${!isFirst\n              ? html`\n                  <button\n                    type=\"button\"\n                    class=${cn(\n                      'inline-flex h-8 items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',\n                      this.type === 'primary'\n                        ? 'bg-secondary text-secondary-foreground hover:bg-secondary/80'\n                        : 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',\n                    )}\n                    @click=${() => this.prev()}\n                  >\n                    ${step.prevButtonText || 'Previous'}\n                  </button>\n                `\n              : nothing}\n            ${!isLast\n              ? html`\n                  <button\n                    type=\"button\"\n                    class=${cn(\n                      'inline-flex h-8 items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',\n                      this.type === 'primary'\n                        ? 'bg-secondary text-secondary-foreground hover:bg-secondary/80'\n                        : 'bg-primary text-primary-foreground hover:bg-primary/90',\n                    )}\n                    @click=${() => this.next()}\n                  >\n                    ${step.nextButtonText || 'Next'}\n                  </button>\n                `\n              : html`\n                  <button\n                    type=\"button\"\n                    class=${cn(\n                      'inline-flex h-8 items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',\n                      this.type === 'primary'\n                        ? 'bg-secondary text-secondary-foreground hover:bg-secondary/80'\n                        : 'bg-primary text-primary-foreground hover:bg-primary/90',\n                    )}\n                    @click=${() => this.finish()}\n                  >\n                    ${step.finishButtonText || 'Finish'}\n                  </button>\n                `}\n          </div>\n        </div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-tour') || customElements.define('uip-tour', UipTour)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-tour': UipTour\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/tour/tour.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/button.json",
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
