{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "onboarding-flow",
  "type": "registry:block",
  "title": "Onboarding Flow",
  "description": "A paged welcome flow: spotlit illustrations that drift with the swipe, story-style progress, a button that grows.",
  "categories": [
    "screen"
  ],
  "dependencies": [
    "react-native-reanimated",
    "react-native-safe-area-context",
    "react-native-worklets"
  ],
  "registryDependencies": [
    "https://kinetik-ui.dev/r/kinetik-tokens.json",
    "https://kinetik-ui.dev/r/kinetik-motion.json",
    "https://kinetik-ui.dev/r/aurora-veil.json",
    "https://kinetik-ui.dev/r/glint-card.json",
    "https://kinetik-ui.dev/r/scramble-text.json",
    "https://kinetik-ui.dev/r/tilt-deck.json",
    "https://kinetik-ui.dev/r/word-cascade.json"
  ],
  "files": [
    {
      "path": "src/components/kinetik/screens/onboarding-flow.tsx",
      "type": "registry:file",
      "target": "~/components/kinetik/screens/onboarding-flow.tsx",
      "content": "import { useRef, useState, type ReactNode } from 'react';\nimport {\n  Platform,\n  Pressable,\n  StyleSheet,\n  Text,\n  View,\n  type LayoutChangeEvent,\n  type NativeScrollEvent,\n  type NativeSyntheticEvent,\n} from 'react-native';\nimport Animated, {\n  Extrapolation,\n  interpolate,\n  useAnimatedRef,\n  useAnimatedScrollHandler,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  type SharedValue,\n} from 'react-native-reanimated';\nimport { useSafeAreaInsets } from 'react-native-safe-area-context';\n\nimport { AuroraVeil } from '../aurora-veil';\nimport { GlintCard } from '../glint-card';\nimport { ScrambleText } from '../scramble-text';\nimport { TiltDeck, TiltLayer } from '../tilt-deck';\nimport { WordCascade } from '../word-cascade';\nimport { gradient } from '../../../lib/kinetik/tokens/gradient';\nimport { spring } from '../../../lib/kinetik/motion/motion';\nimport { useReduceMotion } from '../../../lib/kinetik/hooks/use-reduce-motion';\nimport { useKinetikTheme } from '../../../lib/kinetik/tokens/theme';\nimport { minTouchTarget, palettes, withAlpha } from '../../../lib/kinetik/tokens/tokens';\n\nexport type OnboardingSlide = {\n  key: string;\n  /** Small label above the title. */\n  eyebrow?: string;\n  title: string;\n  body: string;\n  /** Illustration above the text. Moves with a little parallax while paging. */\n  visual?: ReactNode;\n};\n\nexport type OnboardingFlowProps = {\n  /** Called from the last slide's button. */\n  onFinish: () => void;\n  /** Called by Skip. Defaults to `onFinish`. */\n  onSkip?: () => void;\n  /** Defaults to three slides about Kinetik; replace with your own. */\n  slides?: readonly OnboardingSlide[];\n  /** Brand name shown top left. */\n  brand?: string;\n  /** Accessible name of the round next button. Defaults to 'Continue'. */\n  nextLabel?: string;\n  /** Label of the last slide's button. Defaults to 'Get started'. */\n  finishLabel?: string;\n};\n\n/** Size of the round next button. */\nconst BUTTON = 56;\n\n/**\n * A paged welcome flow. Each illustration sits under a soft spotlight and\n * drifts with the swipe, titles cascade in as their slide arrives, progress\n * fills like stories, and the round next button grows into \"Get started\" on\n * the last slide.\n */\nexport function OnboardingFlow({\n  onFinish,\n  onSkip = onFinish,\n  slides: slidesProp,\n  brand = 'Kinetik',\n  nextLabel = 'Continue',\n  finishLabel = 'Get started',\n}: OnboardingFlowProps) {\n  const { colors, space, font, scheme } = useKinetikTheme();\n  const insets = useSafeAreaInsets();\n  const defaults = useDefaultSlides();\n  const slides = slidesProp ?? defaults;\n  const count = slides.length;\n\n  const scroller = useAnimatedRef<Animated.ScrollView>();\n  const offset = useSharedValue(0);\n  const [width, setWidth] = useState(0);\n  const [index, setIndex] = useState(0);\n  const widthRef = useRef(0);\n\n  const onScroll = useAnimatedScrollHandler((e) => {\n    offset.set(e.contentOffset.x);\n  });\n\n  const onLayout = (e: LayoutChangeEvent) => {\n    const w = e.nativeEvent.layout.width;\n    widthRef.current = w;\n    setWidth(w);\n  };\n\n  const settle = (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n    const w = widthRef.current;\n    if (w > 0) setIndex(Math.round(e.nativeEvent.contentOffset.x / w));\n  };\n\n  const last = index === count - 1;\n  const goNext = () => {\n    if (last) return onFinish();\n    const next = index + 1;\n    scroller.current?.scrollTo({ x: next * widthRef.current, animated: true });\n    setIndex(next);\n  };\n\n  const pad = space[6];\n  const footer = Math.max(BUTTON, width - pad * 2);\n\n  return (\n    <View style={[styles.fill, { backgroundColor: colors.background }]}>\n      {/* On a light ground the aurora is ink, so it is kept faint there. */}\n      <AuroraVeil style={StyleSheet.absoluteFill} intensity={scheme === 'dark' ? 0.4 : 0.12} />\n      {/* The aurora fades out behind the text so the copy sits on a clean ground. */}\n      <View\n        pointerEvents=\"none\"\n        style={[\n          StyleSheet.absoluteFill,\n          gradient(\n            `linear-gradient(180deg, ${withAlpha(colors.background, 0)} 0%, ${withAlpha(colors.background, 0.4)} 40%, ${colors.background} 68%)`,\n          ),\n        ]}\n      />\n      <View\n        style={[styles.fill, { paddingTop: insets.top + space[2], paddingBottom: insets.bottom }]}\n        onLayout={onLayout}\n        testID=\"onboarding-root\"\n      >\n        <View style={[styles.topBar, { paddingHorizontal: pad }]}>\n          <View style={styles.brand}>\n            <View style={styles.mark}>\n              <View style={[styles.markRing, { borderColor: colors.text }]} />\n              <View style={[styles.markDot, { backgroundColor: colors.text }]} />\n            </View>\n            <Text\n              style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.bold }}\n            >\n              {brand}\n            </Text>\n          </View>\n          <Pressable\n            accessibilityRole=\"button\"\n            accessibilityLabel=\"Skip introduction\"\n            accessibilityElementsHidden={last}\n            importantForAccessibility={last ? 'no-hide-descendants' : 'auto'}\n            disabled={last}\n            onPress={onSkip}\n            hitSlop={8}\n            style={[styles.skip, { opacity: last ? 0 : 1 }]}\n          >\n            <Text\n              style={{\n                color: colors.textMuted,\n                fontSize: font.size.sm,\n                fontWeight: font.weight.medium,\n              }}\n            >\n              Skip\n            </Text>\n          </Pressable>\n        </View>\n\n        <Animated.ScrollView\n          ref={scroller}\n          horizontal\n          pagingEnabled\n          showsHorizontalScrollIndicator={false}\n          onScroll={onScroll}\n          scrollEventThrottle={16}\n          onMomentumScrollEnd={settle}\n          style={styles.fill}\n        >\n          {width > 0 &&\n            slides.map((slide, i) => (\n              <Page\n                key={slide.key}\n                slide={slide}\n                index={i}\n                count={count}\n                width={width}\n                offset={offset}\n                active={i === index}\n              />\n            ))}\n        </Animated.ScrollView>\n\n        <View style={[styles.footer, { marginHorizontal: pad, marginBottom: space[5] }]}>\n          <View\n            accessible\n            accessibilityRole=\"text\"\n            accessibilityLabel={`Step ${index + 1} of ${count}`}\n            style={styles.segments}\n          >\n            {slides.map((s, i) => (\n              <Segment key={s.key} index={i} count={count} offset={offset} width={width} />\n            ))}\n          </View>\n          <NextButton\n            offset={offset}\n            width={width}\n            count={count}\n            full={footer}\n            label={last ? finishLabel : nextLabel}\n            finishLabel={finishLabel}\n            onPress={goNext}\n          />\n        </View>\n      </View>\n    </View>\n  );\n}\n\n/** How far the last slide has arrived, 0–1. */\nfunction arrival(offset: number, width: number, count: number) {\n  'worklet';\n  if (count <= 1) return 1;\n  if (width <= 0) return 0;\n  return interpolate(offset / width, [count - 2, count - 1], [0, 1], Extrapolation.CLAMP);\n}\n\n/**\n * A round arrow button that widens into a labelled pill as the last slide\n * arrives, and gives a little under the finger.\n */\nfunction NextButton({\n  offset,\n  width,\n  count,\n  full,\n  label,\n  finishLabel,\n  onPress,\n}: {\n  offset: SharedValue<number>;\n  width: number;\n  count: number;\n  full: number;\n  label: string;\n  finishLabel: string;\n  onPress: () => void;\n}) {\n  const { colors, font, radius } = useKinetikTheme();\n  const reduced = useReduceMotion();\n  const pressed = useSharedValue(0);\n\n  const shell = useAnimatedStyle(() => {\n    const t = arrival(offset.get(), width, count);\n    return {\n      width: BUTTON + (full - BUTTON) * t,\n      transform: [{ scale: 1 - pressed.get() * 0.04 }],\n    };\n  });\n  const arrow = useAnimatedStyle(() => {\n    const t = arrival(offset.get(), width, count);\n    return { opacity: 1 - Math.min(1, t * 2.5), transform: [{ translateX: t * 24 }] };\n  });\n  const text = useAnimatedStyle(() => {\n    const t = arrival(offset.get(), width, count);\n    return { opacity: Math.max(0, t * 2 - 1), transform: [{ scale: 0.92 + t * 0.08 }] };\n  });\n\n  return (\n    <Pressable\n      accessibilityRole=\"button\"\n      accessibilityLabel={label}\n      onPress={onPress}\n      onPressIn={() => pressed.set(withSpring(1, spring('snappy', reduced)))}\n      onPressOut={() => pressed.set(withSpring(0, spring('bouncy', reduced)))}\n      style={styles.buttonSlot}\n    >\n      <Animated.View\n        style={[styles.button, { backgroundColor: colors.ember, borderRadius: radius.pill }, shell]}\n      >\n        <Animated.View style={[styles.center, arrow]}>\n          <View style={[styles.arrowStem, { backgroundColor: colors.onEmber }]} />\n          <View style={[styles.arrowHead, { borderColor: colors.onEmber }]} />\n        </Animated.View>\n        <Animated.Text\n          numberOfLines={1}\n          style={[\n            styles.center,\n            styles.finish,\n            { color: colors.onEmber, fontSize: font.size.md, fontWeight: font.weight.bold },\n            text,\n          ]}\n        >\n          {finishLabel}\n        </Animated.Text>\n      </Animated.View>\n    </Pressable>\n  );\n}\n\nfunction Page({\n  slide,\n  index,\n  count,\n  width,\n  offset,\n  active,\n}: {\n  slide: OnboardingSlide;\n  index: number;\n  count: number;\n  width: number;\n  offset: SharedValue<number>;\n  active: boolean;\n}) {\n  const { colors, space, font } = useKinetikTheme();\n\n  // The illustration lags the page, so it seems to sit further back.\n  const visualStyle = useAnimatedStyle(() => {\n    const progress = offset.get() / width - index;\n    return {\n      opacity: interpolate(Math.abs(progress), [0, 0.8], [1, 0], Extrapolation.CLAMP),\n      transform: [\n        { translateX: progress * width * 0.45 },\n        { scale: interpolate(Math.abs(progress), [0, 1], [1, 0.9], Extrapolation.CLAMP) },\n      ],\n    };\n  });\n  // The copy fades a little faster than it moves.\n  const copyStyle = useAnimatedStyle(() => {\n    const progress = offset.get() / width - index;\n    return { opacity: interpolate(Math.abs(progress), [0, 0.6], [1, 0], Extrapolation.CLAMP) };\n  });\n\n  return (\n    <View\n      style={[styles.page, { width, paddingHorizontal: space[6] }]}\n      accessibilityElementsHidden={!active}\n      importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}\n    >\n      <View style={styles.stage}>\n        {/* A soft spotlight the illustration stands in. */}\n        <View\n          pointerEvents=\"none\"\n          style={[styles.spot, { boxShadow: `0px 0px 120px 90px ${colors.emberSoft}` }]}\n        />\n        <Animated.View style={[styles.visual, visualStyle]}>{slide.visual}</Animated.View>\n      </View>\n      <Animated.View style={[{ gap: space[3], paddingBottom: space[6] }, copyStyle]}>\n        <Text\n          style={{\n            color: colors.textSubtle,\n            fontSize: font.size.xs,\n            fontWeight: font.weight.semibold,\n            letterSpacing: 1.4,\n            fontVariant: ['tabular-nums'],\n          }}\n        >\n          {String(index + 1).padStart(2, '0')} / {String(count).padStart(2, '0')}\n          {slide.eyebrow ? `   ·   ${slide.eyebrow.toUpperCase()}` : ''}\n        </Text>\n        <WordCascade\n          text={slide.title}\n          replayKey={active ? `on-${index}` : 'off'}\n          animateOnMount={index === 0}\n          style={{\n            color: colors.text,\n            fontSize: 34,\n            fontWeight: font.weight.bold,\n            letterSpacing: -1.2,\n            lineHeight: 38,\n          }}\n        />\n        <Text style={{ color: colors.textMuted, fontSize: font.size.md, lineHeight: 24 }}>\n          {slide.body}\n        </Text>\n      </Animated.View>\n    </View>\n  );\n}\n\n/** One story-style progress bar; fills as its slide arrives. */\nfunction Segment({\n  index,\n  count,\n  offset,\n  width,\n}: {\n  index: number;\n  count: number;\n  offset: SharedValue<number>;\n  width: number;\n}) {\n  const { colors, radius } = useKinetikTheme();\n  const fill = useAnimatedStyle(() => {\n    const t = width > 0 ? offset.get() / width - index + 1 : index === 0 ? 1 : 0;\n    return { width: `${Math.max(0, Math.min(1, t)) * 100}%` };\n  });\n  const hide = useAnimatedStyle(() => ({\n    opacity: 1 - arrival(offset.get(), width, count),\n  }));\n  return (\n    <Animated.View\n      style={[\n        styles.segment,\n        { backgroundColor: withAlpha(colors.text, 0.14), borderRadius: radius.pill },\n        hide,\n      ]}\n    >\n      <Animated.View style={[styles.segmentFill, { backgroundColor: colors.text }, fill]} />\n    </Animated.View>\n  );\n}\n\nfunction useDefaultSlides(): OnboardingSlide[] {\n  const { colors, space, font, radius, shadow } = useKinetikTheme();\n  return [\n    {\n      key: 'explain',\n      eyebrow: 'Motion with a purpose',\n      title: 'Interfaces that move like they mean it.',\n      body: 'Every transition shows where something came from and where it went, so people never lose their place.',\n      visual: (\n        <View style={styles.fan}>\n          {/* Two more cards stacked behind, peeking over the top, for depth. */}\n          {[2, 1].map((level) => (\n            <View\n              key={level}\n              style={[\n                styles.stacked,\n                {\n                  backgroundColor: colors.surface,\n                  borderColor: colors.borderStrong,\n                  borderRadius: radius.lg,\n                  opacity: 1 - level * 0.3,\n                  transform: [{ translateY: -14 * level }, { scale: 1 - level * 0.06 }],\n                },\n              ]}\n            />\n          ))}\n          <TiltDeck\n            accessibilityLabel=\"Album card\"\n            contentStyle={{ padding: 0 }}\n            radius={radius.lg}\n            style={[styles.card, { boxShadow: shadow.lg }]}\n          >\n            <View\n              style={[\n                styles.art,\n                gradient(\n                  `linear-gradient(160deg, ${withAlpha(colors.ember, 0.9)} 0%, ${withAlpha(colors.ember, 0.25)} 45%, ${colors.surfaceSunken} 100%)`,\n                ),\n              ]}\n            >\n              <TiltLayer\n                depth={-0.6}\n                style={[styles.sun, { backgroundColor: withAlpha(colors.highlight, 0.3) }]}\n              />\n              <TiltLayer depth={0.7} style={[styles.hill, { backgroundColor: colors.surface }]} />\n            </View>\n            <TiltLayer depth={0.4} style={{ padding: space[4], gap: 2 }}>\n              <Text\n                style={{ color: colors.text, fontSize: font.size.md, fontWeight: font.weight.bold }}\n              >\n                Press a corner\n              </Text>\n              <Text style={{ color: colors.textMuted, fontSize: font.size.sm }}>\n                Layers shift at their own depth\n              </Text>\n            </TiltLayer>\n          </TiltDeck>\n        </View>\n      ),\n    },\n    {\n      key: 'physical',\n      eyebrow: 'Feels physical',\n      title: 'Light, weight and a little give.',\n      body: 'Springs instead of timers, a glint that follows your finger, haptics only where they carry meaning.',\n      visual: (\n        <GlintCard\n          glintColor={colors.highlight}\n          radius={18}\n          intensity={0.85}\n          style={[styles.card, styles.metal, gradient(METAL), { boxShadow: shadow.lg }]}\n          contentStyle={{ flex: 1, padding: space[5], justifyContent: 'space-between' }}\n        >\n          <View style={styles.row}>\n            <Text style={[styles.metalText, { fontSize: 12, fontWeight: '800', letterSpacing: 3 }]}>\n              KINETIK\n            </Text>\n            <Text style={[styles.metalText, { fontSize: 11, opacity: 0.6, letterSpacing: 1.6 }]}>\n              MEMBER\n            </Text>\n          </View>\n          <View style={[styles.chip, gradient(CHIP)]} />\n          <View style={{ gap: 2 }}>\n            <Text style={[styles.metalText, { fontSize: font.size.lg, fontWeight: '700' }]}>\n              Drag across me\n            </Text>\n            <Text style={[styles.metalText, { fontSize: font.size.sm, opacity: 0.6 }]}>\n              or tilt your phone\n            </Text>\n          </View>\n        </GlintCard>\n      ),\n    },\n    {\n      key: 'yours',\n      eyebrow: 'Yours to change',\n      title: 'Copied into your app, not hidden in a package.',\n      body: 'One command adds the source and installs matching native versions. Edit anything.',\n      visual: (\n        <View\n          style={[\n            styles.card,\n            styles.terminal,\n            {\n              backgroundColor: colors.surfaceSunken,\n              borderColor: colors.borderStrong,\n              borderRadius: radius.lg,\n              boxShadow: shadow.lg,\n            },\n          ]}\n        >\n          <View\n            style={[\n              styles.terminalBar,\n              { borderColor: colors.border, paddingHorizontal: space[4] },\n            ]}\n          >\n            {[0.9, 0.6, 0.35].map((a) => (\n              <View\n                key={a}\n                style={[styles.light, { backgroundColor: withAlpha(colors.text, a * 0.5) }]}\n              />\n            ))}\n            <Text style={[styles.terminalTitle, { color: colors.textSubtle }]}>my-app</Text>\n          </View>\n          <View style={{ padding: space[4], gap: space[2] }}>\n            <View style={styles.prompt}>\n              <Text style={{ color: colors.textSubtle, fontSize: font.size.sm, fontFamily: MONO }}>\n                ${' '}\n              </Text>\n              <ScrambleText\n                text=\"npx kinetik-ui add glint-card\"\n                style={{ color: colors.text, fontSize: font.size.sm, fontFamily: MONO }}\n              />\n            </View>\n            {[\n              'components/kinetik/glint-card.tsx',\n              'lib/kinetik/tokens.ts',\n              '2 native packages',\n            ].map((line) => (\n              <Text\n                key={line}\n                numberOfLines={1}\n                style={{ color: colors.textMuted, fontSize: font.size.xs, fontFamily: MONO }}\n              >\n                <Text style={{ color: colors.success }}>✓ </Text>\n                {line}\n              </Text>\n            ))}\n          </View>\n        </View>\n      ),\n    },\n  ];\n}\n\nconst MONO = Platform.select({ ios: 'Menlo', default: 'monospace' });\n// The member card is black metal on either theme, so it always uses the dark palette.\nconst METAL_INK = palettes.dark;\nconst METAL = `linear-gradient(135deg, ${METAL_INK.surfaceRaised} 0%, ${METAL_INK.surface} 45%, ${METAL_INK.background} 72%, ${METAL_INK.skeleton} 100%)`;\nconst CHIP = `linear-gradient(140deg, ${METAL_INK.text} 0%, ${METAL_INK.textSubtle} 45%, ${METAL_INK.textMuted} 70%, ${METAL_INK.textSubtle} 100%)`;\n\nconst styles = StyleSheet.create({\n  fill: { flex: 1 },\n  topBar: {\n    height: minTouchTarget,\n    flexDirection: 'row',\n    alignItems: 'center',\n    justifyContent: 'space-between',\n  },\n  brand: { flexDirection: 'row', alignItems: 'center', gap: 8 },\n  mark: { width: 22, height: 14, justifyContent: 'center' },\n  markRing: {\n    position: 'absolute',\n    left: 0,\n    width: 14,\n    height: 14,\n    borderRadius: 7,\n    borderWidth: 1.5,\n  },\n  markDot: { position: 'absolute', left: 8, width: 14, height: 14, borderRadius: 7 },\n  skip: {\n    minHeight: minTouchTarget,\n    minWidth: minTouchTarget,\n    alignItems: 'flex-end',\n    justifyContent: 'center',\n  },\n  page: { flex: 1 },\n  stage: { flex: 1, alignItems: 'center', justifyContent: 'center' },\n  spot: { position: 'absolute', width: 1, height: 1, top: '50%', left: '50%' },\n  visual: { width: '100%', alignItems: 'center' },\n  card: { width: '100%', maxWidth: 300 },\n  fan: { width: '100%', maxWidth: 300, alignItems: 'center' },\n  stacked: {\n    ...StyleSheet.absoluteFill,\n    borderWidth: StyleSheet.hairlineWidth,\n  },\n  art: { height: 150, overflow: 'hidden' },\n  sun: { position: 'absolute', width: 72, height: 72, borderRadius: 36, top: 28, left: 40 },\n  hill: {\n    position: 'absolute',\n    width: 220,\n    height: 220,\n    borderRadius: 110,\n    right: -50,\n    bottom: -170,\n    transform: [{ scaleX: 2.2 }],\n  },\n  metal: { aspectRatio: 1.586, borderColor: METAL_INK.borderStrong },\n  metalText: { color: METAL_INK.text },\n  chip: { width: 40, height: 30, borderRadius: 6 },\n  row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },\n  prompt: { flexDirection: 'row', alignItems: 'center' },\n  terminal: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' },\n  terminalBar: {\n    height: 36,\n    flexDirection: 'row',\n    alignItems: 'center',\n    gap: 6,\n    borderBottomWidth: StyleSheet.hairlineWidth,\n  },\n  terminalTitle: { flex: 1, textAlign: 'center', marginRight: 42, fontSize: 12, fontFamily: MONO },\n  light: { width: 10, height: 10, borderRadius: 5 },\n  footer: {\n    height: BUTTON,\n    flexDirection: 'row',\n    alignItems: 'center',\n    justifyContent: 'space-between',\n  },\n  segments: { flexDirection: 'row', alignItems: 'center', gap: 6 },\n  segment: { width: 28, height: 3, overflow: 'hidden' },\n  segmentFill: { height: '100%' },\n  buttonSlot: { position: 'absolute', right: 0, top: 0 },\n  button: { height: BUTTON, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },\n  center: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },\n  finish: { left: 0, right: 0, textAlign: 'center' },\n  arrowStem: { width: 18, height: 2, borderRadius: 1 },\n  arrowHead: {\n    position: 'absolute',\n    right: 0,\n    width: 9,\n    height: 9,\n    borderTopWidth: 2,\n    borderRightWidth: 2,\n    borderTopRightRadius: 1,\n    transform: [{ rotate: '45deg' }],\n  },\n});\n"
    }
  ],
  "meta": {
    "version": "1.0.0",
    "kind": "template",
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "category": "screen",
    "web": "live",
    "perf": {
      "budget": "One shader background plus the visible slide. Paging, parallax, the progress bars and the growing button are scroll-driven worklets; nothing runs on the JS thread while swiping.",
      "measurements": []
    },
    "a11y": {
      "reducedMotion": "Titles appear without cascading and the background holds still (handled by Word Cascade and Aurora Veil). Paging is user-driven, so it stays.",
      "screenReader": "Only the current slide is exposed. The progress bars read as \"Step 2 of 3\". The round button is named Continue, then Get started. Skip is hidden on the last slide.",
      "touchTarget": "Skip is at least 44 pt; the round button is 56 pt and spans the width on the last slide."
    },
    "requires": "React Native New Architecture (Reanimated 4)",
    "guide": "## Usage\n\n```tsx\nimport { OnboardingFlow } from '@/components/kinetik/screens/onboarding-flow';\n\nexport default function Welcome() {\n  return (\n    <OnboardingFlow\n      brand=\"Acme\"\n      onFinish={() => router.replace('/home')}\n      slides={[\n        { key: 'a', eyebrow: 'Plan', title: 'Your week at a glance.', body: '…', visual: <Hero /> },\n        { key: 'b', title: 'Share with your team.', body: '…' },\n      ]}\n    />\n  );\n}\n```\n\nRender it as a full screen (it fills its parent and handles the safe area).\n\n## When to use\n\n- First launch, or after a major update, to explain two to four ideas.\n- When each idea has a visual worth showing; the illustrations carry the flow.\n\n## When not to use\n\n- To ask for permissions or collect data. Ask in context, where the reason is obvious.\n- More than four slides. People skip long tours; teach in the product instead.\n\n## Notes\n\n- Default slides show Tilt Deck, Glint Card and Scramble Text; pass `slides` to replace them.\n- The round next button grows into `finishLabel` as the last slide arrives.\n- The title of each slide replays its cascade when the slide arrives.\n- `onSkip` defaults to `onFinish`.\n"
  },
  "docs": "Prefer `npx kinetik-ui add onboarding-flow`: it installs dependencies with expo install, so versions match your Expo SDK."
}
