Skip to content

Commit e5aae91

Browse files
andresdjassoclaude
andcommitted
feat(emcn/icons): add MessageCircle icon + icon audit worklist and gallery tooling
- New MessageCircle glyph in the emcn house style (24px viewBox, 1.55 stroke), filling a lucide-only gap from the audit; now used for the chat surfaces - Icon design worklist + lucide->emcn migration map docs for the redesign pass - render-icon-gallery script + generated gallery for side-by-side review Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 65bac26 commit e5aae91

6 files changed

Lines changed: 1829 additions & 1 deletion

File tree

apps/sim/components/emcn/icons/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export { HelpCircle } from './help-circle'
4646
export { HexSimple } from './hex-simple'
4747
export { Home } from './home'
4848
export { ImageUp } from './image-up'
49-
export { Info } from './info'
49+
// `Info` (lucide-style icon) is intentionally not re-exported here: the emcn barrel
50+
// already exports an `Info` tooltip component, and re-exporting both collides. The
51+
// toast imports this icon directly from './info', so the barrel export is unneeded.
5052
export { Integration } from './integration'
5153
export { Key } from './key'
5254
export { KeySquare } from './key-square'
@@ -60,6 +62,7 @@ export { LogIn } from './log-in'
6062
export { LogOut } from './log-out'
6163
export { Mail } from './mail'
6264
export { ManageWorkspace } from './manage-workspace'
65+
export { MessageCircle } from './message-circle'
6366
export { Mic } from './mic'
6467
export { MoreHorizontal } from './more-horizontal'
6568
export { NoWrap } from './no-wrap'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { SVGProps } from 'react'
2+
3+
/**
4+
* MessageCircle icon component - round chat bubble with tail
5+
* @param props - SVG properties including className, fill, etc.
6+
*/
7+
export function MessageCircle(props: SVGProps<SVGSVGElement>) {
8+
return (
9+
<svg
10+
xmlns='http://www.w3.org/2000/svg'
11+
width='24'
12+
height='24'
13+
viewBox='0 0 24 24'
14+
fill='none'
15+
stroke='currentColor'
16+
strokeWidth='1.55'
17+
strokeLinecap='round'
18+
strokeLinejoin='round'
19+
aria-hidden='true'
20+
{...props}
21+
>
22+
<path d='M7.9 20A9 9 0 1 0 4 16.1L2 22Z' />
23+
</svg>
24+
)
25+
}
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
import { createElement } from 'react'
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
import * as Lucide from 'lucide-react'
6+
7+
const ICONS_DIR = join(import.meta.dir, '../components/emcn/icons')
8+
9+
/** Extract { exportName: svgMarkup } from every emcn icon .tsx file. */
10+
function extractEmcn(): Record<string, string> {
11+
const out: Record<string, string> = {}
12+
for (const file of readdirSync(ICONS_DIR)) {
13+
if (!file.endsWith('.tsx')) continue
14+
const src = readFileSync(join(ICONS_DIR, file), 'utf8')
15+
const re = /export\s+(?:function|const)\s+(\w+)[\s\S]*?(<svg[\s\S]*?<\/svg>)/g
16+
let m: RegExpExecArray | null
17+
while ((m = re.exec(src))) {
18+
out[m[1]] = jsxSvgToHtml(m[2])
19+
}
20+
}
21+
return out
22+
}
23+
24+
/** Convert a JSX <svg> string into browser-renderable HTML. */
25+
function jsxSvgToHtml(jsx: string): string {
26+
return jsx
27+
.replace(/\{\.\.\.[^}]*\}/g, '') // {...props}
28+
.replace(/[\w-]+=\{[^}]*\}/g, '') // attr={expr}
29+
.replace(/\bclassName=/g, 'class=')
30+
.replace(/\bstrokeWidth=/g, 'stroke-width=')
31+
.replace(/\bstrokeLinecap=/g, 'stroke-linecap=')
32+
.replace(/\bstrokeLinejoin=/g, 'stroke-linejoin=')
33+
.replace(/\bstrokeMiterlimit=/g, 'stroke-miterlimit=')
34+
.replace(/\bstrokeDasharray=/g, 'stroke-dasharray=')
35+
.replace(/\bstrokeOpacity=/g, 'stroke-opacity=')
36+
.replace(/\bfillRule=/g, 'fill-rule=')
37+
.replace(/\bclipRule=/g, 'clip-rule=')
38+
.replace(/\bfillOpacity=/g, 'fill-opacity=')
39+
.replace(/\bclipPath=/g, 'clip-path=')
40+
.replace(/\bwidth='[^']*'/, '') // strip fixed size → CSS sizes it
41+
.replace(/\bheight='[^']*'/, '')
42+
.replace(/aria-hidden='true'/g, '')
43+
}
44+
45+
function lucideSvg(name: string): string | null {
46+
const Comp = (Lucide as Record<string, unknown>)[name]
47+
if (!Comp) return null
48+
try {
49+
return renderToStaticMarkup(
50+
createElement(Comp as React.ComponentType, { width: 24, height: 24, strokeWidth: 1.75 })
51+
)
52+
} catch {
53+
return null
54+
}
55+
}
56+
57+
const emcn = extractEmcn()
58+
59+
type Item = { name: string; count: string; src: 'emcn' | 'lucide'; lucide?: string }
60+
type Section = { title: string; note?: string; items: Item[] }
61+
62+
const e = (name: string, count: string): Item => ({ name, count, src: 'emcn' })
63+
const l = (name: string, count: string, lucide?: string): Item => ({
64+
name,
65+
count,
66+
src: 'lucide',
67+
lucide: lucide ?? name,
68+
})
69+
70+
const sections: Section[] = [
71+
{
72+
title: 'TIER 1 · Core — Arrows & Chevrons',
73+
note: 'Redesign first. Tier-2 chevrons/arrows are rotations of these.',
74+
items: [
75+
e('ChevronDown', '21 +24'),
76+
e('ArrowLeft', '14 +5'),
77+
e('ArrowUp', '11 +15'),
78+
e('ArrowDown', '10 +6'),
79+
e('ArrowRight', '6 +7'),
80+
e('ArrowUpDown', '1 +1'),
81+
],
82+
},
83+
{
84+
title: 'TIER 1 · Core — Actions & Controls',
85+
items: [
86+
e('Search', '44 +14'),
87+
e('Plus', '27 +29'),
88+
e('X', '16 +31'),
89+
e('Check', '16 +23'),
90+
e('Pencil', '16 +7'),
91+
e('Trash', '25'),
92+
e('Trash2', '2 +4'),
93+
e('Settings', '11 +2'),
94+
e('MoreHorizontal', '9 +9'),
95+
e('Send', '13'),
96+
e('Download', '18'),
97+
e('Upload', '17'),
98+
e('Duplicate', '11'),
99+
e('RefreshCw', '3 +5'),
100+
],
101+
},
102+
{
103+
title: 'TIER 1 · Core — Files, Folders & Data',
104+
items: [
105+
e('File', '23 +1'),
106+
e('Files', '18'),
107+
e('Folder', '15 +3'),
108+
e('FolderPlus', '4'),
109+
e('Table', '21 +1'),
110+
e('Database', '19 +3'),
111+
e('Library', '12 +1'),
112+
e('Clipboard', '4 +12'),
113+
],
114+
},
115+
{
116+
title: 'TIER 1 · Core — Status & Primitives',
117+
items: [
118+
e('Eye', '10 +7'),
119+
e('EyeOff', '1 +7'),
120+
e('Lock', '7 +3'),
121+
e('Unlock', '3 +2'),
122+
e('Key', '9 +1'),
123+
e('Info', '5 +10'),
124+
e('Square', '7 +2'),
125+
e('Calendar', '10'),
126+
e('Clock', '2 +4'),
127+
e('Loader', '10'),
128+
e('User', '10 +1'),
129+
e('Users', '5 +2'),
130+
e('Link', '10'),
131+
e('Bell', '2 +2'),
132+
e('Pin', '3 +2'),
133+
e('PinOff', '2 +1'),
134+
e('Paperclip', '1 +5'),
135+
e('PlayOutline', '15'),
136+
e('Pause', '4 +3'),
137+
],
138+
},
139+
{
140+
title: 'TIER 1 · Core — Type Markers (design as a unified set)',
141+
items: [
142+
e('TypeText', '4'),
143+
e('TypeNumber', '4'),
144+
e('TypeBoolean', '4'),
145+
e('TypeJson', '2'),
146+
],
147+
},
148+
{
149+
title: 'TIER 0 · Name-aliases — no design, just alias existing emcn glyph',
150+
note: 'Shown is the existing emcn glyph each lucide name maps to.',
151+
items: [
152+
e('X', 'XIcon'),
153+
e('Send', 'SendIcon'),
154+
e('Server', 'ServerIcon'),
155+
e('Wrench', 'WrenchIcon'),
156+
e('TagIcon', 'Tag'),
157+
e('TriangleAlert', 'AlertTriangle'),
158+
e('CircleAlert', 'AlertCircle'),
159+
e('CircleCheck', 'CheckCircle2'),
160+
],
161+
},
162+
{
163+
title: 'TIER 2 · lucide-only — derivable from a core glyph (rotate / compose / variant)',
164+
items: [
165+
l('ChevronRight', '18'),
166+
l('ChevronUp', '9'),
167+
l('ChevronLeft', '6'),
168+
l('ChevronsUpDown', '4'),
169+
l('MoreVertical', '2'),
170+
l('ArrowLeftRight', '5'),
171+
l('ArrowUpLeft', '1'),
172+
l('RotateCcw', '4'),
173+
l('XCircle', '3'),
174+
l('PauseCircle', '1'),
175+
l('Circle', '5'),
176+
l('CircleOff', '2'),
177+
l('Minus', '1'),
178+
l('Settings2', '1'),
179+
l('KeyRound', '1'),
180+
l('LibraryBig', '1'),
181+
l('FolderOpen', '1'),
182+
l('FileText', '4'),
183+
l('MicOff', '1'),
184+
l('Filter', '1'),
185+
l('Image', '2'),
186+
l('ExternalLink', '8'),
187+
],
188+
},
189+
{
190+
title: 'TIER 3 · lucide-only — net-new glyphs to design from scratch',
191+
items: [
192+
l('RepeatIcon', '10', 'Repeat'),
193+
l('SplitIcon', '10', 'Split'),
194+
l('Wand2', '4'),
195+
l('GraduationCap', '3'),
196+
l('Bot', '1'),
197+
l('Building2', '1'),
198+
l('Camera', '1'),
199+
l('Compass', '1'),
200+
l('FormInput', '1'),
201+
l('GitBranch', '1'),
202+
l('Github', '1'),
203+
l('Globe', '1'),
204+
l('Hash', '1'),
205+
l('History', '1'),
206+
l('MessageCircle', '1'),
207+
l('Moon', '1'),
208+
l('Music', '1'),
209+
l('Phone', '1'),
210+
l('Rss', '1'),
211+
l('Scan', '1'),
212+
l('Scissors', '1'),
213+
l('SendToBack', '1'),
214+
l('Share2', '1'),
215+
l('Sparkles', '1'),
216+
l('Sun', '1'),
217+
l('Webhook', '1'),
218+
l('Workflow', '1'),
219+
],
220+
},
221+
]
222+
223+
function cell(it: Item): string {
224+
let svg: string | null
225+
let label: string
226+
if (it.src === 'emcn') {
227+
svg = emcn[it.name] ?? null
228+
label = it.name
229+
} else {
230+
svg = lucideSvg(it.lucide ?? it.name)
231+
label = it.name
232+
}
233+
const art = svg
234+
? `<div class="art">${svg}</div>`
235+
: `<div class="art missing">?</div>`
236+
return `<div class="cell"><div class="box">${art}</div><div class="name">${label}</div><div class="count">${it.count}</div></div>`
237+
}
238+
239+
const body = sections
240+
.map(
241+
(s) => `
242+
<section>
243+
<h2>${s.title} <span class="n">(${s.items.length})</span></h2>
244+
${s.note ? `<p class="note">${s.note}</p>` : ''}
245+
<div class="grid">${s.items.map(cell).join('')}</div>
246+
</section>`
247+
)
248+
.join('')
249+
250+
const total = sections.reduce((a, s) => a + s.items.length, 0)
251+
252+
const html = `<!doctype html>
253+
<html><head><meta charset="utf-8"><title>Sim icon update map</title>
254+
<style>
255+
:root { --ink:#1a1a1a; --muted:#8a8a8a; --line:#ececec; --bg:#fff; }
256+
* { box-sizing: border-box; }
257+
body { margin:0; font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:#fafafa; padding:32px 40px 80px; }
258+
header h1 { font-size:22px; margin:0 0 2px; }
259+
header p { color:var(--muted); margin:0 0 28px; }
260+
section { margin:0 0 36px; }
261+
h2 { font-size:13px; letter-spacing:.04em; text-transform:uppercase; color:var(--ink); border-bottom:1px solid var(--line); padding-bottom:8px; margin:0 0 4px; }
262+
h2 .n { color:var(--muted); font-weight:400; }
263+
.note { color:var(--muted); font-size:12px; margin:6px 0 14px; }
264+
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(96px,1fr)); gap:10px; margin-top:14px; }
265+
.cell { display:flex; flex-direction:column; align-items:center; gap:6px; padding:14px 6px 10px; background:var(--bg); border:1px solid var(--line); border-radius:10px; }
266+
.box { width:40px; height:40px; display:flex; align-items:center; justify-content:center; color:var(--ink); }
267+
.art svg { width:24px; height:24px; display:block; }
268+
.art.missing { color:#c00; font-size:20px; }
269+
.name { font-size:11px; font-weight:500; text-align:center; word-break:break-word; }
270+
.count { font-size:10px; color:var(--muted); font-variant-numeric:tabular-nums; }
271+
</style></head>
272+
<body>
273+
<header>
274+
<h1>Sim — icons to update (${total})</h1>
275+
<p>Counts = emcn usage + lucide sites to migrate. Artwork is the current live glyph.</p>
276+
</header>
277+
${body}
278+
</body></html>`
279+
280+
const outPath = join(import.meta.dir, '../../../icon-gallery.html')
281+
writeFileSync(outPath, html)
282+
console.log(`Wrote ${outPath}${total} icons`)

0 commit comments

Comments
 (0)