익스클루시브
Figurines <mv-figurines>
잠금 전에 작고 엄격한 한도를 실감하게 하는 남은 시도 횟수 표시기입니다. 코드, PIN, 비밀번호 입력 시도, 결제 재시도, 답안 제출 횟수, 무료 내보내기 횟수에 적합합니다.
문화적 레퍼런스
그리고 아무도 없었다, 애거사 크리스티(1939년, 책). 식탁 위에 인형 열 개가 서 있고 누군가 죽을 때마다 하나씩 사라지므로, 모두가 몇 개 남았는지 정확히 셀 수 있고 마지막 하나를 두려워합니다. UI에서는 제한된 시도 하나하나가 선 위에 서 있는 작은 토큰입니다. 실패할 때마다 하나가 쓰러지며 빈자리를 남기고, 텍스트는 0이 되면 어떻게 되는지 알려 주며, 잠금 직전에는 마지막 토큰이 강조됩니다.
작동 방식
잠금 전에 작고 엄격한 한도를 실감하게 하는 남은 시도 횟수 표시기: 코드, PIN, 비밀번호 시도, 결제 재시도, 답변 시도, 무료 내보내기. 시도 한 번마다 중립적인 토큰 하나가 가는 선 위에 서 있습니다. 실패할 때마다 하나가 짧고 차분하게 쓰러지며 그 자리에 점선 윤곽을 남기므로, 남은 것과 쓴 것을 한눈에 셀 수 있습니다. 텍스트는 항상 개수와 0의 의미를 알려 줍니다(“3 of 5 attempts left · then you’ll have to wait 15 minutes”). 마지막 토큰은 링으로 둘러싸이고 더 크고 따뜻한 색으로 표시되며, 표시기 전체가 더 강하지만 불안을 주지 않는 “Last attempt” 경고로 바뀝니다. 0이 되면 연결된 필드를 비활성화하고(포커스는 설명에 유지) 지정한 exhausted 텍스트나 리치 슬롯(“Update card” 링크)을 표시할 수 있습니다. cooldown이나 절대 시각 until을 지정하면 선이 다시 채워지는 동안 카운트다운하고, 끝나면 모든 토큰을 다시 세웁니다. fail()은 취소 가능한 mv-attempt-fail을 거치고, reset(), restore(n), sync({ remaining, max, until })로 서버와 맞춰 두며, 개수는 항상 서버 쪽이 우선합니다. for 속성은 aria-describedby를 통해 필드와 연결하고(커스텀 엘리먼트의 내부 input 포함), 각 변경은 polite 방식으로, 마지막 시도와 잠금은 assertive 방식으로 안내됩니다.
| 카테고리 | 피드백 |
|---|---|
| 유형 | Web Component (<mv-figurines>) |
| 상태 | 안정 |
| 키트 | 정직한 시스템 상태 |
| Keywords | exclusive, culture, attempts, retries, lockout, rate-limit, otp, 2fa, pin, password, login, security, payment, dunning, quiz, assessment, quota, free-tier, cooldown, countdown, form, hint |
When to use
- A 2FA code, PIN or password field allows a few tries before the account or device locks for a while
- A failed payment can be retried a limited number of times before the subscription is suspended
- An assessment or quiz gives each question a few answer attempts before revealing the answer
- A free tier grants a handful of exports or generations and users should see the last one coming
Avoid when
- The budget refills continuously over time or is large and metered (API calls, AI messages, credits) → use Recharge instead
- A session is about to expire and someone must confirm they are still there → use Hatch instead
- The limit is only enforced client-side: it informs, the server must still count attempts and lock
설치
node scripts/add.mjs figurines --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["figurines"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/figurines/figurines.js, components/figurines/figurines.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<input id="pin" inputmode="numeric" aria-label="PIN">
<mv-figurines for="pin" max="5" remaining="3" cooldown="15m"></mv-figurines>
<!-- on each wrong PIN: figurines.fail() -->기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="fg-demo" style="width:min(100%,56rem);margin-inline:auto">
<style>
#fg-demo { display:grid; gap:1rem; align-content:start }
#fg-demo .fg-main { display:grid; grid-template-columns:minmax(0,1fr) 16.5rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#fg-demo .fg-signin { display:grid; grid-template-columns:minmax(0,1fr); gap:1rem; align-content:start; padding:1.5rem 1.75rem 1.625rem }
#fg-demo .fg-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.875rem; letter-spacing:-.01em; color:var(--mv-fg) }
#fg-demo .fg-logo { display:grid; place-items:center; width:1.625rem; height:1.625rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#fg-demo .fg-logo svg { width:.9375rem; height:.9375rem }
#fg-demo h3 { margin:0; font-size:1.25rem; letter-spacing:-.015em }
#fg-demo .fg-lead { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.5 }
#fg-demo .fg-field { display:grid; gap:.5rem; justify-items:start }
#fg-demo .fg-field label { font-size:.8125rem; font-weight:600 }
#fg-demo .fg-row { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap }
#fg-demo .fg-msg { min-height:1.25rem; margin:0; font-size:.8125rem; color:var(--mv-fg-muted) }
#fg-demo .fg-msg[data-tone="error"] { color:var(--mv-danger) }
#fg-demo .fg-msg[data-tone="ok"] { color:var(--mv-success) }
#fg-demo .fg-hint { color:var(--mv-fg-subtle); font-size:.75rem }
#fg-demo .fg-hint code { font:600 .75rem var(--mv-font-mono); color:var(--mv-fg-muted) }
#fg-demo .fg-side { display:grid; gap:.875rem; align-content:start; padding:1.25rem; border-inline-start:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#fg-demo .fg-k { margin:0; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#fg-demo .fg-actions { display:grid; gap:.5rem }
#fg-demo .fg-actions .mv-button { justify-content:flex-start }
#fg-demo .fg-log { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#fg-demo .fg-log li:first-child { color:var(--mv-fg) }
#fg-demo .fg-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem }
#fg-demo .fg-card { display:grid; gap:.75rem; align-content:start; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#fg-demo .fg-card h4 { margin:0; font-size:.875rem; letter-spacing:-.005em }
#fg-demo .fg-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
#fg-demo .fg-file { display:flex; align-items:center; gap:.625rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); font-size:.8125rem }
#fg-demo .fg-file svg { width:1.125rem; height:1.125rem; color:var(--mv-fg-subtle); flex:none }
#fg-demo .fg-file span { display:grid; min-width:0 }
#fg-demo .fg-file small { color:var(--mv-fg-subtle); font-size:.6875rem }
#fg-demo .fg-choices { display:grid; gap:.375rem }
#fg-demo .fg-choices .mv-button { justify-content:flex-start; width:100% }
#fg-demo .fg-choices .mv-button[data-picked="wrong"] { text-decoration:line-through; color:var(--mv-fg-subtle) }
#fg-demo .fg-choices .mv-button[data-picked="right"] { border-color:var(--mv-success); color:var(--mv-success) }
#fg-demo .fg-inv { display:flex; justify-content:space-between; gap:.5rem; font-size:.8125rem }
#fg-demo .fg-inv b { font-variant-numeric:tabular-nums }
@media (max-width:46rem) {
#fg-demo .fg-main { grid-template-columns:minmax(0,1fr) }
#fg-demo .fg-side { border-inline-start:0; border-top:1px solid var(--mv-border) }
#fg-demo .fg-grid { grid-template-columns:minmax(0,1fr) }
#fg-demo .fg-signin { padding:1.25rem }
#fg-demo mv-otp { --mv-otp-slot-width:2.25rem; --mv-otp-slot-height:2.75rem; --mv-otp-gap:.375rem }
}
</style>
<!-- Main: two-step sign-in, 5 code attempts (2 already spent), a 30 s pause at zero (15 min in production) -->
<section class="fg-main" aria-label="Two-step verification">
<form class="fg-signin" id="fg-form" novalidate>
<span class="fg-brand">
<span class="fg-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4 7v5c0 4.5 3.4 8.2 8 9 4.6-.8 8-4.5 8-9V7z"/></svg></span>
Acme
</span>
<div>
<h3>Check your phone</h3>
<p class="fg-lead">Enter the 6-digit code we texted to (•••) •••-••42 to finish signing in as [email protected].</p>
</div>
<div class="fg-field">
<label for="fg-otp">Verification code</label>
<mv-otp id="fg-otp" length="6" separator></mv-otp>
</div>
<mv-figurines id="fg-left" for="fg-otp fg-verify" max="5" remaining="3" cooldown="30s" disable-at-zero
exhausted="Sign-in is paused to protect this account"></mv-figurines>
<div class="fg-row">
<button type="submit" class="mv-button" id="fg-verify">Verify and sign in</button>
<span class="fg-hint">The right code is <code>424242</code></span>
</div>
<p class="fg-msg" id="fg-msg" aria-live="polite"></p>
</form>
<aside class="fg-side" aria-label="Demo controls">
<p class="fg-k">Simulate</p>
<div class="fg-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fg-wrong">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>
Submit a wrong code
</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fg-sync">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12a8 8 0 0 1 14-5.3L20 9M20 4v5h-5M20 12a8 8 0 0 1-14 5.3L4 15M4 20v-5h5"/></svg>
Server: 1 attempt left
</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="fg-reset">Start over</button>
</div>
<p class="fg-k">Events</p>
<ol class="fg-log" id="fg-log" aria-label="Event log"><li>Ready · 3 of 5 left</li></ol>
</aside>
</section>
<div class="fg-grid">
<!-- Free-tier exports: the last one is marked -->
<section class="fg-card" aria-labelledby="fg-ex-title">
<h4 id="fg-ex-title">Free exports</h4>
<div class="fg-file">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>
<span>Q3 board deck.pdf<small>24 slides · 8.2 MB</small></span>
</div>
<mv-figurines id="fg-exports" data-variant="plain" shape="dot" unit="free export|free exports" max="3" remaining="1" for="fg-export" disable-at-zero
consequence="then exports need the Pro plan" exhausted="Upgrade to keep exporting"></mv-figurines>
<div class="fg-row">
<button type="button" class="mv-button" data-size="sm" id="fg-export">Export PDF</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="fg-upgrade">Upgrade</button>
</div>
</section>
<!-- Payment retries: exhausted, counting down to an absolute time -->
<section class="fg-card" aria-labelledby="fg-pay-title">
<h4 id="fg-pay-title">Invoice INV-3187</h4>
<div class="fg-inv"><span>Team plan · October</span><b>$240.00</b></div>
<mv-figurines id="fg-pay" shape="square" unit="retry|retries" max="3" remaining="0" for="fg-retry" disable-at-zero data-size="sm">
<span slot="exhausted">Card ending 4417 was declined. <a href="#" id="fg-card">Update card</a></span>
</mv-figurines>
<div class="fg-row">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="fg-retry">Retry payment</button>
</div>
</section>
<!-- Assessment: three tries, stacked -->
<section class="fg-card" aria-labelledby="fg-q-title">
<h4 id="fg-q-title">Knowledge check · Q4</h4>
<p>Which HTTP status tells a client it is sending too many requests?</p>
<div class="fg-choices" id="fg-choices">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" data-answer="403">403 Forbidden</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" data-answer="503" data-picked="wrong" disabled>503 Service Unavailable</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" data-answer="429">429 Too Many Requests</button>
</div>
<mv-figurines id="fg-quiz" data-variant="stacked" shape="diamond" max="3" remaining="2" unit="try|tries"
consequence="then the answer is shown" exhausted="The answer was 429 Too Many Requests"></mv-figurines>
</section>
</div>
<script type="module">
const $ = (id) => document.getElementById(id);
const left = $("fg-left");
const otp = $("fg-otp");
const msg = $("fg-msg");
const log = $("fg-log");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const note = (text) => {
const li = document.createElement("li");
li.textContent = `${time()} · ${text}`;
log.prepend(li);
while (log.children.length > 6) log.lastElementChild.remove();
};
const say = (text, tone = "") => { msg.textContent = text; msg.dataset.tone = tone; };
// Sign-in: the app checks the code; figurines only counts and explains.
$("fg-form").addEventListener("submit", (e) => {
e.preventDefault();
if (left.remaining === 0) return;
const code = otp.value ?? "";
if (code.length < 6) { say("Enter all 6 digits.", "error"); otp.focus?.(); return; }
if (code === "424242") {
say("Code confirmed. Signing you in…", "ok");
otp.valid = true;
left.reset();
return;
}
otp.invalid = true;
say("That code isn’t right.", "error");
left.fail();
otp.clear?.();
});
$("fg-wrong").addEventListener("click", () => { say("That code isn’t right.", "error"); left.fail(); });
$("fg-sync").addEventListener("click", () => left.sync({ remaining: 1 }));
$("fg-reset").addEventListener("click", () => { left.reset(); say(""); otp.clear?.(); });
left.addEventListener("mv-attempt-fail", (e) => note(`mv-attempt-fail · ${e.detail.remaining} left${e.detail.last ? " (last)" : ""}`));
left.addEventListener("mv-exhausted", (e) => {
note(`mv-exhausted · until ${new Date(e.detail.until).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" })}`);
say("");
});
left.addEventListener("mv-replenish", (e) => { note(`mv-replenish · ${e.detail.reason} · ${e.detail.remaining} left`); if (e.detail.reason === "cooldown") say(""); });
// Exports: spending a free export is not a failure, but it is the same countdown.
const exportsLeft = $("fg-exports");
$("fg-export").addEventListener("click", () => exportsLeft.fail());
$("fg-upgrade").addEventListener("click", () => exportsLeft.reset());
// Payment: the server says retries are paused until an absolute time.
const pay = $("fg-pay");
pay.sync({ until: Date.now() + 45_000 });
$("fg-retry").addEventListener("click", () => pay.fail());
$("fg-card").addEventListener("click", (e) => { e.preventDefault(); pay.reset(); });
// Quiz: each wrong pick spends a try; the right one ends the question.
const quiz = $("fg-quiz");
const choices = $("fg-choices");
const lock = () => choices.querySelectorAll("button").forEach((b) => { b.disabled = true; if (b.dataset.answer === "429") b.dataset.picked = "right"; });
choices.addEventListener("click", (e) => {
const b = e.target.closest("button[data-answer]");
if (!b || b.disabled) return;
if (b.dataset.answer === "429") { b.dataset.picked = "right"; lock(); return; }
b.dataset.picked = "wrong";
b.disabled = true;
if (quiz.fail() === 0) lock();
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
max | number (1-24) | 5 | Number of attempts, one token each. Changing it rebuilds the row without animation. |
remaining | number | max | Attempts left. Reflected by the component; setting it from outside (server count) animates the difference and announces it, like sync(). |
cooldown | time ("15m", "30s", "1h", "1d", ms number) | Wait applied when the count reaches zero without a future until: the tokens come back cooldown later. Also writes the default consequence (“then you’ll have to wait 15 minutes”). | |
until | ISO date | epoch ms | Absolute time at which every token comes back (your server’s lockout end or quota reset). Under an hour the text counts down (“Try again in 14:32”); beyond, it states the time (“Try again tomorrow at 9:00 AM”). Derived from Date.now(), so throttled background tabs don’t drift. Reflected when the component computes it from cooldown. | |
consequence | string | What happens at zero, written to follow the count (“then this account locks for 15 minutes”). Unslotted child content takes precedence. | |
exhausted | string | Text shown at zero instead of the consequence (“Sign-in is paused to protect this account”). slot="exhausted" content takes precedence. | |
unit | "singular|plural" | attempt|attempts | Noun used in every text and announcement: “try|tries”, “retry|retries”, “free export|free exports”… |
for | id list | Space-separated ids of the field(s) and button(s) the count applies to. Each gets aria-describedby pointing to the status (and so does the inner native control of a custom element such as mv-otp). | |
disable-at-zero | boolean | Sets disabled on the for targets while the count is zero and afterwards re-enables exactly the ones it disabled. If one had focus, focus moves to the status text instead of being lost. | |
reveal | always | after-fail | always | after-fail hides the indicator while no attempt has been spent (it stays referenced by aria-describedby), then fades it in. |
shape | peg | dot | square | diamond | peg | Abstract token shape. |
quiet | boolean | No live announcements (when your form already announces its own errors together with the count). | |
locale | BCP 47 | en-US | Locale of the absolute time in “Try again at 3:45 PM”. |
data-variant | plain | stacked | Default: a subtle box. plain: no box, for a hint line under a field. stacked: tokens above centered text, for PIN pads, lock screens and quiz cards. | |
data-size | sm | lg | Token and text size. | |
data-state | full | spent | last | exhausted | Set by the component (styleable). data-cooldown is added while a countdown runs, data-concealed while reveal="after-fail" hides it. |
Properties
| Name | 유형 | Description |
|---|---|---|
remaining | number | Attempts left. Setting it is a server sync: the difference animates and is announced, no mv-attempt-fail. |
max | number | Number of tokens (mirrors the attribute). |
until | number (epoch ms) | Date | string | null | When the tokens come back. Read it, or set it from the server’s lockout end. |
state | "full" | "spent" | "last" | "exhausted" | Current phase (read-only). |
strings | Partial<Record<string, string>> | Overrides for every text and announcement (keys: unit, count ({n} {max} {units}), last ({unit}), none ({units}), consequenceWait ({duration}), cooldown ({time}), cooldownAt ({at}), timer ({time} {at}), announceLast ({consequence}), announceNone ({exhausted}), announceReplenish ({Units} {count}), announceReady ({count})). English defaults. |
Methods
| Name | Description |
|---|---|
fail() | One attempt spent: emits the cancelable mv-attempt-fail, tips the last standing token over, announces the count; at zero starts the cooldown and emits mv-exhausted. Returns the attempts left (unchanged if cancelled). |
reset() | Stands every token back up and clears until (after a success, a password reset, a new card). Cancelable mv-replenish; returns false if refused. |
restore(n = 1) | Gives back n attempts (earned retry, support grant). Cancelable mv-replenish. |
sync({ remaining?, max?, until? }) | Aligns with the server’s truth in one call. Losses tip tokens over without mv-attempt-fail; reaching zero emits mv-exhausted (reason "sync"); gains emit a non-cancelable mv-replenish. |
Events
| Name | Description |
|---|---|
mv-attempt-fail | Cancelable, before a token falls. detail: { remaining (after), previous, max, last (true when one is left) }. preventDefault() keeps the count (e.g. a network error that should not cost an attempt). |
mv-exhausted | The count reached zero. detail: { reason: "fail" | "sync", until (epoch ms or null), max }. |
mv-replenish | Tokens come back. detail: { reason: "reset" | "restore" | "cooldown" | "sync", previous, remaining, max }. Cancelable except for sync; cancelling a cooldown refill leaves the count at zero so you can ask the server first. |
Content structure
| Name | Description |
|---|---|
(default) | Rich consequence shown after the count before zero (may contain a link). |
exhausted | Rich content shown at zero instead of the consequence (“Card ending 4417 was declined. Update card”). |
CSS classes
| Name | Description |
|---|---|
mv-figurines-row | Decorative token row (aria-hidden, data-shape): .mv-figurines-tokens > .mv-figurines-slot-token (data-spent, data-last) > .mv-figurines-token, and .mv-figurines-line > .mv-figurines-line-fill (cooldown progress). |
mv-figurines-status | The text referenced by aria-describedby (focusable with tabindex=-1): -icon, -text, -count, -sep, -consequence, -exhausted, -clock. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-figurines-token | var(--mv-accent) | Standing token color, and the cooldown refill line. |
--mv-figurines-last | var(--mv-warning) | Color of the last token and of the last-attempt state. |
--mv-figurines-exhausted | var(--mv-danger) | Tint of the zero state. |
--mv-figurines-ghost | var(--mv-border-strong) | Dashed outline left by a spent token. |
--mv-figurines-gap | 0.3125rem | Space between tokens. |
--mv-figurines-progress | 0 | Cooldown progress 0-1, set by the component (read it to style your own bar). |
Accessibility
The text is always there and never relies on color: “3 of 5 attempts left · then you’ll have to wait 15 minutes”, “Last attempt” with a warning icon, “No attempts left” with a lock icon; the last token is also larger and ringed. The token row is aria-hidden and ignores the pointer. for adds the status id to aria-describedby on each target (and on the native input inside a custom element such as mv-otp, once it is upgraded), so the count and consequence are read with the field; the ids are removed on disconnect. Each failure is announced in a polite live region (“2 of 5 attempts left.”); the last attempt is announced assertively with its consequence (“Last attempt. Then you’ll have to wait 15 minutes.”), and so is the lockout (“No attempts left. Sign-in is paused to protect this account. Try again at 3:45 PM.”); refills are announced politely. quiet turns announcements off when your form already speaks. The visible m:ss countdown is aria-hidden; a role="timer" element inside the status (implicitly aria-live off) gives the spoken form on demand (“Try again in 28 seconds, at 3:45 PM”), so nothing chatters every second. disable-at-zero only re-enables what it disabled, and moves focus to the status text (tabindex=-1, visible focus ring) when the focused field gets disabled, instead of dropping it to the page. Reduced motion (OS or data-motion="reduce"): tokens fade instead of toppling or rising, the neighbor does not wobble, the halo does not pulse, the refill line jumps. Forced colors: tokens use CanvasText, the last one Highlight, spent outlines GrayText. RTL: tokens fall toward the inline end.