Zum Inhalt springen
UI Kit
Accent

Feature Flags

Feature-Flag-Screens. Schalter je Umgebung, stufenweises Ausrollen mit Notaus, Zielregeln mit Testperson, A/B-Aufteilung mit Ergebnissen und ein Vergleich der Umgebungen.

Eine von 3 Components in Developer Settings

5 Variants

Variants

Flag-Liste

Jedes Flag mit Schlüssel zum Kopieren, Verantwortlichen, Ausrollstand und einem Schalter je Umgebung, filterbar nach veralteten Flags oder Suche.

Im Studio öffnen

Stufenweises Ausrollen

Ein Anteilsregler mit 100 Personen, die sich füllen, die Stufen auf einer Linie und ein Notaus, der den Anteil behält.

Im Studio öffnen

Zielregeln

Wenn-dann-Regeln in Reihenfolge mit Merkmal, Bedingung und Wert, ein Standard für alle anderen und eine Testperson, die zeigt, welche Regel greift.

Im Studio öffnen

A/B-Experiment

Ein Teilungsbalken zum Ziehen oder per Tasten, beide Varianten mit Quote, Unterschied zur Kontrolle und wie sicher das Ergebnis ist.

Im Studio öffnen

Umgebungen

Das Flag in jeder Umgebung nebeneinander, Unterschiede markiert, und eine Kopie von einer Umgebung in die nächste.

Im Studio öffnen

Details

Was Feature Flags mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/feature-flags.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 86 eigene Texte

Props

Die Schnittstelle FeatureFlagsProps aus components/ui/feature-flags.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    FeatureFlagsVariant

    flag-listrollouttargetingexperimentenvironments

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlePflicht
    string
  • subtitleoptional
    string
  • todayoptional
    string

    ISO date that counts as today

  • environmentsoptional
    FlagEnv[]
  • flagsoptional
    Flag[]
  • flagoptional
    Flag

    rollout, targeting, experiment, environments: the flag on screen

  • stagesoptional
    number[]

    rollout: steps of a staged rollout in percent

  • rulesoptional
    TargetRule[]

    targeting

  • attributesoptional
    { id: string; label: string; placeholder?: string }[]
  • variantsoptional
    string[]

    targeting, environments: names of the variants a flag can serve

  • armsoptional
    ExperimentArm[]

    experiment

  • metricoptional
    string
  • configsoptional
    EnvConfig[]

    environments

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on every saved change. Without it the block simulates the request.

Eingebaute Texte

Diese Texte schreibt Feature Flags selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 86 Texte anzeigen
searchEnglisch Search flagsDeutsch Flags suchen
discardEnglisch DiscardDeutsch Verwerfen
allEnglisch AllDeutsch Alle
staleEnglisch StaleDeutsch Veraltet
staleHintEnglisch Unchanged for over 60 days. Remove it from the code when you can.Deutsch Seit über 60 Tagen unverändert. Entferne es aus dem Code, sobald es geht.
flagEnglisch FlagDeutsch Flag
ownerEnglisch OwnerDeutsch Verantwortlich
updatedEnglisch Changed {date}Deutsch Geändert {date}
toggleEnglisch {flag} in {env}Deutsch {flag} in {env}
turnedOnEnglisch {flag} is on in {env}.Deutsch {flag} ist in {env} an.
turnedOffEnglisch {flag} is off in {env}.Deutsch {flag} ist in {env} aus.
rolloutNEnglisch {n}% rolloutDeutsch {n} % ausgerollt
noMatchEnglisch No flag matches your search.Deutsch Kein Flag passt zur Suche.
clearEnglisch Clear searchDeutsch Suche leeren
copyKeyEnglisch Copy key {key}Deutsch Schlüssel {key} kopieren
copiedEnglisch CopiedDeutsch Kopiert
rolloutEnglisch RolloutDeutsch Ausrollen
rolloutTextEnglisch Share of signed in users in production who get the feature. The same user always lands on the same side.Deutsch Anteil der angemeldeten Nutzer in Produktion, die die Funktion bekommen. Dieselbe Person landet immer auf derselben Seite.
usersEnglisch {n} of 100 usersDeutsch {n} von 100 Nutzern
stagesTitleEnglisch Staged rolloutDeutsch Stufenweise ausrollen
stagesTextEnglisch Raise the share step by step and watch the error rate in between.Deutsch Erhöhe den Anteil Schritt für Schritt und beobachte dazwischen die Fehlerquote.
stageEnglisch Step {n}Deutsch Stufe {n}
currentEnglisch NowDeutsch Jetzt
nextStepEnglisch Go to {n}%Deutsch Auf {n} % gehen
fullEnglisch Released to everyoneDeutsch Für alle freigegeben
killEnglisch Turn off for everyoneDeutsch Für alle ausschalten
killTextEnglisch Stops the feature in production right away, the rollout stays saved.Deutsch Stoppt die Funktion in Produktion sofort, der Ausrollstand bleibt gespeichert.
killedEnglisch Off for everyone. Turn it on again to continue at {n}%.Deutsch Für alle aus. Schalte es wieder ein, um bei {n} % weiterzumachen.
resumeEnglisch Turn on againDeutsch Wieder einschalten
saveEnglisch Save rolloutDeutsch Ausrollen speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch Rollout saved at {n}%.Deutsch Bei {n} % gespeichert.
rulesEnglisch Targeting rulesDeutsch Zielregeln
rulesTextEnglisch Rules are checked from top to bottom. The first match decides.Deutsch Regeln werden von oben nach unten geprüft. Der erste Treffer entscheidet.
ifEnglisch IfDeutsch Wenn
serveEnglisch then serveDeutsch dann
everyoneElseEnglisch Everyone else getsDeutsch Alle anderen bekommen
addRuleEnglisch Add ruleDeutsch Regel hinzufügen
removeRuleEnglisch Remove rule {n}Deutsch Regel {n} entfernen
moveUpEnglisch Move rule {n} upDeutsch Regel {n} nach oben
moveDownEnglisch Move rule {n} downDeutsch Regel {n} nach unten
attributeEnglisch AttributeDeutsch Merkmal
operatorEnglisch OperatorDeutsch Bedingung
valueEnglisch ValueDeutsch Wert
isEnglisch isDeutsch ist
is-notEnglisch is notDeutsch ist nicht
containsEnglisch containsDeutsch enthält
ends-withEnglisch ends withDeutsch endet auf
inEnglisch is one ofDeutsch ist eins von
variantEnglisch VariantDeutsch Variante
testEnglisch Try a userDeutsch Nutzer ausprobieren
testTextEnglisch Fill in attributes and see which rule matches.Deutsch Merkmale eintragen und sehen, welche Regel greift.
matchesEnglisch Rule {n} matches, this user gets {v}Deutsch Regel {n} greift, diese Person bekommt {v}
noRuleEnglisch No rule matches, this user gets {v}Deutsch Keine Regel greift, diese Person bekommt {v}
emptyValueEnglisch Fill in a valueDeutsch Wert eintragen
saveRulesEnglisch Save rulesDeutsch Regeln speichern
savedRulesEnglisch Rules saved.Deutsch Regeln gespeichert.
experimentEnglisch ExperimentDeutsch Experiment
splitEnglisch Traffic splitDeutsch Aufteilung
splitTextEnglisch Drag the handle or use the arrow keys.Deutsch Griff ziehen oder Pfeiltasten nutzen.
visitorsEnglisch VisitorsDeutsch Besucher
conversionsEnglisch ConversionsDeutsch Abschlüsse
rateEnglisch RateDeutsch Quote
liftEnglisch vs. controlDeutsch ggü. Kontrolle
controlEnglisch ControlDeutsch Kontrolle
winnerEnglisch Likely betterDeutsch Wahrscheinlich besser
tooEarlyEnglisch Too early to callDeutsch Noch zu früh
confidenceEnglisch {n}% sureDeutsch {n} % sicher
applySplitEnglisch Apply splitDeutsch Aufteilung übernehmen
splitSavedEnglisch Split of {a}% and {b}% saved.Deutsch Aufteilung von {a} % und {b} % gespeichert.
shareOfEnglisch Share of {name}Deutsch Anteil von {name}
envsTextEnglisch The same flag in each environment. Differences are marked.Deutsch Dasselbe Flag in jeder Umgebung. Unterschiede sind markiert.
statusEnglisch StatusDeutsch Status
onEnglisch OnDeutsch An
offEnglisch OffDeutsch Aus
rulesNEnglisch {n} rulesDeutsch {n} Regeln
rulesOneEnglisch 1 ruleDeutsch 1 Regel
unsavedRulesEnglisch Unsaved changes to the rulesDeutsch Ungespeicherte Änderungen an den Regeln
youEnglisch youDeutsch dir
fallbackEnglisch Everyone elseDeutsch Alle anderen
differsEnglisch Differs from {env}Deutsch Anders als {env}
promoteEnglisch Copy {from} to {to}Deutsch {from} nach {to} kopieren
promotingEnglisch Copying…Deutsch Wird kopiert…
promotedEnglisch {to} now matches {from}.Deutsch {to} entspricht jetzt {from}.
sameEnglisch Same as {env}Deutsch Wie {env}
byEnglisch by {name}Deutsch von {name}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Feature Flags reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio, radiogroup, slider 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

  • 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, 1.235 Zeilen
components/ui/feature-flags.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type FeatureFlagsVariant = 'flag-list' | 'rollout' | 'targeting' | 'experiment' | 'environments';

export interface FlagEnv {
  id: string;
  name: string;
}

export interface Flag {
  id: string;
  /** Key used in code, e.g. new-checkout */
  key: string;
  name: string;
  description?: string;
  /** On or off per environment id */
  on: Record<string, boolean>;
  /** Share of users that get it in production, 0 to 100 */
  rollout?: number;
  tags?: string[];
  owner?: string;
  /** ISO date of the last change */
  updated?: string;
  /** No change for a long time, safe to remove from the code */
  stale?: boolean;
}

export interface TargetRule {
  id: string;
  attribute: string;
  operator: 'is' | 'is-not' | 'contains' | 'ends-with' | 'in';
  value: string;
  /** Variant that the matching users get */
  serve: string;
}

export interface ExperimentArm {
  id: string;
  name: string;
  /** Share of traffic, all arms add up to 100 */
  share: number;
  visitors: number;
  conversions: number;
  control?: boolean;
}

export interface EnvConfig {
  /** Environment id */
  env: string;
  on: boolean;
  rollout: number;
  rules: number;
  /** Variant for everyone else */
  fallback: string;
  updated?: string;
  by?: string;
}

export interface FeatureFlagsProps {
  variant?: FeatureFlagsVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** ISO date that counts as today */
  today?: string;
  environments?: FlagEnv[];
  flags?: Flag[];
  /** rollout, targeting, experiment, environments: the flag on screen */
  flag?: Flag;
  /** rollout: steps of a staged rollout in percent */
  stages?: number[];
  /** targeting */
  rules?: TargetRule[];
  attributes?: { id: string; label: string; placeholder?: string }[];
  /** targeting, environments: names of the variants a flag can serve */
  variants?: string[];
  /** experiment */
  arms?: ExperimentArm[];
  metric?: string;
  /** environments */
  configs?: EnvConfig[];
  /** Called on every saved change. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'Search flags',
    discard: 'Discard',
    all: 'All',
    stale: 'Stale',
    staleHint: 'Unchanged for over 60 days. Remove it from the code when you can.',
    flag: 'Flag',
    owner: 'Owner',
    updated: 'Changed {date}',
    toggle: '{flag} in {env}',
    turnedOn: '{flag} is on in {env}.',
    turnedOff: '{flag} is off in {env}.',
    rolloutN: '{n}% rollout',
    noMatch: 'No flag matches your search.',
    clear: 'Clear search',
    copyKey: 'Copy key {key}',
    copied: 'Copied',
    rollout: 'Rollout',
    rolloutText: 'Share of signed in users in production who get the feature. The same user always lands on the same side.',
    users: '{n} of 100 users',
    stagesTitle: 'Staged rollout',
    stagesText: 'Raise the share step by step and watch the error rate in between.',
    stage: 'Step {n}',
    current: 'Now',
    nextStep: 'Go to {n}%',
    full: 'Released to everyone',
    kill: 'Turn off for everyone',
    killText: 'Stops the feature in production right away, the rollout stays saved.',
    killed: 'Off for everyone. Turn it on again to continue at {n}%.',
    resume: 'Turn on again',
    save: 'Save rollout',
    saving: 'Saving…',
    saved: 'Rollout saved at {n}%.',
    rules: 'Targeting rules',
    rulesText: 'Rules are checked from top to bottom. The first match decides.',
    if: 'If',
    serve: 'then serve',
    everyoneElse: 'Everyone else gets',
    addRule: 'Add rule',
    removeRule: 'Remove rule {n}',
    moveUp: 'Move rule {n} up',
    moveDown: 'Move rule {n} down',
    attribute: 'Attribute',
    operator: 'Operator',
    value: 'Value',
    is: 'is',
    'is-not': 'is not',
    contains: 'contains',
    'ends-with': 'ends with',
    in: 'is one of',
    variant: 'Variant',
    test: 'Try a user',
    testText: 'Fill in attributes and see which rule matches.',
    matches: 'Rule {n} matches, this user gets {v}',
    noRule: 'No rule matches, this user gets {v}',
    emptyValue: 'Fill in a value',
    saveRules: 'Save rules',
    savedRules: 'Rules saved.',
    experiment: 'Experiment',
    split: 'Traffic split',
    splitText: 'Drag the handle or use the arrow keys.',
    visitors: 'Visitors',
    conversions: 'Conversions',
    rate: 'Rate',
    lift: 'vs. control',
    control: 'Control',
    winner: 'Likely better',
    tooEarly: 'Too early to call',
    confidence: '{n}% sure',
    applySplit: 'Apply split',
    splitSaved: 'Split of {a}% and {b}% saved.',
    shareOf: 'Share of {name}',
    envsText: 'The same flag in each environment. Differences are marked.',
    status: 'Status',
    on: 'On',
    off: 'Off',
    rulesN: '{n} rules',
    rulesOne: '1 rule',
    unsavedRules: 'Unsaved changes to the rules',
    you: 'you',
    fallback: 'Everyone else',
    differs: 'Differs from {env}',
    promote: 'Copy {from} to {to}',
    promoting: 'Copying…',
    promoted: '{to} now matches {from}.',
    same: 'Same as {env}',
    by: 'by {name}',
  },
  de: {
    search: 'Flags suchen',
    discard: 'Verwerfen',
    all: 'Alle',
    stale: 'Veraltet',
    staleHint: 'Seit über 60 Tagen unverändert. Entferne es aus dem Code, sobald es geht.',
    flag: 'Flag',
    owner: 'Verantwortlich',
    updated: 'Geändert {date}',
    toggle: '{flag} in {env}',
    turnedOn: '{flag} ist in {env} an.',
    turnedOff: '{flag} ist in {env} aus.',
    rolloutN: '{n} % ausgerollt',
    noMatch: 'Kein Flag passt zur Suche.',
    clear: 'Suche leeren',
    copyKey: 'Schlüssel {key} kopieren',
    copied: 'Kopiert',
    rollout: 'Ausrollen',
    rolloutText: 'Anteil der angemeldeten Nutzer in Produktion, die die Funktion bekommen. Dieselbe Person landet immer auf derselben Seite.',
    users: '{n} von 100 Nutzern',
    stagesTitle: 'Stufenweise ausrollen',
    stagesText: 'Erhöhe den Anteil Schritt für Schritt und beobachte dazwischen die Fehlerquote.',
    stage: 'Stufe {n}',
    current: 'Jetzt',
    nextStep: 'Auf {n} % gehen',
    full: 'Für alle freigegeben',
    kill: 'Für alle ausschalten',
    killText: 'Stoppt die Funktion in Produktion sofort, der Ausrollstand bleibt gespeichert.',
    killed: 'Für alle aus. Schalte es wieder ein, um bei {n} % weiterzumachen.',
    resume: 'Wieder einschalten',
    save: 'Ausrollen speichern',
    saving: 'Wird gespeichert…',
    saved: 'Bei {n} % gespeichert.',
    rules: 'Zielregeln',
    rulesText: 'Regeln werden von oben nach unten geprüft. Der erste Treffer entscheidet.',
    if: 'Wenn',
    serve: 'dann',
    everyoneElse: 'Alle anderen bekommen',
    addRule: 'Regel hinzufügen',
    removeRule: 'Regel {n} entfernen',
    moveUp: 'Regel {n} nach oben',
    moveDown: 'Regel {n} nach unten',
    attribute: 'Merkmal',
    operator: 'Bedingung',
    value: 'Wert',
    is: 'ist',
    'is-not': 'ist nicht',
    contains: 'enthält',
    'ends-with': 'endet auf',
    in: 'ist eins von',
    variant: 'Variante',
    test: 'Nutzer ausprobieren',
    testText: 'Merkmale eintragen und sehen, welche Regel greift.',
    matches: 'Regel {n} greift, diese Person bekommt {v}',
    noRule: 'Keine Regel greift, diese Person bekommt {v}',
    emptyValue: 'Wert eintragen',
    saveRules: 'Regeln speichern',
    savedRules: 'Regeln gespeichert.',
    experiment: 'Experiment',
    split: 'Aufteilung',
    splitText: 'Griff ziehen oder Pfeiltasten nutzen.',
    visitors: 'Besucher',
    conversions: 'Abschlüsse',
    rate: 'Quote',
    lift: 'ggü. Kontrolle',
    control: 'Kontrolle',
    winner: 'Wahrscheinlich besser',
    tooEarly: 'Noch zu früh',
    confidence: '{n} % sicher',
    applySplit: 'Aufteilung übernehmen',
    splitSaved: 'Aufteilung von {a} % und {b} % gespeichert.',
    shareOf: 'Anteil von {name}',
    envsText: 'Dasselbe Flag in jeder Umgebung. Unterschiede sind markiert.',
    status: 'Status',
    on: 'An',
    off: 'Aus',
    rulesN: '{n} Regeln',
    rulesOne: '1 Regel',
    unsavedRules: 'Ungespeicherte Änderungen an den Regeln',
    you: 'dir',
    fallback: 'Alle anderen',
    differs: 'Anders als {env}',
    promote: '{from} nach {to} kopieren',
    promoting: 'Wird kopiert…',
    promoted: '{to} entspricht jetzt {from}.',
    same: 'Wie {env}',
    by: 'von {name}',
  },
};

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

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const DAY = 86400000;
const toDay = (iso: string) => Math.round(Date.parse(iso + 'T00:00:00Z') / DAY);

type P = Omit<FeatureFlagsProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; today: string; locale: Locale };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: FeatureFlagsProps['onAction'], action: string, data: Record<string, unknown>) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : 700);
}

const dateFmt = (locale: Locale, iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(toDay(iso) * DAY + DAY / 2));
const pctFmt = (locale: Locale, n: number, digits = 1) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'percent', maximumFractionDigits: digits, minimumFractionDigits: digits }).format(n);
/** A whole percentage in the format of the language, 25 % or 25% */
const pc = (locale: Locale, n: number) => (locale === 'de' ? `${n} %` : `${n}%`);
const numFmt = (locale: Locale, n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);

function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
  return (
    <section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
          <div className="min-w-0">
            {title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
            {description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

/** The browser select in the look of the kit fields */
function Pick({ label, value, onChange, children, className }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) {
  return (
    <span className={cn('relative block min-w-0', className)}>
      <select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className="h-11 w-full min-w-0 cursor-pointer appearance-none truncate rounded-lg border border-input bg-background ps-3 pe-8 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]">
        {children}
      </select>
      <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
    </span>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

function Flip({ children, className }: { children: string; className?: string }) {
  return (
    <span className={cn('relative inline-flex overflow-hidden', className)}>
      <span key={children} className="kitm-in [--kitm-t:.4s]">
        {children}
      </span>
    </span>
  );
}

function KeyChip({ k, t }: { k: string; t: Texts }) {
  const [done, setDone] = useState(false);
  useEffect(() => {
    if (!done) return;
    const id = window.setTimeout(() => setDone(false), 1400);
    return () => window.clearTimeout(id);
  }, [done]);
  return (
    <button
      type="button"
      aria-label={fill(t.copyKey, { key: k })}
      onClick={async () => {
        try {
          await navigator.clipboard.writeText(k);
        } catch {
          /* The check still shows */
        }
        setDone(true);
      }}
      className="group/key relative inline-flex h-6 max-w-full items-center gap-1 rounded-md bg-muted px-1.5 text-[12px] tabular-nums text-muted-foreground transition-colors after:absolute after:-inset-y-2.5 after:inset-x-0 after:content-[''] hover:text-foreground"
    >
      <span className="truncate">{k}</span>
      <Icon key={String(done)} name={done ? 'check' : 'copy'} size={11} className={cn('shrink-0', done ? 'kitm-tick text-success-foreground' : 'opacity-0 transition-opacity group-hover/key:opacity-100 group-focus-visible/key:opacity-100')} />
      <span className="sr-only" role="status">
        {done ? t.copied : ''}
      </span>
    </button>
  );
}

function SaveBar({ show, dirty, busy, message, saveLabel, t, onDiscard, onSave }: { show: boolean; dirty: boolean; busy: boolean; message: string; saveLabel: string; t: Texts; onDiscard: () => void; onSave: () => void }) {
  return (
    <div className={cn('fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] transition-[translate,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-6', show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-6 opacity-0')} inert={!show}>
      <div className="mx-auto flex max-w-[44rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
        <p className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">
          {!dirty ? (
            <span className="kitm-in flex items-center gap-2 text-success-foreground">
              <Icon name="check-circle" size={16} className="shrink-0" />
              <span className="truncate">{message}</span>
            </span>
          ) : (
            <span className="flex items-center gap-2">
              <span aria-hidden className="size-1.5 shrink-0 rounded-full bg-warning" />
              <span className="truncate">{message}</span>
            </span>
          )}
        </p>
        {dirty && (
          <>
            <Button variant="ghost" size="sm" onClick={onDiscard}>
              {t.discard}
            </Button>
            <Button size="sm" onClick={onSave} disabled={busy} iconLeft={busy ? 'loader' : undefined}>
              {busy ? t.saving : saveLabel}
            </Button>
          </>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* flag-list                                                           */
/* ------------------------------------------------------------------ */

function FlagList({ t, title, subtitle, flags = [], environments = [], locale, onAction }: P) {
  const [items, setItems] = useState(flags);
  const [q, setQ] = useState('');
  const [filter, setFilter] = useState<'all' | 'stale'>('all');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const shown = items.filter((x) => (filter === 'all' || x.stale) && (!q || `${x.name} ${x.key} ${(x.tags ?? []).join(' ')}`.toLowerCase().includes(q.toLowerCase())));
  const toggle = (x: Flag, env: FlagEnv) => {
    const v = !x.on[env.id];
    setItems((l) => l.map((y) => (y.id === x.id ? { ...y, on: { ...y.on, [env.id]: v } } : y)));
    setMsg(fill(v ? t.turnedOn : t.turnedOff, { flag: x.name, env: env.name }));
    void run(onAction, 'toggle', { flag: x.id, env: env.id, on: v });
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.flag}
          value={filter}
          onChange={setFilter}
          options={[
            { value: 'all', label: <>{t.all}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.length}</span></> },
            { value: 'stale', label: <>{t.stale}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((x) => x.stale).length}</span></> },
          ]}
        />
        <Input aria-label={t.search} placeholder={t.search} icon="search" size="sm" type="search" value={q} onChange={(e) => setQ(e.target.value)} className="w-full sm:w-60" />
      </Header>
      <div ref={scope} {...enter} className="mt-6">
        <div className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          {/* Header row only where the environments line up as columns */}
          <div className="hidden grid-cols-[minmax(0,1fr)_repeat(var(--envs),6.5rem)] items-center border-b border-border bg-surface px-5 py-2.5 text-[12px] font-medium text-muted-foreground md:grid" style={{ '--envs': environments.length } as React.CSSProperties}>
            <span>{t.flag}</span>
            {environments.map((e) => (
              <span key={e.id} className="text-center">
                {e.name}
              </span>
            ))}
          </div>
          <ul className="divide-y divide-border">
            {shown.map((x, i) => (
              <li key={x.id} className="kitm-up grid grid-cols-1 gap-3 px-4 py-4 sm:px-5 md:grid-cols-[minmax(0,1fr)_repeat(var(--envs),6.5rem)] md:items-center md:gap-0" style={{ '--envs': environments.length, '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
                <div className="min-w-0 md:pe-4">
                  <p className="flex flex-wrap items-center gap-2">
                    <span className="text-[14px] font-medium text-foreground">{x.name}</span>
                    {x.stale && (
                      <span title={t.staleHint} className="inline-flex items-center gap-1 rounded bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] px-1.5 py-px text-[11px] font-semibold text-warning-foreground">
                        <Icon name="hourglass" size={11} />
                        {t.stale}
                      </span>
                    )}
                    {x.rollout !== undefined && x.rollout < 100 && x.on[environments.at(-1)?.id ?? ''] && <span className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[11px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{fill(t.rolloutN, { n: x.rollout })}</span>}
                  </p>
                  <div className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[12px] text-subtle-foreground">
                    <KeyChip k={x.key} t={t} />
                    {x.owner && <span>{x.owner}</span>}
                    {x.updated && <span>{fill(t.updated, { date: dateFmt(locale, x.updated) })}</span>}
                  </div>
                </div>
                {/* Phones: the environments as a row of labelled switches */}
                <div className="grid grid-cols-3 gap-2 md:contents">
                  {environments.map((e) => (
                    <div key={e.id} className="flex flex-col items-center gap-1.5 rounded-lg bg-surface py-2 md:bg-transparent md:py-0">
                      <span className="text-[11.5px] text-muted-foreground md:hidden">{e.name}</span>
                      <Switch size="sm" checked={Boolean(x.on[e.id])} aria-label={fill(t.toggle, { flag: x.name, env: e.name })} onCheckedChange={() => toggle(x, e)} />
                    </div>
                  ))}
                </div>
              </li>
            ))}
          </ul>
          {!shown.length && (
            <div className="px-4 py-12 text-center">
              <p className="text-[14px] text-muted-foreground">{t.noMatch}</p>
              <Button
                variant="outline"
                size="sm"
                className="mt-4"
                onClick={() => {
                  setQ('');
                  setFilter('all');
                }}
              >
                {t.clear}
              </Button>
            </div>
          )}
        </div>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* rollout                                                             */
/* ------------------------------------------------------------------ */

/** Which of the 100 people get the feature: a fixed, even scatter so a higher share only adds people */
const ORDER = (() => {
  // A seeded shuffle (mulberry32, public domain), the same on server and browser
  let seed = 20261019;
  const rnd = () => {
    seed = (seed + 0x6d2b79f5) >>> 0;
    let x = seed;
    x = Math.imul(x ^ (x >>> 15), x | 1);
    x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
  const a = Array.from({ length: 100 }, (_, i) => i);
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rnd() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
})();
const RANK = ORDER.reduce<number[]>((acc, cell, rank) => ((acc[cell] = rank), acc), []);

function Rollout({ t, title, subtitle, flag, stages = [5, 25, 50, 100], locale, onAction }: P) {
  const id = useId();
  const start = flag?.rollout ?? 0;
  const [saved, setSaved] = useState(start);
  const [n, setN] = useState(start);
  const [killed, setKilled] = useState(false);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const next = stages.find((s) => s > n);
  const save = async (v = n) => {
    setBusy(true);
    await run(onAction, 'rollout', { flag: flag?.id, percent: v });
    setSaved(v);
    setN(v);
    setBusy(false);
    setMsg(fill(t.saved, { n: v }));
  };
  return (
    <Main className="pb-28">
      <Header title={title} subtitle={subtitle}>
        {flag && <KeyChip k={flag.key} t={t} />}
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start">
        <Card title={t.rollout} description={t.rolloutText} className="kitm-up">
          {/* Side by side only where the card is wide enough, the narrow lg column stacks */}
          <div className="@container">
          <div className={cn('grid gap-6 transition-opacity duration-300 @xl:grid-cols-[minmax(0,1fr)_14rem] @xl:items-center', killed && 'opacity-45')}>
            <div>
              <p className="flex items-baseline gap-3">
                <span className="text-[44px] font-semibold leading-none tracking-[-0.04em] tabular-nums text-foreground">
                  <Flip>{pc(locale, n)}</Flip>
                </span>
                <span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.users, { n })}</span>
              </p>
              <label htmlFor={`${id}-r`} className="sr-only">
                {t.rollout}
              </label>
              <input id={`${id}-r`} type="range" min={0} max={100} step={1} value={n} disabled={killed} aria-valuetext={pc(locale, n)} onChange={(e) => setN(Number(e.target.value))} className="mt-5 h-11 w-full cursor-pointer accent-[var(--primary)] disabled:cursor-not-allowed" />
              <div className="mt-1 flex flex-wrap gap-1.5">
                {[0, ...stages].map((s) => (
                  <button key={s} type="button" disabled={killed} onClick={() => setN(s)} className={cn('h-11 min-w-11 rounded-md border px-2.5 text-[12.5px] font-medium tabular-nums transition-colors sm:h-8 sm:min-w-0', n === s ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                    {pc(locale, s)}
                  </button>
                ))}
              </div>
            </div>
            {/* 100 people, filled ones get the feature. The same people stay in as the share grows. */}
            <div aria-hidden className="mx-auto grid w-full max-w-[14rem] grid-cols-10 gap-1.5">
              {Array.from({ length: 100 }, (_, i) => (
                <span key={i} className={cn('aspect-square rounded-full transition-[background-color,transform] duration-300 ease-[var(--ease-kit)]', RANK[i] < n ? 'scale-100 bg-primary' : 'scale-75 bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]')} style={{ transitionDelay: `${(RANK[i] % 10) * 12}ms` }} />
              ))}
            </div>
          </div>
          </div>
        </Card>
        <div className="grid gap-6 lg:sticky lg:top-6">
          <Card title={t.stagesTitle} description={t.stagesText} className="kitm-up [--kitm-d:100ms]">
            <ol className="relative grid gap-3">
              <span aria-hidden className="absolute bottom-3 start-[11px] top-3 w-0.5 rounded-full bg-muted" />
              <span aria-hidden className="absolute start-[11px] top-3 w-0.5 origin-top rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)]" style={{ height: 'calc(100% - 1.5rem)', transform: `scaleY(${round(Math.max(0, stages.filter((s) => s <= saved).length - 1) / Math.max(1, stages.length - 1), 3)})` }} />
              {stages.map((s, i) => {
                const reached = saved >= s;
                const isNow = reached && (stages[i + 1] === undefined || saved < stages[i + 1]);
                return (
                  <li key={s} className="relative flex items-center gap-3">
                    <span className={cn('relative z-[1] grid size-6 shrink-0 place-items-center rounded-full border-2 transition-colors duration-300', reached ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-card')}>{reached && <Icon name="check" size={12} strokeWidth={2.6} />}</span>
                    <span className="flex-1 text-[13.5px] text-foreground">
                      {fill(t.stage, { n: i + 1 })} · <span className="tabular-nums">{pc(locale, s)}</span>
                    </span>
                    {isNow && <span className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[11px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.current}</span>}
                  </li>
                );
              })}
            </ol>
            <Button className="mt-5 w-full" variant="outline" disabled={!next || killed || busy || n !== saved} onClick={() => next && save(next)} iconLeft="arrow-up">
              {next ? fill(t.nextStep, { n: next }) : t.full}
            </Button>
          </Card>
          <Card className={cn('kitm-up [--kitm-d:160ms]', killed && 'border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))]')}>
            <p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
              <Icon name="power" size={16} className={killed ? 'text-destructive' : 'text-muted-foreground'} />
              {t.kill}
            </p>
            <p key={String(killed)} className="kitm-in mt-1 text-pretty text-[13px] leading-snug text-muted-foreground">
              {killed ? fill(t.killed, { n: saved }) : t.killText}
            </p>
            <Button
              size="sm"
              variant={killed ? 'primary' : 'destructive'}
              className="mt-4 w-full"
              onClick={() => {
                setKilled(!killed);
                void run(onAction, killed ? 'resume' : 'kill', { flag: flag?.id });
              }}
            >
              {killed ? t.resume : t.kill}
            </Button>
          </Card>
        </div>
      </div>
      <SaveBar show={n !== saved || Boolean(msg)} dirty={n !== saved} busy={busy} message={n !== saved ? `${pc(locale, saved)} → ${pc(locale, n)}` : msg} saveLabel={t.save} t={t} onDiscard={() => setN(saved)} onSave={() => save()} />
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* targeting                                                           */
/* ------------------------------------------------------------------ */

const OPS: TargetRule['operator'][] = ['is', 'is-not', 'contains', 'ends-with', 'in'];

function matches(r: TargetRule, v: string) {
  const a = v.trim().toLowerCase();
  const b = r.value.trim().toLowerCase();
  if (!b) return false;
  switch (r.operator) {
    case 'is':
      return a === b;
    case 'is-not':
      return a !== b;
    case 'contains':
      return a.includes(b);
    case 'ends-with':
      return a.endsWith(b);
    case 'in':
      return b.split(/\s*,\s*/).includes(a);
  }
}

function Targeting({ t, title, subtitle, flag, rules = [], attributes = [], variants = ['on', 'off'], onAction }: P) {
  const id = useId();
  const [saved, setSaved] = useState(rules);
  const [list, setList] = useState(rules);
  const [fallback, setFallback] = useState(variants.at(-1) ?? '');
  const [savedFallback, setSavedFallback] = useState(variants.at(-1) ?? '');
  const [probe, setProbe] = useState<Record<string, string>>(() => Object.fromEntries(attributes.map((a) => [a.id, a.placeholder ?? ''])));
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useRef<HTMLOListElement>(null);
  const dirty = JSON.stringify(list) !== JSON.stringify(saved) || fallback !== savedFallback;
  const hit = list.findIndex((r) => matches(r, probe[r.attribute] ?? ''));
  const set = (rid: string, patch: Partial<TargetRule>) => {
    setMsg('');
    setList((l) => l.map((r) => (r.id === rid ? { ...r, ...patch } : r)));
  };
  // Rules glide to their new place when moved
  const before = useRef<Map<string, number>>(new Map());
  if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id ?? '', el.getBoundingClientRect().top]));
  useIso(() => {
    if (!listRef.current || prefersReducedMotion()) return;
    Array.from(listRef.current.children).forEach((el) => {
      const a = before.current.get((el as HTMLElement).dataset.id ?? '');
      const b = el.getBoundingClientRect().top;
      if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 420, easing: KITM_EASE });
    });
  }, [list.map((r) => r.id).join()]);
  const move = (i: number, d: number) => {
    const j = i + d;
    if (j < 0 || j >= list.length) return;
    setList((l) => {
      const c = [...l];
      [c[i], c[j]] = [c[j], c[i]];
      return c;
    });
  };
  const attrLabel = (a: string) => attributes.find((x) => x.id === a)?.label ?? a;
  return (
    <Main className="pb-28">
      <Header title={title} subtitle={subtitle}>
        {flag && <KeyChip k={flag.key} t={t} />}
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <Card title={t.rules} description={t.rulesText} className="kitm-up" bodyClassName="p-0 sm:p-0">
          <ol ref={listRef} className="divide-y divide-border">
            {list.map((r, i) => (
              <li key={r.id} data-id={r.id} className={cn('relative bg-card px-4 py-4 transition-colors duration-300 sm:px-5', hit === i && 'bg-[color-mix(in_oklab,var(--success)_6%,var(--card))]')}>
                {hit === i && <span aria-hidden className="absolute inset-y-0 start-0 w-0.5 bg-success" />}
                <div className="flex items-start gap-3">
                  <span className="mt-2.5 grid size-6 shrink-0 place-items-center rounded-full bg-muted text-[11.5px] font-semibold tabular-nums text-muted-foreground">{i + 1}</span>
                  <div className="grid min-w-0 flex-1 grid-cols-2 gap-2 sm:grid-cols-[auto_minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,1.2fr)] sm:items-center">
                    <span className="hidden text-[12.5px] font-medium text-muted-foreground sm:block">{t.if}</span>
                    <Pick label={t.attribute} value={r.attribute} onChange={(v) => set(r.id, { attribute: v })}>
                      {attributes.map((a) => (
                        <option key={a.id} value={a.id}>
                          {a.label}
                        </option>
                      ))}
                    </Pick>
                    <Pick label={t.operator} value={r.operator} onChange={(v) => set(r.id, { operator: v as TargetRule['operator'] })}>
                      {OPS.map((o) => (
                        <option key={o} value={o}>
                          {t[o]}
                        </option>
                      ))}
                    </Pick>
                    <input aria-label={t.value} value={r.value} placeholder={t.emptyValue} onChange={(e) => set(r.id, { value: e.target.value })} className={cn('col-span-2 h-11 min-w-0 rounded-lg border bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:col-span-1 sm:h-9 sm:text-[13px]', r.value.trim() ? 'border-input' : 'border-[color-mix(in_oklab,var(--warning)_70%,var(--border))]')} />
                  </div>
                </div>
                <div className="mt-2.5 flex flex-wrap items-center gap-2 ps-9">
                  <span className="text-[12.5px] font-medium text-muted-foreground">{t.serve}</span>
                  <Segmented label={t.variant} value={r.serve} onChange={(v) => set(r.id, { serve: v })} options={variants.map((v) => ({ value: v, label: v }))} />
                  <span className="ms-auto flex items-center">
                    <button type="button" aria-label={fill(t.moveUp, { n: i + 1 })} disabled={i === 0} onClick={() => move(i, -1)} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                      <Icon name="chevron-up" size={16} />
                    </button>
                    <button type="button" aria-label={fill(t.moveDown, { n: i + 1 })} disabled={i === list.length - 1} onClick={() => move(i, 1)} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                      <Icon name="chevron-down" size={16} />
                    </button>
                    <button type="button" aria-label={fill(t.removeRule, { n: i + 1 })} onClick={() => setList((l) => l.filter((x) => x.id !== r.id))} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-8">
                      <Icon name="trash" size={15} />
                    </button>
                  </span>
                </div>
              </li>
            ))}
          </ol>
          <div className="border-t border-border px-4 py-3 sm:px-5">
            <Button variant="ghost" size="sm" iconLeft="plus" onClick={() => setList((l) => [...l, { id: `r${Date.now()}`, attribute: attributes[0]?.id ?? '', operator: 'is', value: '', serve: variants[0] ?? '' }])}>
              {t.addRule}
            </Button>
          </div>
          <div className={cn('flex flex-wrap items-center gap-3 border-t border-border bg-surface px-4 py-4 sm:px-5', hit < 0 && 'bg-[color-mix(in_oklab,var(--success)_6%,var(--surface))]')}>
            <span className="text-[13px] font-medium text-foreground">{t.everyoneElse}</span>
            <Segmented label={t.everyoneElse} value={fallback} onChange={setFallback} options={variants.map((v) => ({ value: v, label: v }))} />
          </div>
        </Card>
        {/* Desktop: a test person next to the rules. Tablets and phones: below them. */}
        <Card title={t.test} description={t.testText} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6">
          <div className="grid gap-3">
            {attributes.map((a) => (
              <label key={a.id} htmlFor={`${id}-${a.id}`} className="grid gap-1.5 text-[13px] font-medium text-foreground">
                {a.label}
                <input id={`${id}-${a.id}`} value={probe[a.id] ?? ''} onChange={(e) => setProbe((p) => ({ ...p, [a.id]: e.target.value }))} className="h-11 rounded-lg border border-input bg-background px-3 text-base font-normal text-foreground outline-none focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-sm" />
              </label>
            ))}
          </div>
          <p key={`${hit}-${hit >= 0 ? list[hit].serve : fallback}`} role="status" className="kitm-in mt-4 flex items-start gap-2.5 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] leading-snug text-foreground">
            <Icon name="target" size={15} className="mt-px shrink-0 text-success-foreground" />
            <span>
              {hit >= 0 ? fill(t.matches, { n: hit + 1, v: list[hit].serve }) : fill(t.noRule, { v: fallback })}
              {hit >= 0 && <span className="mt-0.5 block text-[12px] text-muted-foreground">{`${attrLabel(list[hit].attribute)} ${t[list[hit].operator]} ${list[hit].value}`}</span>}
            </span>
          </p>
        </Card>
      </div>
      <SaveBar
        show={dirty || Boolean(msg)}
        dirty={dirty}
        busy={busy}
        message={dirty ? t.unsavedRules : msg}
        saveLabel={t.saveRules}
        t={t}
        onDiscard={() => {
          setList(saved);
          setFallback(savedFallback);
        }}
        onSave={async () => {
          setBusy(true);
          await run(onAction, 'save-rules', { flag: flag?.id, rules: list, fallback });
          setSaved(list);
          setSavedFallback(fallback);
          setBusy(false);
          setMsg(t.savedRules);
        }}
      />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* experiment                                                          */
/* ------------------------------------------------------------------ */

/** Two-sided normal test of the rate difference, returns the confidence 0 to 1 */
function confidence(a: ExperimentArm, b: ExperimentArm) {
  const p1 = a.conversions / Math.max(1, a.visitors);
  const p2 = b.conversions / Math.max(1, b.visitors);
  const p = (a.conversions + b.conversions) / Math.max(1, a.visitors + b.visitors);
  const se = Math.sqrt(p * (1 - p) * (1 / Math.max(1, a.visitors) + 1 / Math.max(1, b.visitors)));
  if (!se) return 0;
  const z = Math.abs(p2 - p1) / se;
  // Normal CDF by a short series (Abramowitz and Stegun 7.1.26)
  const tt = 1 / (1 + 0.3275911 * (z / Math.SQRT2));
  const erf = 1 - ((((1.061405429 * tt - 1.453152027) * tt + 1.421413741) * tt - 0.284496736) * tt + 0.254829592) * tt * Math.exp(-(z * z) / 2);
  return erf;
}

function Experiment({ t, title, subtitle, flag, arms = [], metric, locale, onAction }: P) {
  const [split, setSplit] = useState(arms[0]?.share ?? 50);
  const [saved, setSaved] = useState(arms[0]?.share ?? 50);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const trackRef = useRef<HTMLDivElement>(null);
  const control = arms.find((a) => a.control) ?? arms[0];
  const rate = (a: ExperimentArm) => a.conversions / Math.max(1, a.visitors);
  const best = arms.reduce((b, a) => (rate(a) > rate(b) ? a : b), arms[0]);
  const conf = control && best && best !== control ? confidence(control, best) : 0;
  const maxRate = Math.max(...arms.map(rate), 0.0001);
  const drag = (clientX: number) => {
    const r = trackRef.current?.getBoundingClientRect();
    if (!r) return;
    setSplit(Math.max(5, Math.min(95, Math.round(((clientX - r.left) / r.width) * 100))));
  };
  return (
    <Main className="pb-28">
      <Header title={title} subtitle={subtitle}>
        {flag && <KeyChip k={flag.key} t={t} />}
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-6">
        <Card title={t.split} description={t.splitText} className="kitm-up">
          {arms.length >= 2 && (
            <>
              <div className="flex justify-between text-[13px] font-medium text-foreground">
                <span>
                  {arms[0].name} <span className="tabular-nums text-muted-foreground">{pc(locale, split)}</span>
                </span>
                <span>
                  {arms[1].name} <span className="tabular-nums text-muted-foreground">{pc(locale, 100 - split)}</span>
                </span>
              </div>
              <div
                ref={trackRef}
                className="relative mt-3 flex h-12 touch-none select-none overflow-hidden rounded-lg"
                onPointerDown={(e) => {
                  e.currentTarget.setPointerCapture(e.pointerId);
                  drag(e.clientX);
                }}
                onPointerMove={(e) => e.buttons === 1 && drag(e.clientX)}
              >
                <span className="h-full bg-[color-mix(in_oklab,var(--foreground)_22%,var(--card))] transition-[flex-basis] duration-150" style={{ flexBasis: `${split}%` }} />
                <span className="h-full flex-1 bg-primary" />
                <span
                  role="slider"
                  tabIndex={0}
                  aria-label={fill(t.shareOf, { name: arms[0].name })}
                  aria-valuemin={5}
                  aria-valuemax={95}
                  aria-valuenow={split}
                  aria-valuetext={`${arms[0].name} ${pc(locale, split)}, ${arms[1].name} ${pc(locale, 100 - split)}`}
                  onKeyDown={(e) => {
                    const d = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 0;
                    if (!d) return;
                    e.preventDefault();
                    setSplit((s) => Math.max(5, Math.min(95, s + d * (e.shiftKey ? 10 : 1))));
                  }}
                  className="absolute inset-y-1 grid w-3 -translate-x-1/2 cursor-ew-resize place-items-center rounded-md bg-card shadow-[var(--shadow-md)] outline-none ring-1 ring-border focus-visible:ring-2 focus-visible:ring-primary"
                  style={{ left: `${split}%` }}
                >
                  <span className="h-5 w-0.5 rounded-full bg-border-strong" />
                </span>
              </div>
            </>
          )}
        </Card>
        <section className="kitm-up [--kitm-d:100ms] grid gap-4 md:grid-cols-2">
          {arms.map((a, i) => {
            const r = rate(a);
            const lift = control && a !== control ? r / Math.max(0.0001, rate(control)) - 1 : 0;
            const isBest = a === best && a !== control;
            return (
              <article key={a.id} className={cn('relative rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5', isBest && conf >= 0.95 ? 'border-[color-mix(in_oklab,var(--success)_50%,var(--border))]' : 'border-border')}>
                <div className="flex items-start justify-between gap-3">
                  <p className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
                    <span aria-hidden className={cn('size-2.5 rounded-sm', i === 0 ? 'bg-[color-mix(in_oklab,var(--foreground)_40%,var(--card))]' : 'bg-primary')} />
                    {a.name}
                    {a.control && <span className="rounded bg-muted px-1.5 py-px text-[11px] font-medium text-muted-foreground">{t.control}</span>}
                  </p>
                  {isBest && (
                    <span className={cn('rounded px-1.5 py-0.5 text-[11.5px] font-semibold', conf >= 0.95 ? 'bg-[color-mix(in_oklab,var(--success)_15%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
                      {conf >= 0.95 ? t.winner : t.tooEarly} · {fill(t.confidence, { n: Math.round(conf * 100) })}
                    </span>
                  )}
                </div>
                <p className="mt-4 flex items-baseline gap-3">
                  <span className="text-[32px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{pctFmt(locale, r)}</span>
                  <span className="text-[13px] text-muted-foreground">{metric ?? t.rate}</span>
                  {a !== control && (
                    <span className={cn('text-[13px] font-semibold tabular-nums', lift >= 0 ? 'text-success-foreground' : 'text-destructive')}>
                      {lift >= 0 ? '+' : '−'}
                      {pctFmt(locale, Math.abs(lift))} <span className="font-normal text-muted-foreground">{t.lift}</span>
                    </span>
                  )}
                </p>
                <span aria-hidden className="mt-4 block h-2 overflow-hidden rounded-full bg-track">
                  <span className={cn('kitm-grow-x block h-full rounded-full', i === 0 ? 'bg-[color-mix(in_oklab,var(--foreground)_40%,var(--card))]' : 'bg-primary')} style={{ width: `${round((r / maxRate) * 100)}%`, '--kitm-d': `${200 + i * 100}ms` } as React.CSSProperties} />
                </span>
                <dl className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border text-[13px]">
                  <div className="bg-surface p-3">
                    <dt className="text-[12px] text-muted-foreground">{t.visitors}</dt>
                    <dd className="mt-0.5 font-medium tabular-nums text-foreground">{numFmt(locale, a.visitors)}</dd>
                  </div>
                  <div className="bg-surface p-3">
                    <dt className="text-[12px] text-muted-foreground">{t.conversions}</dt>
                    <dd className="mt-0.5 font-medium tabular-nums text-foreground">{numFmt(locale, a.conversions)}</dd>
                  </div>
                </dl>
              </article>
            );
          })}
        </section>
      </div>
      <SaveBar
        show={split !== saved || Boolean(msg)}
        dirty={split !== saved}
        busy={busy}
        message={split !== saved ? `${saved} / ${100 - saved} → ${split} / ${100 - split}` : msg}
        saveLabel={t.applySplit}
        t={t}
        onDiscard={() => setSplit(saved)}
        onSave={async () => {
          setBusy(true);
          await run(onAction, 'split', { flag: flag?.id, split });
          setSaved(split);
          setBusy(false);
          setMsg(fill(t.splitSaved, { a: split, b: 100 - split }));
        }}
      />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* environments                                                        */
/* ------------------------------------------------------------------ */

function Environments({ t, title, subtitle, flag, environments = [], configs = [], locale, today, onAction }: P) {
  const [list, setList] = useState(configs);
  const [busy, setBusy] = useState('');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const envName = (e: string) => environments.find((x) => x.id === e)?.name ?? e;
  const fields: { key: keyof EnvConfig; label: string; show: (c: EnvConfig) => React.ReactNode }[] = [
    { key: 'on', label: t.status, show: (c) => <span className={cn('inline-flex items-center gap-1.5 font-medium', c.on ? 'text-success-foreground' : 'text-muted-foreground')}><span aria-hidden className={cn('size-1.5 rounded-full', c.on ? 'bg-success' : 'bg-subtle-foreground')} />{c.on ? t.on : t.off}</span> },
    { key: 'rollout', label: t.rollout, show: (c) => <span className="tabular-nums">{pc(locale, c.rollout)}</span> },
    { key: 'rules', label: t.rules, show: (c) => <span className="tabular-nums">{c.rules === 1 ? t.rulesOne : fill(t.rulesN, { n: c.rules })}</span> },
    { key: 'fallback', label: t.fallback, show: (c) => c.fallback },
  ];
  const differs = (i: number, k: keyof EnvConfig) => i > 0 && list[i][k] !== list[i - 1][k];
  const promote = async (i: number) => {
    const from = list[i - 1];
    const to = list[i];
    setBusy(to.env);
    await run(onAction, 'promote', { flag: flag?.id, from: from.env, to: to.env });
    setList((l) => l.map((c, j) => (j === i ? { ...c, on: from.on, rollout: from.rollout, rules: from.rules, fallback: from.fallback, updated: today, by: t.you } : c)));
    setBusy('');
    setMsg(fill(t.promoted, { to: envName(to.env), from: envName(from.env) }));
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        {flag && <KeyChip k={flag.key} t={t} />}
      </Header>
      <p className="mt-2 text-[13.5px] text-muted-foreground">{t.envsText}</p>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[repeat(var(--n),minmax(0,1fr))] lg:gap-0" style={{ '--n': list.length } as React.CSSProperties}>
        {list.map((c, i) => {
          const changed = fields.some((x) => differs(i, x.key));
          return (
            <div key={c.env} className="contents">
              <article className={cn('kitm-up relative flex min-w-0 flex-col rounded-xl border bg-card shadow-[var(--highlight),var(--shadow-sm)]', changed ? 'border-[color-mix(in_oklab,var(--warning)_45%,var(--border))]' : 'border-border', 'lg:mx-2.5 lg:first:ms-0 lg:last:me-0')} style={{ '--kitm-d': `${i * 90}ms` } as React.CSSProperties}>
                <header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
                  <h2 className="text-[15px] font-semibold text-foreground">{envName(c.env)}</h2>
                  {i > 0 && (
                    <span key={String(changed)} className={cn('kitm-in rounded px-1.5 py-0.5 text-[11.5px] font-semibold', changed ? 'bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground')}>
                      {changed ? fill(t.differs, { env: envName(list[i - 1].env) }) : fill(t.same, { env: envName(list[i - 1].env) })}
                    </span>
                  )}
                </header>
                <dl className="flex-1 divide-y divide-border">
                  {fields.map((x) => (
                    <div key={x.key} className={cn('flex items-center justify-between gap-3 px-4 py-3 text-[13.5px] transition-colors duration-500 sm:px-5', differs(i, x.key) && 'bg-[color-mix(in_oklab,var(--warning)_9%,transparent)]')}>
                      <dt className="text-muted-foreground">{x.label}</dt>
                      <dd key={String(c[x.key])} className="kitm-in min-w-0 truncate text-right text-foreground">
                        {x.show(c)}
                      </dd>
                    </div>
                  ))}
                </dl>
                <footer className="border-t border-border px-4 py-3 text-[12px] text-subtle-foreground sm:px-5">
                  {c.updated ? `${fill(t.updated, { date: dateFmt(locale, c.updated) })}${c.by ? ` · ${fill(t.by, { name: c.by })}` : ''}` : '·'}
                </footer>
              </article>
              {/* Between two environments: the way to copy the left one over */}
              {i < list.length - 1 && list.length > 1 && (
                <div className="flex justify-center lg:hidden">
                  <Button size="sm" variant="outline" iconLeft={busy === list[i + 1].env ? 'loader' : 'arrow-down'} disabled={!fields.some((x) => differs(i + 1, x.key)) || Boolean(busy)} onClick={() => promote(i + 1)}>
                    {busy === list[i + 1].env ? t.promoting : fill(t.promote, { from: envName(c.env), to: envName(list[i + 1].env) })}
                  </Button>
                </div>
              )}
            </div>
          );
        })}
      </div>
      {/* Desktop: the copy actions in a row under the columns */}
      <div className="mt-4 hidden lg:grid lg:grid-cols-[repeat(var(--n),minmax(0,1fr))]" style={{ '--n': list.length } as React.CSSProperties}>
        <span />
        {list.slice(1).map((c, j) => (
          <div key={c.env} className="flex justify-center px-2.5">
            <Button size="sm" variant="outline" iconLeft={busy === c.env ? 'loader' : 'arrow-right'} disabled={!fields.some((x) => differs(j + 1, x.key)) || Boolean(busy)} onClick={() => promote(j + 1)}>
              {busy === c.env ? t.promoting : fill(t.promote, { from: envName(list[j].env), to: envName(c.env) })}
            </Button>
          </div>
        ))}
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* FeatureFlags                                                        */
/* ------------------------------------------------------------------ */

/**
 * Feature flag screens: a list with switches per environment, a staged
 * rollout with a kill switch, targeting rules with a test person, an A/B
 * split with results and a comparison across environments with copy.
 *   <FeatureFlags variant="flag-list" title="Flags" environments={…} flags={…} />
 */
export function FeatureFlags({ variant = 'flag-list', sidebar, labels, className, today = '2026-10-19', ...props }: FeatureFlagsProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, today, locale };
  const body = (() => {
    switch (variant) {
      case 'rollout':
        return <Rollout {...p} />;
      case 'targeting':
        return <Targeting {...p} />;
      case 'experiment':
        return <Experiment {...p} />;
      case 'environments':
        return <Environments {...p} />;
      default:
        return <FlagList {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich Feature Flags?

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

Welche Varianten hat Feature Flags?

5 Varianten, Flag-Liste, Stufenweises Ausrollen, Zielregeln, A/B-Experiment und Umgebungen. Du wählst sie mit der Prop variant, zum Beispiel variant="rollout".

Kann ich die Texte von Feature Flags übersetzen?

Ja. Feature Flags bringt 86 eigene Texte auf Deutsch und Englisch mit, etwa „Flags suchen“, „Verwerfen“ und „Alle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Feature Flags einbauen?

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

Kann ich Feature Flags ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert