エクスクルーシブ
Invisibles <mv-invisibles>
実際のバグの原因となる不可視文字や紛らわしい文字を、テキストフィールド内で見えるようにします。IBAN、メールアドレス、API キー、コードに貼り付けられたゼロ幅スペースや結合子、改行なしスペースや特殊なスペース、先頭・末尾の空白、タブ、ソフトハイフン、制御文字、双方向制御文字(Trojan Source)、隠しタグテキスト、ホモグリフ(ラテン文字の単語に紛れたキリル文字の「а」、数字の中の「O」)、全角文字、コマンド内のスマートクォートやダッシュなどが対象です。
文化的リファレンス
『透明人間』、H・G・ウェルズ(1897年、書籍)。透明な科学者は、包帯や衣服、ゴーグルで体を包んだときにだけ姿が見えます:見えないものが、それを包む輪郭によって見えるようになるのです。このコンポーネントは、見えない文字や紛らわしい文字のそれぞれを、フィールド内のその文字の位置にぴったり重ねた目に見える輪郭と短い名前で包み、文字を見て、理解し、削除できるようにします。
仕組み
テキストフィールド内の見えない文字や紛らわしい文字を可視化します。これらは実際のバグの原因になります:IBAN、メールアドレス、API キー、コードに貼り付けられたゼロ幅スペースや結合子、ノーブレークスペースや特殊なスペース、先頭/末尾の空白、タブ、ソフトハイフン、制御文字、双方向制御文字(Trojan Source)、隠しタグテキスト、ホモグリフ(ラテン文字の単語に含まれるキリル文字の「а」、数字の中の「O」)、全角文字、コマンド内のスマートクォートやダッシュ。input や textarea をラップすると、正確でスクロールが同期したミラー(同じフォントメトリクス、パディング、折り返し)が、見えない文字のそれぞれに輪郭を描きます:ゼロ幅文字には細いカプセル、変わったスペースには角括弧、先頭/末尾の空白にはハッチング、紛らわしい文字には点線の下線。1 行のフィールドでは、短いコード(ZWSP、NBSP、⇥、RLO、CYR…)がテキストのすぐ下に固定表示されます。マーカーにホバーするか、キャレットをその隣に移動すると、その文字の名前がコードポイントと問題になる理由とともに表示されます。サマリー行(「3 hidden characters · 1 look-alike」)にはワンクリックの修正(Remove invisible characters、Normalize spaces、Replace look-alikes)があり、ブラウザの編集パイプラインを経由するため Ctrl/Cmd+Z で取り消せます。Undo ボタンもあり、詳細リストからフィールド内の各検出箇所を選択できます。ルールはフィールドに応じて変わります(profile は iban、email、code、username、text、prose:絵文字の結合子、連結する文字体系、フランス語のノーブレークスペースは、正当な場所ではそのまま許可されます)。validate はカスタムの検証メッセージを設定し、mv-invisibles は検出内容を報告し、mv-fix は修正を拒否できます。<pre> または <code> のターゲットはその場で可視化され、各双方向制御文字が分離されるため、テキストは実際に保存された順序で表示されます。Copy clean text も利用できます。検出器は findInvisibles() / cleanInvisibles() としてエクスポートされており、サーバーでも同じルールを適用できます。
| カテゴリー | フォーム |
|---|---|
| タイプ | Web Component(<mv-invisibles>) |
| ステータス | 安定版 |
| キット | ユーザーを取りこぼさないフォーム |
| 同時にインストールされるもの | button |
| Keywords | exclusive, culture, form, validation, input, textarea, unicode, zero-width, whitespace, nbsp, homoglyph, confusable, trojan-source, bidi, sanitize, paste, iban, security, undo, code |
When to use
- Users paste IBANs, emails, API keys or codes copied from PDFs, chats or web pages, and silent mismatches reach support
- A username or email field must catch homoglyph impersonation (Cyrillic or Greek letters inside Latin names)
- Commands or config snippets are pasted from docs that turned quotes and dashes into typographic characters
- A code review or snippet view must expose bidi controls and hidden text before anyone trusts what they read
Avoid when
- The value only needs a format check (length, pattern, checksum) and invisible characters can simply be stripped on submit
- The field is a password: the mirror cannot follow masked text, sanitize on the server instead
- The text is a rich-text document (contenteditable, WYSIWYG editor); the mirror only follows native inputs and textareas
インストール
node scripts/add.mjs invisibles --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["invisibles"], 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/observe.js, components/invisibles/invisibles.js, components/invisibles/invisibles.css。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-invisibles profile="iban">
<label for="iban">IBAN</label>
<input id="iban" value="FR76 3000 6000 0112​3456 7890 189">
</mv-invisibles>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="iv-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#iv-demo .iv-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1.1fr); gap:1.25rem; align-items:start }
#iv-demo .iv-col { display:grid; gap:1.25rem; min-width:0 }
#iv-demo .iv-card { display:grid; gap:1.125rem; min-width:0; margin:0; padding:1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#iv-demo .iv-card > * { min-width:0 }
#iv-demo .iv-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem }
#iv-demo .iv-head h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#iv-demo .iv-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
#iv-demo .iv-mono { font-family:var(--mv-font-mono); font-size:.8125rem }
#iv-demo textarea.iv-mono { font-size:.75rem; line-height:1.6 }
#iv-demo .iv-foot { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; flex-wrap:wrap; padding-top:1rem; border-top:1px solid var(--mv-border) }
#iv-demo .iv-saved { margin-inline-end:auto; color:var(--mv-fg-muted); font-size:.75rem }
#iv-demo .iv-code { margin:0; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); font:.75rem/1.7 var(--mv-font-mono); font-variant-ligatures:none; overflow-x:auto; white-space:pre }
#iv-demo .iv-k { display:block; margin:0 0 .375rem; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#iv-demo .iv-seen { margin:0; padding:.5rem .875rem; border:1px dashed var(--mv-border-strong); border-radius:var(--mv-radius-lg); color:var(--mv-fg-muted); font:.75rem/1.7 var(--mv-font-mono); font-variant-ligatures:none; white-space:pre; overflow-x:auto }
#iv-demo .mv-choice { font-size:.8125rem }
@media (max-width:56rem) { #iv-demo .iv-grid { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="iv-grid">
<!-- Single-line fields: codes pinned under each field, validity blocks the submit -->
<form class="iv-card" id="iv-form" aria-labelledby="iv-form-title">
<div class="iv-head">
<div>
<h3 id="iv-form-title">Payout details</h3>
<p>Pasted from a vendor’s onboarding email</p>
</div>
<span class="mv-badge" data-variant="warning" id="iv-count">Checking…</span>
</div>
<mv-invisibles profile="iban" validate>
<div class="mv-field">
<label class="mv-label" for="iv-iban">IBAN</label>
<input class="mv-input iv-mono" id="iv-iban" name="iban" value="DE89​ 3704 0O44 0532 0130 00" autocomplete="off" spellcheck="false">
</div>
</mv-invisibles>
<mv-invisibles profile="email" validate>
<div class="mv-field">
<label class="mv-label" for="iv-email">Remittance email</label>
<input class="mv-input" id="iv-email" name="email" type="email" value="sofia.mаrtinez@еxample.com" autocomplete="off" spellcheck="false">
</div>
</mv-invisibles>
<mv-invisibles profile="code" validate="warning">
<div class="mv-field">
<label class="mv-label" for="iv-coupon">Partner code</label>
<input class="mv-input iv-mono" id="iv-coupon" name="coupon" value="SPRING2O26 " autocomplete="off" spellcheck="false">
</div>
</mv-invisibles>
<div class="iv-foot">
<span class="iv-saved" id="iv-saved" role="status"></span>
<button class="mv-button" data-variant="outline" type="reset">Restore examples</button>
<button class="mv-button" type="submit">Save payout details</button>
</div>
</form>
<div class="iv-col">
<!-- Multi-line field: tokens in place, the code shows on hover or when the caret touches it -->
<section class="iv-card" aria-labelledby="iv-cmd-title">
<div class="iv-head">
<div>
<h3 id="iv-cmd-title">Deploy command</h3>
<p>Copied from the team wiki. Hover a marker, or move the caret next to it</p>
</div>
</div>
<mv-invisibles profile="code" id="iv-cmd">
<label class="mv-sr-only" for="iv-cmd-field">Deploy command</label>
<textarea class="mv-textarea iv-mono" id="iv-cmd-field" rows="3" spellcheck="false">curl –X POST https://api.example.com/v1/deploys \ 
-H “Authorization: Bearer $DEPLOY_TOKEN” \
-d '{"service": "checkout", "region": "us-east-1"}'</textarea>
</mv-invisibles>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="iv-ws">
<span class="mv-choice-text"><span class="mv-choice-title">Show all whitespace</span></span>
</label>
</section>
<!-- Read-only target: revealed in place, with the true character order -->
<section class="iv-card" aria-labelledby="iv-pr-title">
<div class="iv-head">
<div>
<h3 id="iv-pr-title">Code review</h3>
<p>auth/guard.js · pull request #482</p>
</div>
</div>
<div>
<span class="iv-k" id="iv-seen-k">What the reviewer saw</span>
<pre class="iv-seen" aria-labelledby="iv-seen-k">if (role !== "user‮ ⁦// Check if admin⁩ ⁦") {</pre>
</div>
<div>
<span class="iv-k">What is stored</span>
<mv-invisibles>
<pre class="iv-code"><code>const role = session.user.role;
if (role !== "user‮ ⁦// Check if admin⁩ ⁦") {
grantAccess(adminPanel);
}</code></pre>
</mv-invisibles>
</div>
</section>
</div>
</div>
<script type="module">
const form = document.getElementById("iv-form");
await customElements.whenDefined("mv-invisibles");
const badge = document.getElementById("iv-count");
const saved = document.getElementById("iv-saved");
const fields = [...form.querySelectorAll("mv-invisibles")];
const refresh = () => {
const total = fields.reduce((sum, el) => sum + el.counts.total, 0);
badge.textContent = total ? `${total} to review` : "Ready to save";
badge.dataset.variant = total ? "warning" : "success";
};
form.addEventListener("mv-invisibles", () => { saved.textContent = ""; refresh(); });
form.addEventListener("reset", () => setTimeout(refresh));
form.addEventListener("submit", (e) => {
e.preventDefault();
saved.textContent = "Payout details saved";
});
refresh();
document.getElementById("iv-ws").addEventListener("change", (e) => {
document.getElementById("iv-cmd").showWhitespace = e.target.checked;
});
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
profile | iban | email | code | username | text | prose | email for type=email, prose for textarea, code for pre/code, text otherwise | Rule set for the kind of value. iban, email, code and username are strict: every non-ASCII look-alike letter, bidi mark and odd space is flagged; code also flags smart quotes, typographic dashes and look-alike punctuation; iban and code flag an O/I/l among digits (or a 0/1 among letters) on single-line fields. text flags hidden characters, odd spaces, edges and mixed-script words only. prose is lenient: no-break spaces, soft hyphens and bidi marks are fine, joiners inside emoji and joining scripts are ignored, only words mixing Latin with Cyrillic, Greek or Armenian look-alikes are flagged. |
rules | list of rule ids | Replaces the profile's rules. Ids: zero-width, joiner, soft-hyphen, bidi, bidi-mark, control, tags, nbsp, spaces, tab, edge, line-separator, homoglyph, fullwidth, ambiguous, punctuation. | |
allow | list | Exceptions, comma or space separated: rule ids ("nbsp, edge"), short codes ("ZWJ"), code points ("U+00A0") or single characters. | |
validate | "" | "warning" | Present: the field gets a custom validity message (setCustomValidity) while danger or error findings remain, so native form validation blocks the submit. "warning" also blocks warnings (look-alike punctuation, ambiguous O/0, spaces in multi-line fields). The message is removed as soon as the field is clean, and never touches a validity message the app set itself. | |
validity-message | string | Custom validity message used instead of the default (“This field contains 2 hidden characters. Fix them before continuing.”). | |
show-whitespace | boolean | false | Also draws regular spaces (·) and line ends (¶) faintly in the mirror, like an editor's render-whitespace mode. They are never counted. |
show-clean | boolean | false | Keeps the summary line visible when nothing is found (“No hidden characters”), for fields where the check itself should be visible. |
data-invisibles-target | attribute on a descendant | Marks the element to watch when the wrapper contains several candidates. Otherwise the first input or textarea is used, then the first pre, then the first code. | |
data-invisibles-report | attribute on a descendant | Container that receives the report (summary, fixes, details). By default the report is inserted right after the wrapper's child that contains the field. |
Properties
| Name | タイプ | Description |
|---|---|---|
found | Finding[] | Current findings (read-only copies), sorted by position; identical adjacent characters are merged. Finding: { index, end, length, count, text, codePoint, hex, code, name, hint, rule, category: "invisible" | "space" | "lookalike", severity: "danger" | "error" | "warning", replacement, fix, hidden? } (hidden = decoded tag text). |
counts | { hidden, invisible, space, lookalike, danger, total } | Totals in characters (hidden = invisible + space). |
clean | string | The current text with every fix applied, without touching the field. |
field | HTMLElement | null | The watched element. |
profile / rules / allow / validate / validityMessage / showWhitespace / showClean | reflected | Mirror the attributes; changing one rescans immediately. |
Methods
| Name | Description |
|---|---|
scan() | Rescans now and returns the findings. Call it after setting the field's value from script (programmatic value changes fire no event); input, change, focus and form reset rescan automatically. |
fix(kind = "all") | kind: "invisible" | "spaces" | "lookalikes" | "all". Replaces only the changed span with document.execCommand("insertText"), so native undo, input events and framework bindings all see a normal edit (falls back to setting the value and dispatching input). The caret is mapped through the edit. Returns the number of characters fixed (0 if nothing to fix, read-only field or vetoed). |
copyClean() | Copies the text with every fix applied to the clipboard (the action offered for pre/code targets). Resolves to the number of characters fixed. |
findInvisibles(text, { profile, rules, allow, multiline }) (module export) | The detector alone, DOM-free: returns the same findings, for server-side or Node validation. |
cleanInvisibles(text, options, kind) (module export) | Returns { text, count, ops } with the chosen fixes applied. |
Events
| Name | Description |
|---|---|
mv-invisibles | Findings changed (and once on start if any). detail: { found, counts, value, source: "initial" | "input" | "paste" | "drop" | "change" | "fix" | "reset" | "api" | "config" | "mutation" }. |
mv-fix | Before a fix or a clean copy. detail: { kind, count, before, after, found, mode: "edit" | "copy" }. Cancelable: preventDefault() leaves the value untouched (e.g. log it, or apply your own normalization). |
Content structure
| Name | Description |
|---|---|
(content) | Your label and one input (text, search, email, url, tel), textarea, pre or code, in any markup (a field wrapper, an input group…). The component adds a mirror and a tooltip (both aria-hidden) plus a report after the field; the field itself is only given aria-describedby and, with validate, a custom validity message. |
CSS classes
| Name | Description |
|---|---|
mv-invisibles-mirror | The transparent copy of the text laid over the field (aria-hidden, pointer-events: none). |
mv-invisibles-token | A wrapped character. data-shape: zero | space | edge | break | glyph, data-rule, data-category, data-severity, data-code. |
mv-invisibles-rail / -chip | Codes pinned under single-line fields, with a tick pointing at the exact position (‹ › when scrolled out of view, +N when they do not fit). |
mv-invisibles-tip | Tooltip naming the hovered or caret-adjacent character. |
mv-invisibles-report / -summary / -fixes / -result / -list / -item | The report: data-state (found | fixed | clean) and data-severity on the report. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-invisibles-color | var(--mv-warning) | Tone of hidden characters and odd spaces. |
--mv-invisibles-danger | var(--mv-danger) | Tone of bidi controls and hidden tag text. |
--mv-invisibles-lookalike | var(--mv-accent) | Tone of look-alike characters. |
Accessibility
The mirror, rail and tooltip are purely visual (aria-hidden, pointer-events: none): the field keeps its native role, label, caret, selection, spellcheck and IME behavior, and nothing is inserted into its value. Findings are described in text: the summary element is linked to the field with aria-describedby while there is something to report (removed when the field is clean), and includes a screen-reader sentence naming the first four findings and where they are (“zero width space after DE89”). New findings are announced politely, once the input settles (“Pasted text contains 2 hidden characters: zero width space, no-break space.”). Fixes are real buttons; a fix moves focus into the field (where typing continues), announces its result (“Removed 1 invisible character. Press Ctrl+Z or use Undo to restore.”) and stays undoable natively. The Details toggle uses aria-expanded / aria-controls, and each finding in the list is a button that selects that character in the field, so keyboard and screen-reader users can reach every finding without hovering; moving the caret next to a marker shows the same tooltip as hovering. Meaning never relies on color: every marker has a distinct shape (capsule, bracket, hatching, dotted underline, solid capsule with a notch for dangerous controls) and a text code, and the summary icon changes shape with severity. With validate, the native validity message explains the problem in plain words. The overlay follows the field's own font metrics, so it stays aligned at any zoom level and text size. Reduced motion removes the tooltip fade, the rail slide and the locate flash; forced colors switch markers and chips to system colors while keeping their shapes.