エクスクルーシブ
Understood As <mv-understood-as>
自由入力のリクエストがどう解釈されたかを回答のすぐ隣に示すので、間違った質問に対して計算された結果を誰かが信じてしまうことはありません。例:「Showing results for [Net revenue] · [EU countries] · [Q2 2026 ?] · [Excluding refunds]」。
文化的リファレンス
銀河ヒッチハイク・ガイド、ダグラス・アダムス(1979年、書籍)。巨大なコンピューターが長い年月をかけて究極の答えを導き出しますが、誰も質問が正確に何だったのかを知らなかったため、その答えは役に立たないものでした。UI では、すべての回答がシステムの理解した質問とともに表示され、解釈された各部分と前提は結果を信頼する前に修正でき、回答は再計算されるまで古い質問に対するものとして目に見える形でマークされます。
仕組み
自由入力のリクエストがどう解釈されたかを回答のすぐ横に表示し、誤った質問に対して算出された結果を誰も信じないようにします:「Showing results for [Net revenue] · [EU countries] · [Q2 2026 ?] · [Excluding refunds]」。解釈(クエリ、種類・値・確信度・代替案・元になった語句を持つ各部分、さらにシステムが独自に置いた前提)をプロパティまたは JSON スクリプトとして渡し、結果を [data-answer] 子要素に配置します。各部分は種類のアイコン(metric、scope、time range、entity、filter、sort)付きのチップボタンで、ヒント付きの代替案のリストボックスを開きます(「Q2 2026 · calendar, Apr 1-Jun 30」と「Q2 FY2027 · fiscal」など)。オプションで Remove アクションも付けられます。チップにホバーまたはフォーカスすると、その元になったクエリ内の語句に下線が引かれます。前提は別に一覧表示され(「Assumed: “EU customers” means [billed to an EU address] ✓ Looks right」)、確認または変更できます。確信度の低い部分は破線と「?」マークで描かれ、最も不確かな部分については推測する代わりに、最初に確認の質問を 1 つだけ表示できます。最有力の推測による回答の上に表示するか(clarify="inline")、質問に答えるまで回答を保留します(clarify="block")。何かを変更すると、次の解釈とフラットな params マップを持つキャンセル可能な mv-reinterpret が発火します。その後、アプリが確定させるまで回答は「Updating… Results below are for the previous interpretation」バー(Undo 付き)の下で薄く表示されます。detail.waitUntil(promise) を使うと自動的に確定し、promise が返す改良された解釈を採用し、reject された場合はロールバックします。過去の解釈の短い履歴からワンクリックで復元できます。
| カテゴリー | フィードバック |
|---|---|
| タイプ | Web Component(<mv-understood-as>) |
| ステータス | 安定版 |
| キット | 検証できる AI |
| Keywords | exclusive, culture, search, natural-language, nlq, query, interpretation, ai, assistant, analytics, bi, filters, chips, disambiguation, clarifying-question, assumptions, confidence, stale, rerun, history, explainability |
When to use
- A natural-language analytics or BI query returns a number and the user must see which metric, scope and period were actually used
- An AI assistant turns a request into a search, report or filter and should expose its assumptions before the result is trusted
- Free-text search maps words to filters or entities that can be ambiguous (fiscal vs calendar, a bird vs a machine)
- The app can rerun quickly and wants users to fix one misunderstood detail instead of rephrasing the whole request
Avoid when
- Users build the query themselves from structured filters; nothing was interpreted, so show the active filters → use Facets instead
- The request is still being written and its outcome and constraints should be structured before it is sent → use Intent Composer instead
- The goal is to show how reliable each sentence of an AI answer is, not how the question was read → use Certainty instead
インストール
node scripts/add.mjs understood-as --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["understood-as"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):tokens/tokens.css、core/base.css、core/dismiss.js、core/dom.js、core/element.js、core/motion.js、core/position.js、components/understood-as/understood-as.js、components/understood-as/understood-as.css。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-understood-as>
<script type="application/json">{ "query": "revenue last quarter", "parts": [
{ "id": "metric", "kind": "metric", "value": "net", "match": "revenue", "options": [{ "value": "net", "label": "Net revenue" }, { "value": "gross", "label": "Gross revenue" }] },
{ "id": "period", "kind": "time", "value": "q2", "confidence": 0.4, "match": "last quarter", "options": [{ "value": "q2", "label": "Q2 2026" }, { "value": "last90", "label": "Last 90 days" }] }
] }</script>
<div data-answer>$1.24M</div>
</mv-understood-as>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="ua-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#ua-demo { display:grid; gap:1.25rem }
#ua-demo .ua-card { padding:1.25rem 1.25rem 1.375rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#ua-demo .ua-top { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; margin:0 0 .875rem }
#ua-demo .ua-top h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#ua-demo .ua-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#ua-demo .ua-ask { display:flex; gap:.5rem; margin:0 0 .625rem }
#ua-demo .ua-ask .mv-input { flex:1; min-width:0 }
#ua-demo .ua-presets { display:flex; align-items:center; flex-wrap:wrap; gap:.375rem; margin:0 0 1.125rem; color:var(--mv-fg-muted); font-size:.75rem }
#ua-demo .ua-presets button { cursor:pointer }
#ua-demo .ua-sep { height:1px; margin:0 0 1rem; background:var(--mv-border) }
#ua-demo .ua-kpi { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin:0 0 1rem }
#ua-demo .ua-kpi-label { color:var(--mv-fg-muted); font-size:.75rem }
#ua-demo .ua-kpi-value { font-size:2rem; font-weight:650; letter-spacing:-.03em; line-height:1.1; font-variant-numeric:tabular-nums }
#ua-demo .ua-kpi-delta { font-size:.8125rem; color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#ua-demo .ua-kpi-delta b { color:var(--mv-success); font-weight:600 }
#ua-demo .ua-kpi-delta b[data-down] { color:var(--mv-danger) }
#ua-demo .ua-bars { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#ua-demo .ua-bars li { display:grid; grid-template-columns:8.5rem minmax(0,1fr) 4.5rem; align-items:center; gap:.75rem; font-size:.8125rem }
#ua-demo .ua-bars .ua-track { height:.5rem; border-radius:999px; background:var(--mv-bg-muted); overflow:hidden }
#ua-demo .ua-bars .ua-fill { display:block; height:100%; border-radius:inherit; background:var(--mv-accent); transform-origin:left; scale:var(--v) 1; transition:scale var(--mv-duration-slow) var(--mv-ease-emphasized) }
#ua-demo .ua-bars .ua-num { text-align:right; font-variant-numeric:tabular-nums; color:var(--mv-fg-muted) }
#ua-demo .ua-foot { margin:.875rem 0 0; color:var(--mv-fg-subtle); font-size:.75rem }
#ua-demo .ua-row { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(0,1fr); gap:1.25rem; align-items:start }
#ua-demo .ua-photos { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.625rem; margin:0; padding:0; list-style:none }
#ua-demo .ua-photo { display:grid; gap:.375rem; font-size:.75rem }
#ua-demo .ua-thumb { aspect-ratio:3/2; border-radius:var(--mv-radius-lg); background:linear-gradient(160deg, color-mix(in oklab, var(--mv-accent) var(--a,30%), var(--mv-gray-900)), var(--mv-gray-950)); box-shadow:inset 0 0 0 1px color-mix(in oklab, var(--mv-fg) 8%, transparent) }
#ua-demo .ua-photo b { font-size:.8125rem; font-weight:600 }
#ua-demo .ua-photo span { color:var(--mv-fg-muted) }
#ua-demo .ua-panel { display:grid; gap:.875rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#ua-demo .ua-panel h4 { margin:0; font-size:.8125rem; font-weight:600 }
#ua-demo .ua-k { color:var(--mv-fg-muted); font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; font-weight:600 }
#ua-demo .ua-log { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#ua-demo .ua-log li { display:grid; gap:.125rem; padding:.5rem .625rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); font-size:.75rem }
#ua-demo .ua-log code { font-family:var(--mv-font-mono); font-size:.6875rem; color:var(--mv-fg) }
#ua-demo .ua-log span { color:var(--mv-fg-muted); overflow-wrap:anywhere }
#ua-demo .ua-log .ua-empty { background:none; padding:0; color:var(--mv-fg-subtle) }
#ua-demo .ua-controls { display:grid; gap:.625rem }
@media (max-width:52rem) { #ua-demo .ua-row { grid-template-columns:minmax(0,1fr) } }
@media (forced-colors: active) { #ua-demo .ua-bars .ua-track { border:1px solid CanvasText } #ua-demo .ua-bars .ua-fill { forced-color-adjust:none; background:Highlight } }
@media (max-width:36rem) { #ua-demo .ua-bars li { grid-template-columns:6rem minmax(0,1fr) 3.75rem } }
</style>
<!-- 1. Natural-language analytics: inline clarifying question, assumptions, history -->
<section class="ua-card" aria-labelledby="ua-main-title">
<div class="ua-top">
<div>
<h3 id="ua-main-title">Ask your revenue data</h3>
<p>Tidewell Labs · Finance workspace</p>
</div>
<span class="mv-badge" data-variant="secondary" data-shape="pill">Data as of Sep 23, 2026</span>
</div>
<form class="ua-ask" id="ua-form">
<input class="mv-input" id="ua-q" type="text" aria-label="Question" value="revenue from EU customers last quarter without refunds" autocomplete="off">
<button class="mv-button" type="submit">Ask</button>
</form>
<div class="ua-presets">
<span>Try</span>
<button type="button" class="mv-badge" data-variant="outline" data-shape="pill" data-preset="revenue">revenue from EU customers last quarter…</button>
<button type="button" class="mv-badge" data-variant="outline" data-shape="pill" data-preset="signups">new signups in Germany this month vs last</button>
</div>
<div class="ua-sep"></div>
<mv-understood-as id="ua-main" clarify="inline">
<script type="application/json">
{
"query": "revenue from EU customers last quarter without refunds",
"parts": [
{ "id": "metric", "kind": "metric", "value": "net", "confidence": 0.9, "match": "revenue",
"options": [
{ "value": "net", "label": "Net revenue", "hint": "After discounts and taxes" },
{ "value": "gross", "label": "Gross revenue", "hint": "Before discounts and taxes" },
{ "value": "recurring", "label": "Recurring revenue", "hint": "Subscriptions only" }
] },
{ "id": "scope", "kind": "scope", "value": "eu", "confidence": 0.82, "match": "EU",
"options": [
{ "value": "eu", "label": "EU countries", "hint": "27 member states" },
{ "value": "europe", "label": "Europe", "hint": "EU + UK, Switzerland, Norway" },
{ "value": "emea", "label": "EMEA", "hint": "Europe, Middle East and Africa" }
] },
{ "id": "period", "kind": "time", "value": "cal-q2", "confidence": 0.42, "match": "last quarter",
"question": "By “last quarter”, did you mean the calendar or the fiscal quarter?",
"options": [
{ "value": "cal-q2", "label": "Q2 2026 · calendar", "short": "Q2 2026", "hint": "Apr 1-Jun 30, 2026" },
{ "value": "fy-q2", "label": "Q2 FY2027 · fiscal", "short": "Q2 FY2027", "hint": "May 1-Jul 31, 2026" },
{ "value": "last90", "label": "Last 90 days", "hint": "Jun 25-Sep 23, 2026" }
] },
{ "id": "refunds", "kind": "filter", "value": "refunds", "confidence": 0.95, "match": "without refunds", "removable": true,
"options": [
{ "value": "refunds", "label": "Excluding refunds" },
{ "value": "refunds-chargebacks", "label": "Excluding refunds and chargebacks" }
] }
],
"assumptions": [
{ "id": "customers", "text": "“EU customers” means", "value": "billing", "confidence": 0.7, "match": "customers",
"options": [
{ "value": "billing", "label": "billed to an EU address", "hint": "Billing country on the invoice" },
{ "value": "hq", "label": "headquartered in the EU", "hint": "Company address in the CRM" }
] },
{ "id": "currency", "text": "Amounts in", "value": "usd-monthly", "confidence": 0.8,
"options": [
{ "value": "usd-monthly", "label": "USD at monthly rates", "hint": "Each invoice at its month’s average rate" },
{ "value": "usd-today", "label": "USD at today’s rate" },
{ "value": "eur", "label": "EUR (no conversion)" }
] }
]
}
</script>
<div data-answer id="ua-answer"></div>
</mv-understood-as>
</section>
<div class="ua-row">
<!-- 2. Search: ask before guessing, results held until answered -->
<section class="ua-card" aria-labelledby="ua-photo-title">
<div class="ua-top">
<div>
<h3 id="ua-photo-title">Stock photo search</h3>
<p>clarify="block": results wait for the answer</p>
</div>
</div>
<mv-understood-as id="ua-photo" clarify="block" label="Searching for">
<script type="application/json">
{
"query": "crane at night",
"parts": [
{ "id": "subject", "kind": "entity", "value": "bird", "confidence": 0.35, "match": "crane",
"question": "“Crane”: the bird or the construction machine?",
"options": [
{ "value": "bird", "label": "Crane, the bird", "short": "Crane (bird)", "hint": "Wildlife, 12,400 photos" },
{ "value": "machine", "label": "Crane, the construction machine", "short": "Crane (machine)", "hint": "Industry, 3,150 photos" }
] },
{ "id": "light", "kind": "filter", "value": "night", "confidence": 0.85, "match": "at night", "removable": true,
"options": [
{ "value": "night", "label": "Night shots" },
{ "value": "dusk", "label": "Dusk and night" },
{ "value": "low", "label": "Any low light" }
] },
{ "id": "type", "kind": "other", "kindLabel": "Media type", "value": "photos",
"options": [
{ "value": "photos", "label": "Photos" },
{ "value": "all", "label": "Photos and videos" }
] }
]
}
</script>
<div data-answer id="ua-photo-answer"></div>
</mv-understood-as>
</section>
<aside class="ua-panel" aria-label="Events and options">
<h4>What the app receives</h4>
<div>
<span class="ua-k">Events</span>
<ol class="ua-log" id="ua-log"><li class="ua-empty">Change a chip, confirm an assumption or answer the question.</li></ol>
</div>
<div class="ua-controls">
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="ua-ask-first" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Ask before guessing</span></span>
</label>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="ua-fail">
<span class="mv-choice-text"><span class="mv-choice-title">Make the next rerun fail</span></span>
</label>
</div>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="ua-reset">Reset demo</button>
</aside>
</div>
<script type="module">
const root = document.getElementById("ua-demo");
const $ = (id) => document.getElementById(id);
const main = $("ua-main");
const photo = $("ua-photo");
await customElements.whenDefined("mv-understood-as");
const el = (tag, props = {}, ...kids) => { const n = Object.assign(document.createElement(tag), props); n.append(...kids); return n; };
const initial = { main: main.interpretation, photo: photo.interpretation };
/* ── A fake analytics backend: deterministic numbers from the interpretation ── */
const COUNTRIES = {
eu: [["Germany", 1.31], ["France", 0.98], ["Netherlands", 0.61], ["Spain", 0.52], ["Italy", 0.47]],
europe: [["United Kingdom", 1.42], ["Germany", 1.31], ["France", 0.98], ["Switzerland", 0.66], ["Netherlands", 0.61]],
emea: [["United Kingdom", 1.42], ["Germany", 1.31], ["France", 0.98], ["United Arab Emirates", 0.74], ["Switzerland", 0.66]],
};
const F = {
metric: { net: 1, gross: 1.14, recurring: 0.71 },
scope: { eu: 1, europe: 1.38, emea: 1.61 },
period: { "cal-q2": 1, "fy-q2": 1.05, last90: 1.09 },
refunds: { refunds: 1, "refunds-chargebacks": 0.991, none: 1.034 },
customers: { billing: 1, hq: 0.93 },
};
const PERIOD = { "cal-q2": "Apr 1-Jun 30, 2026", "fy-q2": "May 1-Jul 31, 2026", last90: "Jun 25-Sep 23, 2026" };
const METRIC = { net: "Net revenue", gross: "Gross revenue", recurring: "Recurring revenue" };
function revenueAnswer(p) {
const k = F.metric[p.metric] * F.scope[p.scope] * F.period[p.period] * F.refunds[p.refunds ?? "none"] * F.customers[p.customers];
const eur = p.currency === "eur";
const rate = eur ? 0.92 : p.currency === "usd-today" ? 0.987 : 1;
const fmt = new Intl.NumberFormat(eur ? "de-DE" : "en-US", { style: "currency", currency: eur ? "EUR" : "USD", notation: "compact", maximumSignificantDigits: 3 });
const total = 4.82e6 * k * rate;
const delta = ({ "cal-q2": 6.4, "fy-q2": 4.9, last90: 7.8 })[p.period] - (p.metric === "recurring" ? 2.1 : 0);
const rows = COUNTRIES[p.scope];
const max = rows[0][1];
const frag = document.createDocumentFragment();
const kpi = el("div", { className: "ua-kpi" },
el("div", {}, el("div", { className: "ua-kpi-label", textContent: `${METRIC[p.metric]} · ${PERIOD[p.period]}` }), el("div", { className: "ua-kpi-value", textContent: fmt.format(total) })),
el("div", { className: "ua-kpi-delta" }, el("b", { textContent: `+${delta.toFixed(1)}%` }), " vs the previous period"));
const list = el("ol", { className: "ua-bars", ariaLabel: "Top 5 countries" });
for (const [name, v] of rows) {
const fill = el("span", { className: "ua-fill" });
fill.style.setProperty("--v", (v / max).toFixed(3));
list.append(el("li", {}, el("span", { textContent: name }), el("span", { className: "ua-track" }, fill), el("span", { className: "ua-num", textContent: fmt.format((total * v) / 5.2) })));
}
const foot = el("p", { className: "ua-foot", textContent: `Top 5 of ${p.scope === "eu" ? 27 : p.scope === "europe" ? 30 : 71} countries · ${p.refunds ? "refunds excluded" : "refunds included"} · computed in 0.8 s` });
frag.append(kpi, list, foot);
return frag;
}
function signupsAnswer(p) {
const n = Math.round(1284 * (p.metric === "activated" ? 0.64 : 1) * (p.accounts === "all" ? 1.18 : 1));
const prev = Math.round(n / (p.compare === "full" ? 0.71 : 1.12));
const d = ((n - prev) / prev) * 100;
const frag = document.createDocumentFragment();
const b = el("b", { textContent: `${d >= 0 ? "+" : ""}${d.toFixed(1)}%` });
if (d < 0) b.dataset.down = "";
frag.append(
el("div", { className: "ua-kpi" },
el("div", {}, el("div", { className: "ua-kpi-label", textContent: `${p.metric === "activated" ? "Activated signups" : "New signups"} · Germany · Sep 1-23, 2026` }), el("div", { className: "ua-kpi-value", textContent: n.toLocaleString("en-US") })),
el("div", { className: "ua-kpi-delta" }, b, ` vs ${prev.toLocaleString("en-US")} ${p.compare === "full" ? "in all of August" : "in Aug 1-23"}`)),
el("p", { className: "ua-foot", textContent: "Berlin 31% · Munich 18% · Hamburg 11% · other cities 40%" }));
return frag;
}
const SIGNUPS = {
query: "new signups in Germany this month vs last",
parts: [
{ id: "metric", kind: "metric", value: "new", confidence: 0.88, match: "new signups", options: [
{ value: "new", label: "New signups", hint: "Accounts created" },
{ value: "activated", label: "Activated signups", hint: "Created and completed onboarding" } ] },
{ id: "scope", kind: "scope", label: "Germany", value: "de", confidence: 0.97, match: "Germany" },
{ id: "period", kind: "time", label: "Sep 1-23, 2026", value: "mtd", confidence: 0.9, match: "this month" },
{ id: "compare", kind: "time", kindLabel: "Compared with", value: "same", confidence: 0.5, match: "vs last",
question: "Compare with the same days of August, or all of August?", options: [
{ value: "same", label: "Same days of August", short: "vs Aug 1-23", hint: "Like-for-like, 23 days each" },
{ value: "full", label: "All of August", short: "vs August", hint: "31 days against 23" } ] },
],
assumptions: [
{ id: "accounts", text: "“Signups” counts", value: "self", confidence: 0.75, options: [
{ value: "self", label: "self-serve accounts only" },
{ value: "all", label: "self-serve and sales-led accounts" } ] },
],
};
const PHOTOS = {
bird: [["Crane wading under moonlight", "Camila Ortega", 48], ["Night roost in the wetlands", "João Ribeiro", 36], ["Two silhouettes against the dusk sky", "Ana Lucía Paz", 22], ["Calling at the lake after sunset", "Mateo Silva", 30]],
machine: [["Tower crane over city lights", "Hannah Becker", 34], ["Harbor cranes, long exposure", "Kenji Watanabe", 20], ["Construction site at midnight", "Olivia Brooks", 42], ["Crane lights in the fog", "Luca Romano", 28]],
};
function photoAnswer(p) {
const list = el("ul", { className: "ua-photos" });
const light = { night: "night", dusk: "dusk", low: "low light" }[p.light] ?? "any light";
for (const [title, by, a] of PHOTOS[p.subject]) {
const t = el("div", { className: "ua-thumb" });
t.style.setProperty("--a", `${a}%`);
t.setAttribute("aria-hidden", "true");
list.append(el("li", { className: "ua-photo" }, t, el("b", { textContent: title }), el("span", { textContent: `${by} · ${light} · 6000 × 4000` })));
}
return list;
}
const answer = (host, target, fn) => { target.replaceChildren(fn(host.params)); };
const renderMain = () => answer(main, $("ua-answer"), main.interpretation?.query.includes("signups") ? signupsAnswer : revenueAnswer);
const renderPhoto = () => answer(photo, $("ua-photo-answer"), photoAnswer);
renderMain();
renderPhoto();
/* ── The app reruns on mv-reinterpret, and can veto or fail ── */
const log = (type, text) => {
const list = $("ua-log");
list.querySelector(".ua-empty")?.remove();
list.prepend(el("li", {}, el("code", { textContent: type }), el("span", { textContent: text })));
while (list.children.length > 4) list.lastElementChild.remove();
};
const rerun = (host, render) => (e) => {
const { reason, part, from, to, summary } = e.detail;
log("mv-reinterpret", reason === "history" || reason === "undo" ? `${reason} → ${summary}` : `${reason} · ${part}: ${from} → ${to ?? "removed"}`);
const fail = $("ua-fail").checked;
e.detail.waitUntil(new Promise((resolve, reject) => setTimeout(() => {
if (fail) { $("ua-fail").checked = false; reject(new Error("Warehouse timeout")); return; }
resolve();
}, 900)).then(() => render()));
};
main.addEventListener("mv-reinterpret", rerun(main, renderMain));
photo.addEventListener("mv-reinterpret", rerun(photo, renderPhoto));
for (const host of [main, photo]) {
host.addEventListener("mv-clarify", (e) => log("mv-clarify", e.detail.skipped ? `${e.detail.part}: kept guess “${e.detail.guess}”` : `${e.detail.part} = ${e.detail.value}`));
host.addEventListener("mv-assumption-accept", (e) => log("mv-assumption-accept", `${e.detail.part}: ${e.detail.label}`));
host.addEventListener("mv-reinterpret-error", (e) => log("mv-reinterpret-error", `${e.detail.error.message}: previous interpretation restored`));
}
/* ── New questions: the previous interpretation goes into the history ── */
const ask = (which) => {
const data = which === "signups" ? SIGNUPS : initial.main;
$("ua-q").value = data.query;
main.interpretation = data;
renderMain();
};
$("ua-form").addEventListener("submit", (e) => { e.preventDefault(); ask(/sign\s?-?ups?/i.test($("ua-q").value) ? "signups" : "revenue"); });
root.querySelector(".ua-presets").addEventListener("click", (e) => { const b = e.target.closest("[data-preset]"); if (b) ask(b.dataset.preset); });
$("ua-ask-first").addEventListener("change", (e) => { main.clarify = e.target.checked ? "inline" : "off"; });
$("ua-reset").addEventListener("click", () => {
$("ua-q").value = initial.main.query;
$("ua-ask-first").checked = true; $("ua-fail").checked = false;
main.clarify = "inline";
// null first: a fresh start forgets the questions already answered.
main.interpretation = null; photo.interpretation = null;
main.interpretation = initial.main; photo.interpretation = initial.photo;
renderMain(); renderPhoto();
$("ua-log").replaceChildren(el("li", { className: "ua-empty", textContent: "Change a chip, confirm an assumption or answer the question." }));
});
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
clarify | "inline" | "block" | "off" | inline | When a part is below the low threshold and has a question, ask it upfront (only one question at a time, the least certain part first). inline shows the question above the best-guess answer; block hides the [data-answer] element and shows a placeholder until the question is answered or skipped (“Use the best guess”); off never asks (the part stays marked as uncertain). |
low | number (0..1) | 0.6 | Confidence under which a part is marked as uncertain (dashed chip with a “?” mark, wavy underline in the query, “uncertain” in its accessible name) and may be asked about. A part the user edited, confirmed or accepted is never uncertain. |
label | string | Showing results for | Lead words of the interpretation sentence (visible and read by screen readers). |
history | number | 5 | How many earlier interpretations to keep (0 disables the history button). Every applied change and every new query pushes the previous interpretation. |
stale | boolean | Set by the component when a change was applied and the answer still reflects the previous interpretation (aria-busy on [data-answer], dimmed answer, status bar with Undo). Remove it, call settle() or set a new interpretation once the rerun finished. | |
data-state | ready | stale | held | empty | Set by the component on itself (styleable). held = a blocking question is waiting; empty = no interpretation. | |
data-answer | attribute on a direct child | Marks the element holding the result. The interpretation is inserted just before it; it gets aria-busy while stale and hidden while a blocking question waits. |
Properties
| Name | タイプ | Description |
|---|---|---|
interpretation | { query?, parts: Part[], assumptions?: Part[], …your fields } | The interpretation to display (a normalized copy when read). Part = { id, kind (metric | scope | time | entity | filter | sort | other, or your own with kindLabel), value, label?, options?: [{ value, label, short?, hint? }], confidence? (0..1, default 1), match? (words of the query it came from), question? (clarifying question), removable?, text? (assumption lead, e.g. “Amounts in”) }. A part with assumed: true (or listed in assumptions) is shown as an assumption. Setting it settles a stale answer; a different query pushes the previous one into the history. Can be set before the element is defined. |
params | { [partId]: value } | Flat map of the current values, assumptions included (read-only). Handy to rerun the query. |
summary | string | The interpretation read as one sentence, the text screen readers get (read-only). |
past | [{ interpretation, summary, at }] | Earlier interpretations, newest first (read-only). |
strings | object | UI texts to translate (lead, asked, assumed, looksRight, confirmed, uncertain, remove, stale, updating, undo, earlier, keepGuess, useGuess, held, clarifyLead, announce* templates…). Merge a partial object. |
clarify / low / label / history / stale | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
setPart(id, value) | Changes one part from code through the same path as the UI (cancelable mv-reinterpret with reason "api"). null removes a removable part. Returns false if nothing changed or the event was canceled. |
accept(id) | Confirms an assumption (fires mv-assumption-accept, no rerun). |
settle(interpretation?) | Marks the answer as up to date again; with an argument, adopts that interpretation at the same time. |
undo() | Restores the latest earlier interpretation (mv-reinterpret with reason "undo"). |
Events
| Name | Description |
|---|---|
mv-reinterpret | The user (or setPart) changed the interpretation. detail: { reason: "edit" | "remove" | "clarify" | "history" | "undo" | "api", part (id or null for a full restore), from, to (null when removed), label, interpretation (next), previous, params (next values), summary, waitUntil(promise) }. Cancelable: preventDefault() keeps the current interpretation. With waitUntil the answer settles when the promise resolves (resolve with an interpretation object to adopt the server's refined parse) and rolls back when it rejects. |
mv-reinterpret-error | A waitUntil promise rejected: the previous interpretation is back. detail: { reason, part, error, interpretation }. |
mv-clarify | The clarifying question was answered or skipped. detail: { part, question, value, guess (the value before), skipped, interpretation }. An answer different from the guess is followed by mv-reinterpret with reason "clarify". |
mv-assumption-accept | An assumption was confirmed as right (Looks right, accept(), or picking its current value). detail: { part, value, label, text, interpretation }. No rerun is needed. |
Content structure
| Name | Description |
|---|---|
script[type="application/json"] | Optional direct child holding the initial interpretation (same shape as the property). |
[data-answer] | Direct child holding the result computed for the interpretation (the app owns its content). |
CSS classes
| Name | Description |
|---|---|
mv-understood-as-head | Everything the component renders above the answer: query, sentence, assumptions, question, stale bar. |
mv-understood-as-query / -src | “You asked” line; -src marks the words each part came from (data-part, data-confidence="low", data-linked while its chip is hovered or focused). |
mv-understood-as-line / -chip | The interpretation sentence and its chips (data-part, data-kind, data-confidence="low", data-edited, data-linked, aria-expanded). |
mv-understood-as-assumed / -assumption / -accept | Assumptions row, one item per assumption (data-accepted), and its Looks right button. |
mv-understood-as-clarify / -choice / -skip | The clarifying question, its answers (data-current on the guess) and the skip button. |
mv-understood-as-stale / -undo | Status bar shown while the answer is stale. |
mv-understood-as-held | Placeholder shown instead of the answer while a blocking question waits. |
mv-understood-as-menu / -list / -option / -remove | Popover with the listbox of alternatives (data-kind="part" | "history"). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-understood-as-accent | var(--mv-accent) | Tint of edited chips, linked words, the open chip and the spinner. |
--mv-understood-as-doubt | var(--mv-warning) | Tint of uncertain parts, assumptions and the clarifying question (always paired with a dashed line or a “?” mark). |
--mv-understood-as-stale-opacity | 0.42 | Opacity of the answer while it belongs to the previous interpretation. |
--mv-understood-as-gap | var(--mv-space-4) | Space between the interpretation and the answer. |
--mv-understood-as-list-height | 16rem | Maximum height of the alternatives list. |
Accessibility
The interpretation is a group labelled by a visually hidden sentence (“Showing results for: Net revenue, EU countries, Q2 2026 (uncertain), Excluding refunds. Assumed: “EU customers” means billed to an EU address.”), so screen readers hear it as one statement before reaching the chips. Each editable chip is a native button with aria-haspopup="listbox" and aria-expanded, named with its kind, value and state (“Time range: Q2 2026, uncertain, change”); Enter, Space, ArrowDown or ArrowUp opens a listbox that takes focus and uses aria-activedescendant, with ArrowUp/ArrowDown, Home/End, first-letter typeahead, Enter/Space to choose, Escape to close and return to the chip, and Tab to leave through the Remove action when there is one. Uncertainty is never shown by color alone (dashed outline, “?” mark, wavy underline and the word “uncertain” in the name); edited chips add “set by you”. The clarifying question is a group labelled by the question with one button per answer, the current guess labelled as such. Every change is announced in a polite live region (“Time range changed to Q2 FY2027. Results are updating.”, then “Results updated for: …”, or the rollback message), the answer gets aria-busy while stale, and focus is kept on the edited chip after a change (or moved to it when the question or the assumption button it came from disappears). Nothing is announced on first render. Reduced motion (prefers-reduced-motion or data-motion="reduce"): the spinner becomes a static dotted ring and the menu and chip transitions are instant. Forced colors: chips and answers use system button colors with dashed or thicker borders for uncertain and edited parts, and the active option uses Highlight. Known limits: the component never parses the query itself; your parser or model supplies the parts, options and confidence, and the words are matched back to the query by plain text, so a paraphrased match is not highlighted.