From 4b48b1d93d98a7f1e1134624afafc26c6b4ee748 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:46:17 -0700 Subject: [PATCH 1/3] fix(v2): pod initials drop punctuation and show one wide glyph (TASK-185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `podInitials` split on whitespace only, so the em dash in "Sharpen — pod model" became an initial and the 22px row mark read "S—". It also took two letters of a one-word name, so "设计评审" gave 设计, which wraps to a second line in the mark. Tokenise the way `initialsFor` does — split on whitespace and `_ - / |`, strip `[^\p{L}\p{N}]` from each token, drop whatever is left empty — so punctuation and dashes separate words instead of becoming initials. Keep this function's own pick (first letter of the first two tokens, or two letters of a one-token name) and return a single glyph when either picked glyph is Han, Hiragana, Katakana or Hangul. Slice by code point. Tests drive the function: PM, SP, TC, 设 for 设计评审, G for "Growth 团队", LA for "🚀 Launch", · for a name with no surviving token, and a synthetic astral pair that a UTF-16 slice would cut in half. Four mutations, each red on the arms it names and green everywhere else: whitespace-only tokenising ✕2, the wide-glyph branch ✕1, code-point slicing ✕1, keeping empty tokens ✕2. 118 suites / 1042 tests, tsc 0 with no output, eslint 0. --- frontend/src/v2/__tests__/podRecency.test.ts | 23 +++++++++++++++- frontend/src/v2/lib/podRecency.ts | 29 ++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/frontend/src/v2/__tests__/podRecency.test.ts b/frontend/src/v2/__tests__/podRecency.test.ts index b55f17c00..553f1ecd5 100644 --- a/frontend/src/v2/__tests__/podRecency.test.ts +++ b/frontend/src/v2/__tests__/podRecency.test.ts @@ -37,7 +37,28 @@ describe('podRecency', () => { test('podInitials takes two words, or two letters of one', () => { expect(podInitials('Connectors v2')).toBe('CV'); expect(podInitials('Sharpen')).toBe('SH'); - expect(podInitials(' Payments — memory demo ')).toBe('P—'); expect(podInitials('')).toBe('·'); }); + + test('podInitials separates on punctuation and dashes instead of taking them as initials', () => { + expect(podInitials(' Payments — memory demo ')).toBe('PM'); + expect(podInitials('Sharpen — pod model')).toBe('SP'); + expect(podInitials('Team-Commonly')).toBe('TC'); + expect(podInitials('—')).toBe('·'); + }); + + test('podInitials shows one glyph when a wide glyph is picked', () => { + expect(podInitials('设计评审')).toBe('设'); + expect(podInitials('Growth 团队')).toBe('G'); + }); + + test('podInitials drops an emoji the way it drops punctuation', () => { + expect(podInitials('🚀 Launch')).toBe('LA'); + }); + + test('podInitials slices by code point, so a surrogate pair is never cut in half', () => { + // Synthetic on purpose: a two-unit slice of 'A𝔘' ends between the halves of + // 𝔘 and returns a replacement character, which no real pod name would show. + expect(podInitials('A𝔘')).toBe('A𝔘'); + }); }); diff --git a/frontend/src/v2/lib/podRecency.ts b/frontend/src/v2/lib/podRecency.ts index 96ef2b9b3..777a33678 100644 --- a/frontend/src/v2/lib/podRecency.ts +++ b/frontend/src/v2/lib/podRecency.ts @@ -57,11 +57,34 @@ export const relativeTime = (value: string | number | Date | null | undefined, n return `${Math.floor(delta / YEAR)}y`; }; +// A wide glyph fills the mark on its own. The mark is 22px with 11px mono +// characters, so two Han characters wrap to a second line inside the square. +const WIDE_GLYPH = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + // Two-letter mark for a pod without an avatar: initials of the first two words, // or the first two letters of a one-word name. +// +// Tokenised the way `initialsFor` does, so punctuation and dashes separate words +// instead of becoming initials: "Sharpen — pod model" is SP rather than S—, and +// "Team-Commonly" is TC. An emoji drops out the way punctuation does, which is +// why "🚀 Launch" is LA rather than 🚀L. A name whose initials include a wide +// glyph shows that one glyph — "设计评审" is 设, not the two characters that +// would wrap. Code points rather than UTF-16 units throughout, so a surrogate +// pair is never cut in half. export const podInitials = (name: string): string => { - const words = (name || '').trim().split(/\s+/).filter(Boolean); + const words = String(name || '') + .trim() + .split(/[\s_\-/|]+/) + .map((word) => word.replace(/[^\p{L}\p{N}]/gu, '')) + .filter(Boolean); if (words.length === 0) return '·'; - if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); - return (words[0][0] + words[1][0]).toUpperCase(); + + const first = (word: string): string => Array.from(word)[0] || ''; + const pick = words.length === 1 + ? Array.from(words[0]).slice(0, 2).join('') + : first(words[0]) + first(words[1]); + + const picked = Array.from(pick); + if (picked.some((char) => WIDE_GLYPH.test(char))) return picked[0]; + return pick.toUpperCase(); }; From 2ce4aedaa33af0a5321ad712629345e26e769f2d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:51:52 -0700 Subject: [PATCH 2/3] fix(v2): a CJK pick is a script rule, and the two-word read is pinned (TASK-185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gate finding on #2011. M1 (`first()` -> `word[0]`) was green because 'A𝔘' is a single token and only reaches the one-word slice; '𐐀lpha Beta' pins the two-word branch's own code-point read and the mutation now reds it. Renames WIDE_GLYPH -> CJK_SCRIPT. The predicate is a script list and JS \p{…} cannot express East_Asian_Width, so 'ABC' -> 'AB'; that is now stated scope pinned by a test, rather than a comment promising a rendering fact the function cannot compute. The 22px note stays as motivation for the rule. Also corrects the doc comment: the tokenising matches initialsFor's split and strip but deliberately not its parenthetical drop. --- frontend/src/v2/__tests__/podRecency.test.ts | 12 +++++++- frontend/src/v2/lib/podRecency.ts | 30 +++++++++++++------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/frontend/src/v2/__tests__/podRecency.test.ts b/frontend/src/v2/__tests__/podRecency.test.ts index 553f1ecd5..6a27b3cee 100644 --- a/frontend/src/v2/__tests__/podRecency.test.ts +++ b/frontend/src/v2/__tests__/podRecency.test.ts @@ -47,9 +47,12 @@ describe('podRecency', () => { expect(podInitials('—')).toBe('·'); }); - test('podInitials shows one glyph when a wide glyph is picked', () => { + test('podInitials shows one glyph when a CJK glyph is picked', () => { expect(podInitials('设计评审')).toBe('设'); expect(podInitials('Growth 团队')).toBe('G'); + // Stated scope rather than an oversight: the predicate is a script list and + // JS \p{…} cannot express East_Asian_Width, so fullwidth Latin shows two. + expect(podInitials('ABC')).toBe('AB'); }); test('podInitials drops an emoji the way it drops punctuation', () => { @@ -61,4 +64,11 @@ describe('podRecency', () => { // 𝔘 and returns a replacement character, which no real pod name would show. expect(podInitials('A𝔘')).toBe('A𝔘'); }); + + test('podInitials reads the first code point of each of two words', () => { + // Pins the two-word branch's own code-point read. 'A𝔘' above is a single + // token, so it only exercises the one-word slice; a UTF-16 read here would + // cut 𐐀 in half and return a replacement character. + expect(podInitials('𐐀lpha Beta')).toBe('𐐀B'); + }); }); diff --git a/frontend/src/v2/lib/podRecency.ts b/frontend/src/v2/lib/podRecency.ts index 777a33678..d6136da92 100644 --- a/frontend/src/v2/lib/podRecency.ts +++ b/frontend/src/v2/lib/podRecency.ts @@ -57,20 +57,28 @@ export const relativeTime = (value: string | number | Date | null | undefined, n return `${Math.floor(delta / YEAR)}y`; }; -// A wide glyph fills the mark on its own. The mark is 22px with 11px mono -// characters, so two Han characters wrap to a second line inside the square. -const WIDE_GLYPH = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; +// The mark is 22px with 11px mono characters, so two Han characters wrap to a +// second line inside the square — that is why a CJK pick is cut to one glyph. +// +// This is a SCRIPT list, so the rule it states is a CJK rule and not a width +// rule. JS `\p{…}` expresses General_Category, Script and binary properties but +// not East_Asian_Width, so `AB` (fullwidth Latin, EAW=Fullwidth) is out of +// scope by decision and shows both glyphs. The 22px sentence above is the +// motivation for the rule, not a contract this predicate can compute. +const CJK_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; // Two-letter mark for a pod without an avatar: initials of the first two words, // or the first two letters of a one-word name. // -// Tokenised the way `initialsFor` does, so punctuation and dashes separate words -// instead of becoming initials: "Sharpen — pod model" is SP rather than S—, and -// "Team-Commonly" is TC. An emoji drops out the way punctuation does, which is -// why "🚀 Launch" is LA rather than 🚀L. A name whose initials include a wide -// glyph shows that one glyph — "设计评审" is 设, not the two characters that -// would wrap. Code points rather than UTF-16 units throughout, so a surrogate -// pair is never cut in half. +// Tokenising matches `initialsFor`: the same split, the same [^\p{L}\p{N}] +// strip and the same filter, so punctuation and dashes separate words instead +// of becoming initials and "Sharpen — pod model" is SP rather than S—. It does +// not copy initialsFor's parenthetical drop, deliberately: "(v2)" distinguishes +// a pod name even though "(lead)" never distinguishes an agent. An emoji drops +// out the way punctuation does, which is why "🚀 Launch" is LA rather than 🚀L. A +// name whose picked glyphs are CJK shows one glyph — "设计评审" is 设, not the +// two characters that would wrap. Code points rather than UTF-16 units +// throughout, so a surrogate pair is never cut in half. export const podInitials = (name: string): string => { const words = String(name || '') .trim() @@ -85,6 +93,6 @@ export const podInitials = (name: string): string => { : first(words[0]) + first(words[1]); const picked = Array.from(pick); - if (picked.some((char) => WIDE_GLYPH.test(char))) return picked[0]; + if (picked.some((char) => CJK_SCRIPT.test(char))) return picked[0]; return pick.toUpperCase(); }; From 1b29c3111906fad6b0ca7d752a4f10b9f985e4ad Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:54:54 -0700 Subject: [PATCH 3/3] test(v2): every CJK script is pinned, and the fullwidth scope sits in its own test (TASK-185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the re-gate finding on #2011: Han alone carried the predicate, so dropping Hiragana, Katakana or Hangul left the suite green and a one-script "simplification" would have gone unnoticed. '한국어 팀' is two words on purpose, exercising first() together with the CJK branch, which nothing did before. Moves the fullwidth-Latin assertion into its own test so widening the class relocates a stated boundary instead of tripping the CJK test. All four arms now red, each naming its own test; restored 11/11. --- frontend/src/v2/__tests__/podRecency.test.ts | 22 +++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/__tests__/podRecency.test.ts b/frontend/src/v2/__tests__/podRecency.test.ts index 6a27b3cee..caf20a8d1 100644 --- a/frontend/src/v2/__tests__/podRecency.test.ts +++ b/frontend/src/v2/__tests__/podRecency.test.ts @@ -47,11 +47,27 @@ describe('podRecency', () => { expect(podInitials('—')).toBe('·'); }); - test('podInitials shows one glyph when a CJK glyph is picked', () => { + test('podInitials shows one glyph when the picked glyph is CJK', () => { expect(podInitials('设计评审')).toBe('设'); expect(podInitials('Growth 团队')).toBe('G'); - // Stated scope rather than an oversight: the predicate is a script list and - // JS \p{…} cannot express East_Asian_Width, so fullwidth Latin shows two. + }); + + test('podInitials cuts every CJK script, not just Han', () => { + // Han alone carried the predicate until these existed: dropping any ONE of + // the other three scripts from the class left the suite green, so a later + // "simplification" to a single script would have gone unnoticed. + expect(podInitials('ひらがな')).toBe('ひ'); + expect(podInitials('カタカナ')).toBe('カ'); + // Two words on purpose, so first() and the CJK branch are exercised together. + expect(podInitials('한국어 팀')).toBe('한'); + }); + + test('podInitials states its scope: a fullwidth-Latin pick is not CJK', () => { + // The predicate is a script list and JS \p{…} cannot express + // East_Asian_Width, so fullwidth Latin shows both glyphs. Asserted rather + // than left in a comment — a known miss a test pins is a decision, and this + // assertion belongs in its own test so widening the class relocates it + // instead of tripping the CJK test. expect(podInitials('ABC')).toBe('AB'); });