Repository navigation
Description
Summary
id(web): the TS world presenter's graphemeWidth (pkgs/id/web/src/world-surface.ts:321) returns width 2 for every grapheme cluster that contains U+FE0F (variation selector 16):
if (codePoints.includes(0xfe0f)) return 2;Rust's unicode-width 0.2.2 — the validator the server side uses (pkgs/id/src/world_presentation.rs, parse_surface assigns each cell's columns from UnicodeWidthStr::width(grapheme)) — widens a base+FE0F cluster to 2 only for its emoji-presentable bases (digits, #, *, ©, ®, ™, suits, ▶/◀, ↔/↩/⤴, 〰/〽/㊗/㊙, actual emoji, …). For ordinary bases the cluster stays width 1, and a lone U+FE0F is width 0.
So a Rust-valid screen whose row contains e.g. a️ (U+0061 U+FE0F) is built by parse_surface with columns: 1 — and the web presenter then computes TS width 2 ≠ 1 and renderWorldSurface returns false for the whole screen, which falls back to raw text. This is exactly the #1581/#761 failure mode (one mis-measured cell rejects the entire frame), in a class those fixes and the #2962 differential never scoped: #2962 covers single code points alone and +U+FE0E; it never tested multi-codepoint clusters ending in U+FE0F.
Evidence (main c0fb853a; both files sha256-identical in the sandbox and laptop clones)
TS side — verbatim extraction of WIDE_RANGES/codePointWidth/graphemeWidth from world-surface.ts (sha256 5e04c698…), run under bun on the laptop; Rust side — standalone probe pinned to unicode-width = "=0.2.2" (the crate/version world_presentation.rs uses), same inputs. Probe: ~/git/fleet/probe-2026-10-31-laneA-vs16/ (laptop).
| cluster | Rust 0.2.2 | TS graphemeWidth |
|---|---|---|
a + FE0F |
1 | 2 |
x / Z / é / Ω + FE0F |
1 | 2 |
→ (U+2192) + FE0F |
1 | 2 |
! / ? / % / space + FE0F |
1 | 2 |
| lone FE0F | 0 | 2 |
1 / # / * / 0 + FE0F |
2 | 2 (agree) |
| © / ® / ™ + FE0F | 2 | 2 (agree) |
| ♠ / ♥ / ♦ / ▶ / ◀ / ↔ / ↩ / ⤴ / 〰 / 〽 / ㊗ / ㊙ + FE0F | 2 | 2 (agree) |
| ⚠ (U+26A0) + FE0F, ☝ + FE0F, ❤ + FE0F, 😀 + FE0F | 2 | 2 (agree) |
| ZWJ alone (U+200D) | 0 | 1 (same function, minor: Rust rejects width-0 cells, TS would admit one at columns: 1) |
Existing tests only cover the agreeing cases: world-surface.test.ts:271-272 exercises U+26A0+FE0F (
Why it matters
The two validators are a matched pair by design — the TS file's own comments insist "The two validators must not disagree" (world-surface.ts, on the symbol-cap removal). Any world program that emits a letter, digit-free symbol, or arrow followed by VS16 (copy-pasted text and styled labels do this in the wild) produces a screen the server accepts and the browser refuses wholesale, degrading the world view to raw text with no error pointing at the cell. The fix is small and well-scoped: gate the FE0F shortcut on the same emoji-presentable base set unicode-width uses (the file already maintains exactly such a table for the FE0E narrowing case, TEXT_PRESENTATION_RANGES, plus the extra VS16-only bases like digits/#/* — the Rust probe above enumerates the agreeing set to seed it).
Acceptance criteria
-
graphemeWidthwidens a cluster containing U+FE0F to 2 only when the base is one unicode-width 0.2.2 widens (its VS16/emoji-presentation set); a cluster of a letter/punctuation/arrow/space + FE0F measures 1, and a lone FE0F measures 0. - The ZWJ-alone case (Rust 0 vs TS 1) is dispositioned in the same pass — fixed or explicitly documented as unreachable, with the reasoning recorded.
- Parity tests in
world-surface.test.ts: Rust-valid cellsa+FE0F,→+FE0F,!+FE0F atcolumns: 1render; the agreeing wide cases (⚠️ , ☝️, keycap9+FE0F+U+20E3, ©️) still render atcolumns: 2. - Re-verified by a differential probe (real TS
graphemeWidthvsunicode-width = "=0.2.2") over base+FE0F clusters for a representative base set spanning both classes — zero mismatches. - Distinct from, and does not reopen: id(web): world presenter still mis-measures ⬛/⭕, narrow-base skin tones, composed Hangul jamo and long graphemes — #1431's folded-in classes survived its fix, and the 16-unit symbol cap has no Rust twin #1581 (closed; ZWJ/modifier/FE0E classes), id(web): world presenter residual width divergences vs unicode-width 0.2.2 — 2529 code points in classes #1581 never scoped (trigrams, CJK radicals, format chars) #2962 (open; single-code-point alone/+FE0E residual tables — this issue is the multi-codepoint FE0F cluster path only).
Activity
dezren39 commentedon Nov 1, 2026
🤖 Agent note · filed
37 remaining items
dezren39 commentedon Nov 2, 2026
🤖 Agent note · picked up
Merge pull request #4355 from developing-today/queue/5693-3265-fe0f-w…
dezren39 commentedon Nov 2, 2026
🤖 Agent note · done