Skip to content
UI Kit
Accent color

Feature flags

Feature flag screens. 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.

One of 3 components in Developer settings

5 variants

Variants

Flag list

Every flag with its key to copy, owner, rollout and a switch per environment, filtered by stale flags or search.

Open in Studio

Staged rollout

A share slider with 100 people that fill in, the steps of the rollout on a line and a kill switch that keeps the share.

Open in Studio

Targeting rules

If and then rules in order with attribute, condition and value, a default for everyone else and a test person that shows which rule matches.

Open in Studio

A/B experiment

A split bar to drag or move with the keys, both variants with rate, lift against control and how sure the result is.

Open in Studio

Environments

The flag side by side in each environment, differences marked, and a copy from one environment to the next.

Open in Studio

Details

What Feature flags brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.1 · since 1.1.0
File
components/ui/feature-flags.tsx
Brings along
utilskitbuttoniconinputswitchsidebar
npm packages
None
Languages
English and German, 86 texts of its own

Props

The FeatureFlagsProps interface from components/ui/feature-flags.tsx, with the comments from the code.

  • variantoptional
    FeatureFlagsVariant

    flag-listrollouttargetingexperimentenvironments

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

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

  • titlerequired
    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.

Built-in texts

Feature flags writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 86 texts
searchEnglish Search flagsGerman Flags suchen
discardEnglish DiscardGerman Verwerfen
allEnglish AllGerman Alle
staleEnglish StaleGerman Veraltet
staleHintEnglish Unchanged for over 60 days. Remove it from the code when you can.German Seit über 60 Tagen unverändert. Entferne es aus dem Code, sobald es geht.
flagEnglish FlagGerman Flag
ownerEnglish OwnerGerman Verantwortlich
updatedEnglish Changed {date}German Geändert {date}
toggleEnglish {flag} in {env}German {flag} in {env}
turnedOnEnglish {flag} is on in {env}.German {flag} ist in {env} an.
turnedOffEnglish {flag} is off in {env}.German {flag} ist in {env} aus.
rolloutNEnglish {n}% rolloutGerman {n} % ausgerollt
noMatchEnglish No flag matches your search.German Kein Flag passt zur Suche.
clearEnglish Clear searchGerman Suche leeren
copyKeyEnglish Copy key {key}German Schlüssel {key} kopieren
copiedEnglish CopiedGerman Kopiert
rolloutEnglish RolloutGerman Ausrollen
rolloutTextEnglish Share of signed in users in production who get the feature. The same user always lands on the same side.German Anteil der angemeldeten Nutzer in Produktion, die die Funktion bekommen. Dieselbe Person landet immer auf derselben Seite.
usersEnglish {n} of 100 usersGerman {n} von 100 Nutzern
stagesTitleEnglish Staged rolloutGerman Stufenweise ausrollen
stagesTextEnglish Raise the share step by step and watch the error rate in between.German Erhöhe den Anteil Schritt für Schritt und beobachte dazwischen die Fehlerquote.
stageEnglish Step {n}German Stufe {n}
currentEnglish NowGerman Jetzt
nextStepEnglish Go to {n}%German Auf {n} % gehen
fullEnglish Released to everyoneGerman Für alle freigegeben
killEnglish Turn off for everyoneGerman Für alle ausschalten
killTextEnglish Stops the feature in production right away, the rollout stays saved.German Stoppt die Funktion in Produktion sofort, der Ausrollstand bleibt gespeichert.
killedEnglish Off for everyone. Turn it on again to continue at {n}%.German Für alle aus. Schalte es wieder ein, um bei {n} % weiterzumachen.
resumeEnglish Turn on againGerman Wieder einschalten
saveEnglish Save rolloutGerman Ausrollen speichern
savingEnglish Saving…German Wird gespeichert…
savedEnglish Rollout saved at {n}%.German Bei {n} % gespeichert.
rulesEnglish Targeting rulesGerman Zielregeln
rulesTextEnglish Rules are checked from top to bottom. The first match decides.German Regeln werden von oben nach unten geprüft. Der erste Treffer entscheidet.
ifEnglish IfGerman Wenn
serveEnglish then serveGerman dann
everyoneElseEnglish Everyone else getsGerman Alle anderen bekommen
addRuleEnglish Add ruleGerman Regel hinzufügen
removeRuleEnglish Remove rule {n}German Regel {n} entfernen
moveUpEnglish Move rule {n} upGerman Regel {n} nach oben
moveDownEnglish Move rule {n} downGerman Regel {n} nach unten
attributeEnglish AttributeGerman Merkmal
operatorEnglish OperatorGerman Bedingung
valueEnglish ValueGerman Wert
isEnglish isGerman ist
is-notEnglish is notGerman ist nicht
containsEnglish containsGerman enthält
ends-withEnglish ends withGerman endet auf
inEnglish is one ofGerman ist eins von
variantEnglish VariantGerman Variante
testEnglish Try a userGerman Nutzer ausprobieren
testTextEnglish Fill in attributes and see which rule matches.German Merkmale eintragen und sehen, welche Regel greift.
matchesEnglish Rule {n} matches, this user gets {v}German Regel {n} greift, diese Person bekommt {v}
noRuleEnglish No rule matches, this user gets {v}German Keine Regel greift, diese Person bekommt {v}
emptyValueEnglish Fill in a valueGerman Wert eintragen
saveRulesEnglish Save rulesGerman Regeln speichern
savedRulesEnglish Rules saved.German Regeln gespeichert.
experimentEnglish ExperimentGerman Experiment
splitEnglish Traffic splitGerman Aufteilung
splitTextEnglish Drag the handle or use the arrow keys.German Griff ziehen oder Pfeiltasten nutzen.
visitorsEnglish VisitorsGerman Besucher
conversionsEnglish ConversionsGerman Abschlüsse
rateEnglish RateGerman Quote
liftEnglish vs. controlGerman ggü. Kontrolle
controlEnglish ControlGerman Kontrolle
winnerEnglish Likely betterGerman Wahrscheinlich besser
tooEarlyEnglish Too early to callGerman Noch zu früh
confidenceEnglish {n}% sureGerman {n} % sicher
applySplitEnglish Apply splitGerman Aufteilung übernehmen
splitSavedEnglish Split of {a}% and {b}% saved.German Aufteilung von {a} % und {b} % gespeichert.
shareOfEnglish Share of {name}German Anteil von {name}
envsTextEnglish The same flag in each environment. Differences are marked.German Dasselbe Flag in jeder Umgebung. Unterschiede sind markiert.
statusEnglish StatusGerman Status
onEnglish OnGerman An
offEnglish OffGerman Aus
rulesNEnglish {n} rulesGerman {n} Regeln
rulesOneEnglish 1 ruleGerman 1 Regel
unsavedRulesEnglish Unsaved changes to the rulesGerman Ungespeicherte Änderungen an den Regeln
youEnglish youGerman dir
fallbackEnglish Everyone elseGerman Alle anderen
differsEnglish Differs from {env}German Anders als {env}
promoteEnglish Copy {from} to {to}German {from} nach {to} kopieren
promotingEnglish Copying…German Wird kopiert…
promotedEnglish {to} now matches {from}.German {to} entspricht jetzt {from}.
sameEnglish Same as {env}German Wie {env}
byEnglish by {name}German von {name}

Accessibility

Read from the source of the component.

  • Feature flags handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles radio, radiogroup, slider and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,235 lines
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>
  );
}

Frequent questions

How do I install Feature flags?

With aiw add @ai-website/feature-flags. The command puts components/ui/feature-flags.tsx into your project, the code is yours from then on. It brings along button, icon, input, switch and sidebar. It needs no further npm packages.

Which variants does Feature flags have?

5 variants, Flag list, Staged rollout, Targeting rules, A/B experiment and Environments. You pick one with the variant prop, for example variant="rollout".

Can I translate the texts of Feature flags?

Yes. Feature flags carries 86 texts of its own in English and German, such as “Search flags”, “Discard” and “All”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Feature flags?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Feature flags with aiw_components and install it with aiw_add @ai-website/feature-flags. Name the variant in your prompt, for example variant="flag-list", and the assistant writes your content straight into the props.

Can I adjust Feature flags without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching FeatureFlags code to copy.

Often searched

Jump to

Copied to the clipboard