How Inspect Characters is built
Inspect Characters breaks pasted text down character by character, showing each one’s Unicode code point, UTF-8 bytes, UTF-16 code units, HTML escape, and general category. This post is about the class of bug this tool is built to surface: text that looks identical to two other pieces of text but isn’t.
Tech used
Grapheme-first decomposition, not index-based iteration
Iterating a JavaScript string with a plain for loop or bracket indexing walks UTF-16 code units, which silently breaks apart surrogate pairs and misses that some visible “characters” are actually multiple Unicode code points combined with zero-width joiners. The engine instead uses Intl.Segmenter (granularity: 'grapheme') to split the input into grapheme clusters first — the units a person would call individual characters — and only then decomposes each cluster into its constituent code points for display. This is the same foundational API Count Text uses for the same reason: code-unit iteration and human perception of “one character” frequently disagree, and grapheme segmentation is what closes that gap. Array.from() / spread-based code point extraction (rather than .length-based indexing) handles surrogate pairs correctly within each cluster.
Implementation & operational notes
Invisible characters are the whole reason to build this tool, so they get visual treatment, not just a table row. Zero-width space (U+200B), zero-width joiner (U+200D), byte-order mark (U+FEFF), no-break space (U+00A0), and the bidirectional control characters (U+200E, U+200F, U+202A–U+202E) all render as nothing or as something visually indistinguishable from an ordinary space — which is exactly what makes them a recurring, hard-to-diagnose bug source: two strings that look pixel-identical in a text editor can fail an equality check because one of them has a stray ZWSP in the middle. Each of these gets a colored border, background highlight, and a plain-language label (“Zero Width Space”) rather than being left to blend into the surrounding output like it does everywhere else.
Where this tool’s boundary sits relative to Hex Viewer. Hex Viewer reads a file and shows its bytes in offset order — a read-only view of a file’s raw contents. This tool takes pasted text and shows it character by character, keyed to Unicode semantics rather than byte offsets. The inputs are different (a file versus a clipboard paste) and so is the primary key each table is organized around (byte offset versus character), which is enough of a structural difference that folding one into the other wouldn’t simplify either.
Grapheme decomposition earns its keep on emoji sequences specifically. A ZWJ-joined family emoji decomposes into its full sequence of constituent code points — each base emoji plus each joiner shown individually — while still being counted and displayed as the single grapheme cluster it visually is. Getting this right required verifying, not assuming, that Intl.Segmenter groups the whole joined sequence as one cluster rather than splitting at the joiners; the e2e suite checks this directly against a real ZWJ emoji sequence rather than trusting the API’s documented behavior alone.