Automation rules
Automation screens. When and then rules with switches, an editor that reads the rule back as a sentence, templates, a run log with retry and a schedule with the next runs.
One of 3 components in Automation and builders
Variants
Details
What Automation rules brings along, read from the registry and the source.
- Category
- Automation and builders
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/automation-rules.tsx
- npm packages
- None
- Languages
- English and German, 82 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AutomationRulesProps interface from components/ui/automation-rules.tsx, with the comments from the code.
variantoptionalAutomationRulesVariantrule-listrule-editortemplatesrun-logschedule
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
triggersoptionalRuleOption[]fieldsoptionalRuleOption[]actionsoptionalRuleOption[]rulesoptionalRule[]ruleoptionalRulerule-editor: the rule on screen
templatesoptionalRuleTemplate[]runsoptionalRuleRun[]timezoneoptionalstringschedule
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, toggle, retry and use. Without it the block simulates the request.
Built-in texts
Automation rules writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 82 texts
| Key | English | German |
|---|---|---|
| rules | English Rules | German Regeln |
| when | English When | German Wenn |
| sentenceIf | English if | German und |
| then | English Then | German Dann |
| and | English and | German und |
| or | English or | German oder |
| all | English All | German Alle |
| active | English Active | German Aktiv |
| paused | English Paused | German Pausiert |
| failing | English Needs attention | German Braucht Aufmerksamkeit |
| runs | English {n} runs | German {n} Läufe |
| lastRun | English Last run {when} | German Zuletzt {when} |
| never | English Not run yet | German Noch nicht gelaufen |
| toggle | English Run {name} | German {name} ausführen |
| newRule | English New rule | German Neue Regel |
| search | English Search rules | German Regeln suchen |
| noMatch | English No rule matches. | German Keine Regel passt. |
| trigger | English Trigger | German Auslöser |
| conditions | English Conditions | German Bedingungen |
| oneCondition | English + 1 condition | German + 1 Bedingung |
| nConditions | English + {n} conditions | German + {n} Bedingungen |
| actions | English Actions | German Aktionen |
| matchAll | English All conditions | German Alle Bedingungen |
| matchAny | English Any condition | German Eine Bedingung |
| field | English Field | German Feld |
| op | English Condition | German Bedingung |
| value | English Value | German Wert |
| is | English is | German ist |
| is-not | English is not | German ist nicht |
| greater | English is more than | German ist größer als |
| less | English is less than | German ist kleiner als |
| contains | English contains | German enthält |
| addCondition | English Add condition | German Bedingung hinzufügen |
| addAction | English Add action | German Aktion hinzufügen |
| remove | English Remove {what} | German {what} entfernen |
| moveUp | English Move action {n} up | German Aktion {n} nach oben |
| target | English With | German Mit |
| sentence | English In one sentence | German In einem Satz |
| name | English Rule name | German Name der Regel |
| save | English Save rule | German Regel speichern |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Rule saved and active. | German Regel gespeichert und aktiv. |
| test | English Test with the last event | German Mit dem letzten Ereignis testen |
| testing | English Testing… | German Wird getestet… |
| tested | English Every condition held, {n} actions would run. | German Alle Bedingungen erfüllt, {n} Aktionen würden laufen. |
| noActions | English Add at least one action. | German Füge mindestens eine Aktion hinzu. |
| templates | English Templates | German Vorlagen |
| allCategories | English All | German Alle |
| use | English Use template | German Vorlage nutzen |
| using | English Setting up… | German Wird eingerichtet… |
| used | English {name} is set up. Check the details and turn it on. | German {name} ist eingerichtet. Prüfe die Details und schalte sie ein. |
| usesN | English Used by {n} teams | German Von {n} Teams genutzt |
| steps | English Steps | German Schritte |
| runLog | English Runs | German Läufe |
| success | English Succeeded | German Erfolgreich |
| failed | English Failed | German Fehlgeschlagen |
| skipped | English Skipped | German Übersprungen |
| retry | English Run again | German Erneut ausführen |
| retrying | English Running… | German Läuft… |
| retried | English Run again, it succeeded this time. | German Erneut ausgeführt, diesmal erfolgreich. |
| duration | English {ms} ms | German {ms} ms |
| showSteps | English Show steps of the run for {subject} | German Schritte des Laufs für {subject} zeigen |
| schedule | English Schedule | German Zeitplan |
| scheduleText | English Runs the rule on its own at the times you pick. | German Führt die Regel zu den gewählten Zeiten von selbst aus. |
| every | English Repeat | German Wiederholen |
| hourly | English Hourly | German Stündlich |
| daily | English Daily | German Täglich |
| weekly | English Weekly | German Wöchentlich |
| monthly | English Monthly | German Monatlich |
| on | English On | German Am |
| at | English At | German Um |
| atMinute | English At minute | German Zur Minute |
| dayOfMonth | English Day of the month | German Tag im Monat |
| timezone | English Time zone | German Zeitzone |
| next | English Next runs | German Nächste Läufe |
| pickDay | English Pick at least one day. | German Wähle mindestens einen Tag. |
| saveSchedule | English Save schedule | German Zeitplan speichern |
| scheduleSaved | English Schedule saved. Next run {when}. | German Zeitplan gespeichert. Nächster Lauf {when}. |
| summaryHourly | English Every hour at minute {m} | German Jede Stunde zur Minute {m} |
| summaryDaily | English Every day at {t} | German Jeden Tag um {t} |
| summaryWeekly | English Every {days} at {t} | German Jeden {days} um {t} |
| summaryMonthly | English On day {d} of every month at {t} | German Am {d}. jeden Monats um {t} |
Accessibility
Read from the source of the component.
- Automation rules handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-controls, aria-expanded, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles group, radio, radiogroup 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,181 lines
'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>
);
}Frequent questions
How do I install Automation rules?
With aiw add @ai-website/automation-rules. The command puts components/ui/automation-rules.tsx into your project, the code is yours from then on. It brings along button, icon, switch and sidebar. It needs no further npm packages.
Which variants does Automation rules have?
5 variants, Rule list, Rule editor, Rule templates, Run log and Schedule. You pick one with the variant prop, for example variant="rule-editor".
Can I translate the texts of Automation rules?
Yes. Automation rules carries 82 texts of its own in English and German, such as “Rules”, “When” and “if”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Automation rules?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Automation rules with aiw_components and install it with aiw_add @ai-website/automation-rules. Name the variant in your prompt, for example variant="rule-list", and the assistant writes your content straight into the props.
Can I adjust Automation rules without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching AutomationRules code to copy.