익스클루시브
Insist <mv-insist>
사용자가 막혔을 때만 나타나고, 오래 헤맬수록 더 직접적으로 바뀌는 점진적 힌트입니다.
문화적 레퍼런스
젤다의 전설 시간의 오카리나, Nintendo EAD(미야모토 시게루, 아오누마 에이지)(1998, 게임). 요정 동료는 플레이어가 진행하는 동안에는 조용히 있다가, 플레이어가 막힌 것 같으면 빛을 내며 중요한 곳으로 날아가 말을 걸고, 고전이 길어질수록 힌트가 더 직접적으로 바뀝니다. UI에서는 측정된 막힘 신호(시간, 잘못된 동작, 망설임)가 은은한 빛에서 코치 마크로, 다시 “Show me”가 붙은 직접적인 안내로 단계가 올라가며, 실제로 진행이 이루어지면 조용해집니다.
작동 방식
사용자가 막혔을 때만 나타나고, 오래 고전할수록 더 직접적으로 바뀌는 단계적 힌트. 폼, 온보딩 카드 또는 빈 상태를 감싸고, 목표(클릭되거나 변경되거나 제출된 선택자, 완료 이벤트, 또는 complete())와 최대 세 단계를 선언합니다. 대상 위의 은은한 빛, 이어서 대상에 고정된 작은 코치 마크, 마지막으로 대상으로 스크롤하고 포커스를 두는 “Show me”가 붙은 직접적인 안내입니다(사용자 대신 클릭하는 일은 절대 없습니다). 막힘은 추측하지 않고 측정합니다: 진행 없이 흐른 시간(사용자가 입력 중이거나, 탭이 숨겨져 있거나, 영역이 화면 밖에 있으면 일시 정지), 잘못된 동작(비활성화된 컨트롤 누르기, 유효성 검사 오류, aria-invalid, 분노 클릭, 같은 컨트롤 사이를 오락가락함), 그리고 망설임(포인터가 대상 근처에 머물면 시계가 빨라짐). 각 단계는 자체 지연을 기다리며, 잘못된 동작은 지연의 절반으로 계산되지만 단계를 건너뛰지는 않습니다. 실제 진행(필드가 유효해짐, 새 값, progress())이 있으면 모든 것이 조용해지고 시계가 다시 시작됩니다. target="auto"는 아직 목표를 가로막고 있는 첫 번째 필드를 따라가며, 필드별 문구(data-insist-hint, data-insist-instruction)를 사용하고 후광이 그 필드로 날아갑니다. “I’ve got it”은 힌트를 영구적으로 중단하고(remember를 지정하면 localStorage에 기억), Escape는 힌트를 한 단계만 되돌리며, 공유 코디네이터가 모든 인스턴스에 걸쳐 화면에 힌트를 하나만 유지합니다. mv-hint는 취소할 수 있어 앱이 어느 단계든 거부하거나 대체할 수 있고, mv-goal은 얼마나 많은 도움이 필요했는지 보고합니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-insist>) |
| 상태 | 안정 |
| 키트 | 사용자를 놓치지 않는 폼 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, onboarding, hint, coach-mark, stuck, help, guidance, empty-state, form, validation, nudge, adaptive, progressive-disclosure, analytics, accessibility, spotlight |
When to use
- A setup form has a step many users get stuck on and a static help text would be ignored or clutter it for everyone
- An onboarding checklist or empty state should point to the next action only for users who don’t find it on their own
- A disabled Save or Continue button keeps getting clicked and users need to learn which field still blocks it
- Product analytics should measure how much help each goal needs (levels shown, mistakes, time) before redesigning it
Avoid when
- The help should open on demand when the user points at or focuses a control → use Tooltip instead
- Every new user must be walked through several features in a fixed order, stuck or not → use Stepper instead
- The information is always needed to complete the field, such as a format or a legal notice: show it inline as a field description
설치
node scripts/add.mjs insist --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["insist"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, core/position.js, components/insist/insist.js, components/insist/insist.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-insist goal="#save" delay="5s" hint="Name your workspace, then choose Save.">
<form><label>Workspace name <input name="workspace" required></label> <button id="save">Save</button></form>
</mv-insist>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="iq-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#iq-demo .iq-layout { display:grid; grid-template-columns:minmax(0,1fr) 17.5rem; gap:1.25rem; align-items:start }
#iq-demo .iq-main { display:grid; gap:1rem; min-width:0 }
#iq-demo .iq-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#iq-demo .iq-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; padding:1.125rem 1.25rem 0 }
#iq-demo .iq-head h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
#iq-demo .iq-head p { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#iq-demo .iq-form { display:grid; gap:1rem; padding:1.125rem 1.25rem 1.25rem; margin:0 }
#iq-demo .iq-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem }
#iq-demo .iq-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#iq-demo .iq-secure { display:inline-flex; align-items:center; gap:.375rem; color:var(--mv-fg-muted); font-size:.75rem }
#iq-demo .iq-secure svg { width:.875rem; height:.875rem }
#iq-demo .iq-foot-actions { display:flex; gap:.5rem }
#iq-demo .iq-success { display:flex; align-items:center; gap:.75rem; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); background:color-mix(in oklab, var(--mv-success) 9%, var(--mv-surface)); font-size:.8125rem }
#iq-demo .iq-success[hidden] { display:none }
#iq-demo .iq-success svg { width:1.125rem; height:1.125rem; flex:none; color:var(--mv-success) }
#iq-demo .iq-team .mv-empty { padding-block:1.5rem }
#iq-demo .iq-hud { position:sticky; top:1rem; display:grid; gap:1rem; 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 }
#iq-demo .iq-hud h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.8125rem; font-weight:600 }
#iq-demo .iq-k { color:var(--mv-fg-muted); font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; font-weight:600 }
#iq-demo .iq-levels { display:grid; grid-template-columns:repeat(4,1fr); gap:.25rem; margin:0; padding:0; list-style:none }
#iq-demo .iq-levels li { display:grid; gap:.3125rem; justify-items:center; color:var(--mv-fg-subtle); font-size:.6875rem; text-align:center; line-height:1.2 }
#iq-demo .iq-levels li::before { content:""; width:100%; height:.3125rem; border-radius:var(--mv-radius-full); background:var(--mv-bg-muted); transition:background-color var(--mv-duration-normal) }
#iq-demo .iq-levels li[data-on]::before { background:var(--mv-accent) }
#iq-demo .iq-levels li[data-now] { color:var(--mv-fg); font-weight:600 }
#iq-demo .iq-meter { position:relative; height:.5rem; border-radius:var(--mv-radius-full); background:var(--mv-bg-muted); overflow:hidden }
#iq-demo .iq-meter i { position:absolute; inset:0; border-radius:inherit; background:color-mix(in oklab, var(--mv-accent) 70%, transparent); transform-origin:left; scale:var(--v,0) 1; transition:scale 200ms linear }
#iq-demo .iq-meter b { position:absolute; top:0; bottom:0; width:2px; margin-left:-1px; background:var(--mv-surface-raised) }
#iq-demo .iq-meter-row { display:flex; justify-content:space-between; gap:.5rem; margin-top:.375rem; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
#iq-demo .iq-stats { display:grid; grid-template-columns:1fr 1fr; gap:.5rem; margin:0 }
#iq-demo .iq-stats div { display:grid; gap:.125rem; padding:.5rem .625rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#iq-demo .iq-stats dt { color:var(--mv-fg-muted); font-size:.6875rem }
#iq-demo .iq-stats dd { margin:0; font-size:1rem; font-weight:650; letter-spacing:-.01em; font-variant-numeric:tabular-nums }
#iq-demo .iq-stats dd small { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:500; letter-spacing:0 }
#iq-demo .iq-log { display:grid; align-content:start; gap:.375rem; margin:0; padding:0; list-style:none; min-height:4.75rem }
#iq-demo .iq-log li { display:flex; justify-content:space-between; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#iq-demo .iq-log li b { overflow:hidden; color:var(--mv-fg); font-weight:500; font-family:var(--mv-font-mono); font-size:.6875rem; text-overflow:ellipsis; white-space:nowrap }
#iq-demo .iq-log li span { white-space:nowrap; font-variant-numeric:tabular-nums }
#iq-demo .iq-sep { height:1px; background:var(--mv-border) }
#iq-demo .iq-preview { display:flex; align-items:center; justify-content:space-between; gap:.5rem; flex-wrap:wrap }
#iq-demo .iq-preview > span { display:flex; gap:.25rem }
#iq-demo .iq-note { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center; text-wrap:balance }
@media (max-width:54rem) {
#iq-demo .iq-layout { grid-template-columns:minmax(0,1fr) }
#iq-demo .iq-hud { position:static }
}
@media (max-width:34rem) {
#iq-demo .iq-row { grid-template-columns:minmax(0,1fr) }
#iq-demo .iq-secure { display:none }
#iq-demo .iq-foot { justify-content:flex-end }
}
@media (forced-colors: active) {
#iq-demo .iq-levels li::before, #iq-demo .iq-meter { forced-color-adjust:none; background:Canvas; border:1px solid CanvasText }
#iq-demo .iq-levels li[data-on]::before, #iq-demo .iq-meter i { forced-color-adjust:none; background:Highlight }
#iq-demo .iq-meter b { forced-color-adjust:none; background:CanvasText }
}
</style>
<div class="iq-layout">
<div class="iq-main">
<!-- Main: a payouts form. target="auto" follows the first field that still blocks Save. -->
<mv-insist id="iq-payout" goal="#iq-save" target="auto" delay="1s, 5s, 9s"
hint="Everything checks out. Save to start receiving payouts."
instruction="Choose Save payout method to finish this step.">
<section class="iq-card" aria-labelledby="iq-title">
<header class="iq-head">
<div>
<h3 id="iq-title">Set up payouts</h3>
<p>Where should we send your earnings? Transfers go out every Friday.</p>
</div>
<span class="mv-badge" data-variant="secondary" data-shape="pill">Step 2 of 3</span>
</header>
<form class="iq-form" id="iq-form" onsubmit="event.preventDefault()">
<div class="iq-row">
<div class="mv-field">
<label class="mv-label" for="iq-holder">Account holder</label>
<input class="mv-input" id="iq-holder" value="Maya Chen" required autocomplete="name">
</div>
<div class="mv-field">
<label class="mv-label" for="iq-country">Bank country</label>
<select class="mv-select" id="iq-country">
<option selected>United States</option>
<option>Canada</option>
<option>Puerto Rico</option>
</select>
</div>
</div>
<div class="iq-row">
<div class="mv-field">
<label class="mv-label" for="iq-routing" data-required>Routing number</label>
<input class="mv-input" id="iq-routing" inputmode="numeric" required pattern="[0-9]{9}" maxlength="9" placeholder="9 digits" autocomplete="off"
data-insist-hint="It’s the 9-digit number printed at the bottom left of your checks."
data-insist-instruction="Type the 9-digit routing number. To try it here, use 110000000.">
</div>
<div class="mv-field">
<label class="mv-label" for="iq-account" data-required>Account number</label>
<input class="mv-input" id="iq-account" inputmode="numeric" required pattern="[0-9]{4,17}" maxlength="17" placeholder="4 to 17 digits" autocomplete="off"
data-insist-hint="Your bank app shows it under account details, next to the routing number."
data-insist-instruction="Enter the account number from your bank app. To try it here, use 000123456789.">
</div>
</div>
</form>
<div class="iq-foot">
<span class="iq-secure"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Encrypted, never shown in full</span>
<span class="iq-foot-actions">
<button type="button" class="mv-button" data-variant="ghost" data-size="sm">Back</button>
<button type="button" class="mv-button" data-size="sm" id="iq-save" aria-disabled="true">Save payout method</button>
</span>
</div>
<div class="iq-success" id="iq-success" role="status" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8.5 12.5 2.5 2.5 4.5-5"/></svg>
<span id="iq-success-text">Payouts are set up. Your first transfer arrives Friday, September 25.</span>
</div>
</section>
</mv-insist>
<!-- Second instance: an empty state. The coordinator makes it wait while the form holds the floor. -->
<mv-insist id="iq-team" class="iq-team" goal="#iq-invite" delay="3s"
hint="Invite your accountant so they can download payout reports without your password."
instruction="Choose Invite teammates and enter their work email.">
<section class="mv-empty" data-variant="compact" data-border aria-labelledby="iq-team-title">
<div class="mv-empty-media">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M19 8v6M16 11h6"/></svg>
</div>
<p class="mv-empty-title" id="iq-team-title">No teammates yet</p>
<p class="mv-empty-description">Share payout reports with your team or accountant.</p>
<div class="mv-empty-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="iq-invite">Invite teammates</button>
</div>
</section>
</mv-insist>
</div>
<aside class="iq-hud" aria-label="Stuck detector">
<h4>Stuck detector <span class="mv-badge" data-variant="secondary" data-shape="pill" id="iq-state">watching</span></h4>
<ol class="iq-levels" id="iq-levels" aria-label="Hint level">
<li data-on data-now>Quiet</li><li>Glow</li><li>Hint</li><li>Try this</li>
</ol>
<div>
<div class="iq-meter" aria-hidden="true"><i id="iq-fill"></i><b id="iq-t1"></b><b id="iq-t2"></b></div>
<div class="iq-meter-row"><span>Stuck clock</span><span id="iq-rate">×1</span></div>
</div>
<dl class="iq-stats">
<div><dt>Stuck time</dt><dd id="iq-stuck">0.0 s</dd></div>
<div><dt>No progress for</dt><dd id="iq-idle">0.0 s</dd></div>
<div><dt>Wrong actions</dt><dd id="iq-mistakes">0</dd></div>
<div><dt>Hesitation</dt><dd id="iq-hes">0.0 s</dd></div>
</dl>
<div class="iq-sep"></div>
<div style="display:grid;gap:.5rem">
<span class="iq-k">Events</span>
<ol class="iq-log" id="iq-log"><li><b>watching…</b><span>0:00</span></li></ol>
</div>
<div class="iq-sep"></div>
<div class="iq-preview">
<span class="iq-k">Preview</span>
<span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" data-level="1" aria-label="Preview level 1, glow">1</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" data-level="2" aria-label="Preview level 2, coach mark">2</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" data-level="3" aria-label="Preview level 3, instruction">3</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="iq-restart">Restart</button>
</span>
</div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="iq-remember">
<span class="mv-choice-text"><span class="mv-choice-title">Remember “I’ve got it”</span></span>
</label>
</aside>
</div>
<p class="iq-note">Delays are shortened to 1, 5 and 9 seconds here. Press the disabled Save button, linger near a field or leave it alone: the hints escalate. Type a valid value and they stop.</p>
<script type="module">
const $ = (id) => document.getElementById(id);
const pay = $("iq-payout"), team = $("iq-team"), save = $("iq-save");
const fields = [$("iq-holder"), $("iq-routing"), $("iq-account")];
await customElements.whenDefined("mv-insist");
// The Save button stays aria-disabled (focusable, announced) until every field is valid.
const sync = () => save.setAttribute("aria-disabled", String(!fields.every((f) => f.validity.valid)));
$("iq-form").addEventListener("input", sync);
sync();
// Event log.
let t0 = Date.now();
const log = $("iq-log");
const clock = () => { const s = Math.floor((Date.now() - t0) / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const add = (name, info) => {
const li = document.createElement("li");
const b = document.createElement("b");
b.textContent = name;
const s = document.createElement("span");
s.textContent = info ? `${info} · ${clock()}` : clock();
li.append(b, s);
log.prepend(li);
while (log.children.length > 4) log.lastElementChild.remove();
};
const who = (e) => (e.currentTarget === team ? "team · " : "");
for (const el of [pay, team]) {
el.addEventListener("mv-hint", (e) => add(`mv-hint ${e.detail.level}`, who(e) + e.detail.reason));
el.addEventListener("mv-progress", (e) => add("mv-progress", who(e) + e.detail.reason));
el.addEventListener("mv-hint-dismiss", (e) => add("mv-hint-dismiss", who(e) + e.detail.reason));
el.addEventListener("mv-goal", (e) => add("mv-goal", `${who(e)}level ${e.detail.maxLevel}`));
}
pay.addEventListener("mv-goal", (e) => {
const { maxLevel, mistakes } = e.detail;
const help = maxLevel ? `after a level ${maxLevel} hint` : "with no help at all";
$("iq-success-text").textContent = `Payouts are set up ${help}${mistakes ? ` and ${mistakes} wrong action${mistakes > 1 ? "s" : ""}` : ""}. Your first transfer arrives Friday, September 25.`;
$("iq-success").hidden = false;
});
// Live monitor.
const names = ["watching", "hinting", "waiting", "paused", "done", "dismissed"];
const tone = { hinting: "info", waiting: "warning", done: "success", dismissed: "outline" };
const sec = (ms) => `${(ms / 1000).toFixed(1)} s`;
const paint = () => {
const s = pay.signals;
const [a, b, c] = s.thresholds;
$("iq-t1").style.left = `${(a / c) * 100}%`;
$("iq-t2").style.left = `${(b / c) * 100}%`;
$("iq-fill").style.setProperty("--v", Math.min(1, s.stuck / c).toFixed(3));
$("iq-stuck").textContent = sec(s.stuck);
$("iq-idle").textContent = sec(s.idle);
$("iq-hes").textContent = sec(s.hesitation);
const top = Object.entries(s.reasons).sort((x, y) => y[1] - x[1])[0];
$("iq-mistakes").replaceChildren(String(s.mistakes), ...(top ? [" ", Object.assign(document.createElement("small"), { textContent: top[0] })] : []));
$("iq-rate").textContent = s.typing ? "paused · typing" : s.hesitating ? "×2.5 · hesitating" : "×1";
[...$("iq-levels").children].forEach((li, i) => {
li.toggleAttribute("data-on", i <= s.level);
li.toggleAttribute("data-now", i === s.level);
});
const st = pay.state;
const badge = $("iq-state");
badge.textContent = st === "waiting" ? "waiting for its turn" : st;
badge.dataset.variant = tone[st] ?? "secondary";
};
const timer = setInterval(() => (pay.isConnected ? paint() : clearInterval(timer)), 200);
paint();
// Controls.
for (const btn of document.querySelectorAll("#iq-demo .iq-preview [data-level]")) {
btn.addEventListener("click", () => {
if (pay.state === "done" || pay.state === "dismissed") pay.reset();
pay.show(Number(btn.dataset.level));
});
}
$("iq-restart").addEventListener("click", () => {
$("iq-routing").value = "";
$("iq-account").value = "";
sync();
$("iq-success").hidden = true;
pay.forget();
team.forget();
t0 = Date.now();
log.replaceChildren();
add("restart");
});
$("iq-remember").addEventListener("change", (e) => { pay.remember = e.target.checked ? "iq-demo-payouts" : null; });
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
goal | CSS selector | What the user should reach. A button or link is reached when clicked (not while disabled, and not a submit button whose form is still invalid), a field when it changes to a valid, non-empty value, a form when it is submitted. Looked up inside the element first, then in the document. Changing it resets the component: set the next step’s goal to chain an onboarding. | |
target | CSS selector | "auto" | the goal | What the hints point at, if not the goal itself. "auto" points at the first field in the region that is invalid (native constraints or aria-invalid), and falls back to the goal once everything is valid. |
done | event names (space or comma separated) | Custom events that mean the goal is reached (e.g. "upload-complete"), when dispatched inside the region, on the goal, on the element, the document or window, bubbling or not. | |
delay | time | list of 2-3 times | 8s | Stuck time before each level. One value is spaced evenly (8s → 8s, 16s, 24s); two values extend the last gap (5s, 12s → 19s); three are used as given. Accepts s, ms, m or bare milliseconds. |
max-level | 0 | 1 | 2 | 3 | 3 | Highest level the escalation may reach (1 = glow only). 0 keeps the detector running (signals, mv-progress, mv-goal) without ever showing a hint. |
hint | string | Level 2 text when there is no <template data-level="2"> and the target has no data-insist-hint. | |
instruction | string | Level 3 text when there is no <template data-level="3"> and the target has no data-insist-instruction. Without any level 3 text, level 3 repeats the level 2 text with “Show me”. | |
remember | string (storage key) | Opt-in persistence: “I’ve got it” and reaching the goal are stored in localStorage under mv-insist:<key>, so the hints never come back on the next visit. forget() clears it. | |
placement | bottom | top | left | right, with -start | -end | bottom | Preferred side of the coach mark relative to the target (flips and shifts to stay in the viewport). |
priority | number | 0 | Coordinator rank. A higher-priority instance that becomes stuck takes the floor from the one showing a hint (which waits); equal priorities wait their turn. |
disabled | boolean | Pauses everything: the hint hides, the clock stops, the reached level is kept for when it is re-enabled. | |
data-insist-hint / data-insist-instruction | string (on a field or control) | Per-target texts for levels 2 and 3, used when that element is the target (made for target="auto"). They take precedence over the templates and attributes. | |
data-state / data-level | set by the component | data-state: watching | hinting | waiting (another instance holds the floor) | paused | done | dismissed. data-level: the level on screen (absent at 0). |
Properties
| Name | 유형 | Description |
|---|---|---|
level | 0 | 1 | 2 | 3 | Level on screen (read-only). |
state | "watching" | "hinting" | "waiting" | "paused" | "done" | "dismissed" | Current state (read-only). |
signals | { stuck, idle, hesitation, mistakes, reasons, level, reached, thresholds, typing, hesitating } | What the detector sees (read-only): stuck = weighted stuck time in ms (the escalation clock), idle = plain time without progress, hesitation = ms spent lingering near the target, mistakes and reasons ({ disabled, invalid, rage, "back-and-forth", api: count }), reached = escalation level (may exceed level while waiting or vetoed), thresholds = the three delays in ms, typing / hesitating = whether the clock is currently frozen / sped up. |
strings | Partial<Record<string, string>> | Overrides for visible texts and announcements (keys: kicker2, kicker3, showMe, dismiss, above, below, announce2 ({text}), announce3 ({text})). English defaults. |
MvInsist.active | MvInsist | null | Static: the instance currently holding the floor (showing a hint). |
Methods
| Name | Description |
|---|---|
show(level) | Forces a level now, as if the user had been stuck that long (clamped to max-level, goes through mv-hint and the coordinator). Returns true if it is on screen. |
progress(reason?) | Tells the component the user moved forward (a step the DOM cannot show, e.g. a file finished uploading): hides the hint and restarts the clock. |
mistake(reason?) | Reports a wrong action the component cannot see (server rejected the value, failed upload…): worth half a delay, never skips a level. |
complete(source?) | Marks the goal as reached: hints stop, mv-goal fires, stored with remember. |
dismiss({ remember = true }) | Stops hints for good, like the “I’ve got it” button. |
reset() | Starts over (clock, signals, done or dismissed state in memory). A stored dismissal is kept. |
forget() | Clears the stored dismissal or completion, then reset(). |
Events
| Name | Description |
|---|---|
mv-hint | Cancelable, before a level appears. detail: { level, previous, target, text, reason: "idle" | "friction" | "hesitation" | "api", signals }. preventDefault() vetoes that level (not retried until the escalation reaches a higher one): show your own UI instead, or log it. |
mv-hint-dismiss | The user closed a hint. detail: { level, reason: "button" | "escape" | "api", remembered, permanent }. Escape is a snooze (permanent false): the hint steps back one level and the snoozed level returns after one more delay of being stuck. |
mv-goal | The goal is reached. detail: { source: "click" | "change" | "submit" | "event" | "api", level (on screen at that moment), maxLevel (highest level shown), elapsed (ms since start), mistakes }. |
mv-progress | Progress reset the escalation. detail: { reason: "field" | "fixed" | "api" | …, level (the level that was on screen) }. |
mv-show-me | Cancelable, when “Show me” is chosen. detail: { target, level }. Open the accordion or tab that contains the target here; preventDefault() replaces the built-in scroll and focus. |
Content structure
| Name | Description |
|---|---|
(content) | The region to watch: a form, an onboarding card, an empty state. The component appends its own layer (halo), coach mark (popover) and live region at the end. |
template[data-level="1|2|3"] | Level content as direct child templates. Levels 2 and 3 may contain rich inline markup (strong, kbd, a); data-target on a template points that level at another element; data-show-me="false" on level 3 hides “Show me”. |
CSS classes
| Name | Description |
|---|---|
mv-insist-layer / mv-insist-halo | aria-hidden overlay with the glow ring positioned around the target. data-level="1|2|3|done", data-fly while it travels to a new target, data-off when the target is out of view. |
mv-insist-mark | Coach mark (Popover API, top layer, non-modal): -head (-kicker + -kicker-icon, -where: off-screen direction), -body, -actions, -show (“Show me”), -dismiss (“I’ve got it”). data-level, data-side, data-off="above | below" when docked at the viewport edge. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-insist-color | var(--mv-accent) | Halo, ripple and level 3 accents. |
--mv-insist-success | var(--mv-success) | Color of the brief confirmation ring when the goal is reached while a hint is up. |
Accessibility
Nothing is modal and focus is never moved by the component on its own: hints appear, change and disappear around what the user is doing. Level 1 is visual only (the halo is aria-hidden, pointer-events: none). Levels 2 and 3 open a labelled group in the top layer (Popover API, manual, so it never grabs focus) and are announced once in a polite live region (“Hint: …”, “Try this: …”); the coach mark text is added to the target’s aria-describedby while it is shown, so it is read again whenever the target gets focus, and removed afterwards. The coach mark follows the region in the Tab order; Escape (from the region or the target, unless another component handled it) snoozes the hint, “I’ve got it” stops hints for good, and if focus was inside the coach mark when it closes it moves to the target, never to the page. “Show me” is the only action that moves focus, and only because the user asked: it scrolls the target into view (smoothly unless reduced motion) and focuses it, but never activates it. Escalation is deferred while the user is typing or pressing, so a hint never appears under someone mid-keystroke, and progress removes it immediately. Keyboard activation of a disabled control counts as a wrong action like a click. When the target is scrolled out of view, the coach mark docks at the viewport edge with a direction label and offers “Show me”. Reduced motion (OS or data-motion="reduce"): no ripple, no flight, no confirmation flash; the ring is static. Forced colors: the halo becomes a Highlight outline and the coach mark a CanvasText border. Colors always come with shape and words (ring, labels).