Zum Inhalt springen
UI Kit
Accent

KI-Einstieg

Die ersten Minuten mit einem KI-Assistenten. Was er kann, mit einem Beispiel, das sich selbst tippt, eine erste Aufgabe zum Starten, die Datenquellen zum Verbinden mit dem, was jede liest, und eine ehrliche Seite zu Grenzen und Versprechen.

Eine von 3 Components in KI-Einrichtung

4 Variants

Variants

Was er kann

Fähigkeiten als Reiter, jede mit einer Anfrage, die sich selbst tippt, und dem Ergebnis danach. Pfeiltasten wechseln, Telefone wischen die Reihe.

Im Studio öffnen

Erste Aufgabe

Eine Handvoll Aufgaben, die sofort klappen, jeweils mit Dauer und was sie brauchen, oder eine Aufgabe in eigenen Worten. Starten öffnet den Chat damit.

Im Studio öffnen

Quellen verbinden

Datenquellen mit dem, was der Assistent jeweils lesen würde, einzeln verbunden mit einem Haken, der einspringt, und einem Zähler im Fuß.

Im Studio öffnen

Grenzen und Versprechen

Vor dem ersten Chat stehen ehrlich nebeneinander, worin der Assistent gut ist, wofür er nicht gemacht ist, und drei Versprechen zu den Daten.

Im Studio öffnen

Details

Was KI-Einstieg mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/ai-onboarding.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 21 eigene Texte

Props

Die Schnittstelle AiOnboardingProps aus components/ui/ai-onboarding.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AiOnboardingVariant

    capabilitiesfirst-taskconnect-sourceslimits-promise

  • nameoptional
    string

    The assistant's name

  • titleoptional
    string
  • descriptionoptional
    string
  • capabilitiesoptional
    AiCapability[]
  • tasksoptional
    AiStarterTask[]
  • sourcesoptional
    AiSource[]
  • canoptional
    string[]

    limits-promise: what it does well and what it does not

  • cannotoptional
    string[]
  • promisesoptional
    AiPromiseItem[]
  • stepoptional
    { current: number; total: number }

    Step shown in the progress line, e.g. { current: 2, total: 3 }

  • onNextoptional
    () => void

    Continue, the step is marked done and the next segment fills

  • onBackoptional
    () => void

    Back and Skip only show when they are handled

  • onSkipoptional
    () => void
  • onStartoptional
    (task: string) => void

    first-task: the chosen task or the own words

Eingebaute Texte

Diese Texte schreibt KI-Einstieg selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 21 Texte anzeigen
stepEnglisch Step {n} of {total}Deutsch Schritt {n} von {total}
nextEnglisch ContinueDeutsch Weiter
backEnglisch BackDeutsch Zurück
skipEnglisch Skip for nowDeutsch Später
requestEnglisch You askDeutsch Du fragst
resultEnglisch ResultDeutsch Ergebnis
startEnglisch Start with thisDeutsch Damit starten
startedEnglisch Your first chat starts with this task.Deutsch Dein erster Chat startet mit dieser Aufgabe.
stepDoneEnglisch Step {n} doneDeutsch Schritt {n} erledigt
needsEnglisch NeedsDeutsch Braucht
connectEnglisch ConnectDeutsch Verbinden
connectedEnglisch ConnectedDeutsch Verbunden
readsEnglisch ReadsDeutsch Liest
laterEnglisch You can connect more later in settings.Deutsch Weitere Quellen kannst du später in den Einstellungen verbinden.
countEnglisch {n} of {total} connectedDeutsch {n} von {total} verbunden
canEnglisch Good atDeutsch Gut darin
cannotEnglisch Not made forDeutsch Nicht gemacht für
promiseEnglisch Our promiseDeutsch Unser Versprechen
agreeEnglisch Understood, start nowDeutsch Verstanden, jetzt starten
customEnglisch Or describe your own taskDeutsch Oder beschreib deine eigene Aufgabe
customPlaceholderEnglisch Plan the team day in NovemberDeutsch Plane den Teamtag im November

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • KI-Einstieg reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-label, aria-live, aria-orientation, aria-selected, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen progressbar, radio, radiogroup, status, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 570 Zeilen
components/ui/ai-onboarding.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollFade } from '@/components/ui/motion';

export type AiOnboardingVariant = 'capabilities' | 'first-task' | 'connect-sources' | 'limits-promise';

export interface AiCapability {
  id: string;
  title: string;
  description: string;
  icon?: IconName;
  /** A sample request */
  prompt: string;
  /** The sample result, shown when the capability is picked */
  result: string;
}

export interface AiStarterTask {
  id: string;
  label: string;
  icon?: IconName;
  /** About how long it takes, e.g. "1 min" */
  time?: string;
  /** What the assistant asks for to start */
  needs?: string;
}

export interface AiSource {
  id: string;
  name: string;
  icon?: IconName;
  description: string;
  /** What the assistant reads */
  reads: string;
  connected?: boolean;
}

export interface AiPromiseItem {
  title: string;
  description: string;
  icon?: IconName;
}

export interface AiOnboardingProps {
  variant?: AiOnboardingVariant;
  /** The assistant's name */
  name?: string;
  title?: string;
  description?: string;
  capabilities?: AiCapability[];
  tasks?: AiStarterTask[];
  sources?: AiSource[];
  /** limits-promise: what it does well and what it does not */
  can?: string[];
  cannot?: string[];
  promises?: AiPromiseItem[];
  /** Step shown in the progress line, e.g. { current: 2, total: 3 } */
  step?: { current: number; total: number };
  /** Continue, the step is marked done and the next segment fills */
  onNext?: () => void;
  /** Back and Skip only show when they are handled */
  onBack?: () => void;
  onSkip?: () => void;
  /** first-task: the chosen task or the own words */
  onStart?: (task: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    step: 'Step {n} of {total}',
    next: 'Continue',
    back: 'Back',
    skip: 'Skip for now',
    
    
    request: 'You ask',
    result: 'Result',
    start: 'Start with this',
    started: 'Your first chat starts with this task.',
    stepDone: 'Step {n} done',
    needs: 'Needs',
    connect: 'Connect',
    connected: 'Connected',
    reads: 'Reads',
    later: 'You can connect more later in settings.',
    count: '{n} of {total} connected',
    can: 'Good at',
    cannot: 'Not made for',
    promise: 'Our promise',
    agree: 'Understood, start now',
    custom: 'Or describe your own task',
    customPlaceholder: 'Plan the team day in November',
  },
  de: {
    step: 'Schritt {n} von {total}',
    next: 'Weiter',
    back: 'Zurück',
    skip: 'Später',
    
    
    request: 'Du fragst',
    result: 'Ergebnis',
    start: 'Damit starten',
    started: 'Dein erster Chat startet mit dieser Aufgabe.',
    stepDone: 'Schritt {n} erledigt',
    needs: 'Braucht',
    connect: 'Verbinden',
    connected: 'Verbunden',
    reads: 'Liest',
    later: 'Weitere Quellen kannst du später in den Einstellungen verbinden.',
    count: '{n} von {total} verbunden',
    can: 'Gut darin',
    cannot: 'Nicht gemacht für',
    promise: 'Unser Versprechen',
    agree: 'Verstanden, jetzt starten',
    custom: 'Oder beschreib deine eigene Aufgabe',
    customPlaceholder: 'Plane den Teamtag im November',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AiOnboardingProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

const CSS =
  '@keyframes aob-up{from{opacity:0;transform:translateY(8px)}}@keyframes aob-swap{from{opacity:0;transform:translateY(6px);filter:blur(3px)}}@keyframes aob-check{from{transform:scale(0) rotate(-30deg)}}' +
  '@media (prefers-reduced-motion:no-preference){.aob-up{animation:aob-up .45s var(--ease-kit) var(--aob-d,0ms) both}.aob-swap{animation:aob-swap .5s var(--ease-kit) both}.aob-check{animation:aob-check .4s var(--ease-kit) both}}';

function Style() {
  return (
    <style href="kit-ai-onboarding" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

/** The onboarding window: progress line, heading, body and footer */
function Shell({ t, name, title, description, step, done, children, footer, className, wide }: { t: Texts; name?: string; title?: string; description?: string; step?: { current: number; total: number }; done?: boolean; children: React.ReactNode; footer?: React.ReactNode; className?: string; wide?: boolean }) {
  const filled = step ? Math.min(step.total, step.current + (done ? 1 : 0)) : 0;
  return (
    <section className={cn('kit-glow kit-dots relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <Reveal effect="scale">
          <div className={cn('mx-auto w-full overflow-hidden rounded-[24px] border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]', wide ? 'max-w-[60rem]' : 'max-w-[44rem]')}>
            {step && (
              <div className="flex gap-1.5 px-5 pt-5 sm:px-8 sm:pt-7" role="progressbar" aria-valuemin={1} aria-valuemax={step.total} aria-valuenow={filled} aria-label={fill(t.step, { n: step.current, total: step.total })}>
                {Array.from({ length: step.total }, (_, i) => (
                  <span key={i} className="h-1 flex-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
                    <span className={cn('block h-full origin-left rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)]', i < filled ? 'scale-x-100' : 'scale-x-0')} style={{ transitionDelay: `${i * 120}ms` }} />
                  </span>
                ))}
              </div>
            )}
            <div className="px-5 pb-2 pt-6 sm:px-8 sm:pt-7">
              <p className="flex items-center gap-2 text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">
                <span aria-hidden className="grid size-6 place-items-center rounded-full bg-primary text-primary-foreground">
                  <Icon name="sparkle" size={13} />
                </span>
                {name}
                {step && <span className="text-subtle-foreground">· {fill(t.step, { n: step.current, total: step.total })}</span>}
              </p>
              {title && <h2 className="mt-3 text-balance text-[24px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[30px]">{title}</h2>}
              {description && <p className="mt-2 max-w-[36rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </div>
            <div className="px-5 py-5 sm:px-8">{children}</div>
            {footer && <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border bg-surface/60 px-5 py-4 sm:px-8 [&>:only-child]:ml-auto">{footer}</div>}
          </div>
        </Reveal>
      </div>
    </section>
  );
}

const primaryBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg bg-primary px-5 text-[14px] max-sm:w-full font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-10';
const ghostBtn = 'inline-flex h-11 items-center justify-center rounded-lg px-3 text-[14px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-10';

function Footer({ t, children, onBack, onSkip }: { t: Texts; children?: React.ReactNode; onBack?: () => void; onSkip?: () => void }) {
  return (
    <>
      {(onBack || onSkip) && (
      <span className="flex gap-1">
        {onBack && (
          <button type="button" onClick={onBack} className={ghostBtn}>
            <Icon name="arrow-left" size={15} className="sm:mr-1" />
            <span className="max-sm:sr-only">{t.back}</span>
          </button>
        )}
        {onSkip && (
          <button type="button" onClick={onSkip} className={ghostBtn}>
            {t.skip}
          </button>
        )}
      </span>
      )}
      {children}
    </>
  );
}

/** Continue, then the step reads as done */
function Next({ t, done, step, onClick, label }: { t: Texts; done: boolean; step?: { current: number; total: number }; onClick: () => void; label?: string }) {
  return done ? (
    <span role="status" className="aob-up inline-flex h-11 items-center gap-2 text-[14px] font-medium text-success-foreground sm:h-10">
      <Icon name="check-circle" size={16} />
      {fill(t.stepDone, { n: step?.current ?? 1 })}
    </span>
  ) : (
    <button type="button" onClick={onClick} className={primaryBtn}>
      {label ?? t.next}
      <Icon name="arrow-right" size={15} />
    </button>
  );
}

/* ------------------------------------------------------------------ */
/* capabilities                                                        */
/* ------------------------------------------------------------------ */

function Capabilities({ t, name, title, description, capabilities = [], step, onNext, onBack, onSkip, className }: P) {
  const [sel, setSel] = useState(0);
  const [typed, setTyped] = useState('');
  const [next, setNext] = useState(false);
  const [ref, inView] = useInView<HTMLDivElement>();
  // Types once when it first comes into view, not again on every return
  const [seen, setSeen] = useState(false);
  useEffect(() => {
    if (inView) setSeen(true);
  }, [inView]);
  const cur = capabilities[sel];
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const [row, fade] = useScrollFade<HTMLDivElement>();
  // The example request types itself, then the result appears
  useEffect(() => {
    if (!cur) return;
    if (!seen || prefersReducedMotion()) return setTyped(cur.prompt);
    setTyped('');
    let i = 0;
    const id = window.setInterval(() => {
      i += 2;
      setTyped(cur.prompt.slice(0, i));
      if (i >= cur.prompt.length) window.clearInterval(id);
    }, 22);
    return () => window.clearInterval(id);
  }, [cur, seen]);
  const done = typed.length >= (cur?.prompt.length ?? 0);
  return (
    <Shell t={t} name={name} title={title} description={description} step={step} done={next} className={className} wide footer={<Footer t={t} onSkip={onSkip} onBack={onBack}><Next t={t} done={next} step={step} onClick={() => (onNext?.(), setNext(true))} /></Footer>}>
      <div ref={ref} className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] md:gap-6">
        <div ref={row} role="tablist" aria-orientation="vertical" aria-label={title} className={cn('kit-no-scrollbar -mx-5 flex gap-2 overflow-x-auto px-5 md:mx-0 md:flex-col md:overflow-visible md:px-0 md:[mask-image:none]', fade)}>
          {capabilities.map((c, i) => {
            const on = i === sel;
            return (
              <button
                key={c.id}
                ref={(el) => {
                  refs.current[i] = el;
                }}
                type="button"
                role="tab"
                aria-selected={on}
                tabIndex={on ? 0 : -1}
                onClick={() => setSel(i)}
                onKeyDown={(e) => {
                  const d = ['ArrowDown', 'ArrowRight'].includes(e.key) ? 1 : ['ArrowUp', 'ArrowLeft'].includes(e.key) ? -1 : 0;
                  if (!d) return;
                  e.preventDefault();
                  const n = (i + d + capabilities.length) % capabilities.length;
                  setSel(n);
                  refs.current[n]?.focus();
                }}
                className={cn('flex w-[15rem] shrink-0 items-start gap-3 rounded-xl border p-3 text-left transition-[border-color,background-color] duration-300 md:w-full', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border hover:bg-accent/60')}
              >
                <span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg transition-colors', on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>
                  <Icon name={c.icon ?? 'sparkle'} size={17} />
                </span>
                <span className="min-w-0">
                  <span className="block text-[14px] font-medium text-foreground">{c.title}</span>
                  <span className="mt-0.5 block text-[12.5px] leading-snug text-muted-foreground">{c.description}</span>
                </span>
              </button>
            );
          })}
        </div>
        {cur && (
          <div role="tabpanel" className="rounded-2xl border border-border bg-background/60 p-4 sm:p-5" aria-live="polite">
            <p className="text-[12px] font-medium text-subtle-foreground">{t.request}</p>
            <p className="mt-1.5 min-h-[3.25rem] rounded-xl bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] px-3.5 py-2.5 text-[14.5px] leading-relaxed text-foreground">
              {typed}
              {!done && <span aria-hidden className="ml-0.5 inline-block h-[1em] w-0.5 translate-y-[0.15em] animate-kit-pulse bg-primary" />}
            </p>
            <p className="mt-4 text-[12px] font-medium text-subtle-foreground">{t.result}</p>
            <div className="relative mt-1.5">
              <p aria-hidden className="invisible whitespace-pre-line text-[14.5px] leading-relaxed">{cur.result}</p>
              <p key={`${sel}-${done}`} className={cn('absolute inset-0 whitespace-pre-line text-pretty text-[14.5px] leading-relaxed text-foreground', done ? 'aob-swap' : 'opacity-0')}>
                {cur.result}
              </p>
            </div>
          </div>
        )}
      </div>
    </Shell>
  );
}

/* ------------------------------------------------------------------ */
/* first-task                                                          */
/* ------------------------------------------------------------------ */

function FirstTask({ t, name, title, description, tasks = [], step, onBack, onSkip, onStart, className }: P) {
  const [pick, setPick] = useState<string | null>(null);
  const [own, setOwn] = useState('');
  const [go, setGo] = useState(false);
  const chosen = tasks.find((x) => x.id === pick);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  return (
    <Shell
      t={t}
      name={name}
      title={title}
      description={description}
      step={step}
      done={go}
      className={className}
      footer={
        <Footer t={t} onBack={onBack} onSkip={onSkip}>
          {go ? (
            <span role="status" className="aob-up inline-flex items-center gap-2 text-[14px] font-medium text-success-foreground">
              <Icon name="check-circle" size={16} />
              {t.started}
            </span>
          ) : (
            <button type="button" disabled={!pick && !own.trim()} onClick={() => (onStart?.(chosen?.label ?? own.trim()), setGo(true))} className={primaryBtn}>
              {t.start}
              <Icon name="arrow-right" size={15} />
            </button>
          )}
        </Footer>
      }
    >
      <Stagger role="radiogroup" aria-label={title} step={70} className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
        {tasks.map((x, i) => {
          const on = pick === x.id;
          return (
            <button
              key={x.id}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={on}
              tabIndex={on || (!pick && i === 0) ? 0 : -1}
              onClick={() => (setPick(x.id), setOwn(''), setGo(false))}
              onKeyDown={(e) => {
                const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                if (!d) return;
                e.preventDefault();
                const n = (i + d + tasks.length) % tasks.length;
                setPick(tasks[n].id);
                setOwn('');
                setGo(false);
                refs.current[n]?.focus();
              }}
              className={cn('group flex h-full items-start gap-3 rounded-xl border p-3.5 text-left transition-[border-color,background-color,transform] duration-300 ease-[var(--ease-kit)]', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0')}>
              <span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg transition-colors', on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>
                <Icon name={on ? 'check' : x.icon ?? 'sparkle'} size={17} className={on ? 'aob-check' : undefined} />
              </span>
              <span className="min-w-0 flex-1">
                <span className="block text-[14px] font-medium leading-snug text-foreground">{x.label}</span>
                <span className="mt-1 flex flex-wrap gap-x-3 text-[12px] text-muted-foreground">
                  {x.time && (
                    <span className="inline-flex items-center gap-1">
                      <Icon name="clock" size={12} />
                      {x.time}
                    </span>
                  )}
                  {x.needs && (
                    <span>{`${t.needs} ${x.needs}`}</span>
                  )}
                </span>
              </span>
            </button>
          );
        })}
      </Stagger>
      <label className="mt-4 block">
        <span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.custom}</span>
        <input value={own} onChange={(e) => (setOwn(e.target.value), setPick(null), setGo(false))} placeholder={t.customPlaceholder} className="h-11 w-full rounded-xl border border-border bg-background px-3.5 text-[14.5px] text-foreground outline-none placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] focus-visible:outline-none" />
      </label>
      {chosen && <span className="sr-only" aria-live="polite">{chosen.label}</span>}
    </Shell>
  );
}

/* ------------------------------------------------------------------ */
/* connect-sources                                                     */
/* ------------------------------------------------------------------ */

function ConnectSources({ t, name, title, description, sources = [], step, onNext, onBack, onSkip, className }: P) {
  const [on, setOn] = useState<Record<string, boolean>>(() => Object.fromEntries(sources.map((s) => [s.id, !!s.connected])));
  const [busy, setBusy] = useState<string | null>(null);
  const [next, setNext] = useState(false);
  const n = sources.filter((s) => on[s.id]).length;
  const connect = (id: string) => {
    setBusy(id);
    window.setTimeout(() => (setOn((o) => ({ ...o, [id]: true })), setBusy(null)), prefersReducedMotion() ? 0 : 900);
  };
  return (
    <Shell
      t={t}
      name={name}
      title={title}
      description={description}
      step={step}
      done={next}
      className={className}
      footer={
        <Footer t={t} onBack={onBack} onSkip={onSkip}>
          <Next t={t} done={next} step={step} onClick={() => (onNext?.(), setNext(true))} />
        </Footer>
      }
    >
      <Stagger as="ul" step={70} className="space-y-2.5">
        {sources.map((s) => {
          const c = on[s.id];
          return (
            <li key={s.id} className={cn('flex flex-col gap-3 rounded-xl border p-3.5 transition-[border-color,background-color] duration-500 sm:flex-row sm:items-center', c ? 'border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_5%,var(--card))]' : 'border-border')}>
              <div className="flex min-w-0 flex-1 items-start gap-3">
                <span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-xl border border-border bg-background text-foreground">
                  <Icon name={s.icon ?? 'plug'} size={19} />
                </span>
                <div className="min-w-0">
                  <p className="text-[14.5px] font-medium text-foreground">{s.name}</p>
                  <p className="text-[13px] leading-snug text-muted-foreground">{s.description}</p>
                  <p className="mt-1 flex items-start gap-1 text-[12px] text-subtle-foreground">
                    <Icon name="eye" size={12} className="mt-0.5 shrink-0" />
                    {`${t.reads} ${s.reads}`}
                  </p>
                </div>
              </div>
              {c ? (
                <span className="inline-flex h-11 shrink-0 items-center gap-1.5 self-start px-1 text-[13.5px] font-medium text-success-foreground sm:h-9 sm:self-auto">
                  <span className="aob-check grid size-5 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_18%,var(--card))] text-success-foreground">
                    <Icon name="check" size={12} strokeWidth={2.6} />
                  </span>
                  {t.connected}
                </span>
              ) : (
                <button type="button" disabled={busy === s.id} onClick={() => connect(s.id)} className="inline-flex h-11 shrink-0 items-center gap-1.5 self-start rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground hover:bg-accent disabled:opacity-70 sm:h-9 sm:self-auto">
                  <Icon name={busy === s.id ? 'loader' : 'plug'} size={15} className={busy === s.id ? 'animate-spin' : undefined} />
                  {t.connect}
                </button>
              )}
            </li>
          );
        })}
      </Stagger>
      <div className="mt-4 flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
        <p className="flex items-start gap-2 text-[12.5px] text-muted-foreground">
          <Icon name="info" size={14} className="mt-px shrink-0" />
          {t.later}
        </p>
        <p className="shrink-0 text-[12.5px] font-medium tabular-nums text-foreground" aria-live="polite">
          {fill(t.count, { n, total: sources.length })}
        </p>
      </div>
    </Shell>
  );
}

/* ------------------------------------------------------------------ */
/* limits-promise                                                      */
/* ------------------------------------------------------------------ */

function LimitsPromise({ t, name, title, description, can = [], cannot = [], promises = [], step, onNext, onBack, onSkip, className }: P) {
  const [ok, setOk] = useState(false);
  return (
    <Shell
      t={t}
      name={name}
      title={title}
      description={description}
      step={step}
      done={ok}
      className={className}
      wide
      footer={
        <Footer t={t} onBack={onBack} onSkip={onSkip}>
          <Next t={t} done={ok} step={step} label={t.agree} onClick={() => (onNext?.(), setOk(true))} />
        </Footer>
      }
    >
      <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
        {[
          { label: t.can, items: can, icon: 'check' as IconName, tone: 'success' },
          { label: t.cannot, items: cannot, icon: 'minus' as IconName, tone: 'muted' },
        ].map((col, ci) => (
          <Reveal key={col.label} delay={ci * 120} className="rounded-2xl border border-border bg-background/60 p-4 sm:p-5">
            <p className="text-[13px] font-semibold text-foreground">{col.label}</p>
            <ul className="mt-3 space-y-2.5">
              {col.items.map((x, i) => (
                <li key={x} className="aob-up flex items-start gap-2.5 text-[14px] leading-snug text-foreground" style={{ '--aob-d': `${200 + ci * 120 + i * 70}ms` } as React.CSSProperties}>
                  <span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-full', col.tone === 'success' ? 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>
                    <Icon name={col.icon} size={12} strokeWidth={2.4} />
                  </span>
                  {x}
                </li>
              ))}
            </ul>
          </Reveal>
        ))}
      </div>
      {promises.length > 0 && (
        <div className="mt-5">
          <p className="mb-3 text-[13px] font-semibold text-foreground">{t.promise}</p>
          <Stagger step={90} className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-3">
            {promises.map((p) => (
              <div key={p.title} className="bg-card p-4">
                <Icon name={p.icon ?? 'shield'} size={18} className="text-primary" />
                <p className="mt-2.5 text-[14px] font-medium text-foreground">{p.title}</p>
                <p className="mt-1 text-pretty text-[13px] leading-relaxed text-muted-foreground">{p.description}</p>
              </div>
            ))}
          </Stagger>
        </div>
      )}
    </Shell>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Onboarding for an AI assistant: what it can do with an example that types
 * itself, a first task to start with, the data sources to connect with what
 * each one reads, and an honest page on limits and promises.
 */
export function AiOnboarding({ variant = 'capabilities', labels, ...props }: AiOnboardingProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const key = JSON.stringify([props.capabilities, props.tasks, props.sources]);
  const body = (() => {
    switch (variant) {
      case 'first-task':
        return <FirstTask key={key} {...p} />;
      case 'connect-sources':
        return <ConnectSources key={key} {...p} />;
      case 'limits-promise':
        return <LimitsPromise {...p} />;
      default:
        return <Capabilities key={key} {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich KI-Einstieg?

Mit aiw add @ai-website/ai-onboarding. Der Befehl legt die Datei components/ui/ai-onboarding.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat KI-Einstieg?

4 Varianten, Was er kann, Erste Aufgabe, Quellen verbinden und Grenzen und Versprechen. Du wählst sie mit der Prop variant, zum Beispiel variant="first-task".

Kann ich die Texte von KI-Einstieg übersetzen?

Ja. KI-Einstieg bringt 21 eigene Texte auf Deutsch und Englisch mit, etwa „Weiter“, „Zurück“ und „Später“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent KI-Einstieg einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Einstieg mit aiw_components und installieren sie mit aiw_add @ai-website/ai-onboarding. Nenn im Prompt die Variante, etwa variant="capabilities", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich KI-Einstieg ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden AiOnboarding-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert