Exclusifbêta

Word Rules <mv-word-rules>

Constructeur de règles, de filtres et d’automatisations où chaque règle est une phrase faite de tuiles de mots que l’on déplace : sujets (« Order »), opérateurs (« is », « is not », « over ») et propriétés (« late », « $500 »), enchaînés par « and ». Une ligne ne s’allume comme règle active que tant que ses mots forment une phrase valide selon une petite grammaire typée (sujet [and sujet]… opérateur propriété [and [opérateur] propriété]…, les opérateurs et les sujets déclarant quels types de propriétés ils acceptent) ; une ligne incomplète ou cassée reste atténuée, marque le mot qui la casse et dit ce qui manque (« Add a property after “over”, like “$500” »), et se termine par un emplacement fantôme ayant la forme du mot attendu. Les mots se glissent depuis une réserve typée (pointeur et tactile, avec aperçu en direct : la règle s’active sous votre doigt avant que vous lâchiez), s’ajoutent à la règle courante par un clic ou avec Entrée, ou se saisissent avec Espace, se déplacent avec les flèches et se déposent avec Espace ; déposer un mot sur la réserve le supprime. Les types de tuiles se distinguent par la forme et le trait, pas seulement par la couleur (bloc carré, capsule en contour, étiquette à bord épais, liaison en pointillés), et la réserve atténue les mots qui ne peuvent pas venir ensuite. Le vocabulaire provient d’éléments enfants ou d’une propriété JSON ; les règles actives ressortent sous forme de données structurées (rules, mv-move annulable, mv-change indiquant ce qui s’est activé et désactivé) et comme valeur de formulaire, et les lignes s’allongent à mesure que vous écrivez.

CatégorieFormulaires
TypeWeb Component (<mv-word-rules>)
Statutbêta
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

  • 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/marvelous

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["word-rules"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : 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.

Utilisation

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :

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

Référence culturelle

Baba Is You, Arvi Teikari (Hempuli) (2019, jeu). Les règles de chaque niveau sont des blocs de mots physiques posés au sol, et une règle ne s’applique que tant que ses mots s’alignent en une phrase valide : pousser un seul mot réécrit ce qui est vrai. Dans l’interface, filtres et automatisations sont des phrases faites de tuiles de mots typées que l’utilisateur réarrange ; une ligne n’est active que tant qu’elle est syntaxiquement valide, et déplacer un mot active ou désactive des règles en direct.

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.

Cette page a été traduite par IA. Signaler un problème de traduction