DEV Community

OmniDev
OmniDev

Posted on

Building Fluentic Style: Making Generated CSS Debuggable Again

This is part of my Building Fluentic Style series, where I’m writing down the design decisions, tradeoffs, and small surprises from building Fluentic Style.

Generated CSS is not the problem.

Generated CSS that cannot explain where it came from is the problem.

That is one of the things I kept running into with styling tools over the years.

A library can have a nice authoring API. It can have good TypeScript support. It can emit atomic CSS. It can extract CSS in production. It can even give readable class names.

But eventually something looks wrong in the browser.

Then you inspect the element.

And the real question is not:

What CSS rule won?
Enter fullscreen mode Exit fullscreen mode

The browser already tells you that.

The real question is:

Which authored styling decision produced this generated CSS rule?
Enter fullscreen mode Exit fullscreen mode

That is the part I wanted Fluentic to care about from the beginning.

Docs for the debugging pieces are here:

Atomic CSS Should Not Sacrifice Sourcemaps

Fluentic emits atomic CSS in development and production.

I like atomic CSS output.

It dedupes repeated declarations. It gives small reusable rules. It makes priority and extraction easier to reason about. It works well with a compiler.

But atomic CSS also makes debugging easier to ruin.

One authored style can become many generated rules:

const button = style({
  display: 'inline-flex',
  alignItems: 'center',
  paddingInline: 16,
  paddingBlock: 8,
  backgroundColor: '#2563eb',
  color: '#ffffff',
}).hover({
  backgroundColor: '#1d4ed8',
});
Enter fullscreen mode Exit fullscreen mode

The browser does not see “button style”.

It sees generated atomic CSS.

So if the hover background is wrong, a readable class name is not enough for me.

I do not only want to know “this came from something button-ish.”

I want the generated hover background-color rule to point back to the backgroundColor property inside the .hover(...) chain call.

That is the level of debugging I kept wanting.

generated hover background-color rule
  -> button.styles.ts
  -> .hover({ backgroundColor: '#1d4ed8' })
Enter fullscreen mode Exit fullscreen mode

For the base rule:

generated background-color rule
  -> button.styles.ts
  -> backgroundColor: '#2563eb'
Enter fullscreen mode Exit fullscreen mode

That is the difference between “search around this file” and “DevTools can take me to the authored decision.”

Atomic CSS is cool.

But it should not mean giving up precise sourcemaps.

Readable Names Are Helpful, But Not Enough

Some styling systems work around generated CSS by putting extra information into class names or generated CSS labels.

That can be useful.

A class name with a component name, file hint, or line hint is better than a hash with no context.

But readable names are still not the same as source-level debugging.

A label can tell you roughly where to look.

A sourcemap can take you to the exact property.

And styling bugs often need exactness.

If a file has many style objects, many spreads, many variants, and the same property repeated in base, hover, media, scope, and theme overrides, “somewhere near Button” is not enough.

I want DevTools to answer:

Which property?
Which chain call?
Which slot?
Which scope?
Which JSX element?
Which reusable value?
Enter fullscreen mode Exit fullscreen mode

That was the bar I wanted for Fluentic.

Debugging Starts From The Browser

Most styling bugs start from the rendered page.

You see something wrong:

wrong color
wrong spacing
wrong hover state
wrong responsive rule
wrong override
wrong component part
Enter fullscreen mode Exit fullscreen mode

Then you inspect the element.

At that point, Fluentic tries to keep several paths back to source.

wrong generated CSS rule?
  follow the rule sourcemap

wrong DOM element?
  follow the element marker

wrong spread or merge value?
  choose style trace or value trace

wrong component override?
  follow slot and scope identity
Enter fullscreen mode Exit fullscreen mode

That is why debugging cannot be bolted on after everything becomes a string.

Fluentic APIs create structured style data first.

style()       -> element style identity
chain methods -> selector / media / merge identity
style.slot()  -> component part identity
style.scope() -> grouped override identity
tokens        -> shared value identity
css prop      -> rendered JSX element identity
Enter fullscreen mode Exit fullscreen mode

That identity is useful for runtime composition.

It is useful for production CSS extraction.

And it is useful for development debugging.

Property-Level Sourcemaps

The first piece is property-level sourcemaps.

If this is the authored code:

const title = style({
  color: '#0f172a',
  fontWeight: 700,
}).hover({
  color: '#2563eb',
});
Enter fullscreen mode Exit fullscreen mode

then the generated base color rule should trace to:

title.styles.ts
color: '#0f172a'
Enter fullscreen mode Exit fullscreen mode

And the generated hover color rule should trace to:

title.styles.ts
.hover({ color: '#2563eb' })
Enter fullscreen mode Exit fullscreen mode

Not just the file.

Not just the nearest style call.

The property is the useful target.

That matters because in real code, the same CSS property can appear many times:

const title = style({
  color: '#0f172a',
}).hover({
  color: '#2563eb',
}).focusVisible({
  color: '#1d4ed8',
}).media('(max-width: 700px)', {
  color: '#334155',
});
Enter fullscreen mode Exit fullscreen mode

If DevTools only points to the top of the style object, I still have to guess.

If the generated rule points to the property in the right chain context, the browser already did most of the work.

Chain Calls Need Their Own Context

A style chain is not only an object.

The same property inside different chain calls means different things.

const button = style({
  backgroundColor: '#2563eb',
}).hover({
  backgroundColor: '#1d4ed8',
}).active({
  backgroundColor: '#1e40af',
});
Enter fullscreen mode Exit fullscreen mode

There are three backgroundColor values here.

They should not all debug the same way.

base backgroundColor
hover backgroundColor
active backgroundColor
Enter fullscreen mode Exit fullscreen mode

When the active rule wins, I want to land on the active property.

When the hover rule wins, I want to land on the hover property.

This is where Fluentic’s chain structure matters. The generated rule can keep the selector/media context that produced it.

So DevTools does not just answer:

backgroundColor exists in this style
Enter fullscreen mode Exit fullscreen mode

It can answer:

this generated background-color rule came from backgroundColor inside .hover(...)
Enter fullscreen mode Exit fullscreen mode

That is a much better debugging experience.

Element Markers: From DOM Back To JSX

CSS rule sourcemaps answer:

Which style produced this CSS rule?
Enter fullscreen mode Exit fullscreen mode

But sometimes the question starts from the DOM node:

Which JSX element attached this style?
Enter fullscreen mode Exit fullscreen mode

In development, Fluentic can add an element marker class to host elements that receive a css prop.

Conceptually:

return <article css={css.root}>...</article>;
Enter fullscreen mode Exit fullscreen mode

gets a development-only marker.

The marker rule is intentionally tiny:

.<marker-class>{--fluentic-element-marker:0}
Enter fullscreen mode Exit fullscreen mode

It exists so DevTools has a sourcemap target that points back to the JSX element where the css prop was attached.

That helps when the CSS rule is not the first question.

Sometimes you inspect a DOM node and just want to know:

Which component rendered this?
Which JSX line attached css={css.root}?
Is this the root element or an inner slot?
Enter fullscreen mode Exit fullscreen mode

Element markers make the DOM-to-JSX path explicit.

No custom browser extension should be required for that basic workflow. It should work with normal browser DevTools and sourcemaps.

Spread And Merge Need Two Debug Views

Reusable style fragments are useful.

They also make sourcemaps tricky.

Example:

const textReset = style.raw({
  margin: 0,
  lineHeight: 1.4,
});

const title = style({
  ...textReset,
  fontWeight: 700,
});
Enter fullscreen mode Exit fullscreen mode

If DevTools shows the generated line-height rule, where should it point?

There are two useful answers.

Sometimes I want the original value:

textReset
lineHeight: 1.4
Enter fullscreen mode Exit fullscreen mode

Sometimes I want the style that used it:

title
...textReset
Enter fullscreen mode Exit fullscreen mode

Both are valid.

So Fluentic has sourcemap modes:

StyleDevUtils.setSourcemapMode.toStyle();
StyleDevUtils.setSourcemapMode.toValue();
Enter fullscreen mode Exit fullscreen mode

toStyle() points spread or merged rules toward the style call that assembled them.

toValue() points them toward the original reusable value when possible.

This is one of those small details that feels boring until you are debugging a real component library.

When a shared reset is wrong, I want the original value.

When one component uses the shared reset in a surprising way, I want the use site.

One fixed sourcemap answer cannot cover both questions well.

Slots And Scopes Should Not Become Anonymous

Root styling is the easy case.

Reusable components are harder.

A component might expose styleable parts:

const buttonStyles = {
  root: style.slot({
    display: 'inline-flex',
    border: 0,
  }),

  icon: style.slot({
    width: 16,
    height: 16,
  }),

  label: style.slot({
    fontWeight: 700,
  }),
};
Enter fullscreen mode Exit fullscreen mode

Then an outside variant or theme might target those parts:

const danger = style.scope([
  buttonStyles.root({
    backgroundColor: '#dc2626',
  }),

  buttonStyles.label({
    color: '#ffffff',
  }),
]);
Enter fullscreen mode Exit fullscreen mode

After generation, all of this still becomes CSS.

But the authored meaning is different:

root base style
icon base style
label base style
danger scope -> root override
danger scope -> label override
Enter fullscreen mode Exit fullscreen mode

Those identities should not disappear.

If the label is white, I want to know whether that came from the label base style or the danger scope override.

If the root background changed, I want to know which scope produced it.

If a parent provides a theme override, I want to know which slot it targeted.

The generated CSS should not flatten all of those decisions into anonymous classes.

Slots and scopes are part of the debug story, not only the authoring API.

Tokens Are The One I Still Want To Improve

Most of the trail is already in place: properties, chain calls, spreads, merges, slots, scopes, and JSX css props.

The piece I am still thinking about is tokens.

Tokens already carry identity for styling and theming. But I still want token debugging to become more source-aware too.

For example, if a generated rule uses a token value, I want the debugging path to help answer:

Which token was used?
Where was that token defined?
Which theme changed it?
Which component consumed it?
Enter fullscreen mode Exit fullscreen mode

That is harder because token values can move through theme layers and runtime resolution.

But it is the direction I want.

A style system should not stop at “this CSS variable exists.”

It should help explain the token path too.

Dev Utilities Should Be Normal DevTools-Friendly

I did not want the basic debugging story to require a custom browser extension.

Extensions can be great.

But the first path should work with browser DevTools, generated CSS, and sourcemaps.

Fluentic exposes development utilities through the console:

StyleDevUtils.info();
StyleDevUtils.usage();
StyleDevUtils.getConfig();
Enter fullscreen mode Exit fullscreen mode

info() gives a quick overview of the active development setup:

runtime mode
CSS mode
priority mode
sourcemap mode
element marker state
plugin state
Enter fullscreen mode Exit fullscreen mode

There are toggles too:

StyleDevUtils.setSourcemapMode.toStyle();
StyleDevUtils.setSourcemapMode.toValue();

StyleDevUtils.setElementMarker.toOn();
StyleDevUtils.setElementMarker.toOff();

StyleDevUtils.setPriorityMode.toLayer();
StyleDevUtils.setPriorityMode.toSort();
Enter fullscreen mode Exit fullscreen mode

These are not separate styling APIs.

They are debugging views.

Sometimes I want source traces to point at the style call.

Sometimes I want them to point at the reusable value.

Sometimes I want element markers on.

Sometimes I want to inspect priority layers.

I should not have to rewrite component code to change the debugging view.

Runtime-Only Development Still Has A Trace Path

Plugin-powered development has the best metadata because the plugin sees source files while transforming them.

But Fluentic also supports runtime-only development.

In that mode, Fluentic can use:

StyleDevUtils.traceSourcemap();
Enter fullscreen mode Exit fullscreen mode

That lets a runtime setup fetch browser-served JavaScript sourcemaps and remap development CSS comments closer to the original TypeScript.

It is not the same as plugin-recorded metadata, because the plugin has more source context.

But the principle is the same:

generated CSS should not be a dead end
Enter fullscreen mode Exit fullscreen mode

Even in runtime-only mode, I still wanted a path back toward authored code.

React Server Components Need Debugging Too

Modern React apps can move styles through server and client boundaries.

For Next.js App Router and React Server Components development, Fluentic provides a dev client:

import { StyleDev } from '@fluentic/style/dev/rsc';

export default function RootLayout(props: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {props.children}
        {process.env.NODE_ENV === 'development'
          ? <StyleDev enableStyleDevUtils />
          : null}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

That keeps the same StyleDevUtils workflow available when styles are moving through an RSC development path.

This mattered to me because debugging should not only work in the smallest Vite demo.

If the library supports modern React modes, the debugging workflow has to follow.

Why This Works: Identity Survives Long Enough

This is the design reason behind all of this.

Fluentic keeps style information as structured data before it becomes final CSS.

author API -> style data -> runtime resolver
                       -> compiler extraction
                       -> debug metadata
Enter fullscreen mode Exit fullscreen mode

If style information becomes opaque strings too early, the browser can still get CSS.

But debugging loses context.

Fluentic tries to keep identity attached long enough:

style()       -> element style
chain method  -> selector/media context
style.slot()  -> component part
style.scope() -> grouped override
token/value   -> themeable source
css prop      -> rendered JSX element
Enter fullscreen mode Exit fullscreen mode

That identity is what lets generated atomic CSS point back toward source.

It is also what lets the same authoring API support runtime composition, production extraction, and development debugging.

Debuggability is not separate from the style model.

It has to be part of it.

The Tradeoff I Wanted

I do not think every styling library has to make the same tradeoff.

Some libraries choose the smallest possible runtime.

Some optimize around strict build-time constraints.

Some prioritize framework compatibility.

Some use readable generated class names as the main debug hint.

Some rely on labels and naming conventions.

Those can all be reasonable choices.

But for Fluentic, I wanted source-level debugging to be first-class.

Because this is the thing I kept wanting as a user:

If a styling library generates CSS for me,
it should help me debug that generated CSS too.
Enter fullscreen mode Exit fullscreen mode

I do not want a library that feels great while writing styles but becomes vague when the UI is wrong.

I do not want to choose between:

nice authoring API
good generated CSS
real sourcemaps
Enter fullscreen mode Exit fullscreen mode

I want those designed together.

That does not mean Fluentic debugging is done forever.

It is still beta. Tokens can get better. Different bundlers and framework modes will keep exposing edge cases. Debugging UI is always full of weird details.

But the direction is clear:

generated CSS should lead back to source
Enter fullscreen mode Exit fullscreen mode

Not only through readable names.

Not only approximately.

Through sourcemaps, debug metadata, element markers, trace modes, and preserved style identity.

What Comes Next

This post is the high-level debugging story.

The next step is showing more of the mechanics:

  • how generated atomic CSS maps to authored properties
  • how element markers connect DOM nodes to JSX
  • how toStyle() and toValue() change spread/merge tracing
  • how slots and scopes stay visible while debugging overrides
  • how debug class names and sourcemaps work together
  • how this behaves with extraction and runtime-known values

The goal is simple:

When a style is wrong, DevTools should help answer what code produced it.

Not make you guess.


Fluentic Style is still new and currently in beta. I am looking for early users to try it in real React, Preact, Solid, and component-library codebases.

Useful links:

Feedback would help a lot right now, especially around debugging workflows, sourcemap quality, generated CSS inspection, bundler integrations, token tracing, and whether this source-tracing model matches the way you debug real UI.

Top comments (0)