From 59e696d3b884464f5b40f2f05c5bb15fbe10a319 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:36:34 +0000 Subject: [PATCH 1/4] chore(frontend): replace mixed Unicode glyphs with one SVG icon set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar draws Feather-style inline SVGs, but row actions, toasts, sort arrows, column-filter triggers, tooltips and expanders used Unicode glyphs (▶ ⏸ ⏵ ✎ ✕ ↻ ⚠ ↕ ▲ ▼ ⛛ ⓘ ⚙︎ ↓ ×). Those come from whatever font the platform picks, so their size and weight drift between browsers, and ⏸ / ⏵ / ⚠ can render as color emoji on macOS. Add src/icons.ts with one set of Feather (MIT) shapes, plus Lucide's chevrons-up-down, all on the sidebar's 24px grid with a 2px round stroke and currentColor. It exposes iconSvg() for the template-literal renderers and createIcon() for the DOM-built ones that avoid innerHTML. Use it for the planned-purchase row actions, toast kind and close icons, the Opportunities sort indicators and expand chevrons, every column-filter trigger, the History retry/ops-hint badges, the user-row expander, the federation download icon, modal close buttons, the copy tick, the info tooltips and the Exchange settings button. The costly "Run now" action becomes a labelled button (icon + text) so it no longer looks like the other small icon-only buttons. A jest guard fails if the replaced glyphs come back in UI source. Refs #751 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/icons.test.ts | 77 +++++++++++ .../src/__tests__/plans-permissions.test.ts | 4 + frontend/src/confirmDialog.ts | 3 +- frontend/src/federation.ts | 7 +- frontend/src/history.ts | 11 +- frontend/src/icons.ts | 126 ++++++++++++++++++ frontend/src/index.html | 24 ++-- frontend/src/ladder.ts | 3 +- frontend/src/lib/history-filter-popover.ts | 7 +- frontend/src/plans.ts | 13 +- frontend/src/recommendations.ts | 21 +-- frontend/src/riexchange.ts | 7 +- frontend/src/settings.ts | 3 +- frontend/src/styles/icons.css | 28 ++++ frontend/src/styles/index.css | 1 + frontend/src/toast.ts | 16 ++- frontend/src/users/userList.ts | 7 +- 17 files changed, 304 insertions(+), 54 deletions(-) create mode 100644 frontend/src/__tests__/icons.test.ts create mode 100644 frontend/src/icons.ts create mode 100644 frontend/src/styles/icons.css diff --git a/frontend/src/__tests__/icons.test.ts b/frontend/src/__tests__/icons.test.ts new file mode 100644 index 000000000..8ab144f22 --- /dev/null +++ b/frontend/src/__tests__/icons.test.ts @@ -0,0 +1,77 @@ +/** + * Tests for the shared SVG icon set (issue #751). + */ +import fs from 'fs'; +import path from 'path'; +import { createIcon, iconSvg, type IconName } from '../icons'; +import { showToast } from '../toast'; + +const ALL: IconName[] = [ + 'alert-triangle', 'check', 'check-circle', 'chevron-down', 'chevron-right', + 'chevron-up', 'chevrons-up-down', 'download', 'edit', 'filter', 'info', + 'pause', 'play', 'rotate-cw', 'settings', 'x', 'x-circle', +]; + +function parse(markup: string): SVGSVGElement { + const host = document.createElement('div'); + host.innerHTML = markup; + return host.firstElementChild as SVGSVGElement; +} + +describe('icons', () => { + it.each(ALL)('%s renders as a decorative currentColor SVG', (name) => { + const svg = parse(iconSvg(name)); + expect(svg.tagName.toLowerCase()).toBe('svg'); + expect(svg.getAttribute('class')).toBe(`icon icon-${name}`); + expect(svg.getAttribute('viewBox')).toBe('0 0 24 24'); + expect(svg.getAttribute('stroke')).toBe('currentColor'); + expect(svg.getAttribute('aria-hidden')).toBe('true'); + expect(svg.getAttribute('focusable')).toBe('false'); + expect(svg.childElementCount).toBeGreaterThan(0); + }); + + it.each(ALL)('createIcon(%s) builds the same SVG as iconSvg', (name) => { + const built = createIcon(name); + expect(built.namespaceURI).toBe('http://www.w3.org/2000/svg'); + expect(built.outerHTML).toBe(parse(iconSvg(name)).outerHTML); + }); + + it('toasts use an SVG icon and an SVG close button for every kind', () => { + for (const kind of ['success', 'info', 'warning', 'error'] as const) { + showToast({ message: kind, kind, timeout: null }); + } + const toasts = Array.from(document.querySelectorAll('.toast')); + expect(toasts).toHaveLength(4); + for (const t of toasts) { + expect(t.querySelector('.toast-icon svg.icon')).not.toBeNull(); + expect(t.querySelector('.toast-icon')?.textContent).toBe(''); + expect(t.querySelector('.toast-close svg.icon-x')).not.toBeNull(); + } + document.body.replaceChildren(); + }); + + // Regression guard: the platform-font glyphs replaced by the icon set + // must not creep back into the rendered UI. Comments and tests are + // allowed to name them. + it('the replaced Unicode icon glyphs no longer appear in UI source', () => { + const banned = /[⏸⏵✎⛛ⓘ⚙↻]|ⓘ|⛛|\\u26db|\\u2195/i; + const srcDir = path.join(__dirname, '..'); + const offenders: string[] = []; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__tests__') walk(full); + continue; + } + if (!/\.(ts|html)$/.test(entry.name) || entry.name === 'docs.html') continue; + fs.readFileSync(full, 'utf8').split('\n').forEach((line, i) => { + const code = line.replace(/\/\/.*$/, '').replace(/^\s*\*.*$/, ''); + if (banned.test(code)) offenders.push(`${path.relative(srcDir, full)}:${i + 1}`); + }); + } + }; + walk(srcDir); + expect(offenders).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/plans-permissions.test.ts b/frontend/src/__tests__/plans-permissions.test.ts index 53a4d835a..c59747c5b 100644 --- a/frontend/src/__tests__/plans-permissions.test.ts +++ b/frontend/src/__tests__/plans-permissions.test.ts @@ -135,6 +135,10 @@ describe('Plans page permission gating (issue #365)', () => { const buttons = Array.from(pp.querySelectorAll('td.actions button')); expect(buttons.length).toBeGreaterThanOrEqual(4); for (const b of buttons) expect(b.getAttribute('aria-label')).toBeTruthy(); + // Issue #751: actions use the shared SVG icon set, not platform-font + // glyphs, and the money-triggering Run now action carries a text label. + for (const b of buttons) expect(b.querySelector('svg.icon')).not.toBeNull(); + expect(pp.querySelector('button[data-action="run"]')?.textContent?.trim()).toBe('Run now'); }); }); diff --git a/frontend/src/confirmDialog.ts b/frontend/src/confirmDialog.ts index 6b5a7f136..495b1b440 100644 --- a/frontend/src/confirmDialog.ts +++ b/frontend/src/confirmDialog.ts @@ -16,6 +16,7 @@ */ import { FOCUSABLE_SELECTOR } from './modal'; +import { createIcon } from './icons'; let dialogSeq = 0; @@ -61,7 +62,7 @@ export function confirmDialog(opts: ConfirmDialogOptions): Promise { closeBtn.type = 'button'; closeBtn.className = 'modal-confirm-close'; closeBtn.setAttribute('aria-label', 'Close'); - closeBtn.textContent = '×'; + closeBtn.appendChild(createIcon('x')); dialog.appendChild(closeBtn); const titleEl = document.createElement('h3'); diff --git a/frontend/src/federation.ts b/frontend/src/federation.ts index f6240f71b..d53ce10db 100644 --- a/frontend/src/federation.ts +++ b/frontend/src/federation.ts @@ -10,6 +10,7 @@ import { getFederationIaC } from './api'; import { showToast } from './toast'; +import { createIcon } from './icons'; // --------------------------------------------------------------------------- // Download helper @@ -102,12 +103,12 @@ function makeFormatButton(opt: FormatOption, target: string, source: string): HT // .btn-download class together signal the click action). btn.setAttribute('aria-label', `Download ${opt.label} (${opt.sublabel}) for ${target.toUpperCase()}`); - // A leading ↓ glyph communicates "download" visually. aria-hidden so - // screen readers rely on the aria-label above, not the glyph. + // A leading download icon communicates "download" visually. aria-hidden + // so screen readers rely on the aria-label above, not the icon. const iconSpan = document.createElement('span'); iconSpan.className = 'download-icon'; iconSpan.setAttribute('aria-hidden', 'true'); - iconSpan.textContent = '↓'; + iconSpan.appendChild(createIcon('download')); btn.appendChild(iconSpan); const labelSpan = document.createElement('span'); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index b4c183d44..5014303f9 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -11,6 +11,7 @@ import { switchTab } from './navigation'; import { confirmDialog } from './confirmDialog'; import { approvalDetailsUnavailableMessage, buildApprovalDetailsBody } from './approval-details'; import { showToast } from './toast'; +import { iconSvg } from './icons'; import { getCurrentUser } from './state'; import { canAccess } from './permissions'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; @@ -946,11 +947,13 @@ function renderActionCell(p: HistoryPurchase): string { const title = p.redrive_unsafe ? 'Permanent: this execution can never be retried; check the account, then start a new purchase if needed' : 'Operator action required — Retry will not help until this is fixed'; - return `⚠ ${escapeHtml(p.ops_hint)}`; + return `${iconSvg('alert-triangle')} ${escapeHtml(p.ops_hint)}`; } if (canRetryFailedRow(p) && p.purchase_id) { const overThreshold = retryThresholdReached(p); - const label = overThreshold ? `⚠ Retried ${p.retry_attempt_n ?? 0}× — click to override` : '↻ Retry'; + const label = overThreshold + ? `${iconSvg('alert-triangle')} Retried ${p.retry_attempt_n ?? 0}× — click to override` + : `${iconSvg('rotate-cw')} Retry`; const cls = overThreshold ? 'btn-link history-retry-btn history-retry-over-threshold' : 'btn-link history-retry-btn'; return ``; } @@ -963,14 +966,14 @@ function renderActionCell(p: HistoryPurchase): string { // row (failed_v2 was retried into v3 AND is itself a retry of v1). const lineage: string[] = []; if (p.retry_execution_id) { - lineage.push(`↻ Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}`); + lineage.push(`${iconSvg('rotate-cw')} Retried as #${escapeHtml(shortExecID(p.retry_execution_id))}`); } if ((p.retry_attempt_n ?? 0) > 0) { // We don't carry a back-pointer field — a future enhancement // could surface the predecessor's exec ID via the API. For now // we render a static badge (no link target) so users at least // see "this is a retry" provenance. - lineage.push(`↻ Retry #${p.retry_attempt_n}`); + lineage.push(`${iconSvg('rotate-cw')} Retry #${p.retry_attempt_n}`); } // Build the trailing action buttons so they compose with lineage links // — a retry-descendant can also have an active listing that needs a diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts new file mode 100644 index 000000000..afbc79a7c --- /dev/null +++ b/frontend/src/icons.ts @@ -0,0 +1,126 @@ +/** + * One SVG icon set for the whole UI (issue #751). + * + * Before this module the sidebar used inline Feather SVGs while row + * actions, toasts, sort arrows, filter triggers and tooltips used Unicode + * glyphs (▶ ⏸ ⏵ ✎ ✕ ↻ ⚠ ↕ ▲ ▼ ⛛ ⓘ ⚙︎). Those glyphs come from whatever + * font the platform picks, so their size and weight drift between + * browsers, and ⏸ / ⏵ / ⚠ can render as color emoji on macOS. Every icon + * here uses the same 24x24 grid, 2px round stroke and `currentColor`, so + * it inherits the surrounding text color and matches the sidebar icons. + * + * Shapes are copied from Feather (MIT, https://feathericons.com); the + * one icon Feather lacks, chevrons-up-down, comes from its fork Lucide + * (ISC, https://lucide.dev). + * + * Icons are decorative: both renderers set aria-hidden="true" and + * focusable="false", so callers must give the surrounding control a + * text label or an aria-label. + */ + +type Attrs = Readonly>; +type Shape = readonly [tag: 'path' | 'line' | 'polyline' | 'polygon' | 'circle' | 'rect', attrs: Attrs]; + +const ICONS = { + 'alert-triangle': [ + ['path', { d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }], + ['line', { x1: '12', y1: '9', x2: '12', y2: '13' }], + ['line', { x1: '12', y1: '17', x2: '12.01', y2: '17' }], + ], + check: [['polyline', { points: '20 6 9 17 4 12' }]], + 'check-circle': [ + ['path', { d: 'M22 11.08V12a10 10 0 1 1-5.93-9.14' }], + ['polyline', { points: '22 4 12 14.01 9 11.01' }], + ], + 'chevron-down': [['polyline', { points: '6 9 12 15 18 9' }]], + 'chevron-right': [['polyline', { points: '9 18 15 12 9 6' }]], + 'chevron-up': [['polyline', { points: '18 15 12 9 6 15' }]], + 'chevrons-up-down': [ + ['path', { d: 'm7 15 5 5 5-5' }], + ['path', { d: 'm7 9 5-5 5 5' }], + ], + download: [ + ['path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }], + ['polyline', { points: '7 10 12 15 17 10' }], + ['line', { x1: '12', y1: '15', x2: '12', y2: '3' }], + ], + edit: [['path', { d: 'M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z' }]], + filter: [['polygon', { points: '22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3' }]], + info: [ + ['circle', { cx: '12', cy: '12', r: '10' }], + ['line', { x1: '12', y1: '16', x2: '12', y2: '12' }], + ['line', { x1: '12', y1: '8', x2: '12.01', y2: '8' }], + ], + pause: [ + ['rect', { x: '6', y: '4', width: '4', height: '16' }], + ['rect', { x: '14', y: '4', width: '4', height: '16' }], + ], + play: [['polygon', { points: '5 3 19 12 5 21 5 3' }]], + 'rotate-cw': [ + ['polyline', { points: '23 4 23 10 17 10' }], + ['path', { d: 'M20.49 15a9 9 0 1 1-2.12-9.36L23 10' }], + ], + settings: [ + ['circle', { cx: '12', cy: '12', r: '3' }], + ['path', { d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z' }], + ], + x: [ + ['line', { x1: '18', y1: '6', x2: '6', y2: '18' }], + ['line', { x1: '6', y1: '6', x2: '18', y2: '18' }], + ], + 'x-circle': [ + ['circle', { cx: '12', cy: '12', r: '10' }], + ['line', { x1: '15', y1: '9', x2: '9', y2: '15' }], + ['line', { x1: '9', y1: '9', x2: '15', y2: '15' }], + ], +} as const satisfies Record; + +export type IconName = keyof typeof ICONS; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** Attributes shared by every icon's root. Sized in em so the icon tracks the font size of its context. */ +function rootAttrs(name: IconName): Attrs { + return { + class: `icon icon-${name}`, + viewBox: '0 0 24 24', + width: '1em', + height: '1em', + fill: 'none', + stroke: 'currentColor', + 'stroke-width': '2', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'aria-hidden': 'true', + focusable: 'false', + }; +} + +function attrString(attrs: Attrs): string { + return Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' '); +} + +/** + * Markup for an icon, for use inside the template-literal renderers + * (``). The output is built only from + * the constant table above, never from caller data, so it is safe to + * interpolate into innerHTML. + */ +export function iconSvg(name: IconName): string { + const shapes = (ICONS[name] as readonly Shape[]) + .map(([tag, attrs]) => `<${tag} ${attrString(attrs)}/>`) + .join(''); + return `${shapes}`; +} + +/** DOM version of iconSvg for the createElement-based renderers that avoid innerHTML. */ +export function createIcon(name: IconName): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, 'svg'); + for (const [k, v] of Object.entries(rootAttrs(name))) svg.setAttribute(k, v); + for (const [tag, attrs] of ICONS[name] as readonly Shape[]) { + const child = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) child.setAttribute(k, v); + svg.appendChild(child); + } + return svg; +} diff --git a/frontend/src/index.html b/frontend/src/index.html index ebbbdd24c..3b1d5c0fb 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -304,7 +304,7 @@

Coverage

Convertible RI Exchange

- +
@@ -352,7 +352,7 @@

Global Configuration

three checkboxes below. Use a span with an id that the grouping div references via aria-labelledby instead. --> - ⓘSelect which cloud providers to monitor for commitment recommendations. + Select which cloud providers to monitor for commitment recommendations.
@@ -363,7 +363,7 @@

Global Configuration

- ⓘEmail address for purchase notifications and approval requests. + Email address for purchase notifications and approval requests.
@@ -376,7 +376,7 @@

Global Configuration

input and provides the click target. aria-labelledby on the input keeps the descriptive text as the toggle's accessible name. Issue #464. --> Auto-collect Recommendations - ⓘAutomatically fetch recommendations from cloud providers on a schedule. + Automatically fetch recommendations from cloud providers on a schedule.