ExklusivBeta
Word Rules <mv-word-rules>
Baukasten für Regeln, Filter und Automatisierungen, in dem jede Regel ein Satz aus Wortkacheln ist, die du verschiebst: Subjekte („Order“), Operatoren („is“, „is not“, „over“) und Eigenschaften („late“, „$500“), verkettet mit „and“. Eine Zeile leuchtet nur dann als aktive Regel auf, wenn ihre Wörter nach einer kleinen typisierten Grammatik einen gültigen Satz bilden (Subjekt [and Subjekt]… Operator Eigenschaft [and [Operator] Eigenschaft]…, wobei Operatoren und Subjekte angeben, welche Arten von Eigenschaften sie akzeptieren); eine unvollständige oder fehlerhafte Zeile bleibt gedimmt, markiert das Wort, an dem sie scheitert, sagt, was fehlt („Add a property after ‚over‘, like ‚$500‘“), und endet mit einem Geister-Platzhalter in der Form des erwarteten Worts. Wörter werden aus einer typisierten Ablage gezogen (Zeiger und Touch, mit Live-Vorschau: Die Regel schaltet sich schon unter deinem Finger ein, bevor du loslässt), per Klick oder Enter in die aktuelle Regel gesetzt oder mit der Leertaste aufgenommen, mit den Pfeiltasten bewegt und mit der Leertaste abgelegt; ein Wort auf der Ablage fallen zu lassen entfernt es. Kacheltypen unterscheiden sich durch Form und Kontur, nicht nur durch Farbe (eckiger Block, umrandete Kapsel, Tag mit dicker Kante, gepunkteter Verbinder), und die Ablage dimmt Wörter, die nicht als Nächstes kommen können. Das Vokabular kommt aus Kindelementen oder einer JSON-Property; die aktiven Regeln kommen als strukturierte Daten heraus (rules, abbrechbares mv-move, mv-change mit dem, was ein- und ausgeschaltet wurde) und als Formularwert, und die Zeilen wachsen beim Schreiben mit.
| Kategorie | Formulare |
|---|---|
| Typ | Web Component (<mv-word-rules>) |
| Status | Beta |
| Keywords | exclusive, culture, rule-builder, query-builder, filter, automation, conditions, sentence, natural-language, word-tiles, drag-and-drop, keyboard-drag, grammar, segmentation, targeting, feature-flags, form-associated, json |
When to use
- Non-technical users must build filters, alerts or automations from a fixed vocabulary without learning a query syntax
- A triage, routing or segmentation screen should show which rules are currently in force and why a draft rule is not
- Feature-flag or audience targeting rules need a readable sentence form and a JSON output the backend can evaluate
- A rules editor must work with touch and keyboard alone, with every word move announced to screen readers
Avoid when
- Users narrow a product listing with a handful of known facets such as price, size or color → use Facets instead
- Filtering a data grid by column values is enough, with no need to name or combine rules → use Data table instead
- Rules need nested groups, OR branches or free-form values typed by the user: a query editor or a code field fits better
Installation
node scripts/add.mjs word-rules --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["word-rules"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/form.js, core/motion.js, components/word-rules/word-rules.js, components/word-rules/word-rules.css.
Verwendung
Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<div id="wr-demo" style="width:min(100%,72rem);margin-inline:auto">
<style>
#wr-demo { display:grid; gap:1.25rem }
#wr-demo .wr-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#wr-demo .wr-main { display:grid; grid-template-columns:minmax(0,1.25fr) minmax(0,1fr); }
#wr-demo .wr-builder { display:grid; gap:1rem; align-content:start; padding:1.25rem; border-inline-end:1px solid var(--mv-border) }
#wr-demo .wr-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
#wr-demo .wr-head > div { flex:1 1 18rem; min-width:0 }
#wr-demo .wr-head h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em; line-height:1.3 }
#wr-demo .wr-head p { margin:.2rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
#wr-demo .wr-actions { display:flex; gap:.375rem; flex-wrap:wrap }
#wr-demo .wr-orders { display:grid; grid-template-rows:auto 1fr; min-width:0; background:var(--mv-bg-subtle); border-radius:0 var(--mv-radius-xl) var(--mv-radius-xl) 0 }
#wr-demo .wr-bar { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding:1rem 1.125rem .875rem; border-bottom:1px solid var(--mv-border) }
#wr-demo .wr-bar h4 { display:flex; align-items:center; gap:.5rem; margin:0; font-size:.875rem }
#wr-demo .mv-choice { font-size:.8125rem }
#wr-demo .wr-list { display:grid; align-content:start; gap:.375rem; margin:0; padding:.75rem; list-style:none }
#wr-demo .wr-order { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.25rem .75rem; align-items:center; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface); transition:opacity var(--mv-duration-normal), border-color var(--mv-duration-normal), box-shadow var(--mv-duration-normal) }
#wr-demo .wr-order[data-flagged] { border-color:color-mix(in oklab, var(--mv-warning) 55%, var(--mv-border)); box-shadow:inset 3px 0 0 var(--mv-warning) }
#wr-demo .wr-order[hidden] { display:none }
#wr-demo .wr-who { display:flex; align-items:center; gap:.5rem; min-width:0; font-size:.8125rem; font-weight:600 }
#wr-demo .wr-who span:first-child { color:var(--mv-fg-muted); font:500 .75rem/1 var(--mv-font-mono) }
#wr-demo .wr-who span:nth-child(2) { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#wr-demo .wr-total { font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums; text-align:end }
#wr-demo .wr-meta { display:flex; align-items:center; gap:.375rem; flex-wrap:wrap; color:var(--mv-fg-muted); font-size:.75rem }
#wr-demo .wr-hits { display:flex; gap:.25rem; justify-content:flex-end; min-height:1.25rem }
#wr-demo .wr-hit { display:grid; place-items:center; min-width:1.25rem; height:1.25rem; padding:0 .25rem; border-radius:var(--mv-radius-full); background:var(--mv-warning); color:var(--mv-gray-950); font:600 .6875rem/1 var(--mv-font-mono) }
#wr-demo .wr-none { padding:2rem 1rem; color:var(--mv-fg-muted); font-size:.8125rem; text-align:center }
#wr-demo .wr-split { display:grid; grid-template-columns:minmax(0,1.25fr) minmax(0,1fr); gap:0 }
#wr-demo .wr-split > div { padding:1.25rem }
#wr-demo .wr-split > div + div { border-inline-start:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 var(--mv-radius-xl) var(--mv-radius-xl) 0 }
#wr-demo .wr-k { display:block; margin:0 0 .5rem; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
#wr-demo pre { margin:0; max-height:15rem; overflow:auto; color:var(--mv-fg-muted); font:400 .6875rem/1.55 var(--mv-font-mono); white-space:pre-wrap; word-break:break-word }
#wr-demo .wr-log { margin:.75rem 0 0; min-height:1.2rem; color:var(--mv-fg-subtle); font:400 .75rem/1.5 var(--mv-font-mono) }
@media (max-width:56rem) {
#wr-demo .wr-main, #wr-demo .wr-split { grid-template-columns:minmax(0,1fr) }
#wr-demo .wr-builder { border-inline-end:0; border-bottom:1px solid var(--mv-border) }
#wr-demo .wr-orders, #wr-demo .wr-split > div + div { border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl); border-inline-start:0 }
#wr-demo .wr-split > div + div { border-top:1px solid var(--mv-border) }
}
</style>
<!-- 1 · Order triage: the rules flag the orders list live -->
<section class="wr-card wr-main" aria-labelledby="wr-title">
<div class="wr-builder">
<div class="wr-head">
<div>
<h3 id="wr-title">Order triage rules</h3>
<p>A rule is on only while its words form a sentence. Move a word and the orders re-sort themselves.</p>
</div>
<span class="wr-actions">
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="wr-reset">Reset</button>
</span>
</div>
<mv-word-rules id="wr-rules" name="triage" label="Order triage rules">
<button type="button" data-word="noun" value="order" data-accepts="state amount region">Order</button>
<button type="button" data-word="noun" value="customer" data-accepts="tier region">Customer</button>
<button type="button" data-word="operator" value="is" data-accepts="state tier">is</button>
<button type="button" data-word="operator" value="is-not" data-accepts="state tier">is not</button>
<button type="button" data-word="operator" value="over" data-accepts="amount">over</button>
<button type="button" data-word="operator" value="under" data-accepts="amount">under</button>
<button type="button" data-word="operator" value="from" data-accepts="region">from</button>
<button type="button" data-word="property" value="late" data-kind="state">late</button>
<button type="button" data-word="property" value="paid" data-kind="state">paid</button>
<button type="button" data-word="property" value="shipped" data-kind="state">shipped</button>
<button type="button" data-word="property" value="vip" data-kind="tier">VIP</button>
<button type="button" data-word="property" value="new" data-kind="tier">new</button>
<button type="button" data-word="property" value="100" data-kind="amount" data-payload="100">$100</button>
<button type="button" data-word="property" value="500" data-kind="amount" data-payload="500">$500</button>
<button type="button" data-word="property" value="1000" data-kind="amount" data-payload="1000">$1,000</button>
<button type="button" data-word="property" value="eu" data-kind="region">EU</button>
<button type="button" data-word="property" value="us" data-kind="region">US</button>
<button type="button" data-word="property" value="apac" data-kind="region">APAC</button>
<button type="button" data-word="and">and</button>
<div data-line="order is late"></div>
<div data-line="order over 500 and is-not paid"></div>
<div data-line="customer is vip"></div>
<div data-line="order from"></div>
</mv-word-rules>
</div>
<div class="wr-orders">
<div class="wr-bar">
<h4>Open orders <span class="mv-badge" data-variant="warning" id="wr-count">0 flagged</span></h4>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wr-only"> Flagged only</label>
</div>
<ul class="wr-list" id="wr-list" aria-label="Open orders"></ul>
</div>
</section>
<!-- 2 · Compact, vocabulary from JSON, structured output -->
<section class="wr-card wr-split" aria-labelledby="wr-flag-title">
<div>
<div class="wr-head" style="margin-bottom:1rem">
<div>
<h3 id="wr-flag-title">Feature flag audience · “New checkout”</h3>
<p>Compact size, vocabulary set as a JSON property. Try “Workspace on Free”: it lights up, “Workspace is admin” never will.</p>
</div>
</div>
<mv-word-rules id="wr-flag" data-size="sm" label="Audience rules" min-lines="2"></mv-word-rules>
</div>
<div>
<span class="wr-k">rules (mv-change)</span>
<pre id="wr-json"></pre>
<p class="wr-log" id="wr-log">Waiting for a change…</p>
</div>
</section>
<script type="module">
// ── 1 · Order triage ─────────────────────────────────────
const rules = document.getElementById("wr-rules");
const list = document.getElementById("wr-list");
const only = document.getElementById("wr-only");
const count = document.getElementById("wr-count");
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const orders = [
{ id: "#4821", customer: "Lena Müller", region: "eu", tier: "vip", total: 1240, paid: true, shipped: false, late: true },
{ id: "#4822", customer: "Hiro Tanaka", region: "apac", tier: "new", total: 86.5, paid: true, shipped: true, late: false },
{ id: "#4823", customer: "Marcus Johnson", region: "us", tier: "vip", total: 640, paid: false, shipped: false, late: false },
{ id: "#4824", customer: "Sofia Rossi", region: "eu", tier: "regular", total: 212, paid: true, shipped: false, late: true },
{ id: "#4825", customer: "Priya Patel", region: "apac", tier: "regular", total: 1890, paid: false, shipped: false, late: true },
{ id: "#4826", customer: "Diego Hernández", region: "us", tier: "new", total: 54, paid: true, shipped: true, late: false },
{ id: "#4827", customer: "Chloé Martin", region: "eu", tier: "new", total: 530, paid: false, shipped: false, late: false },
{ id: "#4828", customer: "Kwame Mensah", region: "us", tier: "regular", total: 318, paid: true, shipped: true, late: false },
{ id: "#4829", customer: "Aiko Suzuki", region: "apac", tier: "vip", total: 97, paid: true, shipped: false, late: false },
];
// The app decides what each operator means; the component only says which rules are on.
const fact = (o, id) => ({ late: o.late, paid: o.paid, shipped: o.shipped, vip: o.tier === "vip", new: o.tier === "new" })[id];
const test = {
is: (o, p) => fact(o, p.id),
"is-not": (o, p) => !fact(o, p.id),
over: (o, p) => o.total > p.payload,
under: (o, p) => o.total < p.payload,
from: (o, p) => o.region === p.id,
};
const matches = (o, rule) => rule.conditions.every((c) => test[c.operator.id]?.(o, c.property));
const el = (tag, props = {}, ...kids) => { const n = Object.assign(document.createElement(tag), props); n.append(...kids); return n; };
const badge = (text, variant) => { const b = el("span", { className: "mv-badge", textContent: text }); b.dataset.size = "sm"; if (variant) b.dataset.variant = variant; return b; };
const rows = orders.map((o) => {
const hits = el("span", { className: "wr-hits" });
const meta = el("span", { className: "wr-meta" },
o.region.toUpperCase(),
badge(o.paid ? "Paid" : "Unpaid", o.paid ? "success" : "danger"),
badge(o.late ? "Late" : o.shipped ? "Shipped" : "Processing", o.late ? "warning" : o.shipped ? "secondary" : "outline"));
if (o.tier !== "regular") meta.prepend(badge(o.tier === "vip" ? "VIP" : "New customer", "info"));
const li = el("li", { className: "wr-order" },
el("span", { className: "wr-who" }, el("span", { textContent: o.id }), el("span", { textContent: o.customer })),
el("span", { className: "wr-total", textContent: usd.format(o.total) }),
meta, hits);
list.append(li);
return { o, li, hits };
});
const none = el("li", { className: "wr-none", textContent: "No order matches the active rules." });
function apply() {
const active = rules.rules;
let flagged = 0;
for (const { o, li, hits } of rows) {
const lines = active.filter((r) => matches(o, r)).map((r) => r.line);
hits.replaceChildren(...lines.map((n) => el("span", { className: "wr-hit", textContent: n, title: `Rule ${n}` })));
li.toggleAttribute("data-flagged", lines.length > 0);
li.hidden = only.checked && !lines.length;
li.setAttribute("aria-label", `Order ${o.id}, ${o.customer}, ${usd.format(o.total)}${lines.length ? `, flagged by rule ${lines.join(" and ")}` : ""}`);
if (lines.length) flagged++;
}
count.textContent = `${flagged} flagged`;
if (only.checked && !flagged) list.append(none); else none.remove();
}
// ── 2 · Feature flag audience (JSON vocabulary) ─────────
const flag = document.getElementById("wr-flag");
const json = document.getElementById("wr-json");
const log = document.getElementById("wr-log");
flag.vocabulary = [
{ id: "user", label: "User", type: "noun", accepts: ["role", "country"] },
{ id: "workspace", label: "Workspace", type: "noun", accepts: ["plan", "country"] },
{ id: "is", label: "is", type: "operator", accepts: ["role"] },
{ id: "on", label: "on", type: "operator", accepts: ["plan"] },
{ id: "in", label: "in", type: "operator", accepts: ["country"] },
{ id: "admin", label: "admin", type: "property", kind: "role" },
{ id: "beta", label: "beta tester", type: "property", kind: "role" },
{ id: "free", label: "Free", type: "property", kind: "plan" },
{ id: "pro", label: "Pro", type: "property", kind: "plan" },
{ id: "enterprise", label: "Enterprise", type: "property", kind: "plan" },
{ id: "ca", label: "Canada", type: "property", kind: "country" },
{ id: "de", label: "Germany", type: "property", kind: "country" },
{ id: "jp", label: "Japan", type: "property", kind: "country" },
{ id: "and", label: "and", type: "and" },
];
flag.lines = [["user", "is", "beta"], ["workspace", "on", "enterprise", "and", "in"]];
const show = () => {
// One compact line per active rule, the shape a backend would store.
const rows = flag.rules.map(({ line, text, subjects, conditions }) => JSON.stringify({
line, text,
subjects: subjects.map((s) => s.id),
conditions: conditions.map((c) => [c.operator.id, c.property.id]),
}));
json.textContent = rows.length ? `[\n ${rows.join(",\n ")}\n]` : "[]";
};
customElements.whenDefined("mv-word-rules").then(() => {
const initial = rules.lines;
apply();
show();
rules.addEventListener("mv-change", apply);
only.addEventListener("change", apply);
document.getElementById("wr-reset").addEventListener("click", () => { rules.lines = initial; apply(); });
flag.addEventListener("mv-change", (e) => {
show();
const on = e.detail.activated.map((r) => `rule ${r.line} on`);
const off = e.detail.deactivated.map((r) => `rule ${r.line} off`);
log.textContent = `${e.detail.reason} · ${[...on, ...off].join(", ") || "no rule changed state"}`;
});
});
</script>
</div>Kulturelle Referenz
Baba Is You, Arvi Teikari (Hempuli) (2019, Spiel). Die Regeln jedes Levels sind physische Wortblöcke, die auf dem Boden liegen, und eine Regel gilt nur, solange ihre Wörter einen gültigen Satz bilden, sodass das Verschieben eines einzigen Worts neu festlegt, was wahr ist. In der UI sind Filter und Automatisierungen Sätze aus typisierten Wortkacheln, die der Nutzer neu anordnet; eine Zeile ist nur aktiv, solange sie sich parsen lässt, und das Verschieben eines Worts schaltet Regeln live ein und aus.
API
Attributes
| Name | Typ | Default | Description |
|---|---|---|---|
label | string | Rules | Accessible name of the whole builder (role="group"). |
name | string | Form field name: the form submits the active rules as a JSON string (same shape as the rules property). | |
required | boolean | The form is invalid until at least one rule is active (valueMissing, message from strings.required). | |
disabled | boolean | Every tile, slot and clear button is disabled; also set by a disabled <fieldset>. | |
min-lines | number | 2 | Minimum number of lines shown, empty ones included. |
max-lines | number | 12 | Maximum number of lines. Below it, there is always exactly one empty line at the end: writing in it adds the next one. |
data-size | sm | Compact tiles (1.75rem, extra-small text) for side panels and dense settings. |
Properties
| Name | Typ | Description |
|---|---|---|
vocabulary | Array<{ id, label, type: "noun" | "operator" | "property" | "and", kind?, accepts?: string[], payload? }> | The words of the tray. type also accepts the aliases subject, verb, value and joiner. A property has a kind ("amount", "region"…); an operator or a subject may list the kinds it accepts, which the grammar enforces with a hint. payload is any data you want back in the rules (a number for “$500”). Replaces the child declarations; lines keep the words that still exist. |
lines | string[][] | Every line as word ids, inactive ones included: [["order", "is", "late"], …]. Settable with the same shape, with strings ("order is late") or a semicolon-separated string. Setting it emits no event. |
rules | Array<{ line, text, words, subjects, conditions: Array<{ operator, property }> }> | Read-only. The active rules only, in line order: line is the 1-based line number, text the sentence as displayed, words the ids, subjects and conditions the words ({ id, label, type, kind, payload }). Conditions of one rule are joined by “and”; how rules combine is up to the app. |
sentences | Array<{ line, status: "active" | "incomplete" | "invalid" | "empty", text, words, hint }> | Read-only. Every line with its status and the hint shown under it. |
value | string | The active rules as JSON (what the form submits). Setting it accepts rules JSON (their words are used) or anything lines accepts. |
strings | Partial<Record<string, string>> | Overrides for every visible text, hint and announcement (keys: label, tray, trayTitle, trayHint, trayDiscard, group*, type*, line, active, incomplete, invalid, empty, slot*, clear, help, tileLabel, placedLabel, hint*, announce*, preview*, pickedUp, moved, dropped, removed, added, canceled, refused, chooseWord, required). Placeholders: {line}, {word}, {prev}, {example}, {subject}, {type}, {index}, {count}, {text}, {hint}, {status}. English defaults. |
Methods
| Name | Description |
|---|---|
addWord(id, line?) | Adds a word at the end of a rule (1-based line; defaults to the current rule, or the next empty one for a subject after a complete rule). Goes through the cancelable mv-move (source "api"); emits no mv-change. Returns false if the word is unknown or the move is refused. |
clear(line?) | Empties one rule (1-based) or every rule. Emits no event. |
focusLine(line = 1) | Moves focus to the first word (or the drop slot) of a rule. |
Events
| Name | Description |
|---|---|
mv-move | Cancelable, before a word lands, moves or leaves. detail: { word, from: { line, index } | null (from the tray), to: { line, index } | null (removed), source: "pointer" | "keyboard" | "click" | "api" }. preventDefault() puts the word back (limits, permissions, a word that may appear once). |
mv-change | The words of at least one line changed. detail: { rules (active), sentences, activated (rules that turned on or were rewritten while on), deactivated: [{ line, text }] (rules that turned off), reason: "add" | "move" | "remove" | "clear" }. |
mv-clear | Cancelable, before the clear button of a line empties it. detail: { line }. |
Content structure
| Name | Description |
|---|---|
(children [data-word]) | Vocabulary declarations, e.g. <button data-word="operator" value="over" data-accepts="amount">over</button>: data-word is the type, value the id (defaults to the slugified text), data-kind, data-accepts (space or comma list) and data-payload (JSON or text). Hidden, never rendered; the tray is built from them. |
(children [data-line]) | Initial lines: <div data-line="order over 500 and is-not paid"></div> (word ids separated by spaces). Also restored by a form reset. |
CSS classes
| Name | Description |
|---|---|
mv-word-rules-lines / mv-word-rules-line | The board (<ol>) and one line (<li>) with data-status (active | incomplete | invalid | empty), data-current (where clicked words go) and a one-shot data-lit when it turns on. Parts: -num (the lamp), -sentence (role=group), -status (+ -status-icon, -status-text), -clear, -hint. |
mv-word-rules-tile | A word (<button>) with data-type (noun | operator | property | and), data-word (id), data-kind, data-source (tray | line). States: data-error (breaks the sentence), data-held (picked up with the keyboard), data-placeholder (drop position while dragging), data-floating (the copy under the pointer), data-fits="false" (tray word that cannot come next), data-settled. |
mv-word-rules-slot | Ghost slot at the end of a line, shaped like the expected word: data-expect = noun | operator | property | more | and | empty. Clicking it makes the line current and focuses the tray. |
mv-word-rules-tray | The word tray (role=toolbar, roving focus): -tray-head (-tray-title, -tray-hint), -groups, -group[data-type] (-group-label, -glyph legend, -words). data-discard while a dragged word would be removed. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-word-rules-noun | var(--mv-accent) | Hue of subject tiles. |
--mv-word-rules-operator | var(--mv-fg-muted) | Hue of operator tiles. |
--mv-word-rules-property | var(--mv-info) | Hue of property tiles. |
--mv-word-rules-and | var(--mv-fg-subtle) | Hue of joiner tiles. |
--mv-word-rules-active | var(--mv-success) | Active rule: line border, lamp, status and the turn-on sweep. |
--mv-word-rules-invalid | var(--mv-danger) | Broken rule: the word that breaks it, the hint and the discard state of the tray. |
Accessibility
The builder is a labelled group. Each line is an <li> whose words sit in a role="group" named “Rule 2” and described by its status and hint, so entering it reads “Rule 2, Active” or “Rule 4, Incomplete, Add a property after “from”, like “EU””. Every word is a real <button> whose name starts with its visible text, then its type and position (“late, property, word 3 of 3”, plus “does not fit here” on the word that breaks the rule); tray words read “late, property” and share a described help text with the full key map. The board and the tray are one Tab stop each (roving tabindex): in the tray, arrows move between words, Enter adds the word to the current rule and keeps focus in the tray so a whole sentence can be typed, Space picks it up; on the board, arrows move between words and lines, Space or Enter picks a word up, arrows move it (across lines too), Space or Enter drops it, Delete removes it, Escape puts it back, and Tab away drops it. Every step is announced in an assertive live region with the rule it would produce (“Rule 2, word 3 of 3. Rule 2 would be active.”); committed changes are announced politely per line (“Rule 1 active: Order is late.” / “Rule 2 inactive. Add a property after “is not”, like “late”.”). The drop slot is a button (“Add a word to rule 4”) that moves focus to the tray. Types are carried by shape and stroke (square block, capsule, thick-edged tag, dotted capsule) as well as color, with a shape legend in the tray; status uses an icon and a word, never color alone; dimmed words keep AA text contrast. Pointer and touch drag need no fine motor timing (5 px threshold, Escape cancels) and every drag has a click and keyboard equivalent. Reduced motion (OS or data-motion="reduce"): no fly-in, FLIP, hop or sweep, words just change place. Forced colors: system colors for borders, the active lamp and focus.