익스클루시브
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” vs “Q2 FY2027 · fiscal”). 선택적으로 Remove 동작도 제공하며, 칩에 호버하거나 포커스하면 그 칩이 나온 쿼리의 정확한 단어에 밑줄이 그어집니다. 가정은 따로 나열되며(“Assumed: “EU customers” means [billed to an EU address] ✓ Looks right”) 확인하거나 변경할 수 있습니다. 신뢰도가 낮은 부분은 “?” 표시와 함께 점선으로 그려지고, 가장 불확실한 부분은 추측하는 대신 미리 하나의 확인 질문을 던질 수 있습니다. 최선의 추정 답 위에 표시하거나(clarify="inline"), 질문에 답할 때까지 답을 보류합니다(clarify="block"). 무언가를 바꾸면 다음 해석과 평탄한 params 맵을 담은 취소 가능한 mv-reinterpret가 발생합니다. 그러면 앱이 처리할 때까지 답은 Undo가 있는 “Updating… Results below are for the previous interpretation” 바 아래에서 흐리게 표시되고, 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.