Zum Inhalt springen
UI Kit
Accent

Feedback-Widget

Feedback von jeder Seite. Ein Reiter am Rand, ein runder Knopf mit schnellen Reaktionen, die genaue Stelle auf der Seite markieren und ein vollständiger Fehlerbericht mit ausgefülltem Gerät. Schwebende sitzen über dem Fenster oder mit contained in einem Rahmen.

Eine von 3 Components in Umfragen und Feedback

4 Variants

Variants

Reiter am Rand

Ein Reiter am rechten Rand gleitet weg und ein Panel gleitet herein, auf dem Telefon ein Sheet von unten. Die Art des Feedbacks in einer Leiste mit gleitender Kachel, das Textfeld fragt passend zur Art.

Im Studio öffnen

Knopf mit Reaktionen

Ein runder Knopf, dessen Symbol sich in ein Schließen dreht. Ein Tipp auf eine Reaktion ist schon Feedback, darunter öffnet sich ein Kommentarfeld für alle, die mehr sagen wollen.

Im Studio öffnen

Stelle markieren

Besucher markieren die genaue Stelle mit einem Fadenkreuz, per Zeiger oder mit den Pfeiltasten, dort pulsiert eine nummerierte Nadel. Das Panel zeigt die Stelle in einem kleinen Umriss der Ansicht und fragt, was dort nicht stimmt.

Im Studio öffnen

Fehlerbericht

Ein vollständiger Bericht direkt auf der Seite. Schritte zum Nachstellen als nummerierte Liste, die wächst, erwartet und tatsächlich nebeneinander, und das Gerät aus dem Browser gelesen in einer Seitenspalte, damit man sieht, was mitgeht.

Im Studio öffnen

Details

Was Feedback-Widget mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/feedback-widget.tsx
Bringt mit
utilskitbuttoniconinputtextarearevealsectionform-flow
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 47 eigene Texte

Props

Die Schnittstelle FeedbackWidgetProps aus components/ui/feedback-widget.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FeedbackWidgetVariant

    side-tabbubblescreenshotbug-report

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • categoriesoptional
    FeedbackCategory[]

    Kinds of feedback (side-tab, bubble reactions, bug-report severity)

  • askEmailoptional
    boolean

    Ask for an email address to reply to

  • containedoptional
    boolean

    Sit inside the nearest positioned parent instead of over the window

  • pageoptional
    React.ReactNode

    contained: the page drawn behind the widget, e.g. a screenshot of your site

  • defaultOpenoptional
    boolean

    Open on first render, for previews

  • onSubmitoptional
    (data: FeedbackData) => void

Eingebaute Texte

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

Alle 47 Texte anzeigen
feedbackEnglisch FeedbackDeutsch Feedback
openFeedbackEnglisch Give feedbackDeutsch Feedback geben
closeEnglisch CloseDeutsch Schließen
panelTitleEnglisch Tell us what you thinkDeutsch Sag uns, was du denkst
panelTextEnglisch It goes straight to the team that builds this page.Deutsch Es geht direkt an das Team, das diese Seite baut.
kindEnglisch What is it about?Deutsch Worum geht es?
messageEnglisch Your messageDeutsch Deine Nachricht
messageNeededEnglisch Please write a few words so we know what to look at.Deutsch Bitte schreib ein paar Worte, damit wir wissen, wo wir hinschauen sollen.
emailEnglisch Email for a replyDeutsch E-Mail für eine Antwort
sendEnglisch Send feedbackDeutsch Feedback senden
sentTitleEnglisch Thank you, it has arrivedDeutsch Danke, es ist angekommen
sentTextEnglisch We read every message. If you left an address, we reply within two working days.Deutsch Wir lesen jede Nachricht. Mit Adresse antworten wir innerhalb von zwei Werktagen.
anotherEnglisch Send more feedbackDeutsch Weiteres Feedback senden
reactionEnglisch How is this page?Deutsch Wie ist diese Seite?
addCommentEnglisch Want to say more?Deutsch Möchtest du mehr sagen?
markEnglisch Mark the spotDeutsch Stelle markieren
markingEnglisch Click where something is off. Arrow keys move the crosshair, Enter places it, Escape cancels.Deutsch Klicke dorthin, wo etwas nicht stimmt. Pfeiltasten bewegen das Fadenkreuz, Enter setzt es, Escape bricht ab.
spotSetEnglisch Spot markedDeutsch Stelle markiert
remarkEnglisch Mark againDeutsch Neu markieren
spotTextEnglisch {x} % across, {y} % downDeutsch {x} % von links, {y} % von oben
attachEnglisch A screenshot of this view is attached when you sendDeutsch Beim Senden hängt ein Bildschirmfoto dieser Ansicht an
whatHereEnglisch What is wrong at this spot?Deutsch Was stimmt an dieser Stelle nicht?
spotNeededEnglisch Please mark the spot first, so we see what you mean.Deutsch Bitte markiere zuerst die Stelle, damit wir sehen, was du meinst.
reportTitleEnglisch Short titleDeutsch Kurzer Titel
reportTitleHintEnglisch Empty page after invoice downloadDeutsch Leere Seite nach Rechnungsdownload
titleNeededEnglisch Please give the problem a short title, it helps us sort it.Deutsch Bitte gib dem Problem einen kurzen Titel, das hilft uns beim Einordnen.
severityEnglisch How much does it affect you?Deutsch Wie sehr betrifft es dich?
severityNeededEnglisch Please choose how much it affects you, so we can sort it right.Deutsch Bitte wähle, wie sehr es dich betrifft, damit wir es richtig einordnen.
stepsEnglisch Steps to reproduceDeutsch Schritte zum Nachstellen
stepEnglisch Step {n}Deutsch Schritt {n}
addStepEnglisch Add a stepDeutsch Schritt hinzufügen
removeStepEnglisch Remove step {n}Deutsch Schritt {n} entfernen
stepsNeededEnglisch Please describe at least the first step, so we can see it for ourselves.Deutsch Bitte beschreib mindestens den ersten Schritt, damit wir es selbst sehen können.
expectedEnglisch What did you expect?Deutsch Was hast du erwartet?
actualEnglisch What happened instead?Deutsch Was ist stattdessen passiert?
actualNeededEnglisch Please tell us what happened, it is the part we need most.Deutsch Bitte schreib, was passiert ist, das brauchen wir am meisten.
deviceEnglisch Filled in for youDeutsch Für dich ausgefüllt
deviceHintEnglisch Sent with the report so you do not have to look it up.Deutsch Wird mitgeschickt, damit du nichts nachschlagen musst.
browserEnglisch BrowserDeutsch Browser
systemEnglisch SystemDeutsch System
screenEnglisch WindowDeutsch Fenster
pageLabelEnglisch PageDeutsch Seite
detectingEnglisch Read on sendDeutsch Wird beim Senden gelesen
sendReportEnglisch Send reportDeutsch Bericht senden
reportSentEnglisch Report {id} has arrivedDeutsch Bericht {id} ist angekommen
reportSentTextEnglisch You get an email with every change of status. Most reports are looked at the same day.Deutsch Bei jeder Statusänderung bekommst du eine E-Mail. Die meisten Berichte sehen wir uns am selben Tag an.
newReportEnglisch Report another problemDeutsch Weiteres Problem melden

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Feedback-Widget reagiert selbst auf die Tasten Escape, Enter und Leertaste.
  • Setzt aria-controls, aria-describedby, aria-expanded, aria-label, aria-labelledby und aria-modal, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, application, dialog, radiogroup und status.
  • 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. 7 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 859 Zeilen
components/ui/feedback-widget.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { ChipGroup, DoneMark, ErrorSummary, FlowHead, Unfold, useFocusFirstError, type FlowError } from '@/components/ui/form-flow';

/*
 * Feedback from any page. Three floating ones and one inline report:
 *
 *   side-tab    a tab on the right edge opens a panel, a sheet on phones
 *   bubble      a round button, one tap on a reaction, then an optional comment
 *   screenshot  mark the spot on the page (pointer or arrow keys), then describe it
 *   bug-report  a full report inline: steps, expected and actual, the device filled in
 *
 * The floating ones sit `fixed` over the window. With `contained` they sit
 * `absolute` in the nearest positioned parent, for previews (the demos draw a
 * page in a frame). Escape closes, focus returns to the button that opened.
 * Sends { category, message, email, spot, device } to `onSubmit`.
 */

export type FeedbackWidgetVariant = 'side-tab' | 'bubble' | 'screenshot' | 'bug-report';

export interface FeedbackCategory {
  value: string;
  label: string;
  icon?: IconName;
  /** The question the text field asks for this category */
  prompt?: string;
}

export interface FeedbackData {
  category?: string;
  message: string;
  email?: string;
  /** screenshot: the marked spot in percent of the page area */
  spot?: { x: number; y: number };
  /** bug-report */
  title?: string;
  steps?: string[];
  expected?: string;
  actual?: string;
  device?: Record<string, string>;
}

export interface FeedbackWidgetProps {
  variant?: FeedbackWidgetVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Kinds of feedback (side-tab, bubble reactions, bug-report severity) */
  categories?: FeedbackCategory[];
  /** Ask for an email address to reply to */
  askEmail?: boolean;
  /** Sit inside the nearest positioned parent instead of over the window */
  contained?: boolean;
  /** contained: the page drawn behind the widget, e.g. a screenshot of your site */
  page?: React.ReactNode;
  /** Open on first render, for previews */
  defaultOpen?: boolean;
  onSubmit?: (data: FeedbackData) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    feedback: 'Feedback',
    openFeedback: 'Give feedback',
    close: 'Close',
    panelTitle: 'Tell us what you think',
    panelText: 'It goes straight to the team that builds this page.',
    kind: 'What is it about?',
    message: 'Your message',
    messageNeeded: 'Please write a few words so we know what to look at.',
    email: 'Email for a reply',
    send: 'Send feedback',
    sentTitle: 'Thank you, it has arrived',
    sentText: 'We read every message. If you left an address, we reply within two working days.',
    another: 'Send more feedback',
    reaction: 'How is this page?',
    addComment: 'Want to say more?',
    mark: 'Mark the spot',
    marking: 'Click where something is off. Arrow keys move the crosshair, Enter places it, Escape cancels.',
    spotSet: 'Spot marked',
    remark: 'Mark again',
    spotText: '{x} % across, {y} % down',
    attach: 'A screenshot of this view is attached when you send',
    whatHere: 'What is wrong at this spot?',
    spotNeeded: 'Please mark the spot first, so we see what you mean.',
    reportTitle: 'Short title',
    reportTitleHint: 'Empty page after invoice download',
    titleNeeded: 'Please give the problem a short title, it helps us sort it.',
    severity: 'How much does it affect you?',
    severityNeeded: 'Please choose how much it affects you, so we can sort it right.',
    steps: 'Steps to reproduce',
    step: 'Step {n}',
    addStep: 'Add a step',
    removeStep: 'Remove step {n}',
    stepsNeeded: 'Please describe at least the first step, so we can see it for ourselves.',
    expected: 'What did you expect?',
    actual: 'What happened instead?',
    actualNeeded: 'Please tell us what happened, it is the part we need most.',
    device: 'Filled in for you',
    deviceHint: 'Sent with the report so you do not have to look it up.',
    browser: 'Browser',
    system: 'System',
    screen: 'Window',
    pageLabel: 'Page',
    detecting: 'Read on send',
    sendReport: 'Send report',
    reportSent: 'Report {id} has arrived',
    reportSentText: 'You get an email with every change of status. Most reports are looked at the same day.',
    newReport: 'Report another problem',
  },
  de: {
    feedback: 'Feedback',
    openFeedback: 'Feedback geben',
    close: 'Schließen',
    panelTitle: 'Sag uns, was du denkst',
    panelText: 'Es geht direkt an das Team, das diese Seite baut.',
    kind: 'Worum geht es?',
    message: 'Deine Nachricht',
    messageNeeded: 'Bitte schreib ein paar Worte, damit wir wissen, wo wir hinschauen sollen.',
    email: 'E-Mail für eine Antwort',
    send: 'Feedback senden',
    sentTitle: 'Danke, es ist angekommen',
    sentText: 'Wir lesen jede Nachricht. Mit Adresse antworten wir innerhalb von zwei Werktagen.',
    another: 'Weiteres Feedback senden',
    reaction: 'Wie ist diese Seite?',
    addComment: 'Möchtest du mehr sagen?',
    mark: 'Stelle markieren',
    marking: 'Klicke dorthin, wo etwas nicht stimmt. Pfeiltasten bewegen das Fadenkreuz, Enter setzt es, Escape bricht ab.',
    spotSet: 'Stelle markiert',
    remark: 'Neu markieren',
    spotText: '{x} % von links, {y} % von oben',
    attach: 'Beim Senden hängt ein Bildschirmfoto dieser Ansicht an',
    whatHere: 'Was stimmt an dieser Stelle nicht?',
    spotNeeded: 'Bitte markiere zuerst die Stelle, damit wir sehen, was du meinst.',
    reportTitle: 'Kurzer Titel',
    reportTitleHint: 'Leere Seite nach Rechnungsdownload',
    titleNeeded: 'Bitte gib dem Problem einen kurzen Titel, das hilft uns beim Einordnen.',
    severity: 'Wie sehr betrifft es dich?',
    severityNeeded: 'Bitte wähle, wie sehr es dich betrifft, damit wir es richtig einordnen.',
    steps: 'Schritte zum Nachstellen',
    step: 'Schritt {n}',
    addStep: 'Schritt hinzufügen',
    removeStep: 'Schritt {n} entfernen',
    stepsNeeded: 'Bitte beschreib mindestens den ersten Schritt, damit wir es selbst sehen können.',
    expected: 'Was hast du erwartet?',
    actual: 'Was ist stattdessen passiert?',
    actualNeeded: 'Bitte schreib, was passiert ist, das brauchen wir am meisten.',
    device: 'Für dich ausgefüllt',
    deviceHint: 'Wird mitgeschickt, damit du nichts nachschlagen musst.',
    browser: 'Browser',
    system: 'System',
    screen: 'Fenster',
    pageLabel: 'Seite',
    detecting: 'Wird beim Senden gelesen',
    sendReport: 'Bericht senden',
    reportSent: 'Bericht {id} ist angekommen',
    reportSentText: 'Bei jeder Statusänderung bekommst du eine E-Mail. Die meisten Berichte sehen wir uns am selben Tag an.',
    newReport: 'Weiteres Problem melden',
  },
};

type Texts = (typeof TEXTS)['en'];

const DEFAULT_CATEGORIES: Record<'en' | 'de', FeedbackCategory[]> = {
  en: [
    { value: 'idea', label: 'Idea', icon: 'lightbulb', prompt: 'What would make this better?' },
    { value: 'problem', label: 'Problem', icon: 'alert-circle', prompt: 'What went wrong?' },
    { value: 'praise', label: 'Praise', icon: 'heart', prompt: 'What do you like?' },
  ],
  de: [
    { value: 'idea', label: 'Idee', icon: 'lightbulb', prompt: 'Was würde es besser machen?' },
    { value: 'problem', label: 'Problem', icon: 'alert-circle', prompt: 'Was ist schiefgelaufen?' },
    { value: 'praise', label: 'Lob', icon: 'heart', prompt: 'Was gefällt dir?' },
  ],
};

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

/** Escape closes, focus moves into the panel on open and back to the opener on close */
function usePanel(defaultOpen?: boolean) {
  const [open, setOpen] = useState(Boolean(defaultOpen));
  const opener = useRef<HTMLButtonElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const userOpened = useRef(false);
  useEffect(() => {
    if (!open) return;
    if (userOpened.current) panel.current?.querySelector<HTMLElement>('[data-autofocus]')?.focus({ preventScroll: true });
    const key = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        setOpen(false);
        opener.current?.focus();
      }
    };
    document.addEventListener('keydown', key);
    return () => document.removeEventListener('keydown', key);
  }, [open]);
  const toggle = () => {
    userOpened.current = true;
    setOpen((o) => !o);
  };
  const close = () => {
    setOpen(false);
    opener.current?.focus();
  };
  return { open, toggle, close, opener, panel };
}

function Sent({ t, onAgain, title, text }: { t: Texts; onAgain: () => void; title?: string; text?: string }) {
  const ref = useRef<HTMLParagraphElement>(null);
  useEffect(() => ref.current?.focus({ preventScroll: true }), []);
  return (
    <div role="status" className="kit-rise flex flex-col items-center px-2 py-6 text-center">
      <DoneMark size={52} />
      <p ref={ref} tabIndex={-1} className="mt-4 text-[17px] font-semibold text-foreground outline-none">
        {title ?? t.sentTitle}
      </p>
      <p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{text ?? t.sentText}</p>
      <Button variant="ghost" size="sm" iconLeft="plus" onClick={onAgain} className="mt-3">
        {t.another}
      </Button>
    </div>
  );
}

function CloseButton({ t, onClick }: { t: Texts; onClick: () => void }) {
  return (
    <button type="button" onClick={onClick} aria-label={t.close} className="-me-2 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
      <Icon name="x" size={18} />
    </button>
  );
}

/** Message form with category, text and optional email, used by side-tab */
function MessageForm({ p, t, cats, onDone }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[]; onDone: () => void }) {
  const id = useId();
  const [cat, setCat] = useState(cats[0]?.value ?? '');
  const [message, setMessage] = useState('');
  const [email, setEmail] = useState('');
  const [error, setError] = useState(false);
  const [attempt, setAttempt] = useState(0);
  const ref = useRef<HTMLFormElement>(null);
  useFocusFirstError(ref, attempt);
  const prompt = cats.find((c) => c.value === cat)?.prompt ?? t.message;
  return (
    <form
      ref={ref}
      noValidate
      className="grid gap-5"
      onSubmit={(e) => {
        e.preventDefault();
        if (!message.trim()) {
          setError(true);
          setAttempt((a) => a + 1);
          return;
        }
        p.onSubmit?.({ category: cat, message, email: email || undefined });
        onDone();
      }}
    >
      {/* Category as a segmented row with a sliding tile */}
      <fieldset className="min-w-0">
        <legend className="text-[13px] font-medium text-foreground">{t.kind}</legend>
        <div className="relative mt-2 grid rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1" style={{ gridTemplateColumns: `repeat(${cats.length}, minmax(0, 1fr))` }}>
          <span
            aria-hidden
            className="absolute inset-y-1 start-1 rounded-md bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none"
            style={{ width: `calc(${round(100 / cats.length, 4)}% - ${round(8 / cats.length, 2)}px)`, translate: `calc(${Math.max(0, cats.findIndex((c) => c.value === cat))} * 100%)` }}
          />
          {cats.map((c, i) => (
            <label key={c.value} htmlFor={`${id}-c${i}`} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center gap-1.5 rounded-md text-[13.5px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
              <input id={`${id}-c${i}`} data-autofocus={i === 0 || undefined} type="radio" name={`${id}-cat`} value={c.value} checked={cat === c.value} onChange={() => setCat(c.value)} className="sr-only" />
              {c.icon && <Icon name={c.icon} size={15} />}
              {c.label}
            </label>
          ))}
        </div>
      </fieldset>
      <Textarea id={`${id}-m`} label={prompt} value={message} onChange={(e) => (setMessage(e.target.value), setError(false))} error={error ? t.messageNeeded : undefined} minRows={4} maxLength={1000} required />
      {p.askEmail !== false && <Input id={`${id}-e`} type="email" label={t.email} optional autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />}
      <Button type="submit" icon="send" className="w-full">
        {t.send}
      </Button>
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* side-tab                                                            */
/* ------------------------------------------------------------------ */

function SideTab({ p, t, cats, pos }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[]; pos: string }) {
  const s = usePanel(p.defaultOpen);
  const [sent, setSent] = useState(false);
  const hid = useId();
  return (
    <>
      <button
        ref={s.opener}
        type="button"
        aria-expanded={s.open}
        aria-controls={hid}
        onClick={s.toggle}
        // Slides out of the frame on purpose while the panel is open (hidden and inert then)
        data-clip-ok
        className={cn(
          pos,
          'right-0 top-1/2 z-30 flex -translate-y-1/2 items-center gap-2 rounded-s-lg border border-e-0 border-border bg-card px-2.5 py-4 text-[13px] font-medium text-foreground shadow-[var(--shadow-md)] transition-[transform,opacity,background-color] duration-500 ease-[var(--ease-kit)] [writing-mode:vertical-rl] hover:bg-surface focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none motion-reduce:transition-none',
          s.open && 'pointer-events-none translate-x-full opacity-0',
        )}
      >
        <Icon name="message-circle" size={16} className="rotate-90" />
        {t.feedback}
      </button>
      <div
        ref={s.panel}
        id={hid}
        role="dialog"
        aria-modal="false"
        aria-labelledby={`${hid}-t`}
        className={cn(
          pos,
          'z-40 flex flex-col bg-card shadow-[var(--shadow-lg)] transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
          // Phone: a sheet from the bottom. Tablet and up: a panel from the right edge.
          'inset-x-0 bottom-0 max-h-[88%] rounded-t-2xl border-t border-border sm:inset-x-auto sm:inset-y-0 sm:end-0 sm:max-h-none sm:w-[23rem] sm:rounded-none sm:border-s sm:border-t-0',
          s.open ? 'translate-y-0 opacity-100 sm:translate-x-0' : 'pointer-events-none translate-y-full opacity-0 sm:translate-x-full sm:translate-y-0',
        )}
        inert={!s.open || undefined}
      >
        <span aria-hidden className="mx-auto mt-2.5 h-1 w-10 shrink-0 rounded-full bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)] sm:hidden" />
        <div className="flex items-start gap-3 border-b border-border px-5 pb-4 pt-3 sm:pt-5">
          <div className="min-w-0 flex-1">
            <h3 id={`${hid}-t`} className="text-[17px] font-semibold text-foreground">
              {t.panelTitle}
            </h3>
            <p className="mt-1 text-[13px] leading-5 text-muted-foreground">{t.panelText}</p>
          </div>
          <CloseButton t={t} onClick={s.close} />
        </div>
        <div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-5 py-5">
          {sent ? <Sent t={t} onAgain={() => setSent(false)} /> : <MessageForm p={p} t={t} cats={cats} onDone={() => setSent(true)} />}
        </div>
      </div>
    </>
  );
}

/* ------------------------------------------------------------------ */
/* bubble                                                              */
/* ------------------------------------------------------------------ */

function Bubble({ p, t, cats, pos }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[]; pos: string }) {
  const s = usePanel(p.defaultOpen);
  const id = useId();
  const [reaction, setReaction] = useState<string | null>(null);
  const [message, setMessage] = useState('');
  const [sent, setSent] = useState(false);
  const prompt = cats.find((c) => c.value === reaction)?.prompt ?? t.addComment;
  return (
    <div className={cn(pos, 'pointer-events-none inset-x-4 bottom-4 z-40 flex flex-col items-end gap-3 sm:inset-x-auto sm:bottom-6 sm:end-6')}>
      <div
        ref={s.panel}
        id={id}
        role="dialog"
        aria-modal="false"
        aria-labelledby={`${id}-t`}
        className={cn(
          'w-full sm:w-[21rem] origin-bottom-right rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-lg)] transition-[transform,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
          s.open ? 'pointer-events-auto scale-100 opacity-100' : 'pointer-events-none translate-y-2 scale-95 opacity-0',
        )}
        inert={!s.open || undefined}
      >
        {sent ? (
          <Sent
            t={t}
            onAgain={() => {
              setSent(false);
              setReaction(null);
              setMessage('');
            }}
          />
        ) : (
          <form
            noValidate
            onSubmit={(e) => {
              e.preventDefault();
              p.onSubmit?.({ category: reaction ?? undefined, message });
              setSent(true);
            }}
          >
            <div className="flex items-center justify-between gap-3">
              <p id={`${id}-t`} className="text-[15px] font-semibold text-foreground">
                {t.reaction}
              </p>
              <CloseButton t={t} onClick={s.close} />
            </div>
            <div role="radiogroup" aria-labelledby={`${id}-t`} className="mt-2 grid grid-cols-4 gap-1.5">
              {cats.map((c, i) => {
                const on = reaction === c.value;
                return (
                  <label key={c.value} htmlFor={`${id}-r${i}`} className={cn('group/r flex cursor-pointer flex-col items-center gap-1.5 rounded-xl border px-1 py-3 text-center transition-[border-color,background-color,box-shadow] duration-200 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]' : 'border-border hover:border-border-strong')}>
                    <input id={`${id}-r${i}`} data-autofocus={i === 0 || undefined} type="radio" name={`${id}-reaction`} value={c.value} checked={on} onChange={() => setReaction(c.value)} className="sr-only" />
                    <span className={cn('grid size-9 place-items-center rounded-full transition-[transform,background-color,color] duration-500 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-110 bg-primary text-primary-foreground' : 'bg-surface-2 text-muted-foreground group-hover/r:-translate-y-0.5')}>
                      <Icon name={c.icon ?? 'smile'} size={18} />
                    </span>
                    <span className={cn('text-[11.5px] leading-tight', on ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{c.label}</span>
                  </label>
                );
              })}
            </div>
            <Unfold open={reaction !== null}>
              <div className="pt-4">
                <Textarea label={prompt} optional value={message} onChange={(e) => setMessage(e.target.value)} minRows={3} maxLength={500} />
                <Button type="submit" icon="send" className="mt-4 w-full">
                  {t.send}
                </Button>
              </div>
            </Unfold>
          </form>
        )}
      </div>
      <button
        ref={s.opener}
        type="button"
        aria-expanded={s.open}
        aria-controls={id}
        aria-label={s.open ? t.close : t.openFeedback}
        onClick={s.toggle}
        className="group/b pointer-events-auto relative grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),var(--shadow-lg)] transition-transform duration-300 ease-[var(--ease-kit)] hover:scale-105 focus-visible:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_30%,transparent),var(--shadow-lg)] focus-visible:outline-none active:scale-95 motion-reduce:hover:scale-100"
      >
        {/* The two icons turn into each other */}
        <Icon name="message-circle" size={22} className={cn('absolute transition-[transform,opacity] duration-300', s.open ? 'rotate-90 scale-50 opacity-0' : 'opacity-100')} />
        <Icon name="x" size={22} className={cn('absolute transition-[transform,opacity] duration-300', s.open ? 'opacity-100' : '-rotate-90 scale-50 opacity-0')} />
      </button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* screenshot                                                          */
/* ------------------------------------------------------------------ */

function Screenshot({ p, t, pos }: { p: FeedbackWidgetProps; t: Texts; pos: string }) {
  const s = usePanel(p.defaultOpen);
  const id = useId();
  const [marking, setMarking] = useState(false);
  const [cross, setCross] = useState({ x: 50, y: 40 });
  const [spot, setSpot] = useState<{ x: number; y: number } | null>(p.defaultOpen ? { x: 26, y: 46 } : null);
  const [message, setMessage] = useState('');
  const [error, setError] = useState<'spot' | 'message' | null>(null);
  const [sent, setSent] = useState(false);
  const layer = useRef<HTMLDivElement>(null);
  const markBtn = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (!marking) return;
    layer.current?.focus({ preventScroll: true });
  }, [marking]);

  const place = (x: number, y: number) => {
    setSpot({ x: round(x, 1), y: round(y, 1) });
    setMarking(false);
    setError(null);
    requestAnimationFrame(() => document.getElementById(`${id}-m`)?.focus({ preventScroll: true }));
  };
  const fromPointer = (e: React.PointerEvent<HTMLDivElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    return { x: Math.min(100, Math.max(0, ((e.clientX - r.left) / r.width) * 100)), y: Math.min(100, Math.max(0, ((e.clientY - r.top) / r.height) * 100)) };
  };
  return (
    <>
      {/* The marking layer covers the page area, crosshair follows pointer and arrow keys */}
      {marking && (
        <div
          ref={layer}
          tabIndex={0}
          role="application"
          aria-label={t.marking}
          className="absolute inset-0 z-30 cursor-crosshair bg-[color-mix(in_oklab,var(--background)_45%,transparent)] outline-none focus-visible:shadow-[inset_0_0_0_3px_color-mix(in_oklab,var(--primary)_45%,transparent)]"
          onPointerMove={(e) => setCross(fromPointer(e))}
          onPointerDown={(e) => {
            const c = fromPointer(e);
            place(c.x, c.y);
          }}
          onKeyDown={(e) => {
            const step = e.shiftKey ? 10 : 2;
            const move: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
            if (move[e.key]) {
              e.preventDefault();
              setCross((c) => ({ x: Math.min(100, Math.max(0, c.x + move[e.key][0])), y: Math.min(100, Math.max(0, c.y + move[e.key][1])) }));
            } else if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              place(cross.x, cross.y);
            } else if (e.key === 'Escape') {
              e.stopPropagation();
              setMarking(false);
              markBtn.current?.focus();
            }
          }}
        >
          <span aria-hidden className="pointer-events-none absolute inset-x-0 h-px bg-[color-mix(in_oklab,var(--primary)_70%,transparent)]" style={{ top: `${round(cross.y, 2)}%` }} />
          <span aria-hidden className="pointer-events-none absolute inset-y-0 w-px bg-[color-mix(in_oklab,var(--primary)_70%,transparent)]" style={{ left: `${round(cross.x, 2)}%` }} />
          <p className="pointer-events-none absolute inset-x-4 top-4 mx-auto max-w-[30rem] rounded-xl border border-border bg-card px-4 py-3 text-center text-[13.5px] leading-snug text-foreground shadow-[var(--shadow-lg)]">{t.marking}</p>
        </div>
      )}
      {/* The marked spot on the page, with a soft pulse */}
      {spot && !marking && (
        <span aria-hidden className="pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-1/2" style={{ left: `${spot.x}%`, top: `${spot.y}%` }}>
          <span className="absolute inset-0 rounded-full bg-primary opacity-40 motion-safe:animate-ping" />
          <span className="relative grid size-7 place-items-center rounded-full border-2 border-background bg-primary text-[12px] font-bold text-primary-foreground shadow-[var(--shadow-md)]">1</span>
        </span>
      )}
      <div className={cn(pos, 'pointer-events-none inset-x-4 bottom-4 z-40 flex flex-col items-end gap-3 sm:inset-x-auto sm:bottom-6 sm:end-6', marking && 'pointer-events-none opacity-0')}>
        <div
          ref={s.panel}
          id={id}
          role="dialog"
          aria-modal="false"
          aria-labelledby={`${id}-t`}
          className={cn('w-full sm:w-[22rem] origin-bottom-right rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-lg)] transition-[transform,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', s.open ? 'pointer-events-auto scale-100 opacity-100' : 'pointer-events-none translate-y-2 scale-95 opacity-0')}
          inert={!s.open || undefined}
        >
          {sent ? (
            <Sent
              t={t}
              onAgain={() => {
                setSent(false);
                setSpot(null);
                setMessage('');
              }}
            />
          ) : (
            <form
              noValidate
              onSubmit={(e) => {
                e.preventDefault();
                if (!spot) {
                  setError('spot');
                  markBtn.current?.focus();
                  return;
                }
                if (!message.trim()) {
                  setError('message');
                  document.getElementById(`${id}-m`)?.focus();
                  return;
                }
                p.onSubmit?.({ message, spot });
                setSent(true);
              }}
            >
              <div className="flex items-center justify-between gap-3">
                <p id={`${id}-t`} className="text-[15px] font-semibold text-foreground">
                  {t.panelTitle}
                </p>
                <CloseButton t={t} onClick={s.close} />
              </div>
              {/* A small outline of the view with the spot in it */}
              <div className="mt-3 flex items-center gap-3.5 rounded-xl border border-border bg-surface p-3">
                <span aria-hidden className="relative h-14 w-20 shrink-0 overflow-hidden rounded-md border border-border bg-background">
                  <span className="absolute inset-x-2 top-2 h-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]" />
                  <span className="absolute start-2 top-5 h-1.5 w-9 rounded-full bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]" />
                  <span className="absolute inset-x-2 bottom-2 grid h-4 grid-cols-3 gap-1">
                    <span className="rounded-sm bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
                    <span className="rounded-sm bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
                    <span className="rounded-sm bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
                  </span>
                  {spot && <span className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-background bg-primary" style={{ left: `${spot.x}%`, top: `${spot.y}%` }} />}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block text-[13.5px] font-medium text-foreground">{spot ? t.spotSet : t.mark}</span>
                  <span className="block text-[12.5px] leading-snug text-muted-foreground">{spot ? t.spotText.replace('{x}', String(Math.round(spot.x))).replace('{y}', String(Math.round(spot.y))) : t.attach}</span>
                </span>
              </div>
              <Button ref={markBtn} type="button" variant={spot ? 'ghost' : 'outline'} size="sm" iconLeft="locate" onClick={() => (setMarking(true), setError(null))} aria-describedby={error === 'spot' ? `${id}-se` : undefined} className="mt-3 w-full" data-autofocus>
                {spot ? t.remark : t.mark}
              </Button>
              {error === 'spot' && (
                <p id={`${id}-se`} role="alert" className="kit-rise mt-2 text-[13px] font-medium text-destructive">
                  {t.spotNeeded}
                </p>
              )}
              <Unfold open={spot !== null}>
                <div className="pt-4">
                  <Textarea id={`${id}-m`} label={t.whatHere} value={message} onChange={(e) => (setMessage(e.target.value), setError(null))} error={error === 'message' ? t.messageNeeded : undefined} minRows={3} maxLength={500} required />
                  <p className="mt-3 flex items-start gap-2 text-[12.5px] leading-snug text-muted-foreground">
                    <Icon name="camera" size={14} className="mt-0.5 shrink-0" />
                    {t.attach}
                  </p>
                  <Button type="submit" icon="send" className="mt-4 w-full">
                    {t.send}
                  </Button>
                </div>
              </Unfold>
            </form>
          )}
        </div>
        <button ref={s.opener} type="button" aria-expanded={s.open} aria-controls={id} onClick={s.toggle} className="pointer-events-auto inline-flex h-11 items-center gap-2 rounded-full border border-border bg-card px-4 text-[14px] font-medium text-foreground shadow-[var(--shadow-lg)] transition-[transform,background-color] duration-300 hover:-translate-y-0.5 hover:bg-surface focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent),var(--shadow-lg)] focus-visible:outline-none motion-reduce:hover:translate-y-0">
          <Icon name="locate" size={17} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
          {t.feedback}
        </button>
      </div>
    </>
  );
}

/* ------------------------------------------------------------------ */
/* bug-report                                                          */
/* ------------------------------------------------------------------ */

function readDevice(t: Texts) {
  const ua = navigator.userAgent;
  const browser = /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Firefox\//.test(ua) ? 'Firefox' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
  const version = (ua.match(/(?:Edg|Chrome|Firefox|Version)\/(\d+)/) ?? [])[1];
  const system = /Windows/.test(ua) ? 'Windows' : /Mac OS X/.test(ua) ? (/(iPhone|iPad)/.test(ua) ? 'iOS' : 'macOS') : /Android/.test(ua) ? 'Android' : /Linux/.test(ua) ? 'Linux' : '';
  return {
    [t.browser]: version ? `${browser} ${version}` : browser,
    [t.system]: system || t.detecting,
    [t.screen]: `${window.innerWidth} × ${window.innerHeight}`,
    [t.pageLabel]: window.location.pathname,
  };
}

function BugReport({ p, t, cats }: { p: FeedbackWidgetProps; t: Texts; cats: FeedbackCategory[] }) {
  const id = useId();
  const [title, setTitle] = useState('');
  const [severity, setSeverity] = useState<string[]>([]);
  const [steps, setSteps] = useState<string[]>(['', '']);
  const [expected, setExpected] = useState('');
  const [actual, setActual] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [attempt, setAttempt] = useState(0);
  const [device, setDevice] = useState<Record<string, string> | null>(null);
  const [sent, setSent] = useState<string | null>(null);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  const added = useRef(false);
  useFocusFirstError(formRef, attempt, summaryRef);
  useEffect(() => setDevice(readDevice(t)), [t]);
  useEffect(() => {
    if (!added.current) return;
    added.current = false;
    document.getElementById(`${id}-s${steps.length - 1}`)?.focus();
  }, [steps.length, id]);

  const summary: FlowError[] = [
    errors.title && { field: `${id}-title`, message: t.reportTitle },
    errors.severity && { field: `${id}-sev`, message: t.severity },
    errors.steps && { field: `${id}-s0`, message: t.steps },
    errors.actual && { field: `${id}-act`, message: t.actual },
  ].filter(Boolean) as FlowError[];
  const clear = (k: string) => errors[k] && setErrors((e) => Object.fromEntries(Object.entries(e).filter(([x]) => x !== k)));

  if (sent)
    return (
      <div className="kit-card mx-auto max-w-[48rem] p-6 sm:p-10">
        <div role="status" className="kit-rise">
          <DoneMark />
          <h3 className="mt-5 text-h3 font-semibold text-foreground">{t.reportSent.replace('{id}', sent)}</h3>
          <p className="mt-2 max-w-[34rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{t.reportSentText}</p>
          <Button
            variant="outline"
            iconLeft="plus"
            className="mt-6"
            onClick={() => {
              setSent(null);
              setTitle('');
              setSeverity([]);
              setSteps(['', '']);
              setExpected('');
              setActual('');
            }}
          >
            {t.newReport}
          </Button>
        </div>
      </div>
    );

  return (
    <Reveal effect="rise" className="mx-auto max-w-[56rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card grid grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_17rem]"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!title.trim()) found.title = t.titleNeeded;
          if (!severity.length) found.severity = t.severityNeeded;
          if (!steps[0]?.trim()) found.steps = t.stepsNeeded;
          if (!actual.trim()) found.actual = t.actualNeeded;
          setErrors(found);
          if (Object.keys(found).length) {
            setAttempt((a) => a + 1);
            return;
          }
          p.onSubmit?.({ title, category: severity[0], message: actual, steps: steps.filter((s) => s.trim()), expected, actual, device: device ?? undefined });
          setSent(`#${String(Date.now()).slice(-5)}`);
        }}
      >
        <div className="grid min-w-0 gap-6 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <Input id={`${id}-title`} label={t.reportTitle} placeholder={t.reportTitleHint} required value={title} onChange={(e) => (setTitle(e.target.value), clear('title'))} error={errors.title} />
          <ChipGroup id={`${id}-sev`} legend={t.severity} required options={cats.map((c) => ({ value: c.value, label: c.label, icon: c.icon }))} value={severity} onChange={(v) => (setSeverity(v), clear('severity'))} error={errors.severity} />
          <fieldset className="min-w-0">
            <legend className="text-sm font-medium text-foreground">
              {t.steps}
              <span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
                *
              </span>
            </legend>
            <ol className="mt-3 grid gap-2">
              {steps.map((s, i) => (
                <li key={i} className="kit-rise flex items-center gap-2">
                  <span aria-hidden className="grid size-7 shrink-0 place-items-center rounded-full bg-surface-2 text-[12px] font-semibold tabular-nums text-muted-foreground">
                    {i + 1}
                  </span>
                  <Input
                    id={`${id}-s${i}`}
                    aria-label={t.step.replace('{n}', String(i + 1))}
                    value={s}
                    invalid={i === 0 && Boolean(errors.steps)}
                    aria-describedby={i === 0 && errors.steps ? `${id}-se` : undefined}
                    onChange={(e) => {
                      const v = e.target.value;
                      setSteps((x) => x.map((y, j) => (j === i ? v : y)));
                      if (i === 0) clear('steps');
                    }}
                    className="flex-1"
                  />
                  {steps.length > 1 && (
                    <button type="button" aria-label={t.removeStep.replace('{n}', String(i + 1))} onClick={() => setSteps((x) => x.filter((_, j) => j !== i))} className="grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none">
                      <Icon name="minus-circle" size={17} />
                    </button>
                  )}
                </li>
              ))}
            </ol>
            {errors.steps && (
              <p id={`${id}-se`} className="kit-rise mt-2 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
                <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
                {errors.steps}
              </p>
            )}
            {steps.length < 8 && (
              <Button
                type="button"
                variant="ghost"
                size="sm"
                iconLeft="plus"
                className="-ms-3 mt-2"
                onClick={() => {
                  added.current = true;
                  setSteps((x) => [...x, '']);
                }}
              >
                {t.addStep}
              </Button>
            )}
          </fieldset>
          <div className="grid grid-cols-1 gap-5 md:grid-cols-2">
            <Textarea label={t.expected} optional value={expected} onChange={(e) => setExpected(e.target.value)} minRows={3} />
            <Textarea id={`${id}-act`} label={t.actual} required value={actual} onChange={(e) => (setActual(e.target.value), clear('actual'))} error={errors.actual} minRows={3} />
          </div>
          <div className="flex justify-end border-t border-border pt-6">
            <Button type="submit" icon="send" className="max-sm:w-full">
              {t.sendReport}
            </Button>
          </div>
        </div>
        {/* What the browser can tell, filled in after load, shown so nothing is sent unseen */}
        <aside className="border-t border-border bg-surface p-6 sm:p-8 lg:border-s lg:border-t-0">
          <p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
            <Icon name="monitor" size={16} className="text-muted-foreground" />
            {t.device}
          </p>
          <p className="mt-1 text-[13px] leading-5 text-muted-foreground">{t.deviceHint}</p>
          <dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-4 lg:grid-cols-1">
            {[t.browser, t.system, t.screen, t.pageLabel].map((k, i) => (
              <div key={k} className="min-w-0">
                <dt className="text-[12px] text-subtle-foreground">{k}</dt>
                <dd className={cn('mt-0.5 truncate text-[13.5px] font-medium tabular-nums', device ? 'kit-rise text-foreground' : 'text-subtle-foreground')} style={{ '--kit-delay': `${i * 90}ms` } as React.CSSProperties}>
                  {device?.[k] ?? t.detecting}
                </dd>
              </div>
            ))}
          </dl>
        </aside>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

const BUG_SEVERITY: Record<'en' | 'de', FeedbackCategory[]> = {
  en: [
    { value: 'blocking', label: 'Blocks my work', icon: 'ban' },
    { value: 'annoying', label: 'Slows me down', icon: 'hourglass' },
    { value: 'cosmetic', label: 'Looks wrong', icon: 'eye' },
  ],
  de: [
    { value: 'blocking', label: 'Blockiert mich', icon: 'ban' },
    { value: 'annoying', label: 'Bremst mich', icon: 'hourglass' },
    { value: 'cosmetic', label: 'Sieht falsch aus', icon: 'eye' },
  ],
};

/**
 * Feedback from any page.
 *   <FeedbackWidget variant="side-tab" />                      fixed over the window
 *   <FeedbackWidget variant="bubble" contained page={…} />     inside a framed preview
 *   <FeedbackWidget variant="bug-report" categories={[…]} onSubmit={(report) => …} />
 */
export function FeedbackWidget(props: FeedbackWidgetProps) {
  const { variant = 'side-tab', eyebrow, title, description, contained, page, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  if (variant === 'bug-report') {
    const cats = props.categories ?? BUG_SEVERITY[locale];
    return (
      <Section className={className}>
        <FlowHead eyebrow={eyebrow} title={title} description={description} className="mx-auto mb-10 max-w-[56rem] sm:mb-14" />
        <BugReport p={props} t={t} cats={cats} />
      </Section>
    );
  }
  const cats = props.categories ?? DEFAULT_CATEGORIES[locale];
  const pos = contained ? 'absolute' : 'fixed';
  const widget =
    variant === 'bubble' ? <Bubble p={props} t={t} cats={cats} pos={pos} /> : variant === 'screenshot' ? <Screenshot p={props} t={t} pos={pos} /> : <SideTab p={props} t={t} cats={cats} pos={pos} />;
  if (!contained) return widget;
  return (
    <Section className={className}>
      <FlowHead eyebrow={eyebrow} title={title} description={description} className="mb-10 sm:mb-14" />
      <Reveal effect="scale">
        <div className="relative h-[40rem] overflow-hidden rounded-2xl border border-border bg-background shadow-[var(--highlight),var(--shadow-lg)] sm:h-[36rem]">
          <div aria-hidden className="pointer-events-none absolute inset-0 select-none">
            {page}
          </div>
          {widget}
        </div>
      </Reveal>
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Feedback-Widget?

Mit aiw add @ai-website/feedback-widget. Der Befehl legt die Datei components/ui/feedback-widget.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, textarea, reveal, section und form-flow. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Feedback-Widget?

4 Varianten, Reiter am Rand, Knopf mit Reaktionen, Stelle markieren und Fehlerbericht. Du wählst sie mit der Prop variant, zum Beispiel variant="bubble".

Kann ich die Texte von Feedback-Widget übersetzen?

Ja. Feedback-Widget bringt 47 eigene Texte auf Deutsch und Englisch mit, etwa „Feedback“, „Feedback geben“ und „Schließen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Feedback-Widget einbauen?

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

Kann ich Feedback-Widget ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert