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()));
}