diff --git a/ui/src/pages/plugins/presentation.ts b/ui/src/pages/plugins/presentation.ts index 8668aaca3ae0..18450a1faf9a 100644 --- a/ui/src/pages/plugins/presentation.ts +++ b/ui/src/pages/plugins/presentation.ts @@ -206,6 +206,27 @@ const FALLBACK_GRADIENTS: ReadonlyArray = [ ["#fb7185", "#9f1239"], ]; +const graphemeSegmenter = + typeof Intl.Segmenter === "function" + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; + +function takeGraphemes(input: string, limit: number): string { + if (!graphemeSegmenter) { + return Array.from(input).slice(0, limit).join(""); + } + let result = ""; + let count = 0; + for (const { segment } of graphemeSegmenter.segment(input)) { + result += segment; + count += 1; + if (count >= limit) { + break; + } + } + return result; +} + export function pluginFallbackGradient(id: string): readonly [string, string] { let hash = 0; for (const char of id) { @@ -224,7 +245,9 @@ export function pluginMonogram(name: string): string { } const first = expectDefined(words[0], "plugin monogram first word"); const second = words[1]; - const initials = second ? `${first.charAt(0)}${second.charAt(0)}` : first.slice(0, 2); + const initials = second + ? `${takeGraphemes(first, 1)}${takeGraphemes(second, 1)}` + : takeGraphemes(first, 2); return initials.toLocaleUpperCase(); } diff --git a/ui/src/pages/plugins/view.test.ts b/ui/src/pages/plugins/view.test.ts index c7bc596d46b9..9a8e8d0a6317 100644 --- a/ui/src/pages/plugins/view.test.ts +++ b/ui/src/pages/plugins/view.test.ts @@ -146,6 +146,42 @@ describe("renderPlugins", () => { ).toContain("manifest invalid"); }); + it("keeps plugin fallback monograms on complete grapheme clusters", () => { + const cases = [ + { id: "emoji-tools", name: "πŸ˜€ Tools", expected: "πŸ˜€T" }, + { id: "mixed-emoji", name: "AπŸ˜€", expected: "AπŸ˜€" }, + { id: "heart-tools", name: "❀️ Tools", expected: "❀️T" }, + { id: "flag-tools", name: "πŸ‡ΊπŸ‡Έ Tools", expected: "πŸ‡ΊπŸ‡ΈT" }, + { id: "developer-tools", name: "πŸ‘©β€πŸ’» Tools", expected: "πŸ‘©β€πŸ’»T" }, + { id: "developer-name", name: "πŸ‘©β€πŸ’»Dev", expected: "πŸ‘©β€πŸ’»D" }, + { id: "combining-mark", name: "é Tools", expected: "ÉT" }, + ]; + const plugins = cases.map(({ id, name }) => createPlugin({ id, name, origin: "global" })); + const container = mount(createProps({ result: createResult(plugins) })); + + for (const { id, expected } of cases) { + expect( + container.querySelector(`[data-plugin-id="${id}"] .plugins-tile--fallback > span`) + ?.textContent, + ).toBe(expected); + } + }); + + it("keeps plugin monograms usable when Intl.Segmenter is unavailable", async () => { + const originalSegmenter = Intl.Segmenter; + Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined }); + vi.resetModules(); + + try { + const { pluginMonogram } = await import("./presentation.ts"); + expect(pluginMonogram("πŸ˜€ Tools")).toBe("πŸ˜€T"); + expect(pluginMonogram("πŸ‘©β€πŸ’» Tools")).toBe("πŸ‘©T"); + } finally { + Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter }); + vi.resetModules(); + } + }); + it("filters the installed inventory by state", () => { const plugins = [ createPlugin({ id: "on", name: "On", enabled: true, state: "enabled" }),