Zum Inhalt springen
UI Kit
Accent

Automationsregeln

Automations-Screens. Wenn-dann-Regeln mit Schaltern, ein Editor, der die Regel als Satz vorliest, Vorlagen, ein Laufprotokoll mit Wiederholen und ein Zeitplan mit den nächsten Läufen.

Eine von 3 Components in Automation und Builder

5 Variants

Variants

Regelliste

Jede Regel als Kette vom Auslöser zu den Aktionen, Läufe und letzter Lauf, ein Schalter zum Pausieren und eine Marke, wenn der letzte Lauf fehlschlug.

Im Studio öffnen

Regeleditor

Wenn, falls und dann als drei verbundene Karten, alle oder eine Bedingung, sortierte Aktionen, die Regel als ein Satz und ein Test mit dem letzten Ereignis.

Im Studio öffnen

Regelvorlagen

Vorlagen nach Kategorie mit ihren Schritten als kleiner Ablauf, wie viele Teams sie nutzen, und Einrichten mit einem Klick.

Im Studio öffnen

Laufprotokoll

Jeder Lauf mit Ergebnis, Anlass und Dauer, die Schritte auf einer Linie mit dem Grund, warum ein Schritt fehlschlug, und Wiederholen.

Im Studio öffnen

Zeitplan

Stündlich, täglich, wöchentlich oder monatlich, Tage als Schalter, die Regel in Worten und die nächsten fünf Läufe live berechnet.

Im Studio öffnen

Details

Was Automationsregeln mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/automation-rules.tsx
Bringt mit
utilskitbuttoniconswitchsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 82 eigene Texte

Props

Die Schnittstelle AutomationRulesProps aus components/ui/automation-rules.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AutomationRulesVariant

    rule-listrule-editortemplatesrun-logschedule

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

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

  • titlePflicht
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • triggersoptional
    RuleOption[]
  • fieldsoptional
    RuleOption[]
  • actionsoptional
    RuleOption[]
  • rulesoptional
    Rule[]
  • ruleoptional
    Rule

    rule-editor: the rule on screen

  • templatesoptional
    RuleTemplate[]
  • runsoptional
    RuleRun[]
  • timezoneoptional
    string

    schedule

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

    Called on save, toggle, retry and use. Without it the block simulates the request.

Eingebaute Texte

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

Alle 82 Texte anzeigen
rulesEnglisch RulesDeutsch Regeln
whenEnglisch WhenDeutsch Wenn
sentenceIfEnglisch ifDeutsch und
thenEnglisch ThenDeutsch Dann
andEnglisch andDeutsch und
orEnglisch orDeutsch oder
allEnglisch AllDeutsch Alle
activeEnglisch ActiveDeutsch Aktiv
pausedEnglisch PausedDeutsch Pausiert
failingEnglisch Needs attentionDeutsch Braucht Aufmerksamkeit
runsEnglisch {n} runsDeutsch {n} Läufe
lastRunEnglisch Last run {when}Deutsch Zuletzt {when}
neverEnglisch Not run yetDeutsch Noch nicht gelaufen
toggleEnglisch Run {name}Deutsch {name} ausführen
newRuleEnglisch New ruleDeutsch Neue Regel
searchEnglisch Search rulesDeutsch Regeln suchen
noMatchEnglisch No rule matches.Deutsch Keine Regel passt.
triggerEnglisch TriggerDeutsch Auslöser
conditionsEnglisch ConditionsDeutsch Bedingungen
oneConditionEnglisch + 1 conditionDeutsch + 1 Bedingung
nConditionsEnglisch + {n} conditionsDeutsch + {n} Bedingungen
actionsEnglisch ActionsDeutsch Aktionen
matchAllEnglisch All conditionsDeutsch Alle Bedingungen
matchAnyEnglisch Any conditionDeutsch Eine Bedingung
fieldEnglisch FieldDeutsch Feld
opEnglisch ConditionDeutsch Bedingung
valueEnglisch ValueDeutsch Wert
isEnglisch isDeutsch ist
is-notEnglisch is notDeutsch ist nicht
greaterEnglisch is more thanDeutsch ist größer als
lessEnglisch is less thanDeutsch ist kleiner als
containsEnglisch containsDeutsch enthält
addConditionEnglisch Add conditionDeutsch Bedingung hinzufügen
addActionEnglisch Add actionDeutsch Aktion hinzufügen
removeEnglisch Remove {what}Deutsch {what} entfernen
moveUpEnglisch Move action {n} upDeutsch Aktion {n} nach oben
targetEnglisch WithDeutsch Mit
sentenceEnglisch In one sentenceDeutsch In einem Satz
nameEnglisch Rule nameDeutsch Name der Regel
saveEnglisch Save ruleDeutsch Regel speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedEnglisch Rule saved and active.Deutsch Regel gespeichert und aktiv.
testEnglisch Test with the last eventDeutsch Mit dem letzten Ereignis testen
testingEnglisch Testing…Deutsch Wird getestet…
testedEnglisch Every condition held, {n} actions would run.Deutsch Alle Bedingungen erfüllt, {n} Aktionen würden laufen.
noActionsEnglisch Add at least one action.Deutsch Füge mindestens eine Aktion hinzu.
templatesEnglisch TemplatesDeutsch Vorlagen
allCategoriesEnglisch AllDeutsch Alle
useEnglisch Use templateDeutsch Vorlage nutzen
usingEnglisch Setting up…Deutsch Wird eingerichtet…
usedEnglisch {name} is set up. Check the details and turn it on.Deutsch {name} ist eingerichtet. Prüfe die Details und schalte sie ein.
usesNEnglisch Used by {n} teamsDeutsch Von {n} Teams genutzt
stepsEnglisch StepsDeutsch Schritte
runLogEnglisch RunsDeutsch Läufe
successEnglisch SucceededDeutsch Erfolgreich
failedEnglisch FailedDeutsch Fehlgeschlagen
skippedEnglisch SkippedDeutsch Übersprungen
retryEnglisch Run againDeutsch Erneut ausführen
retryingEnglisch Running…Deutsch Läuft…
retriedEnglisch Run again, it succeeded this time.Deutsch Erneut ausgeführt, diesmal erfolgreich.
durationEnglisch {ms} msDeutsch {ms} ms
showStepsEnglisch Show steps of the run for {subject}Deutsch Schritte des Laufs für {subject} zeigen
scheduleEnglisch ScheduleDeutsch Zeitplan
scheduleTextEnglisch Runs the rule on its own at the times you pick.Deutsch Führt die Regel zu den gewählten Zeiten von selbst aus.
everyEnglisch RepeatDeutsch Wiederholen
hourlyEnglisch HourlyDeutsch Stündlich
dailyEnglisch DailyDeutsch Täglich
weeklyEnglisch WeeklyDeutsch Wöchentlich
monthlyEnglisch MonthlyDeutsch Monatlich
onEnglisch OnDeutsch Am
atEnglisch AtDeutsch Um
atMinuteEnglisch At minuteDeutsch Zur Minute
dayOfMonthEnglisch Day of the monthDeutsch Tag im Monat
timezoneEnglisch Time zoneDeutsch Zeitzone
nextEnglisch Next runsDeutsch Nächste Läufe
pickDayEnglisch Pick at least one day.Deutsch Wähle mindestens einen Tag.
saveScheduleEnglisch Save scheduleDeutsch Zeitplan speichern
scheduleSavedEnglisch Schedule saved. Next run {when}.Deutsch Zeitplan gespeichert. Nächster Lauf {when}.
summaryHourlyEnglisch Every hour at minute {m}Deutsch Jede Stunde zur Minute {m}
summaryDailyEnglisch Every day at {t}Deutsch Jeden Tag um {t}
summaryWeeklyEnglisch Every {days} at {t}Deutsch Jeden {days} um {t}
summaryMonthlyEnglisch On day {d} of every month at {t}Deutsch Am {d}. jeden Monats um {t}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

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

Bewegung

  • 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.181 Zeilen
components/ui/automation-rules.tsx
'use client';

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

export type AutomationRulesVariant = 'rule-list' | 'rule-editor' | 'templates' | 'run-log' | 'schedule';

export interface RuleOption {
  id: string;
  label: string;
  icon?: IconName;
}

export interface RuleCondition {
  id: string;
  field: string;
  op: 'is' | 'is-not' | 'greater' | 'less' | 'contains';
  value: string;
}

export interface RuleAction {
  id: string;
  /** Option id from actions */
  type: string;
  /** What the action works with, e.g. a channel or a person */
  target: string;
}

export interface Rule {
  id: string;
  name: string;
  /** Option id from triggers */
  trigger: string;
  conditions: RuleCondition[];
  /** all: every condition must hold, any: one is enough */
  match?: 'all' | 'any';
  actions: RuleAction[];
  enabled: boolean;
  runs?: number;
  /** ISO date and time */
  lastRun?: string;
  /** The last run failed */
  failing?: boolean;
}

export interface RuleTemplate {
  id: string;
  name: string;
  description: string;
  category: string;
  /** Short steps, e.g. ["New form entry", "Create contact", "Send welcome email"] */
  steps: string[];
  icon?: IconName;
  uses?: number;
}

export interface RuleRun {
  id: string;
  rule: string;
  /** ISO date and time */
  time: string;
  status: 'success' | 'failed' | 'skipped';
  /** What started it, e.g. Deal #482 */
  subject: string;
  ms: number;
  steps: { label: string; status: 'success' | 'failed' | 'skipped'; detail?: string }[];
}

export interface AutomationRulesProps {
  variant?: AutomationRulesVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** ISO date and time that counts as now */
  now?: string;
  triggers?: RuleOption[];
  fields?: RuleOption[];
  actions?: RuleOption[];
  rules?: Rule[];
  /** rule-editor: the rule on screen */
  rule?: Rule;
  templates?: RuleTemplate[];
  runs?: RuleRun[];
  /** schedule */
  timezone?: string;
  /** Called on save, toggle, retry and use. 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: {
    rules: 'Rules',
    when: 'When',
    sentenceIf: 'if',
    then: 'Then',
    and: 'and',
    or: 'or',
    all: 'All',
    active: 'Active',
    paused: 'Paused',
    failing: 'Needs attention',
    runs: '{n} runs',
    lastRun: 'Last run {when}',
    never: 'Not run yet',
    toggle: 'Run {name}',
    newRule: 'New rule',
    search: 'Search rules',
    noMatch: 'No rule matches.',
    trigger: 'Trigger',
    conditions: 'Conditions',
    oneCondition: '+ 1 condition',
    nConditions: '+ {n} conditions',
    actions: 'Actions',
    matchAll: 'All conditions',
    matchAny: 'Any condition',
    field: 'Field',
    op: 'Condition',
    value: 'Value',
    is: 'is',
    'is-not': 'is not',
    greater: 'is more than',
    less: 'is less than',
    contains: 'contains',
    addCondition: 'Add condition',
    addAction: 'Add action',
    remove: 'Remove {what}',
    moveUp: 'Move action {n} up',
    target: 'With',
    sentence: 'In one sentence',
    name: 'Rule name',
    save: 'Save rule',
    saving: 'Saving…',
    saved: 'Rule saved and active.',
    test: 'Test with the last event',
    testing: 'Testing…',
    tested: 'Every condition held, {n} actions would run.',
    noActions: 'Add at least one action.',
    templates: 'Templates',
    allCategories: 'All',
    use: 'Use template',
    using: 'Setting up…',
    used: '{name} is set up. Check the details and turn it on.',
    usesN: 'Used by {n} teams',
    steps: 'Steps',
    runLog: 'Runs',
    success: 'Succeeded',
    failed: 'Failed',
    skipped: 'Skipped',
    retry: 'Run again',
    retrying: 'Running…',
    retried: 'Run again, it succeeded this time.',
    duration: '{ms} ms',
    showSteps: 'Show steps of the run for {subject}',
    schedule: 'Schedule',
    scheduleText: 'Runs the rule on its own at the times you pick.',
    every: 'Repeat',
    hourly: 'Hourly',
    daily: 'Daily',
    weekly: 'Weekly',
    monthly: 'Monthly',
    on: 'On',
    at: 'At',
    atMinute: 'At minute',
    dayOfMonth: 'Day of the month',
    timezone: 'Time zone',
    next: 'Next runs',
    pickDay: 'Pick at least one day.',
    saveSchedule: 'Save schedule',
    scheduleSaved: 'Schedule saved. Next run {when}.',
    summaryHourly: 'Every hour at minute {m}',
    summaryDaily: 'Every day at {t}',
    summaryWeekly: 'Every {days} at {t}',
    summaryMonthly: 'On day {d} of every month at {t}',
  },
  de: {
    rules: 'Regeln',
    when: 'Wenn',
    sentenceIf: 'und',
    then: 'Dann',
    and: 'und',
    or: 'oder',
    all: 'Alle',
    active: 'Aktiv',
    paused: 'Pausiert',
    failing: 'Braucht Aufmerksamkeit',
    runs: '{n} Läufe',
    lastRun: 'Zuletzt {when}',
    never: 'Noch nicht gelaufen',
    toggle: '{name} ausführen',
    newRule: 'Neue Regel',
    search: 'Regeln suchen',
    noMatch: 'Keine Regel passt.',
    trigger: 'Auslöser',
    conditions: 'Bedingungen',
    oneCondition: '+ 1 Bedingung',
    nConditions: '+ {n} Bedingungen',
    actions: 'Aktionen',
    matchAll: 'Alle Bedingungen',
    matchAny: 'Eine Bedingung',
    field: 'Feld',
    op: 'Bedingung',
    value: 'Wert',
    is: 'ist',
    'is-not': 'ist nicht',
    greater: 'ist größer als',
    less: 'ist kleiner als',
    contains: 'enthält',
    addCondition: 'Bedingung hinzufügen',
    addAction: 'Aktion hinzufügen',
    remove: '{what} entfernen',
    moveUp: 'Aktion {n} nach oben',
    target: 'Mit',
    sentence: 'In einem Satz',
    name: 'Name der Regel',
    save: 'Regel speichern',
    saving: 'Wird gespeichert…',
    saved: 'Regel gespeichert und aktiv.',
    test: 'Mit dem letzten Ereignis testen',
    testing: 'Wird getestet…',
    tested: 'Alle Bedingungen erfüllt, {n} Aktionen würden laufen.',
    noActions: 'Füge mindestens eine Aktion hinzu.',
    templates: 'Vorlagen',
    allCategories: 'Alle',
    use: 'Vorlage nutzen',
    using: 'Wird eingerichtet…',
    used: '{name} ist eingerichtet. Prüfe die Details und schalte sie ein.',
    usesN: 'Von {n} Teams genutzt',
    steps: 'Schritte',
    runLog: 'Läufe',
    success: 'Erfolgreich',
    failed: 'Fehlgeschlagen',
    skipped: 'Übersprungen',
    retry: 'Erneut ausführen',
    retrying: 'Läuft…',
    retried: 'Erneut ausgeführt, diesmal erfolgreich.',
    duration: '{ms} ms',
    showSteps: 'Schritte des Laufs für {subject} zeigen',
    schedule: 'Zeitplan',
    scheduleText: 'Führt die Regel zu den gewählten Zeiten von selbst aus.',
    every: 'Wiederholen',
    hourly: 'Stündlich',
    daily: 'Täglich',
    weekly: 'Wöchentlich',
    monthly: 'Monatlich',
    on: 'Am',
    at: 'Um',
    atMinute: 'Zur Minute',
    dayOfMonth: 'Tag im Monat',
    timezone: 'Zeitzone',
    next: 'Nächste Läufe',
    pickDay: 'Wähle mindestens einen Tag.',
    saveSchedule: 'Zeitplan speichern',
    scheduleSaved: 'Zeitplan gespeichert. Nächster Lauf {when}.',
    summaryHourly: 'Jede Stunde zur Minute {m}',
    summaryDaily: 'Jeden Tag um {t}',
    summaryWeekly: 'Jeden {days} um {t}',
    summaryMonthly: 'Am {d}. jeden Monats um {t}',
  },
};

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 MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);

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

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

function ago(locale: Locale, iso: string, now: string) {
  const rtf = new Intl.RelativeTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { numeric: 'auto' });
  const d = toMin(iso) - toMin(now);
  const a = Math.abs(d);
  if (a < 60) return rtf.format(Math.round(d), 'minute');
  if (a < 1440) return rtf.format(Math.round(d / 60), 'hour');
  return rtf.format(Math.round(d / 1440), 'day');
}

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 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 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>
  );
}

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

/** 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>
  );
}

const field = 'h-11 w-full min-w-0 rounded-lg border border-input 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:h-9 sm:text-[13px]';

/** A trigger or an action as a small tile with its icon */
function Node({ icon, label, tone = 'muted' }: { icon?: IconName; label: string; tone?: 'muted' | 'trigger' | 'action' }) {
  return (
    <span className={cn('inline-flex h-7 max-w-full items-center gap-1.5 rounded-md border px-2 text-[12.5px] font-medium', tone === 'trigger' ? 'border-[color-mix(in_oklab,var(--warning)_40%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_12%,var(--card))] text-foreground' : tone === 'action' ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] text-foreground' : 'border-border bg-surface text-muted-foreground')}>
      {icon && <Icon name={icon} size={13} className={cn('shrink-0', tone === 'trigger' ? 'text-warning-foreground' : tone === 'action' ? 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : '')} />}
      <span className="truncate">{label}</span>
    </span>
  );
}

const STATUS: Record<RuleRun['status'], { icon: IconName; tone: string; dot: string }> = {
  success: { icon: 'check', tone: 'text-success-foreground', dot: 'bg-success' },
  failed: { icon: 'x', tone: 'text-destructive', dot: 'bg-destructive' },
  skipped: { icon: 'minus', tone: 'text-muted-foreground', dot: 'bg-subtle-foreground' },
};

/* ------------------------------------------------------------------ */
/* rule-list                                                           */
/* ------------------------------------------------------------------ */

function RuleList({ t, title, subtitle, rules = [], triggers = [], actions = [], now, locale, onAction }: P) {
  const [items, setItems] = useState(rules);
  const [q, setQ] = useState('');
  const [filter, setFilter] = useState<'all' | 'active' | 'paused'>('all');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const tr = (id: string) => triggers.find((x) => x.id === id);
  const ac = (id: string) => actions.find((x) => x.id === id);
  const shown = items.filter((r) => (filter === 'all' || (filter === 'active' ? r.enabled : !r.enabled)) && (!q || r.name.toLowerCase().includes(q.toLowerCase())));
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Button size="sm" iconLeft="plus" magnetic onClick={() => void onAction?.('new', {})}>
          {t.newRule}
        </Button>
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-4">
        <div className="kitm-up flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <Segmented
            label={t.rules}
            value={filter}
            onChange={setFilter}
            className="w-full sm:w-auto"
            options={[
              { value: 'all', label: t.all },
              { value: 'active', label: <>{t.active}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((r) => r.enabled).length}</span></> },
              { value: 'paused', label: <>{t.paused}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((r) => !r.enabled).length}</span></> },
            ]}
          />
          <label className="relative block sm:w-64">
            <span className="sr-only">{t.search}</span>
            <Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
          </label>
        </div>
        <ul className="grid gap-3">
          {shown.map((r, i) => (
            <li key={r.id} className={cn('kitm-up rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-opacity duration-300 sm:p-5', r.failing && r.enabled ? 'border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))]' : 'border-border', !r.enabled && 'opacity-75')} style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
              <div className="flex items-start gap-4">
                <div className="min-w-0 flex-1">
                  <p className="flex flex-wrap items-center gap-2">
                    <span className="text-[15px] font-semibold text-foreground">{r.name}</span>
                    {r.failing && r.enabled && (
                      <span className="inline-flex items-center gap-1 rounded bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] px-1.5 py-px text-[11px] font-semibold text-destructive">
                        <Icon name="alert-circle" size={11} />
                        {t.failing}
                      </span>
                    )}
                  </p>
                  {/* The rule as a chain: trigger, then each action */}
                  <div className="mt-3 flex flex-wrap items-center gap-1.5">
                    <span className="text-[12px] font-medium text-muted-foreground">{t.when}</span>
                    <Node icon={tr(r.trigger)?.icon} label={tr(r.trigger)?.label ?? r.trigger} tone="trigger" />
                    {r.conditions.length > 0 && <span className="text-[12px] text-subtle-foreground">{r.conditions.length === 1 ? t.oneCondition : fill(t.nConditions, { n: r.conditions.length })}</span>}
                    {r.actions.map((a, k) => (
                      <span key={a.id} className="inline-flex max-w-full items-center gap-1.5">
                        {k === 0 ? <Icon name="arrow-right" size={14} className="mx-0.5 shrink-0 text-subtle-foreground" /> : <span className="text-[12px] text-subtle-foreground">{t.and}</span>}
                        <Node icon={ac(a.type)?.icon} label={ac(a.type)?.label ?? a.type} tone="action" />
                      </span>
                    ))}
                  </div>
                  <p className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px] tabular-nums text-subtle-foreground">
                    <span>{fill(t.runs, { n: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(r.runs ?? 0) })}</span>
                    <span>{r.lastRun ? fill(t.lastRun, { when: ago(locale, r.lastRun, now) }) : t.never}</span>
                  </p>
                </div>
                <Switch
                  checked={r.enabled}
                  aria-label={fill(t.toggle, { name: r.name })}
                  onCheckedChange={(v) => {
                    setItems((l) => l.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)));
                    setMsg(`${r.name}: ${v ? t.active : t.paused}`);
                    void run(onAction, 'toggle', { id: r.id, enabled: v });
                  }}
                />
              </div>
            </li>
          ))}
        </ul>
        {!shown.length && <p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>}
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* rule-editor                                                         */
/* ------------------------------------------------------------------ */

const OPS: RuleCondition['op'][] = ['is', 'is-not', 'greater', 'less', 'contains'];

function RuleEditor({ t, title, subtitle, rule, triggers = [], fields = [], actions = [], locale, onAction }: P) {
  // Inside a sentence English labels go lower case, German nouns keep their capital
  const low = (s: string) => (locale === 'de' ? s : s.charAt(0).toLowerCase() + s.slice(1));
  const id = useId();
  const start = rule ?? { id: 'new', name: '', trigger: triggers[0]?.id ?? '', conditions: [], actions: [], enabled: false, match: 'all' as const };
  const [r, setR] = useState<Rule>({ match: 'all', ...start });
  const [state, setState] = useState<'idle' | 'busy' | 'saved' | 'testing' | 'tested'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const tr = triggers.find((x) => x.id === r.trigger);
  const fieldLabel = (fid: string) => fields.find((x) => x.id === fid)?.label ?? fid;
  const acLabel = (aid: string) => actions.find((x) => x.id === aid)?.label ?? aid;
  const set = (patch: Partial<Rule>) => {
    setR((x) => ({ ...x, ...patch }));
    setState('idle');
  };
  const sentence = (
    <>
      <b className="font-semibold text-foreground">{t.when}</b> {tr ? low(tr.label) : '…'}
      {r.conditions.map((c, i) => (
        <span key={c.id}>
          {i === 0 ? ` ${t.sentenceIf} ` : ` ${r.match === 'any' ? t.or : t.and} `}
          <span className="text-foreground">{fieldLabel(c.field)}</span> {t[c.op]} <span className="rounded bg-muted px-1 text-foreground">{c.value || '…'}</span>
        </span>
      ))}
      {', '}
      <b className="font-semibold text-foreground">{t.then.toLowerCase()}</b>{' '}
      {r.actions.length
        ? r.actions.map((a, i) => (
            <span key={a.id}>
              {i > 0 ? (i === r.actions.length - 1 ? ` ${t.and} ` : ', ') : ''}
              {low(acLabel(a.type))} <span className="text-foreground">{a.target || '…'}</span>
            </span>
          ))
        : '…'}
      {r.actions.length > 0 && r.actions[r.actions.length - 1].target ? '.' : ''}
    </>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
        <div className="grid min-w-0 gap-0">
          {/* When */}
          <section className="kitm-up relative rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <h2 className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
              <span className="grid size-6 place-items-center rounded-md bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground">
                <Icon name="bolt" size={13} />
              </span>
              {t.when}
            </h2>
            <div className="mt-3 grid gap-3 sm:grid-cols-2">
              <label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground" htmlFor={`${id}-name`}>
                {t.name}
                <input id={`${id}-name`} value={r.name} onChange={(e) => set({ name: e.target.value })} className={field} />
              </label>
              <div className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
                {t.trigger}
                <Pick label={t.trigger} value={r.trigger} onChange={(v) => set({ trigger: v })}>
                  {triggers.map((x) => (
                    <option key={x.id} value={x.id}>
                      {x.label}
                    </option>
                  ))}
                </Pick>
              </div>
            </div>
          </section>
          <span aria-hidden className="mx-auto block h-6 w-px bg-border" />
          {/* If */}
          <section className="kitm-up [--kitm-d:80ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <h2 className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
                <span className="grid size-6 place-items-center rounded-md bg-muted text-muted-foreground">
                  <Icon name="filter" size={13} />
                </span>
                {t.conditions}
              </h2>
              {r.conditions.length > 1 && (
                <Segmented
                  label={t.conditions}
                  value={r.match ?? 'all'}
                  onChange={(v) => set({ match: v })}
                  options={[
                    { value: 'all', label: t.matchAll },
                    { value: 'any', label: t.matchAny },
                  ]}
                />
              )}
            </div>
            <ul className="mt-3 grid gap-2">
              {r.conditions.map((c, i) => (
                <li key={c.id} className="kitm-in">
                  {i > 0 && <p className="mb-2 ps-1 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{r.match === 'any' ? t.or : t.and}</p>}
                  <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,1fr)_auto]">
                    <Pick label={t.field} value={c.field} onChange={(v) => set({ conditions: r.conditions.map((x) => (x.id === c.id ? { ...x, field: v } : x)) })} className="max-sm:col-span-1">
                      {fields.map((x) => (
                        <option key={x.id} value={x.id}>
                          {x.label}
                        </option>
                      ))}
                    </Pick>
                    <button type="button" aria-label={fill(t.remove, { what: fieldLabel(c.field) })} onClick={() => set({ conditions: r.conditions.filter((x) => x.id !== c.id) })} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:order-last sm:size-9">
                      <Icon name="x" size={15} />
                    </button>
                    <Pick className="max-sm:col-span-2" label={t.op} value={c.op} onChange={(v) => set({ conditions: r.conditions.map((x) => (x.id === c.id ? { ...x, op: v as RuleCondition['op'] } : x)) })}>
                      {OPS.map((o) => (
                        <option key={o} value={o}>
                          {t[o]}
                        </option>
                      ))}
                    </Pick>
                    <input aria-label={t.value} value={c.value} onChange={(e) => set({ conditions: r.conditions.map((x) => (x.id === c.id ? { ...x, value: e.target.value } : x)) })} className={cn(field, 'max-sm:col-span-2 sm:col-span-1')} />
                  </div>
                </li>
              ))}
            </ul>
            <Button variant="ghost" size="sm" iconLeft="plus" className="mt-2" onClick={() => set({ conditions: [...r.conditions, { id: `c${Date.now()}`, field: fields[0]?.id ?? '', op: 'is', value: '' }] })}>
              {t.addCondition}
            </Button>
          </section>
          <span aria-hidden className="mx-auto block h-6 w-px bg-border" />
          {/* Then */}
          <section className="kitm-up [--kitm-d:160ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <h2 className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
              <span className="grid size-6 place-items-center rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                <Icon name="play" size={12} />
              </span>
              {t.then}
            </h2>
            <ol className="mt-3 grid gap-2">
              {r.actions.map((a, i) => (
                <li key={a.id} className="kitm-in grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-lg border border-border bg-surface p-2 sm:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto]">
                  <span className="grid size-7 place-items-center rounded-full bg-card text-[12px] font-semibold tabular-nums text-foreground ring-1 ring-border">{i + 1}</span>
                  <Pick label={t.actions} value={a.type} className="col-span-2 sm:col-span-1" onChange={(v) => set({ actions: r.actions.map((x) => (x.id === a.id ? { ...x, type: v } : x)) })}>
                    {actions.map((x) => (
                      <option key={x.id} value={x.id}>
                        {x.label}
                      </option>
                    ))}
                  </Pick>
                  <span className="order-last flex items-center">
                    <button type="button" aria-label={fill(t.moveUp, { n: i + 1 })} disabled={i === 0} onClick={() => set({ actions: r.actions.map((x, k) => (k === i - 1 ? r.actions[i] : k === i ? r.actions[i - 1] : x)) })} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-9">
                      <Icon name="arrow-up" size={15} />
                    </button>
                    <button type="button" aria-label={fill(t.remove, { what: acLabel(a.type) })} onClick={() => set({ actions: r.actions.filter((x) => x.id !== a.id) })} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-9">
                      <Icon name="trash" size={15} />
                    </button>
                  </span>
                  <input aria-label={t.target} placeholder={t.target} value={a.target} onChange={(e) => set({ actions: r.actions.map((x) => (x.id === a.id ? { ...x, target: e.target.value } : x)) })} className={cn(field, 'col-span-2 sm:col-span-1')} />
                </li>
              ))}
            </ol>
            {!r.actions.length && <p className="mt-3 text-[13px] text-warning-foreground">{t.noActions}</p>}
            <Button variant="ghost" size="sm" iconLeft="plus" className="mt-2" onClick={() => set({ actions: [...r.actions, { id: `a${Date.now()}`, type: actions[0]?.id ?? '', target: '' }] })}>
              {t.addAction}
            </Button>
          </section>
        </div>
        {/* Desktop: the rule in plain words next to the editor. Tablets and phones: under it, before the buttons. */}
        <aside className="kitm-up [--kitm-d:220ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6">
          <h2 className="text-[12.5px] font-medium text-muted-foreground">{t.sentence}</h2>
          <p className="mt-2 text-pretty text-[15px] leading-relaxed text-muted-foreground" aria-live="polite">
            {sentence}
          </p>
          {state === 'tested' && (
            <p className="kitm-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
              <Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
              {fill(t.tested, { n: r.actions.length })}
            </p>
          )}
          {state === 'saved' && (
            <p className="kitm-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
              <Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
              {t.saved}
            </p>
          )}
          <div className="mt-5 grid gap-2">
            <Button
              disabled={!r.actions.length || state === 'busy'}
              iconLeft={state === 'busy' ? 'loader' : 'check'}
              onClick={async () => {
                setState('busy');
                await run(onAction, 'save', { rule: r });
                setState('saved');
              }}
            >
              {state === 'busy' ? t.saving : t.save}
            </Button>
            <Button
              variant="outline"
              disabled={!r.actions.length || state === 'testing'}
              iconLeft={state === 'testing' ? 'loader' : 'play'}
              onClick={async () => {
                setState('testing');
                await run(onAction, 'test', { rule: r }, 900);
                setState('tested');
              }}
            >
              {state === 'testing' ? t.testing : t.test}
            </Button>
          </div>
        </aside>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* templates                                                           */
/* ------------------------------------------------------------------ */

function Templates({ t, title, subtitle, templates = [], locale, onAction }: P) {
  const cats = useMemo(() => [...new Set(templates.map((x) => x.category))], [templates]);
  const [cat, setCat] = useState('all');
  const [busy, setBusy] = useState('');
  const [done, setDone] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useRef<HTMLUListElement>(null);
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const shown = templates.filter((x) => cat === 'all' || x.category === cat);
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid gap-5">
        <div ref={rowRef} role="group" aria-label={t.templates} className={cn('kitm-up kit-no-scrollbar -mx-4 -my-1 flex gap-2 overflow-x-auto px-4 py-1 sm:mx-0 sm:flex-wrap sm:px-1', fade)}>
          {['all', ...cats].map((c) => (
            <button key={c} type="button" aria-pressed={cat === c} onClick={() => setCat(c)} className={cn('h-11 shrink-0 rounded-lg border px-3.5 text-[13px] font-medium transition-colors sm:h-9', cat === c ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:text-foreground')}>
              {c === 'all' ? t.allCategories : c}
            </button>
          ))}
        </div>
        <ul ref={listRef} key={cat} className="grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 xl:grid-cols-3">
          {shown.map((x, i) => (
            <li key={x.id} className="kitm-in group flex flex-col rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,translate,box-shadow] duration-300 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-5" style={{ '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
              <div className="flex items-start gap-3">
                <span className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                  <Icon name={x.icon ?? 'bolt'} size={18} />
                </span>
                <div className="min-w-0">
                  <h3 className="text-[15px] font-semibold text-foreground">{x.name}</h3>
                  <p className="text-[12px] text-subtle-foreground">{x.category}</p>
                </div>
              </div>
              <p className="mt-3 text-pretty text-[13px] leading-snug text-muted-foreground">{x.description}</p>
              {/* The steps as a small flow */}
              <ol aria-label={t.steps} className="mt-4 grid gap-1.5 border-t border-border pt-4">
                {x.steps.map((s, k) => (
                  <li key={s} className="flex items-center gap-2.5 text-[12.5px] text-foreground">
                    <span className={cn('grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold', k === 0 ? 'bg-[color-mix(in_oklab,var(--warning)_22%,var(--card))] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>{k === 0 ? <Icon name="bolt" size={10} /> : k}</span>
                    <span className="truncate">{s}</span>
                  </li>
                ))}
              </ol>
              <div className="mt-auto flex items-center justify-between gap-3 pt-5">
                <span className="text-[12px] tabular-nums text-subtle-foreground">{x.uses ? fill(t.usesN, { n: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(x.uses) }) : ''}</span>
                {done === x.id ? (
                  <span className="kitm-in inline-flex h-9 items-center gap-1.5 text-[13px] font-medium text-success-foreground">
                    <Icon name="check" size={15} />
                    {t.active}
                  </span>
                ) : (
                  <Button
                    size="sm"
                    variant="outline"
                    disabled={busy === x.id}
                    iconLeft={busy === x.id ? 'loader' : undefined}
                    onClick={async () => {
                      setBusy(x.id);
                      await run(onAction, 'use-template', { id: x.id });
                      setBusy('');
                      setDone(x.id);
                    }}
                  >
                    {busy === x.id ? t.using : t.use}
                  </Button>
                )}
              </div>
            </li>
          ))}
        </ul>
      </div>
      <Live text={done ? fill(t.used, { name: templates.find((x) => x.id === done)?.name ?? '' }) : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* run-log                                                             */
/* ------------------------------------------------------------------ */

function RunLog({ t, title, subtitle, runs = [], rules = [], now, locale, onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(runs);
  const [filter, setFilter] = useState<'all' | RuleRun['status']>('all');
  const [open, setOpen] = useState<string | null>(runs.find((r) => r.status === 'failed')?.id ?? null);
  const [busy, setBusy] = useState('');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const shown = items.filter((r) => filter === 'all' || r.status === filter);
  const ruleName = (rid: string) => rules.find((r) => r.id === rid)?.name ?? rid;
  const label: Record<RuleRun['status'], string> = { success: t.success, failed: t.failed, skipped: t.skipped };
  const time = (iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMin(iso) * MIN));
  const retry = async (r: RuleRun) => {
    setBusy(r.id);
    await run(onAction, 'retry', { id: r.id }, 1000);
    setItems((l) => l.map((x) => (x.id === r.id ? { ...x, status: 'success', steps: x.steps.map((s) => ({ ...s, status: s.status === 'failed' ? 'success' : s.status, detail: s.status === 'failed' ? undefined : s.detail })) } : x)));
    setBusy('');
    setMsg(t.retried);
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.runLog}
          value={filter}
          onChange={setFilter}
          className="grid w-full grid-cols-2 gap-1 sm:flex sm:w-auto"
          options={[
            { value: 'all', label: t.all },
            { value: 'success', label: t.success },
            { value: 'failed', label: <>{t.failed}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((r) => r.status === 'failed').length}</span></> },
            { value: 'skipped', label: t.skipped },
          ]}
        />
      </Header>
      <div ref={scope} {...enter} className="mt-6">
        <ul className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          {shown.map((r) => {
            const s = STATUS[r.status];
            const isOpen = open === r.id;
            return (
              <li key={r.id} className="border-b border-border last:border-b-0">
                <button type="button" aria-expanded={isOpen} aria-controls={`${id}-${r.id}`} aria-label={fill(t.showSteps, { subject: r.subject })} onClick={() => setOpen(isOpen ? null : r.id)} className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] [outline-offset:-2px] items-center gap-x-3.5 gap-y-1 px-4 py-3.5 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)] sm:grid-cols-[auto_minmax(0,1.2fr)_minmax(0,1fr)_7rem_auto] sm:px-5">
                  <span className={cn('grid size-8 place-items-center rounded-full', r.status === 'success' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)]' : r.status === 'failed' ? 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)]' : 'bg-muted', s.tone)}>
                    <Icon key={r.status} name={s.icon} size={15} strokeWidth={2.2} className="kitm-pop" />
                  </span>
                  <span className="min-w-0">
                    <span className="block truncate text-[14px] font-medium text-foreground">{ruleName(r.rule)}</span>
                    <span className="block truncate text-[12.5px] text-muted-foreground sm:hidden">
                      {r.subject} · {time(r.time)}
                    </span>
                  </span>
                  <span className="hidden truncate text-[13px] text-muted-foreground sm:block">{r.subject}</span>
                  <span className="hidden text-right text-[12.5px] tabular-nums text-subtle-foreground sm:block">
                    {ago(locale, r.time, now)}
                    <span className="block">{fill(t.duration, { ms: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(r.ms) })}</span>
                  </span>
                  <Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', isOpen && 'rotate-180')} />
                </button>
                <div id={`${id}-${r.id}`} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!isOpen}>
                  <div className="min-h-0 overflow-hidden">
                    <div className="border-t border-border bg-surface px-4 py-4 sm:ps-[4.25rem] sm:pe-5">
                      <ol className="relative grid gap-3">
                        <span aria-hidden className="absolute bottom-2 start-[9px] top-2 w-px bg-border" />
                        {r.steps.map((st, k) => (
                          <li key={k} className="relative flex items-start gap-3">
                            <span className={cn('relative z-[1] mt-0.5 grid size-[19px] shrink-0 place-items-center rounded-full border-2 border-surface', STATUS[st.status].dot)}>
                              <Icon name={STATUS[st.status].icon} size={10} strokeWidth={3} className="text-white" />
                            </span>
                            <span className="min-w-0">
                              <span className="block text-[13.5px] text-foreground">{st.label}</span>
                              {st.detail && <span className={cn('mt-0.5 block text-pretty text-[12.5px]', st.status === 'failed' ? 'text-destructive' : 'text-muted-foreground')}>{st.detail}</span>}
                            </span>
                          </li>
                        ))}
                      </ol>
                      {r.status === 'failed' && (
                        <Button size="sm" className="mt-4" iconLeft={busy === r.id ? 'loader' : 'rotate-cw'} disabled={busy === r.id} onClick={() => retry(r)}>
                          {busy === r.id ? t.retrying : t.retry}
                        </Button>
                      )}
                    </div>
                  </div>
                </div>
              </li>
            );
          })}
        </ul>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* schedule                                                            */
/* ------------------------------------------------------------------ */

type Freq = 'hourly' | 'daily' | 'weekly' | 'monthly';

function Schedule({ t, title, subtitle, now, timezone = 'Europe/Berlin', locale, onAction }: P) {
  const id = useId();
  const [freq, setFreq] = useState<Freq>('weekly');
  const [days, setDays] = useState<number[]>([0, 2, 4]);
  const [time, setTime] = useState('08:30');
  const [minute, setMinute] = useState(15);
  const [dom, setDom] = useState(1);
  const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const tag = locale === 'de' ? 'de-DE' : 'en-US';
  const dayNames = useMemo(() => Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(tag, { weekday: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 9, 19 + i)))), [tag]);
  const longDays = useMemo(() => Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(tag, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 9, 19 + i)))), [tag]);
  // The next five runs, worked out in UTC so server and browser agree
  const next = useMemo(() => {
    const [h, m] = time.split(':').map(Number);
    const start = toMin(now);
    const out: number[] = [];
    if (freq === 'hourly') {
      let x = Math.floor(start / 60) * 60 + minute;
      while (out.length < 5) {
        if (x > start) out.push(x);
        x += 60;
      }
      return out;
    }
    let day = Math.floor(start / 1440);
    for (let k = 0; k < 400 && out.length < 5; k++, day++) {
      const at = day * 1440 + h * 60 + m;
      if (at <= start) continue;
      const d = new Date(day * 1440 * MIN);
      const wd = (d.getUTCDay() + 6) % 7;
      if (freq === 'daily' || (freq === 'weekly' && days.includes(wd)) || (freq === 'monthly' && d.getUTCDate() === dom)) out.push(at);
    }
    return out;
  }, [freq, days, time, minute, dom, now]);
  const fmt = (min: number) => new Intl.DateTimeFormat(tag, { weekday: 'short', day: 'numeric', month: 'short', hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(min * MIN));
  const clock = (v: string) => {
    const [hh, mm] = v.split(':').map(Number);
    if (Number.isNaN(hh) || Number.isNaN(mm)) return v;
    return new Intl.DateTimeFormat(tag, { hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 0, 1, hh, mm)));
  };
  const join = (xs: string[]) => (xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} ${t.and} ${xs.at(-1)}`);
  const summary = freq === 'hourly' ? fill(t.summaryHourly, { m: String(minute).padStart(2, '0') }) : freq === 'daily' ? fill(t.summaryDaily, { t: clock(time) }) : freq === 'weekly' ? fill(t.summaryWeekly, { days: join(days.map((d) => longDays[d])), t: clock(time) }) : fill(t.summaryMonthly, { d: dom, t: clock(time) });
  const bad = freq === 'weekly' && !days.length;
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <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.schedule} description={t.scheduleText} className="kitm-up">
          <div className="grid gap-6">
            <div>
              <p className="mb-2 text-[13px] font-medium text-foreground">{t.every}</p>
              <Segmented
                label={t.every}
                value={freq}
                onChange={(v) => {
                  setFreq(v);
                  setState('idle');
                }}
                className="grid w-full grid-cols-2 gap-1 sm:flex"
                options={[
                  { value: 'hourly', label: t.hourly },
                  { value: 'daily', label: t.daily },
                  { value: 'weekly', label: t.weekly },
                  { value: 'monthly', label: t.monthly },
                ]}
              />
            </div>
            <div key={freq} className="kitm-in grid gap-5">
              {freq === 'weekly' && (
                <fieldset>
                  <legend className="mb-2 text-[13px] font-medium text-foreground">{t.on}</legend>
                  <div className="grid grid-cols-7 gap-1 sm:gap-1.5">
                    {dayNames.map((d, i) => {
                      const on = days.includes(i);
                      return (
                        <button key={i} type="button" aria-pressed={on} aria-label={longDays[i]} onClick={() => setDays((xs) => (on ? xs.filter((x) => x !== i) : [...xs, i].sort()))} className={cn('h-11 rounded-lg border text-[13px] font-medium transition-[background-color,border-color,color,transform] duration-200 active:scale-95', on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                          {d.replace('.', '')}
                        </button>
                      );
                    })}
                  </div>
                  {bad && <p className="kitm-in mt-2 text-[13px] font-medium text-destructive">{t.pickDay}</p>}
                </fieldset>
              )}
              {freq === 'monthly' && (
                <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-dom`}>
                  {t.dayOfMonth}
                  <input id={`${id}-dom`} type="number" min={1} max={28} value={dom} onChange={(e) => setDom(Math.max(1, Math.min(28, Number(e.target.value) || 1)))} className={cn(field, 'max-w-[8rem] tabular-nums')} />
                </label>
              )}
              <div className="grid gap-4 sm:grid-cols-2">
                {freq === 'hourly' ? (
                  <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-min`}>
                    {t.atMinute}
                    <input id={`${id}-min`} type="number" min={0} max={59} value={minute} onChange={(e) => setMinute(Math.max(0, Math.min(59, Number(e.target.value) || 0)))} className={cn(field, 'tabular-nums')} />
                  </label>
                ) : (
                  <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-t`}>
                    {t.at}
                    <input id={`${id}-t`} type="time" value={time} onChange={(e) => setTime(e.target.value || '00:00')} className={cn(field, 'tabular-nums')} />
                  </label>
                )}
                <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-tz`}>
                  {t.timezone}
                  <input id={`${id}-tz`} readOnly value={timezone} className={cn(field, 'bg-surface text-muted-foreground')} />
                </label>
              </div>
            </div>
            <p className="flex items-start gap-2.5 rounded-lg bg-surface p-3.5 text-[14px] text-foreground">
              <Icon name="repeat" size={16} className="mt-0.5 shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
              <span key={summary} className="kitm-in">
                {summary}
              </span>
            </p>
          </div>
        </Card>
        <Card title={t.next} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
          <ol className="relative">
            {next.map((n, i) => (
              <li key={`${freq}-${n}`} className="kitm-fly flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5" style={{ '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
                <span className={cn('size-2 shrink-0 rounded-full', i === 0 ? 'bg-primary' : 'bg-border-strong')} />
                <span className={cn('text-[13.5px] tabular-nums', i === 0 ? 'font-medium text-foreground' : 'text-muted-foreground')}>{fmt(n)}</span>
              </li>
            ))}
          </ol>
          <div className="border-t border-border p-4 sm:p-5">
            {state === 'saved' && (
              <p className="kitm-in mb-3 flex items-start gap-2 text-[13px] text-success-foreground">
                <Icon name="check-circle" size={15} className="mt-px shrink-0" />
                {fill(t.scheduleSaved, { when: next[0] ? fmt(next[0]) : '' })}
              </p>
            )}
            <Button
              className="w-full"
              disabled={bad || state === 'busy'}
              iconLeft={state === 'busy' ? 'loader' : undefined}
              onClick={async () => {
                setState('busy');
                await run(onAction, 'schedule', { freq, days, time, minute, dom });
                setState('saved');
              }}
            >
              {state === 'busy' ? t.saving : t.saveSchedule}
            </Button>
          </div>
        </Card>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* AutomationRules                                                     */
/* ------------------------------------------------------------------ */

/**
 * Automation screens for apps: a list of when and then rules with switches,
 * an editor that reads the rule back as a sentence, templates to start from,
 * a run log with steps and retry, and a schedule with the next runs.
 *   <AutomationRules variant="rule-list" title="Automations" rules={…} triggers={…} actions={…} />
 */
export function AutomationRules({ variant = 'rule-list', sidebar, labels, className, now = '2026-10-19T14:30', ...props }: AutomationRulesProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, now, locale };
  const body = (() => {
    switch (variant) {
      case 'rule-editor':
        return <RuleEditor {...p} />;
      case 'templates':
        return <Templates {...p} />;
      case 'run-log':
        return <RunLog {...p} />;
      case 'schedule':
        return <Schedule {...p} />;
      default:
        return <RuleList {...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 Automationsregeln?

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

Welche Varianten hat Automationsregeln?

5 Varianten, Regelliste, Regeleditor, Regelvorlagen, Laufprotokoll und Zeitplan. Du wählst sie mit der Prop variant, zum Beispiel variant="rule-editor".

Kann ich die Texte von Automationsregeln übersetzen?

Ja. Automationsregeln bringt 82 eigene Texte auf Deutsch und Englisch mit, etwa „Regeln“, „Wenn“ und „und“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Automationsregeln einbauen?

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

Kann ich Automationsregeln ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert