Skip to content

refactor(ui): move inline styles onto the design system, add an inline-style gate - #11238

Merged
mudler merged 3 commits into
masterfrom
refactor/ui-inline-styles-to-design-system
Jul 30, 2026
Merged

refactor(ui): move inline styles onto the design system, add an inline-style gate#11238
mudler merged 3 commits into
masterfrom
refactor/ui-inline-styles-to-design-system

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

What

The React UI ships a design system in src/App.css (tokens, form grids, data tables, stat cards, callouts) that the pages largely bypassed: ~2,000 style={{ ... }} literals across src/pages and src/components. Each one is a spacing or colour decision made in one file, so no two pages share a rhythm. That inconsistency, at that density, is a large part of why the app reads as unfinished rather than as one product.

This PR takes it to 624, adds the missing shared vocabulary, and puts a ratchet in place so it cannot creep back.

How it is split

Three commits, each readable on its own:

  1. refactor(ui): add layout/text primitives and per-page CSS blocks — a short semantic primitive layer (.stack/.hstack, .text-note/.text-sub/.text-meta/.text-mono, .tone-* + .icon-chip, scale-locked spacing and size steps), plus named blocks for the shapes fifteen pages actually have.
  2. refactor(ui): move repeated inline styles onto shared classes — six passes, each matching a whole style attribute exactly so the swap is provably equivalent. 47 files.
  3. refactor(ui): convert fifteen pages onto named classes, add inline-style gate — full per-page conversions plus the ratchet.

Pages fully converted

Page Before After
P2P.jsx 205 0
Traces.jsx 50 0
ConfigFieldRenderer.jsx 23 0
NodeDetail.jsx 29 1
ModelEditor.jsx 25 1
NodeInstallPicker.jsx 29 1
FineTune.jsx 175 2
Nodes.jsx 33 2
ImportModel.jsx 35 3
Talk.jsx 43 4
AgentJobDetails.jsx 27 4
Usage.jsx 97 6
Settings.jsx 24 6
Middleware.jsx 54 8
Backends.jsx 87 45

Every remainder is genuinely computed at runtime: a data-driven badge colour, a width: ${pct}%, a tooltip's coordinates.

Bugs this surfaced

  • The fine-tuning evaluation toggle was visibly broken. A hand-rolled div with inline width: 36, height: 20 and an absolutely positioned thumb, rendering as a clipped circle. It is the existing Toggle component now.
  • That page's empty state pointed at an invisible button (Click "New Job" to get started, while New Job was scrolled off the top). It carries its own call to action now.
  • .input--file is added at the system level. ImageGen.jsx and VideoGen.jsx truncate their file inputs the same way today; the class is there for a follow-up to apply.
  • The type scale had a hole. Steps are xs 0.6875 / sm 0.8125 / base 0.875, and 0.75rem was in use ~100 times without being on it (plus 0.7, 0.85, 1.1, 0.625). All snapped to the nearest step. This is the only intended visual change.
  • Thirteen distinct table column widths where three or four would do. Pulled into .col-w-* so the ladder is visible in one place; normalising it is a separate change.

The gate

scripts/inline-style-gate.mjs, wired to npm:

npm run lint:inline-styles          # fails if the total went UP
npm run lint:inline-styles:report   # per-file counts, worst first
npm run lint:inline-styles:write    # refresh the baseline after converting

It does not forbid inline styles, since some are legitimate. It enforces that the total never rises, same discipline as the coverage baseline.

It also fails on duplicate className attributes on one element, and that check earns its place. JSX keeps the last className and silently drops the first, so <i className={icon} className="text-xs" /> loses its icon while passing eslint, vite build and the full Playwright suite. 112 of these were introduced during the conversion and repaired. eslint-plugin-react would catch it via jsx-props-no-duplicate-props, but this project does not depend on that plugin, so the check lives next to the tool that causes the problem.

Verification

  • npx eslint src/0 errors (warning count unchanged in kind; the extra ones are the pre-existing no-unused-vars false positives for JSX-only components, since the config lacks react/jsx-uses-vars)
  • npx vite build — passes
  • Playwright page-render-smoke + navigation24/24
  • npm run lint:inline-styles — green on both checks
  • Each converted page was also viewed rendered in a browser

Not in scope

~68 files still have inline styles, dominated by Models.jsx (82) and the remainder of Backends.jsx (45). The gate names them in priority order via --report, and the fifteen converted pages establish the pattern including the hard cases (data-driven colours, shared style objects, dynamic dimensions). This is deliberately a first tranche rather than a big-bang rewrite.

This also only addresses how the UI looks. It does not touch the deeper UX issues (outputs with no server-side identity, no persistence, pages that dead-end).

mudler added 3 commits July 30, 2026 20:51
The React UI already shipped a design system (tokens, form grids, data
tables, stat cards, callouts) that the pages largely bypassed: ~2,000
`style={{ ... }}` literals across src/pages and src/components. Each one is
a spacing or colour decision made locally, so no two pages share a rhythm,
which is the main reason the app reads as unfinished rather than as one
product.

Two additions, both to App.css:

  - A small semantic primitive layer: .stack / .hstack for vertical and
    horizontal rhythm, .text-note / .text-sub / .text-meta / .text-mono for
    the text roles the pages kept re-deriving, .tone-* + .icon-chip for
    semantically tinted icons, plus scale-locked spacing and size steps.
    Deliberately short and semantic, not a utility framework: the size and
    spacing classes exist mainly so that an OFF-scale value stays an inline
    style and therefore stays visible.

  - Named blocks for the shapes fifteen pages actually have (.p2p-diagram,
    .usage-tile, .tr-code, .mw-badge, .set-rail, ...), so those shapes are
    defined once instead of per call site.

Two findings worth recording. The type scale is xs 0.6875 / sm 0.8125 /
base 0.875, and 0.75rem was in use roughly 100 times without being on it
(along with 0.7, 0.85, 1.1 and 0.625rem); all now snap to the nearest step.
And there were thirteen distinct table column widths across the app where
three or four would do; they are pulled into .col-w-* so the ladder is
visible in one place, ready to normalise separately.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
Six passes over src/pages and src/components, each matching a whole
`style={{ ... }}` attribute exactly so the swap is provably equivalent:

  - the identical "waiting for the first response" wrapper, repeated
    verbatim in 21 files
  - text roles (size + colour combinations) onto .text-note / .text-meta /
    .text-sub / .text-mono
  - semantic colours, type-scale steps, .panel-title, .list-row
  - spacing and weight steps, .stack / .hstack rows
  - table column widths, small pills, chart legend swatches
  - the remaining shapes appearing three or more times

One class of bug is worth calling out, because it is what a careless
style-to-class conversion produces and it is invisible to every check we
run. Adding `className="x"` to an element that already had a className
leaves TWO className attributes; JSX keeps the last and silently drops the
first, so `<i className={icon} className="text-xs" />` loses its icon while
passing eslint, `vite build` and the full Playwright suite. 112 of these
were introduced and repaired here. The gate added in a later commit fails
on them.

No visual change is intended beyond snapping off-scale font sizes onto the
type scale. Verified after every pass: eslint 0 errors, vite build passes,
Playwright page-render-smoke + navigation 22/22.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
…yle gate

Full per-page conversions, each one reading the page, naming the shapes it
actually has, and leaving inline only what is computed at runtime:

  P2P 205->0   Traces 50->0   ConfigFieldRenderer 23->0   NodeDetail 29->1
  ModelEditor 25->1   NodeInstallPicker 29->1   FineTune 175->2
  Nodes 33->2   ImportModel 35->3   Talk 43->4   AgentJobDetails 27->4
  Usage 97->6   Settings 24->6   Middleware 54->8   Backends 87->45

Every remainder is genuinely dynamic: a data-driven badge colour, a
`width: ${pct}%`, a tooltip's coordinates.

Naming the shapes made reuse fall out on its own. Nodes reuses the P2P
setup shapes (both present the same "no workers yet, here is how to add
one" flow) and Model Editor reuses the Settings section rail, which was
byte-identical. Two shared *style objects* also turned out to be classes
wearing a costume and were deleted: `monoCell` in Usage and `hintStyle` in
ImportModel.

Some fixes fell out of the conversion. The evaluation toggle on the
fine-tuning page was a hand-rolled div that rendered as a clipped circle;
it is the existing Toggle component now. That page's empty state pointed at
a "New Job" button that was scrolled off the top of the page, and now
carries its own call to action. And `.input--file` is added at the system
level rather than as a local hack, because ImageGen and VideoGen truncate
their file inputs the same way today.

scripts/inline-style-gate.mjs is the ratchet that keeps this from
regressing. It does not forbid inline styles; it fails when the total goes
UP (same discipline as the coverage baseline) and when an element carries
two className attributes. eslint would catch the latter via
react/jsx-props-no-duplicate-props, but that needs eslint-plugin-react,
which this project does not depend on, so the check lives next to the tool
that causes the problem.

  npm run lint:inline-styles          # check against baseline
  npm run lint:inline-styles:report   # per-file counts, worst first
  npm run lint:inline-styles:write    # refresh after converting

Net: 2,061 -> 611 inline styles. eslint 0 errors, vite build passes,
Playwright page-render-smoke + navigation 22/22, gate green on both checks.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5 [Bash] [Edit] [Write]
@mudler
mudler merged commit 6394909 into master Jul 30, 2026
1 of 23 checks passed
@mudler
mudler deleted the refactor/ui-inline-styles-to-design-system branch July 30, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants