From 450aaa733417fa5fbd161d30769bfa30585f99b3 Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 1 Aug 2026 16:23:38 -0600 Subject: [PATCH] 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: 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) --- app/src/Tags.js | 1 - app/src/components/ui/Icon.tsx | 12 +++++++ app/src/components/ui/ui.test.js | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/src/Tags.js b/app/src/Tags.js index f7b59e09..df08e3df 100644 --- a/app/src/Tags.js +++ b/app/src/Tags.js @@ -619,7 +619,6 @@ export function AddTagsButton({ } -export const taggedImageLabel = {corner: 'left', icon: 'tag', color: 'green'}; export const TagsSelector = ({ hideEdit = false, diff --git a/app/src/components/ui/Icon.tsx b/app/src/components/ui/Icon.tsx index f0df6804..f48cfa18 100644 --- a/app/src/components/ui/Icon.tsx +++ b/app/src/components/ui/Icon.tsx @@ -29,6 +29,7 @@ const semanticNames: Record = { '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 = { '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 = { '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 = { '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 = { 'settings': 'IconSettings', 'share': 'IconShare', 'shield': 'IconShield', + 'signal': 'IconAntennaBars5', 'spinner': 'IconLoader2', 'star': 'IconStarFilled', 'star outline': 'IconStar', @@ -131,9 +139,13 @@ const semanticNames: Record = { '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', diff --git a/app/src/components/ui/ui.test.js b/app/src/components/ui/ui.test.js index 1012b27d..b4e94aaf 100644 --- a/app/src/components/ui/ui.test.js +++ b/app/src/components/ui/ui.test.js @@ -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: + * `` 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 = [ + /]*?\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(() => {});