Building Fluentic Style: The Anatomy of Debuggable Atomic CSS

작성자

카테고리:

← 피드로
DEV Community · OmniDev · 2026-08-17 개발(SW)

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 has always had one awkward problem for me.

It can make authoring styles nicer, but debugging them worse.

You write clean component code.

You compose styles with objects, functions, conditions, tokens, variants, themes, or whatever the library gives you.

Then something looks wrong in the browser.

So you open DevTools.

And suddenly you are staring at generated class names, generated CSS, injected style tags, transformed files, or sourcemaps that only kind of help.

That is the moment where a styling library either earns trust or loses it.

Because the question is not only:

Can this library generate CSS?

The real question is:

When the CSS is wrong, can I find the code that made it wrong?

While building Fluentic, this became one of the things I cared about most.

I did not want generated CSS to feel like a black box.

And that changed how I started looking at atomic CSS.

Docs related to this topic:

Atomic CSS Has An Ugly Side

Atomic CSS has a simple core idea:

One CSS property/value.

One generated rule.

One reusable class.

That gives you obvious benefits.

Repeated declarations can dedupe. Output can be reused. Runtime insertion can avoid adding the same rule again and again. Production CSS can be easier for the compiler to collect and sort.

But atomic CSS also has a reputation.

And honestly, some of it is fair.

It can push more classes into the HTML.

It can make rendered markup look noisy.

It can feel less human when you inspect an element and see a long list of generated class names instead of one meaningful component class.

For example, this authored style:

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

Enter fullscreen mode Exit fullscreen mode

does not become one nice .button rule.

Conceptually, it becomes many tiny generated rules:

.display-a1b2c3 { display: inline-flex }
.align-items-d4e5f6 { align-items: center }
.gap-g7h8i9 { gap: 8px }
.border-radius-j1k2l3 { border-radius: 8px }
.padding-inline-m4n5o6 { padding-inline: 16px }
.padding-block-p7q8r9 { padding-block: 8px }
.background-color-s1t2u3 { background-color: #2563eb }
.color-v4w5x6 { color: #ffffff }
.hover-background-color-y7z8a9:hover { background-color: #1d4ed8 }

Enter fullscreen mode Exit fullscreen mode

And the HTML can look busy:

<button
  class="display-a1b2c3 align-items-d4e5f6 gap-g7h8i9 border-radius-j1k2l3 padding-inline-m4n5o6 padding-block-p7q8r9 background-color-s1t2u3 color-v4w5x6 hover-background-color-y7z8a9"
>
  Save
</button>

Enter fullscreen mode Exit fullscreen mode

That is less pretty than:

<button class="button">Save</button>

Enter fullscreen mode Exit fullscreen mode

I do not want to pretend otherwise.

If your main debugging workflow is reading class names from HTML, atomic CSS can feel messy.

That is the bad part.

But DevTools Does Not Debug CSS As A Component Class

The interesting part is what happens after you stop looking at the HTML as the main debugging surface.

When something looks wrong, I usually inspect the element and look at the CSS rule that won.

For example:

background-color: #1d4ed8

Then the real question becomes:

Where did this background-color come from?

Not:

What is the one perfect class name for this whole button?

But:

Where in my source code, ideally the exact line and column, is this winning CSS property declared?

That is where atomic CSS starts to become surprisingly useful.

Because each generated atomic rule is already about one thing.

One rule.

One CSS property.

One value.

One selector or media context.

That maps very nicely to the debugging question.

In Fluentic, a generated base background-color rule can point back to:

backgroundColor: '#2563eb'

Enter fullscreen mode Exit fullscreen mode

And a generated hover background-color rule can point back to:

.hover({
  backgroundColor: '#1d4ed8',
})

Enter fullscreen mode Exit fullscreen mode

More specifically:

Generated hover background-color rule

backgroundColor property

→ inside the .hover(...) chain call

That is the part that made atomic CSS click differently for me.

The tiny rule is not only an output optimization.

It is a good source-tracing unit.

Grouped CSS Makes This Harder

Imagine generated CSS that keeps the whole style object together:

.button-a1b2c3 {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border-radius: 8px;
  padding-inline: 16px;
  padding-block: 8px;
  background-color: #2563eb;
  color: #ffffff;
}

Enter fullscreen mode Exit fullscreen mode

This looks nicer as generated CSS.

But for debugging, it creates a different problem.

One generated rule now represents many authored properties.

If DevTools lands on .button-a1b2c3, where should the source trace go?

display?

alignItems?

gap?

borderRadius?

paddingInline?

backgroundColor?

color?

You can try to map individual generated declaration ranges back to individual source properties, but the shape is already more complicated.

And real component styling does not stay simple for long.

You get:

Spreads.

Merges.

Selectors.

Media queries.

Scopes.

Themes.

Tokens.

Custom transforms.

Runtime-known values.

Grouped CSS can still be useful, but the generated rule carries many authored decisions at once.

Atomic CSS flips that shape.

Instead of one generated rule trying to explain many source locations, each generated rule can focus on one authored styling decision.

.generated-background-color

backgroundColor source location

.generated-padding-inline

paddingInline source location

.generated-hover-background-color

backgroundColor inside .hover(...)

That is the debugging advantage I think people do not talk about enough.

Atomic CSS fragments the output.

But that same fragmentation can make source tracing more precise.

The Hidden Problem With Dedupe

There is one catch.

Atomic CSS is great at dedupe because the same CSS property/value can appear in many places.

For example:

const card = style({
  color: '#0f172a',
});

const title = style({
  color: '#0f172a',
});

Enter fullscreen mode Exit fullscreen mode

Both produce the same CSS idea:

color: #0f172a

Enter fullscreen mode Exit fullscreen mode

For production, sharing one generated rule is great.

If the same atomic rule appears in many places, Fluentic can emit that CSS rule once in the final CSS and reuse the generated class wherever it is needed.

But development has a different goal.

When I inspect a generated class in DevTools, I do not only want to know:

This is color: #0f172a.

I want to know:

Where in my source code is this color: #0f172a declared?

Was it the card?

Was it the title?

Was it a shared helper?

Was it a scope override?

Was it produced by a transform?

That is the tension:

Production wants global dedupe.

Debugging wants callsite precision.

So Fluentic treats development and production differently.

In development, the generated class identity includes callsite-local information.

That means two identical CSS property/value pairs from different style callsites can still get different debug classes and different sourcemap targets.

Conceptually:

Development:

color: #0f172a at card.styles.ts:10

color-card-a1b2c3

→ sourcemap to card.styles.ts:10

color: #0f172a at title.styles.ts:20

color-title-d4e5f6

→ sourcemap to title.styles.ts:20

That is less deduped in development, but it makes DevTools much more trustworthy.

Production does not use that callsite-local behavior.

In production, if the same atomic rule appears in many places, Fluentic emits the CSS rule once and reuses it wherever that same rule is needed.

Conceptually:

Production:

color: #0f172a

→ one generated atomic rule

→ reused by every matching style

That tradeoff matters.

If development used only globally deduped atomic classes, sourcemaps would have to pick one source location for a rule that may have come from many authored places.

That would make the debugging experience feel random.

So Fluentic uses callsite-local identity in development for better source tracing, and production identity for global dedupe.

Development: optimize for precise debugging.

Production: optimize for extracted CSS and global dedupe.

Same styling model.

Different identity goals.

A Fluentic Atomic Rule Is More Than CSS Text

For Fluentic, an atomic rule is not only the CSS string the browser receives.

It can also carry identity:

Rule key.

CSS text.

Property/debug field.

Selector or media context.

Priority.

Callsite/source URL.

Debug metadata.

A simplified mental model looks like this:

{
  key: "...",
  css: ".background-color-abc { background-color: #2563eb }",
  debugField: "backgroundColor",
  priority: "...",
  callsite: {
    filePath: "button.styles.ts",
    line: 12,
    column: 3
  }
}

Enter fullscreen mode Exit fullscreen mode

The browser uses the CSS.

DevTools and Fluentic’s development tooling use the identity.

That identity lets a generated rule answer questions like:

Which property produced me?

Which chain context am I in?

Which source file should DevTools open?

Which priority bucket do I belong to?

Was I produced from a slot or scope?

Was I an element marker rule?

Without that identity, atomic CSS is just a pile of tiny classes.

With that identity, each rule becomes a handle back to the authored style.

That is the difference.

Selector Context Has To Survive Too

A property name alone is not enough.

This style uses the same property three times:

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

Enter fullscreen mode Exit fullscreen mode

Those are three different authored decisions.

They produce different CSS contexts:

Base backgroundColor.

Hover backgroundColor.

Active backgroundColor.

A useful debugger should not collapse those together.

A generated base rule should trace to the base backgroundColor.

A generated hover rule should trace to the backgroundColor inside .hover(...).

A generated active rule should trace to the backgroundColor inside .active(...).

This is why Fluentic style chains produce structured style data before CSS.

The chain call is part of the identity.

Style property

  • selector/media chain context → generated atomic rule → source trace

That is much more useful than:

This came from the button style somewhere.

I want the generated CSS to tell me exactly which part of the style chain produced it.

Priority Is Part Of Debugging Too

Finding the source is only half of debugging CSS.

The other half is understanding why a rule won.

In a real component system, the same CSS property can come from many places:

Base style.

Hover state.

Active state.

Media query.

Scope override.

Theme override.

Parent-provided style.

Weighted value.

Layer.

Fluentic needs to decide which generated rule wins without depending on random insertion order.

So generated rules carry priority information.

That priority can account for things like:

Selector context.

Scope context.

Media context.

Property order.

Value weight.

Layer.

This matters in DevTools.

When I inspect a generated rule, I do not only care where it came from.

I also care why it is taking effect.

Atomic CSS gives Fluentic small units to sort, layer, dedupe, and inspect.

The output is not just:

Many tiny classes.

It is:

Many tiny rules with known identity and priority.

That is much more debuggable.

Slots And Scopes Need Source Identity Too

Root element styles are only the beginning.

Reusable components usually have parts:

const cardStyles = {
  root: style.slot({
    padding: 16,
    backgroundColor: '#ffffff',
  }),

  title: style.slot({
    fontWeight: 700,
    color: '#0f172a',
  }),

  body: style.slot({
    color: '#475569',
  }),
};

Enter fullscreen mode Exit fullscreen mode

Then outside styles can target those parts:

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

  cardStyles.title({
    color: '#dc2626',
  }),
]);

Enter fullscreen mode Exit fullscreen mode

Eventually this becomes generated CSS.

But the authored decisions are different:

Card root base background.

Card title base color.

Danger scope root border.

Danger scope title color.

If the title is red in the browser, I want to know:

Was this title base style?

Was this danger scope?

Which slot was targeted?

Which style call produced the rule?

Atomic output should not erase those identities.

So Fluentic’s rule identity has to work with component concepts too.

Not only CSS property names.

That is why slots and scopes are part of the style data model. They are not just helper functions that disappear before debugging.

Debug Class Names Help, But They Are Not Enough

Readable generated class names can help.

A class like this is nicer:

.background-color-blue-abc

than this:

.a1b2c3

But class names should not carry the whole debugging burden.

Names can get long.

Names can be compressed or changed by production output.

Names can only describe so much.

For Fluentic, debug class names are hints.

Sourcemaps and debug metadata are the real debugging path.

Debug class name helps you recognize the rule.

Sourcemap/debug metadata helps DevTools take you to the source.

Atomic CSS makes this especially useful because the generated rule is already small enough to point at one authored property in one styling context.

That is the thing I wanted.

Not just readable generated CSS.

Clickable generated CSS.

Atomic CSS Is Output, Not The Authoring Model

This is an important distinction.

Fluentic does not ask you to author atomic classes by hand.

You can write style chains:

const button = style({
  display: 'inline-flex',
  alignItems: 'center',
  paddingInline: 16,
}).hover({
  backgroundColor: '#1d4ed8',
});

Enter fullscreen mode Exit fullscreen mode

You can write Tailwind-like class-name chains:

const button = cx('inline-flex', 'items-center', 'px-4')
  .hover('bg-blue-700');

Enter fullscreen mode Exit fullscreen mode

You can even define custom style dialects:

const button = ui({
  row: true,
  center: true,
  gapX: 8,
});

Enter fullscreen mode Exit fullscreen mode

Atomic CSS is the output format.

Not the authoring model.

That is why Fluentic can keep higher-level concepts like:

Style chains.

Slots.

Scopes.

Tokens.

Themes.

Runtime-known values.

Debug metadata.

and still emit atomic CSS underneath.

The user gets a higher-level styling API.

The browser gets small CSS rules.

DevTools gets source traces.

That is the intended triangle.

Dev And Prod Use Atomic CSS Differently

Fluentic emits atomic CSS in both development and production, but the goals are different.

In development, the atomic rules carry extra debugging help:

Readable debug names.

Callsite-local identities.

Sourcemap callsites.

Debug fields.

Element marker rules.

Priority inspection.

StyleDevUtils controls.

In production, the output is prepared for the app:

Extracted CSS.

Globally deduped atomic rules.

Prepared JavaScript output.

Runtime support for runtime-known values.

Same basic rule model.

Different delivery mode.

That was important to me.

I did not want one styling system for development and a completely different styling system for production.

If development output is atomic and production output is atomic, the mental model stays closer.

But development and production do not need the same identity strategy.

Development needs precise source tracing.

Production needs extracted CSS and global dedupe.

Why This Made Debugging Feel Possible

Before building this, I mostly thought about atomic CSS in the usual way:

Dedupe.

Reuse.

Small output.

Fast insertion.

Those are real benefits.

But Fluentic made me care about another benefit:

One generated rule can map to one authored styling decision.

That is the debug story.

If generated CSS is grouped too coarsely, sourcemaps have to explain too much at once.

If generated CSS is atomic, the source trace can be much more direct.

One CSS property/value.

One generated atomic rule.

One source location.

Of course, real styling has more context than that.

Selectors, media queries, scopes, themes, spreads, merges, runtime values, and duplicated declarations all make the system more complicated.

But atomic CSS gives the debugging model a clean base unit.

That is why I think atomic CSS is not only a performance or output-size choice.

It can also be a debugging choice.

The Tradeoff

The tradeoff is still real.

Atomic CSS can make HTML noisier.

It can make generated stylesheets less pleasant to read by hand.

It can feel strange if you expect one component class to describe the whole component.

I accept that cost because Fluentic is not trying to make generated HTML the main debugging interface.

The generated class is a handle.

The sourcemap is the path back to source.

The browser DevTools should become useful again.

That is the bet.

The Result

For Fluentic, atomic CSS is not just:

Smaller generated CSS.

It is also:

A precise source-tracing unit.

A dedupe unit.

A priority unit.

A runtime insertion unit.

A production extraction unit.

That is the anatomy I care about.

The rule is tiny, but it carries enough identity to stay useful.

That is what makes Fluentic’s generated CSS debuggable.

Not because every class name is beautiful.

But because every generated rule can know what authored decision produced it.

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

Useful links:

Feedback would help a lot right now, especially around generated CSS inspection, atomic output, sourcemaps, rule priority, and whether this debugging model matches how you inspect real UI.

원문에서 계속 ↗