ui: map the twelve icon names that were rendering as question marks

The calculators page had seven "?" glyphs in a row -- `cogs`, `tint`, `food`,
`signal`, `thermometer`, `car`, `th large` -- and `arrow left` was unmapped
while `arrow down`, `right` and `up` were all there.  Also `tags`, `comments`,
`chevron down`, and `closed captioning outline`.

An unmapped name is not subtle: it renders IconHelpCircle and logs a console
error.  It shipped anyway, because nothing failed on it.

Three tests already guarded this and all three missed it.  They cover the
theme picker's choices, the names the mimetype helpers can return, and the
map's own integrity -- none of them looks at what call sites write.  The new
guard walks src/ and checks every name it finds, in each of the four
spellings, each of which was hiding at least one of these:

    <Icon name='x'/> and icon='x'    the plain JSX prop
    {icon: 'x'}                      a data table; how the calculators declare theirs
    icon={active ? 'tags' : 'tag'}   a ternary, where `tags` was unmapped
    name={s === 'descending' ? ...}  a comparison operand, which is NOT a name

The last one is why the extraction strips comparison operands first: the sort
arrow in DataTable tests a direction, and reading 'descending' as an icon name
would be a false positive that trains everyone to ignore this test.

Verified by planting each spelling in the file it was found in, and by
reverting the map additions -- the guard reports them.  Also swept thirteen
routes in a real browser with console.error hooked: no icon errors left.

Drops `taggedImageLabel`, a Semantic Label prop bag with no consumers, whose
`corner: 'left'` was never an icon name at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-01 16:23:38 -06:00 • committed by lrnselfreliance
parent 014219d273
commit bd77ac090e
3 changed files with 72 additions and 1 deletions

View file

@ -619,7 +619,6 @@ export function AddTagsButton({
</>
}
export const taggedImageLabel = {corner: 'left', icon: 'tag', color: 'green'};
export const TagsSelector = ({
hideEdit = false,

View file

@ -29,6 +29,7 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'archive': 'IconArchive',
'arrow alternate circle up': 'IconCircleArrowUp',
'arrow down': 'IconArrowDown',
'arrow left': 'IconArrowLeft',
'arrow right': 'IconArrowRight',
'arrow up': 'IconArrowUp',
'balance scale': 'IconScale',
@ -36,11 +37,13 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'bug': 'IconBug',
'calculator': 'IconCalculator',
'calendar': 'IconCalendar',
'car': 'IconCar',
'certificate': 'IconCertificate',
'charset': 'IconLanguage',
'check': 'IconCheck',
'check circle': 'IconCircleCheck',
'checkmark': 'IconCheck',
'chevron down': 'IconChevronDown',
'chevron left': 'IconChevronLeft',
'chevron right': 'IconChevronRight',
'chrome': 'IconBrandChrome',
@ -49,7 +52,10 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'circle notched': 'IconLoader2',
'close': 'IconX',
'closed captioning': 'IconBadgeCc',
'closed captioning outline': 'IconBadgeCc',
'columns': 'IconColumns',
'cogs': 'IconSettingsCog',
'comments': 'IconMessages',
'copy': 'IconCopy',
'cube': 'IconCube',
'directory': 'IconFolder',
@ -84,6 +90,7 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'folder open': 'IconFolderOpen',
'folder open outline': 'IconFolderOpen',
'folder outline': 'IconFolder',
'food': 'IconToolsKitchen2',
'font': 'IconTypography',
'globe': 'IconWorld',
'hand point right': 'IconHandFinger',
@ -122,6 +129,7 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'settings': 'IconSettings',
'share': 'IconShare',
'shield': 'IconShield',
'signal': 'IconAntennaBars5',
'spinner': 'IconLoader2',
'star': 'IconStarFilled',
'star outline': 'IconStar',
@ -131,9 +139,13 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'sync': 'IconRefresh',
'tachometer alternate': 'IconGauge',
'tag': 'IconTag',
'tags': 'IconTags',
'terminal': 'IconTerminal2',
'th': 'IconLayoutGrid',
'th large': 'IconLayoutGrid',
'thermometer': 'IconTemperature',
'thumbs up': 'IconThumbUp',
'tint': 'IconDroplet',
'trash': 'IconTrash',
'undo': 'IconArrowBackUp',
'unlock': 'IconLockOpen',

View file

@ -114,6 +114,66 @@ describe('Icon', () => {
expect(names.filter(name => !resolveIconName(name))).toEqual([]);
});
it('maps every icon name written anywhere in the app', () => {
/*
* The three checks above cover the theme picker, the mimetype helpers, and the map's
* own integrity -- and between them they missed eleven names, because none of them
* looks at what call sites actually write. The calculators page had seven "?"
* glyphs in a row: `cogs`, `tint`, `food`, `signal`, `thermometer`, `car`, `th
* large`. `arrow left` was missing while `arrow down`, `right` and `up` were there.
*
* An unmapped name is not subtle -- it is a help-circle glyph and a console error --
* but nothing failed, so it shipped and stayed until somebody looked at the page.
*
* Every spelling matters, and each one hid a real defect:
* `<Icon name='x'/>` and `icon='x'` -- the plain JSX prop
* `{icon: 'x'}` -- a data table; how calculators declare theirs
* `icon={active ? 'tags' : 'tag'}` -- a ternary, where `tags` was unmapped
* The last two are exactly the forms an audit by eye skips.
*/
const patterns = [
/<Icon\s[^>]*?\bname=['"]([^'"]+)['"]/gs,
/\bicons?(?:After)?=['"]([^'"]+)['"]/g,
/\bicons?(?:Name)?\s*:\s*['"]([^'"]+)['"]/g,
];
// Any string literal inside an icon={...} expression is a candidate name.
const expressions = /\b(?:icons?(?:After)?|name)=\{([^}]*)\}/g;
const src = path.join(__dirname, '..', '..');
const walk = (directory) => fs.readdirSync(directory, {withFileTypes: true})
.flatMap(entry => {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : walk(full);
if (!/\.(js|jsx|ts|tsx)$/.test(entry.name)) return [];
// This file names an unmapped icon on purpose, one test below.
if (full === __filename) return [];
return [[full, fs.readFileSync(full, 'utf8')]];
});
const unmapped = [];
for (const [file, source] of walk(src)) {
const names = patterns.flatMap(pattern => [...source.matchAll(pattern)].map(m => m[1]));
for (const match of source.matchAll(expressions)) {
/*
* Drop comparison operands first. `name={sorted === 'descending' ? 'arrow
* down' : 'arrow up'}` picks an icon by testing a sort direction, and
* 'descending' is the thing being tested, not a name.
*/
const expression = match[1]
.replace(/[=!]==?\s*['"][^'"]*['"]/g, '')
.replace(/['"][^'"]*['"]\s*[=!]==?/g, '');
names.push(...[...expression.matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]));
}
for (const name of names) {
if (!resolveIconName(name)) {
unmapped.push(`${path.relative(src, file)}: ${name}`);
}
}
}
expect([...new Set(unmapped)]).toEqual([]);
});
it('reports an unknown name instead of failing silently', () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});