익스클루시브

Word Rules <mv-word-rules>

모든 규칙이 옮길 수 있는 단어 타일로 이루어진 문장인 규칙, 필터, 자동화 빌더입니다. 주어(“Order”), 연산자(“is”, “is not”, “over”), 속성(“late”, “$500”)을 “and”로 연결합니다.

문화적 레퍼런스

Baba Is You, 아르비 테이카리(Hempuli) (2019, 게임). 각 레벨의 규칙은 바닥에 놓인 물리적인 단어 블록이며, 규칙은 단어들이 유효한 문장으로 정렬되어 있는 동안에만 성립하므로, 단어 하나를 밀면 무엇이 참인지가 다시 쓰입니다. UI에서는 필터와 자동화가 사용자가 재배열하는 유형별 단어 타일의 문장이며, 한 줄은 문법적으로 해석될 때만 활성화되고, 단어를 옮기면 규칙이 실시간으로 켜지고 꺼집니다.

작동 방식

모든 규칙이 직접 옮기는 단어 타일로 이루어진 문장인 규칙, 필터, 자동화 빌더입니다. 주어(“Order”), 연산자(“is”, “is not”, “over”), 속성(“late”, “$500”)을 “and”로 연결합니다. 한 줄은 그 단어들이 작은 유형 문법(subject [and subject]… operator property [and [operator] property]…, 연산자와 주어가 받을 수 있는 속성의 종류를 선언)에 따라 유효한 문장을 이룰 때만 활성 규칙으로 켜집니다. 불완전하거나 잘못된 줄은 흐리게 남아 문제를 일으키는 단어를 표시하고 무엇이 빠졌는지 알려 주며(“Add a property after “over”, like “$500””), 기대하는 단어 모양의 고스트 슬롯으로 끝납니다. 단어는 유형별 트레이에서 드래그하거나(포인터와 터치, 실시간 미리보기 지원: 손을 떼기 전에 손가락 아래에서 규칙이 켜집니다), 클릭하거나 Enter로 현재 규칙에 넣거나, Space로 집어 화살표 키로 옮기고 Space로 놓을 수 있으며, 단어를 트레이에 놓으면 제거됩니다. 타일 유형은 색상만이 아니라 모양과 테두리로 구분되며(사각 블록, 윤곽선 캡슐, 두꺼운 가장자리의 태그, 점선 연결어), 트레이는 다음에 올 수 없는 단어를 흐리게 표시합니다. 어휘는 자식 요소나 JSON 속성에서 가져오며, 활성 규칙은 구조화된 데이터(rules, 취소 가능한 mv-move, 무엇이 켜지고 꺼졌는지 담은 mv-change)와 폼 값으로 출력되고, 작성할수록 줄이 늘어납니다.

카테고리폼
유형Web Component (<mv-word-rules>)
상태안정
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

설치

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

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["word-rules"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): 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.

사용법

기본 마크업입니다. 여기서 시작해 속성, data-*, 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>

API

Attributes

Name유형DefaultDescription
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

Name유형Description
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 (its number kept readable on Highlight) and focus. Known limits: the grammar is fixed (a subject, then operator and property pairs chained with “and”; no “or”, no nesting or parentheses); a rule is checked word by word against accepts lists, not evaluated, so the app applies the emitted rules itself; drag uses pointer events inside one builder, not native drag and drop between builders or apps; after Delete, focus moves to the next control of the same line (a word, the add slot or the clear button).

이 페이지는 AI로 번역되었습니다. 번역 문제 신고