익스클루시브

Invisibles <mv-invisibles>

텍스트 필드에서 실제 버그를 일으키는 보이지 않는 문자와 비슷하게 생긴 문자를 드러냅니다. IBAN, 이메일, API 키, 코드에 붙여 넣은 너비 없는 공백과 결합 문자, 줄 바꿈 없는 공백과 특수 공백, 앞뒤 공백, 탭, 소프트 하이픈, 제어 문자, 양방향 제어 문자(Trojan source), 숨겨진 태그 텍스트, 동형 이의 문자(라틴 단어 속 키릴 문자 “а”, 숫자 사이의 “O”), 전각 문자, 명령어 속 스마트 따옴표나 대시 등을 찾아냅니다.

문화적 레퍼런스

투명인간, H. G. 웰스(1897, 책). 투명한 과학자는 몸을 붕대, 옷, 고글로 감쌀 때만 보입니다. 보이지 않는 것이 그것을 감싼 윤곽을 통해 보이게 됩니다. 이 컴포넌트는 보이지 않는 문자나 닮은꼴 문자 하나하나를 눈에 보이는 윤곽과 짧은 이름으로 감싸 필드 안의 정확한 위치에 배치하므로, 그 문자를 보고, 이해하고, 제거할 수 있습니다.

작동 방식

실제 버그를 일으키는 텍스트 필드 속 보이지 않는 문자와 닮은꼴 문자를 눈에 보이게 합니다: IBAN, 이메일, API 키, 코드에 붙여 넣어진 폭 없는 공백과 결합 문자, 줄 바꿈 없는 공백과 특이한 공백, 앞뒤 공백, 탭, 소프트 하이픈, 제어 문자, 양방향 제어 문자(Trojan Source), 숨겨진 태그 텍스트, 동형 이의 문자(라틴 단어 속 키릴 문자 “а”, 숫자 사이의 “O”), 전각 문자, 명령어 속 둥근 따옴표나 대시. input이나 textarea를 감싸면, 스크롤이 동기화된 정확한 미러(같은 글꼴 메트릭, 패딩, 줄 바꿈)가 보이지 않는 문자마다 윤곽을 그립니다. 폭 없는 문자에는 가는 캡슐, 특이한 공백에는 대괄호, 앞뒤 공백에는 빗금, 닮은꼴 문자에는 점선 밑줄을 사용하며, 한 줄 필드에서는 짧은 코드(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
Keywordsexclusive, 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/marvelous

Marvelous 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&#8203;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&#x200B; 3704&#xA0;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&#x430;rtinez@&#x435;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&#x20;" 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 &#x2013;X POST https://api.example.com/v1/deploys \&#x20;
  -H &#x201C;Authorization: Bearer $DEPLOY_TOKEN&#x201D; \
  -d&#xA0;'{"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&#x202E; &#x2066;// Check if admin&#x2069; &#x2066;") {</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&#x202E; &#x2066;// Check if admin&#x2069; &#x2066;") {
  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유형DefaultDescription
profileiban | email | code | username | text | proseemail for type=email, prose for textarea, code for pre/code, text otherwiseRule 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.
ruleslist of rule idsReplaces 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.
allowlistExceptions, 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-messagestringCustom validity message used instead of the default (“This field contains 2 hidden characters. Fix them before continuing.”).
show-whitespacebooleanfalseAlso draws regular spaces (·) and line ends (¶) faintly in the mirror, like an editor's render-whitespace mode. They are never counted.
show-cleanbooleanfalseKeeps the summary line visible when nothing is found (“No hidden characters”), for fields where the check itself should be visible.
data-invisibles-targetattribute on a descendantMarks 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-reportattribute on a descendantContainer 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
foundFinding[]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).
cleanstringThe current text with every fix applied, without touching the field.
fieldHTMLElement | nullThe watched element.
profile / rules / allow / validate / validityMessage / showWhitespace / showCleanreflectedMirror the attributes; changing one rescans immediately.

Methods

NameDescription
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

NameDescription
mv-invisiblesFindings changed (and once on start if any). detail: { found, counts, value, source: "initial" | "input" | "paste" | "drop" | "change" | "fix" | "reset" | "api" | "config" | "mutation" }.
mv-fixBefore 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

NameDescription
(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

NameDescription
mv-invisibles-mirrorThe transparent copy of the text laid over the field (aria-hidden, pointer-events: none).
mv-invisibles-tokenA wrapped character. data-shape: zero | space | edge | break | glyph, data-rule, data-category, data-severity, data-code.
mv-invisibles-rail / -chipCodes 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-tipTooltip naming the hovered or caret-adjacent character.
mv-invisibles-report / -summary / -fixes / -result / -list / -itemThe report: data-state (found | fixed | clean) and data-severity on the report.

CSS variables

NameDefaultDescription
--mv-invisibles-colorvar(--mv-warning)Tone of hidden characters and odd spaces.
--mv-invisibles-dangervar(--mv-danger)Tone of bidi controls and hidden tag text.
--mv-invisibles-lookalikevar(--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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고