From 6b8181b5fda4a3f722f8e7c62144993e7ce9ff91 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 21 Jun 2026 20:38:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(a2ui):=20normalize=20icon=20names=20to=20M?= =?UTF-8?q?aterial=20Symbols=20ligatures=20(camelCase=20=E2=86=92=20snake?= =?UTF-8?q?=5Fcase)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2UI catalogs commonly emit camelCase icon identifiers (accountCircle, shoppingCart, moreVert) while Material Symbols ligatures are snake_case. The Icon component now converts the name before rendering so the existing A2UI icon vocabulary renders as real glyphs (not raw text). Single-word / already-snake_case names + emoji pass through unchanged; the original name is kept as the aria-label. Verified live in examples/chat (A2UI mode): an Account card renders account_circle / shopping_cart / settings / notifications / star glyphs. --- .../lib/a2ui/catalog/icon.component.spec.ts | 23 ++++++++++++++++++- .../src/lib/a2ui/catalog/icon.component.ts | 19 ++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts index 93c560014..d9f8e20e5 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from 'vitest'; -import { A2uiIconComponent } from './icon.component'; +import { A2uiIconComponent, toMaterialSymbolName } from './icon.component'; describe('A2uiIconComponent', () => { // Display-only component: renders name() input as a . @@ -11,3 +11,24 @@ describe('A2uiIconComponent', () => { expect(A2uiIconComponent).toBeDefined(); }); }); + +describe('toMaterialSymbolName', () => { + it('converts camelCase identifiers to snake_case ligatures', () => { + expect(toMaterialSymbolName('accountCircle')).toBe('account_circle'); + expect(toMaterialSymbolName('shoppingCart')).toBe('shopping_cart'); + expect(toMaterialSymbolName('moreVert')).toBe('more_vert'); + expect(toMaterialSymbolName('visibilityOff')).toBe('visibility_off'); + expect(toMaterialSymbolName('arrowForward')).toBe('arrow_forward'); + }); + + it('passes single-word and already-snake_case names through unchanged', () => { + expect(toMaterialSymbolName('check')).toBe('check'); + expect(toMaterialSymbolName('star')).toBe('star'); + expect(toMaterialSymbolName('trending_up')).toBe('trending_up'); + }); + + it('leaves non-identifier glyphs (emoji) untouched', () => { + expect(toMaterialSymbolName('✓')).toBe('✓'); + expect(toMaterialSymbolName('⚠️')).toBe('⚠️'); + }); +}); diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.ts index 213ac3122..e42f6de18 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.ts @@ -2,6 +2,20 @@ import { Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; +/** + * Convert an icon identifier to its Material Symbols ligature form. + * + * Material Symbols ligatures are snake_case (`account_circle`, `trending_up`), + * but A2UI catalogs commonly emit camelCase identifiers (`accountCircle`, + * `shoppingCart`). Splitting on lower→upper boundaries and lowercasing maps + * camelCase → the matching ligature. Already-snake_case names, single words, + * and non-identifier glyphs (emoji) have no boundaries to split and pass + * through unchanged. Unknown names still fall back to the browser default. + */ +export function toMaterialSymbolName(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +} + @Component({ selector: 'a2ui-icon', standalone: true, @@ -12,7 +26,7 @@ import type { Spec } from '@json-render/core'; [style.font-size]="size() ? size() + 'px' : '1.125rem'" [attr.aria-label]="name" role="img" - >{{ name }} + >{{ glyphName() }} } `, styles: [` @@ -56,4 +70,7 @@ export class A2uiIconComponent { readonly spec = input(undefined); protected readonly effectiveName = computed(() => this.name() ?? this.icon()); + + /** The effective name as a Material Symbols ligature (camelCase → snake_case). */ + protected readonly glyphName = computed(() => toMaterialSymbolName(this.effectiveName())); }