Exclusivebeta

Word Rules — <mv-word-rules>

Rule, filter and automation builder where every rule is a sentence made of word tiles you move around: subjects (“Order”), operators (“is”, “is not”, “over”) and properties (“late”, “$500”), chained with “and”. A line lights up as an active rule only while its words form a valid sentence under a small typed grammar (subject [and subject]… operator property [and [operator] property]…, with operators and subjects declaring which kinds of properties they accept); an incomplete or broken line stays dim, marks the word that breaks it and says what is missing (“Add a property after “over”, like “$500””), and ends with a ghost slot shaped like the word it expects. Words are dragged from a typed tray (pointer and touch, with a live preview: the rule turns on under your finger before you let go), clicked or Entered into the current rule, or picked up with Space, moved with the arrow keys and dropped with Space; dropping a word on the tray removes it. Tile types are told apart by shape and stroke, not only color (square block, outlined capsule, tag with a thick edge, dotted joiner), and the tray dims words that cannot come next. The vocabulary comes from child elements or a JSON property; the active rules come out as structured data (rules, cancelable mv-move, mv-change with what turned on and off) and as the form value, and lines grow as you write.

CategoryForms
TypeWeb Component (<mv-word-rules>)
Statusbeta
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs word-rules --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["word-rules"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): 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.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<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>

Cultural reference

Baba Is You — Arvi Teikari (Hempuli) (2019, game). The rules of each level are physical word blocks lying on the floor, and a rule only holds while its words line up into a valid sentence, so pushing a single word rewrites what is true. In the UI, filters and automations are sentences of typed word tiles that the user rearranges; a line is active only while it parses, and moving a word turns rules on and off live.

API

Attributes

NameTypeDefaultDescription
labelstringRulesAccessible name of the whole builder (role="group").
namestringForm field name: the form submits the active rules as a JSON string (same shape as the rules property).
requiredbooleanThe form is invalid until at least one rule is active (valueMissing, message from strings.required).
disabledbooleanEvery tile, slot and clear button is disabled; also set by a disabled <fieldset>.
min-linesnumber2Minimum number of lines shown, empty ones included.
max-linesnumber12Maximum number of lines. Below it, there is always exactly one empty line at the end: writing in it adds the next one.
data-sizesmCompact tiles (1.75rem, extra-small text) for side panels and dense settings.

Properties

NameTypeDescription
vocabularyArray<{ 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.
linesstring[][]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.
rulesArray<{ 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.
sentencesArray<{ line, status: "active" | "incomplete" | "invalid" | "empty", text, words, hint }>Read-only. Every line with its status and the hint shown under it.
valuestringThe active rules as JSON (what the form submits). Setting it accepts rules JSON (their words are used) or anything lines accepts.
stringsPartial<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

NameDescription
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

NameDescription
mv-moveCancelable, 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-changeThe 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-clearCancelable, before the clear button of a line empties it. detail: { line }.

Content structure

NameDescription
(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

NameDescription
mv-word-rules-lines / mv-word-rules-lineThe 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-tileA 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-slotGhost 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-trayThe 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

NameDefaultDescription
--mv-word-rules-nounvar(--mv-accent)Hue of subject tiles.
--mv-word-rules-operatorvar(--mv-fg-muted)Hue of operator tiles.
--mv-word-rules-propertyvar(--mv-info)Hue of property tiles.
--mv-word-rules-andvar(--mv-fg-subtle)Hue of joiner tiles.
--mv-word-rules-activevar(--mv-success)Active rule: line border, lamp, status and the turn-on sweep.
--mv-word-rules-invalidvar(--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.