fix(ui): unmerge the class strings that left buttons in browser chrome (#11462)

Eight header controls across seven pages had two or three elements' classes
collapsed into one string. The wrapper or the icon ended up wearing the
button classes, and the buttons themselves were left with no class at all,
so they rendered in the browser's own chrome. Reported on Agent Jobs; the
grep found the rest.

`fas` does not draw anything by itself: it sets
`font-family: "Font Awesome 6 Free"` and weight 900 on whatever carries it,
and the `fa-*` class supplies the glyph via ::before. So

    <button className="btn btn-primary fas fa-plus">

renders its own label "New Task" in the icon font, and

    <div className="hstack btn btn-primary btn-sm fas fa-edit btn-secondary fa-arrow-left">
      <button>Edit</button>
      <button>Back</button>
    </div>

styles the flex wrapper as a button that is both primary and secondary,
points two glyphs at one ::before, and leaves both real buttons bare.

Fixed, all of them keeping the correct `<i>` child they already had:

  - AgentJobs, AgentTaskDetails (x2), AgentCreate - icon classes off the
    button.
  - AgentTaskDetails, AgentJobDetails - wrapper back to plain `hstack`, and
    the two buttons inside each get the variants the wrapper had been
    holding. Back is secondary and leads, Edit/Cancel is the emphatic one
    and trails, matching every other detail header.
  - VoiceLibrary, VoiceProfileCreate - the title `<i>` had swallowed the
    action link's classes, so "Create voice" and "Back to library" were
    unstyled anchors. Back was also drawing a "+" because it had inherited
    fa-plus while its own fa-arrow-left sat up in the title.
  - P2P - a stray fa-circle-info on the title icon.

The ninth instance was ImportModel, where this class of bug was first
found. #11461 rewrote that file and landed first, so nothing is left to fix
there.

Guarded by e2e/class-hygiene.spec.js, which reads the source rather than
walking routes: several of these pages need agent or voice data before they
render a header, so a route walk would skip exactly the pages that had the
bug. It fails on an icon-font class outside an `<i>`/`<span>`, on two glyphs
or two button variants on one element, and on a layout wrapper that is also
a button. Font Awesome modifiers (fa-spin, fa-fw, sizes) are excluded, so
the `fa-spinner fa-spin` idiom stays legal.

e2e: 428 passed.


Assisted-by: Claude Code:claude-opus-5[1m] [Read] [Edit] [Bash] [Playwright]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler's LocalAI [bot]andEttore Di Giacinto authored and GitHub committed 2026-08-11 12:07:42 +02:00
1 parent 7a7fb00730
commit 45cb3983ee
8 files changed
+129 -18

No files matched your search

@@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
// Font Awesome's `fas` / `far` / `fab` classes do not draw an icon on their
// own — they set `font-family: "Font Awesome 6 Free"` and a weight on whatever
// element carries them, and the matching `fa-*` class supplies the glyph
// through ::before. Put them on a <button> and the button's own label text is
// rendered in the icon font; put two `fa-*` classes on one element and they
// fight over the same ::before.
//
// This is not hypothetical. A class-merging edit at some point collapsed
// several "wrapper + button + icon" trios into a single className string, which
// left icon classes on buttons and layout wrappers, and left the real buttons
// with no class at all — rendering them in the browser's own chrome. Both
// symptoms read as "unstyled button" and neither fails a build.
//
// These tests read the source rather than the DOM on purpose: several of the
// affected pages need agent or voice data before they render their header, so a
// route walk would silently skip exactly the pages that had the bug.
const HERE = dirname(fileURLToPath(import.meta.url))
const SRC = join(HERE, '..', 'src')
function jsxFiles(dir) {
const out = []
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) out.push(...jsxFiles(full))
else if (entry.endsWith('.jsx')) out.push(full)
}
return out
}
// Every className="..." literal in the tree, with enough context to report a
// useful location. Template literals are skipped: they are built at runtime and
// this check is about hand-written constant strings.
function classLiterals() {
const found = []
for (const file of jsxFiles(SRC)) {
const text = readFileSync(file, 'utf8')
const lines = text.split('\n')
lines.forEach((line, i) => {
for (const m of line.matchAll(/className="([^"]*)"/g)) {
found.push({
file: relative(join(HERE, '..'), file),
line: i + 1,
classes: m[1].split(/\s+/).filter(Boolean),
// The tag this className sits on, when it is on the same line.
tag: (line.slice(0, m.index).match(/<([A-Za-z][\w.]*)(?![\s\S]*<)/) || [])[1] || '',
})
}
})
}
return found
}
const isIconFont = (c) => c === 'fas' || c === 'far' || c === 'fab'
// Font Awesome's `fa-*` namespace holds two different kinds of class: the glyph
// (`fa-plus`), of which an element may have exactly one, and modifiers that
// size, spin or align it, of which it may have any number. `fa-spinner fa-spin`
// is the documented spinner idiom and must not be mistaken for a conflict.
const FA_MODIFIERS = new Set([
'fa-2xs', 'fa-xs', 'fa-sm', 'fa-lg', 'fa-xl', 'fa-2xl',
'fa-fw', 'fa-ul', 'fa-li', 'fa-border', 'fa-inverse',
'fa-pull-left', 'fa-pull-right',
'fa-beat', 'fa-fade', 'fa-beat-fade', 'fa-bounce', 'fa-flip', 'fa-shake',
'fa-spin', 'fa-spin-pulse', 'fa-spin-reverse', 'fa-pulse',
'fa-rotate-90', 'fa-rotate-180', 'fa-rotate-270', 'fa-rotate-by',
'fa-flip-horizontal', 'fa-flip-vertical', 'fa-flip-both',
'fa-stack', 'fa-stack-1x', 'fa-stack-2x',
'fa-sr-only', 'fa-sr-only-focusable',
])
const isGlyph = (c) =>
c.startsWith('fa-') && !FA_MODIFIERS.has(c) && !/^fa-\d+x$/.test(c)
test.describe('class hygiene', () => {
test('the icon font is only ever set on an icon element', () => {
// <i> and <span> are the icon carriers. Anything else wearing `fas` is
// rendering its own text in the icon font.
const offenders = classLiterals()
.filter(c => c.classes.some(isIconFont))
.filter(c => c.tag && c.tag !== 'i' && c.tag !== 'span')
.map(c => `${c.file}:${c.line} <${c.tag} class="${c.classes.join(' ')}">`)
expect(offenders, 'icon-font classes belong on an <i>, not on the element whose text they would restyle').toEqual([])
})
test('no element carries two competing glyphs or two button variants', () => {
const offenders = []
for (const c of classLiterals()) {
const glyphs = c.classes.filter(isGlyph)
if (glyphs.length > 1) {
offenders.push(`${c.file}:${c.line} two glyphs (${glyphs.join(', ')}) on one ::before`)
}
const variants = c.classes.filter(x => /^btn-(primary|secondary|danger|ghost)$/.test(x))
if (variants.length > 1) {
offenders.push(`${c.file}:${c.line} two button variants (${variants.join(', ')}) on one element`)
}
// A layout wrapper is not a button. Both together means two elements'
// classes were merged into one.
if (c.classes.includes('hstack') && c.classes.includes('btn')) {
offenders.push(`${c.file}:${c.line} a layout wrapper is also styled as a button`)
}
}
expect(offenders, 'merged class strings leave one element over-styled and its siblings bare').toEqual([])
})
})
+1 -1
View File
@@ -942,7 +942,7 @@ export default function AgentCreate() {
<PageHeader
title={isEdit ? `Edit Agent: ${name}` : importedConfig ? 'Import Agent' : 'Create Agent'}
actions={
<button className="btn btn-secondary btn-sm fas fa-arrow-left" onClick={() => navigate('/app/agents')}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agents')}>
<i className="fas fa-arrow-left" aria-hidden="true" /> Back
</button>
}
@@ -164,15 +164,15 @@ export default function AgentJobDetails() {
title="Job Details"
supporting="Live status and reasoning traces"
actions={
<div className="hstack btn btn-danger fas fa-stop btn-secondary fa-arrow-left">
<div className="hstack">
<button className="btn btn-secondary" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" aria-hidden="true" /> Back
</button>
{(job.status === 'running' || job.status === 'pending') && (
<button onClick={handleCancel}>
<button className="btn btn-danger" onClick={handleCancel}>
<i className="fas fa-ban" aria-hidden="true" /> Cancel
</button>
)}
<button onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" aria-hidden="true" /> Back
</button>
</div>
}
/>
+1 -1
View File
@@ -275,7 +275,7 @@ export default function AgentJobs() {
title="Agent Jobs"
supporting="Manage agent tasks and automated workflows"
actions={
<button className="btn btn-primary fas fa-plus" onClick={() => navigate('/app/agent-jobs/tasks/new')}>
<button className="btn btn-primary" onClick={() => navigate('/app/agent-jobs/tasks/new')}>
<i className="fas fa-plus" aria-hidden="true" /> New Task
</button>
}
@@ -170,13 +170,13 @@ export default function AgentTaskDetails() {
title={task.name || 'Task Details'}
supporting={task.description || undefined}
actions={
<div className="hstack btn btn-primary btn-sm fas fa-edit btn-secondary fa-arrow-left">
<button onClick={() => navigate(`/app/agent-jobs/tasks/${id}/edit`)}>
<i className="fas fa-pen" aria-hidden="true" /> Edit
</button>
<button onClick={() => navigate('/app/agent-jobs')}>
<div className="hstack">
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" aria-hidden="true" /> Back
</button>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/app/agent-jobs/tasks/${id}/edit`)}>
<i className="fas fa-pen" aria-hidden="true" /> Edit
</button>
</div>
}
/>
@@ -311,7 +311,7 @@ export default function AgentTaskDetails() {
<PageHeader
title={isNew ? 'Create Task' : 'Edit Task'}
actions={
<button className="btn btn-secondary btn-sm fas fa-arrow-left" onClick={() => navigate('/app/agent-jobs')}>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>
<i className="fas fa-arrow-left" aria-hidden="true" /> Back
</button>
}
+1 -1
View File
@@ -245,7 +245,7 @@ export default function P2P() {
return (
<div className="page page--narrow">
<PageHeader
title={<><i className="fas fa-circle-nodes fa-circle-info" /> {t('p2p.title')}</>}
title={<><i className="fas fa-circle-nodes" aria-hidden="true" /> {t('p2p.title')}</>}
supporting={
<>
{t('p2p.subtitle')}
@@ -194,10 +194,10 @@ JSON` : ''
return (
<main className="voice-library-page">
<PageHeader
title={<><i className="fas fa-wave-square btn btn-primary fa-plus" aria-hidden="true" /> {t('voiceLibrary.title')}</>}
title={<><i className="fas fa-wave-square" aria-hidden="true" /> {t('voiceLibrary.title')}</>}
supporting={t('voiceLibrary.subtitle')}
actions={(
<Link to="/app/voice-library/new">
<Link className="btn btn-primary" to="/app/voice-library/new">
<i className="fas fa-plus" aria-hidden="true" /> {t('voiceLibrary.actions.create')}
</Link>
)}
@@ -137,9 +137,9 @@ export default function VoiceProfileCreate() {
/>
<PageHeader
eyebrow={t('voiceCreate.eyebrow')}
title={<><i className="fas fa-microphone-lines btn btn-secondary fa-arrow-left" aria-hidden="true" /> {t('voiceCreate.title')}</>}
title={<><i className="fas fa-microphone-lines" aria-hidden="true" /> {t('voiceCreate.title')}</>}
supporting={t('voiceCreate.subtitle')}
actions={<Link to="/app/voice-library"><i className="fas fa-plus" aria-hidden="true" /> {t('voiceCreate.actions.back')}</Link>}
actions={<Link className="btn btn-secondary" to="/app/voice-library"><i className="fas fa-arrow-left" aria-hidden="true" /> {t('voiceCreate.actions.back')}</Link>}
/>
<form className="voice-create-grid" onSubmit={submit}>