refactor(ui): move inline styles onto the design system, add an inline-style gate - #11238
Merged
Merged
Conversation
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]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,000style={{ ... }}literals acrosssrc/pagesandsrc/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:
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.refactor(ui): move repeated inline styles onto shared classes— six passes, each matching a wholestyleattribute exactly so the swap is provably equivalent. 47 files.refactor(ui): convert fifteen pages onto named classes, add inline-style gate— full per-page conversions plus the ratchet.Pages fully converted
P2P.jsxTraces.jsxConfigFieldRenderer.jsxNodeDetail.jsxModelEditor.jsxNodeInstallPicker.jsxFineTune.jsxNodes.jsxImportModel.jsxTalk.jsxAgentJobDetails.jsxUsage.jsxSettings.jsxMiddleware.jsxBackends.jsxEvery remainder is genuinely computed at runtime: a data-driven badge colour, a
width: ${pct}%, a tooltip's coordinates.Bugs this surfaced
width: 36, height: 20and an absolutely positioned thumb, rendering as a clipped circle. It is the existingTogglecomponent now.Click "New Job" to get started, while New Job was scrolled off the top). It carries its own call to action now..input--fileis added at the system level.ImageGen.jsxandVideoGen.jsxtruncate their file inputs the same way today; the class is there for a follow-up to apply.xs 0.6875 / sm 0.8125 / base 0.875, and0.75remwas in use ~100 times without being on it (plus0.7,0.85,1.1,0.625). All snapped to the nearest step. This is the only intended visual change..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: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
classNameattributes on one element, and that check earns its place. JSX keeps the lastclassNameand silently drops the first, so<i className={icon} className="text-xs" />loses its icon while passing eslint,vite buildand the full Playwright suite. 112 of these were introduced during the conversion and repaired.eslint-plugin-reactwould catch it viajsx-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-existingno-unused-varsfalse positives for JSX-only components, since the config lacksreact/jsx-uses-vars)npx vite build— passespage-render-smoke+navigation— 24/24npm run lint:inline-styles— green on both checksNot in scope
~68 files still have inline styles, dominated by
Models.jsx(82) and the remainder ofBackends.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).