{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tour",
  "title": "Tour",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/tour/Tour.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { TourTarget } from './tour-target.svelte'\n\n  export interface TourStep {\n    target?: TourTarget\n    title: string\n    description?: string\n    cover?: string\n    mask?: boolean\n    nextButtonText?: string\n    prevButtonText?: string\n    finishButtonText?: string\n  }\n\n  export interface TourProps {\n    open?: boolean\n    current?: number\n    steps: TourStep[]\n    mask?: boolean\n    type?: 'default' | 'primary'\n    zIndex?: number\n    onchange?: (v: number) => void\n    onfinish?: () => void\n    onclose?: () => void\n  }\n</script>\n\n<script lang=\"ts\">\n  import { tick } from 'svelte'\n  import TourMask from './TourMask.svelte'\n  import TourCard from './TourCard.svelte'\n  import { createTourTarget } from './tour-target.svelte'\n\n  let {\n    open = $bindable(false),\n    current = $bindable(0),\n    steps,\n    mask = true,\n    type = 'default',\n    zIndex = 1000,\n    onchange,\n    onfinish,\n    onclose,\n  }: TourProps = $props()\n\n  let stepIndex = $state(current)\n  $effect(() => {\n    if (current !== stepIndex) stepIndex = current\n  })\n\n  const currentStep = $derived(steps[stepIndex] ?? null)\n\n  const tracker = createTourTarget(() => currentStep?.target)\n\n  // Client-only gate: the overlay portals to <body>, so it renders after\n  // hydration to keep SSR markup identical.\n  let mounted = $state(false)\n  $effect(() => {\n    mounted = true\n  })\n\n  /** Element that held focus before the tour opened — restored on close. */\n  let previousFocus: HTMLElement | null = null\n  let wasOpen = false\n\n  $effect(() => {\n    const isOpen = open\n    // Track the step so re-anchoring runs on step change too.\n    const idx = stepIndex\n    void idx\n    if (!isOpen) {\n      tracker.detach()\n      if (wasOpen) {\n        previousFocus?.focus?.()\n        previousFocus = null\n      }\n      wasOpen = false\n      return\n    }\n    if (!wasOpen && typeof document !== 'undefined') {\n      previousFocus = (document.activeElement as HTMLElement | null) ?? null\n    }\n    wasOpen = true\n    tick().then(() => {\n      tracker.attach()\n      const t = steps[stepIndex]?.target\n      if (t && typeof document !== 'undefined') {\n        const el =\n          typeof t === 'string'\n            ? (document.querySelector(t) as HTMLElement | null)\n            : typeof t === 'function'\n              ? t()\n              : t\n        const reduce =\n          typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches\n        el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'center' })\n        // Re-measure after smooth scroll settles; skip long wait when reduce.\n        setTimeout(() => tracker.measure(), reduce ? 0 : 320)\n      }\n    })\n  })\n\n  function setStep(i: number) {\n    stepIndex = i\n    current = i\n    onchange?.(i)\n  }\n\n  function next() {\n    if (stepIndex < steps.length - 1) setStep(stepIndex + 1)\n  }\n\n  function prev() {\n    if (stepIndex > 0) setStep(stepIndex - 1)\n  }\n\n  function finish() {\n    onfinish?.()\n    open = false\n  }\n\n  function skip() {\n    onclose?.()\n    open = false\n  }\n\n  $effect(() => {\n    if (!open || typeof document === 'undefined') return\n    function onKeydown(e: KeyboardEvent) {\n      if (e.key === 'Escape') {\n        e.preventDefault()\n        skip()\n      }\n    }\n    document.addEventListener('keydown', onKeydown)\n    return () => document.removeEventListener('keydown', onKeydown)\n  })\n\n  const showMask = $derived(currentStep?.mask ?? mask)\n\n  function portal(node: HTMLElement) {\n    document.body.appendChild(node)\n    return {\n      destroy() {\n        node.remove()\n      },\n    }\n  }\n</script>\n\n{#if mounted && open && currentStep}\n  <div use:portal style=\"display: contents\">\n    {#if showMask}\n      <TourMask rect={tracker.rect} zIndex={zIndex} />\n    {/if}\n    <TourCard\n      title={currentStep.title}\n      description={currentStep.description}\n      cover={currentStep.cover}\n      rect={tracker.rect}\n      total={steps.length}\n      current={stepIndex}\n      prevText={currentStep.prevButtonText}\n      nextText={currentStep.nextButtonText}\n      finishText={currentStep.finishButtonText}\n      {type}\n      {zIndex}\n      autofocus\n      onprev={prev}\n      onnext={next}\n      onfinish={finish}\n      onskip={skip}\n    />\n  </div>\n{/if}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tour/Tour.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tour/TourMask.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { TargetRect } from './tour-target.svelte'\n\n  export interface TourMaskProps {\n    rect: TargetRect | null\n    zIndex: number\n    opacity?: number\n    padding?: number\n    radius?: number\n  }\n</script>\n\n<script lang=\"ts\">\n  let { rect, zIndex, opacity = 0.5, padding = 4, radius = 6 }: TourMaskProps = $props()\n\n  // Unique mask id so multiple open tours (or other SVG masks on the page) never collide.\n  const uid = $props.id()\n  const maskId = `uipkge-tour-mask-${uid}`\n\n  const cutout = $derived.by(() => {\n    const r = rect\n    if (!r) return null\n    return { x: r.x - padding, y: r.y - padding, w: r.width + padding * 2, h: r.height + padding * 2 }\n  })\n\n  /**\n   * Clip-path leaves a hole over the target so pointer events pass through to the\n   * highlighted element. SVG mask alone does not punch a hit-test hole.\n   */\n  const hitClipPath = $derived.by(() => {\n    const c = cutout\n    if (!c) return undefined\n    const { x, y, w, h } = c\n    return `polygon(evenodd, 0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)`\n  })\n\n  const reduceMotion = $derived(\n    typeof window === 'undefined' ? false : window.matchMedia('(prefers-reduced-motion: reduce)').matches,\n  )\n</script>\n\n<!-- Visual dim with rounded cutout (decorative only — no hit testing). -->\n<svg\n  class=\"pointer-events-none fixed inset-0\"\n  style=\"z-index: {zIndex}; --tour-padding: {padding}px; --tour-radius: {radius}px\"\n  width=\"100%\"\n  height=\"100%\"\n  aria-hidden=\"true\"\n>\n  <defs>\n    <mask id={maskId}>\n      <rect width=\"100%\" height=\"100%\" fill=\"white\" />\n      {#if cutout}\n        <rect x={cutout.x} y={cutout.y} width={cutout.w} height={cutout.h} rx={radius} fill=\"black\" />\n      {/if}\n    </mask>\n  </defs>\n  <rect\n    width=\"100%\"\n    height=\"100%\"\n    fill=\"rgba(0, 0, 0, {opacity})\"\n    mask=\"url(#{maskId})\"\n    style={reduceMotion ? undefined : 'transition: all 200ms ease'}\n  />\n</svg>\n<!-- Hit layer: blocks clicks outside the cutout; hole is click-through. -->\n<div\n  class=\"fixed inset-0\"\n  aria-hidden=\"true\"\n  style=\"z-index: {zIndex}; clip-path: {hitClipPath ?? 'none'}; background: transparent\"\n></div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tour/TourMask.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tour/TourCard.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { TargetRect } from './tour-target.svelte'\n\n  export interface TourCardProps {\n    title: string\n    description?: string\n    cover?: string\n    rect: TargetRect | null\n    total: number\n    current: number\n    prevText?: string\n    nextText?: string\n    finishText?: string\n    type?: 'default' | 'primary'\n    zIndex: number\n    /** When true, move focus into the card (on open / step change). */\n    autofocus?: boolean\n    onprev?: () => void\n    onnext?: () => void\n    onfinish?: () => void\n    onskip?: () => void\n  }\n</script>\n\n<script lang=\"ts\">\n  import { tick } from 'svelte'\n  import { X } from '@lucide/svelte'\n  import { Button } from '$lib/components/ui/button'\n  import { cn } from '$lib/utils'\n\n  let {\n    title,\n    description,\n    cover,\n    rect,\n    total,\n    current,\n    prevText = 'Previous',\n    nextText = 'Next',\n    finishText = 'Finish',\n    type = 'default',\n    zIndex,\n    autofocus = false,\n    onprev,\n    onnext,\n    onfinish,\n    onskip,\n  }: TourCardProps = $props()\n\n  const isLast = $derived(current === total - 1)\n  const isFirst = $derived(current === 0)\n\n  const uid = $props.id()\n  const titleId = `${uid}-title`\n  const descriptionId = `${uid}-description`\n  let cardRef = $state<HTMLElement | null>(null)\n  /** Measured card height used for placement; falls back to estimate until laid out. */\n  let measuredHeight = $state(0)\n\n  const FOCUSABLE =\n    'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\n  function focusCard() {\n    tick().then(() => {\n      cardRef?.focus()\n    })\n  }\n\n  $effect(() => {\n    const el = cardRef\n    if (!el || typeof ResizeObserver === 'undefined') {\n      if (el) measuredHeight = el.getBoundingClientRect().height\n      return\n    }\n    const ro = new ResizeObserver(() => {\n      measuredHeight = el.getBoundingClientRect().height\n    })\n    ro.observe(el)\n    measuredHeight = el.getBoundingClientRect().height\n    return () => ro.disconnect()\n  })\n\n  $effect(() => {\n    // Re-run (and refocus) after step content swaps (title/cover/description).\n    void current\n    void title\n    void description\n    void cover\n    if (autofocus) focusCard()\n  })\n\n  /** Keep Tab cycling inside the dialog while aria-modal is asserted. */\n  function onKeydown(e: KeyboardEvent) {\n    if (e.key !== 'Tab' || !cardRef) return\n    // Prefer getClientRects over offsetParent — fixed-position descendants report null offsetParent.\n    const list = Array.from(cardRef.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n      (el) => el.getClientRects().length > 0,\n    )\n    if (list.length === 0) return\n    const first = list[0]!\n    const last = list[list.length - 1]!\n    if (e.shiftKey) {\n      if (document.activeElement === first || document.activeElement === cardRef) {\n        e.preventDefault()\n        last.focus()\n      }\n    } else if (document.activeElement === last) {\n      e.preventDefault()\n      first.focus()\n    }\n  }\n\n  const cardStyle = $derived.by(() => {\n    const cardWidth = 320\n    const margin = 12\n    const edgePadding = 8\n    // Prefer measured height; estimate only before first layout (cover makes card taller).\n    const cardHeight = measuredHeight || (cover ? 320 : 200)\n    if (!rect) {\n      return `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: ${cardWidth}px; z-index: ${zIndex + 1}`\n    }\n    const { x, y, height } = rect\n    const viewportH = typeof window !== 'undefined' ? window.innerHeight : 768\n    const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024\n    const placeBelow = y + height + margin + cardHeight < viewportH\n    const top = placeBelow ? y + height + margin : Math.max(edgePadding, y - margin - cardHeight)\n    let left = x\n    if (left + cardWidth > viewportW - edgePadding) {\n      left = viewportW - cardWidth - edgePadding\n    }\n    if (left < edgePadding) left = edgePadding\n    return `position: fixed; top: ${top}px; left: ${left}px; width: ${cardWidth}px; z-index: ${zIndex + 1}`\n  })\n</script>\n\n<div\n  bind:this={cardRef}\n  data-uipkge\n  data-slot=\"tour-card\"\n  role=\"dialog\"\n  aria-modal=\"true\"\n  aria-labelledby={titleId}\n  aria-describedby={description ? descriptionId : undefined}\n  tabindex=\"-1\"\n  class={cn(\n    'relative space-y-3 rounded-lg border p-4 shadow-lg outline-none',\n    type === 'primary' ? 'border-primary bg-primary text-primary-foreground' : 'bg-popover text-popover-foreground',\n  )}\n  style={cardStyle}\n  onkeydown={onKeydown}\n>\n  <button\n    type=\"button\"\n    class=\"absolute top-2 right-2 inline-flex size-6 items-center justify-center rounded hover:bg-foreground/10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n    aria-label=\"Close tour\"\n    onclick={() => onskip?.()}\n  >\n    <X class=\"size-4\" aria-hidden=\"true\" />\n  </button>\n\n  {#if cover}\n    <img src={cover} alt=\"\" class=\"w-full rounded-md\" />\n  {/if}\n\n  <div>\n    <div id={titleId} class=\"pr-6 font-semibold\">{title}</div>\n    {#if description}\n      <div id={descriptionId} class=\"mt-1 text-sm opacity-90\">{description}</div>\n    {/if}\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\" aria-atomic=\"true\">\n      {current + 1} / {total}\n    </div>\n    <div class=\"flex gap-2\">\n      {#if !isFirst}\n        <Button size=\"sm\" variant={type === 'primary' ? 'secondary' : 'outline'} onclick={() => onprev?.()}>\n          {prevText}\n        </Button>\n      {/if}\n      {#if !isLast}\n        <Button size=\"sm\" variant={type === 'primary' ? 'secondary' : 'default'} onclick={() => onnext?.()}>\n          {nextText}\n        </Button>\n      {:else}\n        <Button size=\"sm\" variant={type === 'primary' ? 'secondary' : 'default'} onclick={() => onfinish?.()}>\n          {finishText}\n        </Button>\n      {/if}\n    </div>\n  </div>\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tour/TourCard.svelte"
    },
    {
      "path": "packages/registry-svelte/components/tour/tour-target.svelte.ts",
      "content": "import { onDestroy } from 'svelte'\n\nexport type TourTarget = string | (() => HTMLElement | null) | HTMLElement | null\n\nexport interface TargetRect {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nexport function createTourTarget(getTarget: () => TourTarget | undefined) {\n  let rect = $state<TargetRect | null>(null)\n  let element: HTMLElement | null = null\n\n  let resizeObs: ResizeObserver | null = null\n  let raf = 0\n\n  function resolve(): HTMLElement | null {\n    const t = getTarget()\n    if (!t || typeof document === 'undefined') return null\n    if (typeof t === 'string') return document.querySelector(t) as HTMLElement | null\n    if (typeof t === 'function') return t()\n    return t\n  }\n\n  function measure() {\n    if (typeof requestAnimationFrame === 'undefined') return\n    cancelAnimationFrame(raf)\n    raf = requestAnimationFrame(() => {\n      if (!element) {\n        rect = null\n        return\n      }\n      const r = element.getBoundingClientRect()\n      rect = { x: r.left, y: r.top, width: r.width, height: r.height }\n    })\n  }\n\n  function attach() {\n    detach()\n    element = resolve()\n    if (!element) {\n      rect = null\n      return\n    }\n    measure()\n    if (typeof ResizeObserver !== 'undefined' && typeof document !== 'undefined') {\n      resizeObs = new ResizeObserver(measure)\n      resizeObs.observe(element)\n      resizeObs.observe(document.documentElement)\n    }\n    if (typeof window !== 'undefined') {\n      window.addEventListener('scroll', measure, { passive: true, capture: true })\n      window.addEventListener('resize', measure, { passive: true })\n    }\n  }\n\n  function detach() {\n    resizeObs?.disconnect()\n    resizeObs = null\n    if (typeof window !== 'undefined') {\n      window.removeEventListener('scroll', measure, true)\n      window.removeEventListener('resize', measure)\n    }\n    if (typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(raf)\n  }\n\n  onDestroy(detach)\n\n  return {\n    get rect() {\n      return rect\n    },\n    get element() {\n      return element\n    },\n    attach,\n    detach,\n    measure,\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tour/tour-target.svelte.ts"
    },
    {
      "path": "packages/registry-svelte/components/tour/index.ts",
      "content": "export { default as Tour, type TourProps, type TourStep } from './Tour.svelte'\nexport type { TourTarget, TargetRect } from './tour-target.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/tour/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/button.json"
  ],
  "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"
  ]
}