Styling
Tailwind v4 does the styling, the class dictionaries keep it static, and two scanners keep it honest.
An AsheeUI component has no stylesheet of its own: it renders class names, complete, static strings picked from a dictionary, and the package ships one global stylesheet that tells Tailwind where those strings live and holds the few rules a utility cannot express. The application's Tailwind build turns the strings into rules. Everything on this page follows from that division of labour: what the dictionaries are for, why the stylesheet scans two packages, and the two rules the test suite enforces.
Tailwind does the styling
There is no CSS-in-JS runtime, no style object that states a colour or a spacing step, no
@apply block compiled ahead of time, and no stylesheet step in the package build: the build
copies the one stylesheet into dist and emits JavaScript. What a component chooses is a
string of Tailwind utilities, and the consumer's Tailwind, version 4 or newer, compiles it.
@import "tailwindcss";
@import "@asheeui/web/styles";
The order matters, and Installation states it once: the framework's stylesheet extends the Tailwind theme with the colour tokens, so it has to be read after Tailwind's own import.
Inline style is reserved for what only exists once the component runs: a floating list's
position, a layer's z-index, a resizable pane's percentage, an animation's duration, or a
colour a consumer passes as data. The library's own colours, radii, spacing and type are
classes.
The dictionaries
Every family keeps its class strings in one module named after the component
(button-styles.ts, card-styles.ts). Most of them live in @asheeui/core, beside the config
types and the token maps, because the keys are the design language both renderers answer. The
rest sit with a renderer: the web's card-styles.ts, typography-styles.ts and layout
dictionaries are in packages/web, and the native renderer keeps its own. The rule is one
dictionary per family, not one folder.
Their shape is a record keyed by a token, and the key type is what turns the record into a checklist:
export const BUTTON_SIZE_CLASS: Record<Size, string> = {
sm: "h-8 px-3 text-xs gap-1.5",
md: "h-9 px-3.5 text-xs sm:h-10 sm:px-4 sm:text-sm gap-2",
lg: "h-11 px-5 text-sm sm:h-12 sm:px-6 sm:text-base gap-2.5",
};
Add a density to Size and this file stops compiling until it answers for the new one. A
missing size becomes a type error rather than a padding: undefined in a browser. The same
shape covers radii (Record<Radius, string>), spacing (Record<Space, string>) and every
variant-by-colour matrix (Record<Variant, Record<ColorRole, string>>). A family whose maps
are typed more loosely asserts its key set in a test instead.
Where both renderers render a family, the two dictionaries sit side by side and share their
keys: BUTTON_SIZE_CLASS for the web and NATIVE_BUTTON_SIZE_CLASS for the platform, with
the same sm/md/lg and different strings, because a pointer and a thumb need different
heights. One edit changes the scale for both.
A family may transform a resolved key before it looks it up, a button's underlined
treatment resolves the radius to none, so a map is keyed by the value the family means, not
always by the raw name the cascade produced.
Why cn is in every render
Component classes are composed with cn, which is clsx for conditional composition and
tailwind-merge for conflicts: when two utilities set the same property, the later one wins.
Every component appends the consumer's className last, so a conflict resolves the consumer's
way:
<Button radius="md" className="rounded-full">
Save
</Button>
The radius resolves to md and the dictionary contributes rounded-md; rounded-full arrives
last and twMerge keeps it. That is the mechanism behind passing className to adjust one
instance: without the merge, both rules would be in the class list and the stylesheet's order,
not yours, would decide. resolveClassKey covers the other direction, a key a map does not
carry falls back to a named key's class rather than to nothing.
The @source contract
Tailwind generates a utility only when it can read the utility's name as text in a file it has been told to scan. A library's classes are not in the application's source folder, so the library has to say where they are. That is what the four lines at the top of the framework stylesheet do:
| Pattern | What it covers |
|---|---|
@source "./**/*.{ts,tsx}" |
this package's source, the shape this repository renders from |
@source "./**/*.js" |
this package's compiled output, the shape an installed package is |
@source "../../core/src/**/*.{ts,tsx}" |
the core package's source |
@source "../../core/dist/**/*.js" |
the core package's compiled output |
The paths are relative to the stylesheet, not to the application. A pattern that matches nothing is ignored, which is why the source shape and the compiled shape can be listed at the same time.
@asheeui/core is on the list because many of the class names live there. The radius and size
vocabularies, the spacing maps and most components' *-styles.ts modules are in core, so a
stylesheet that scanned only ./ would generate the web package's classes and not core's. The
failure does not look like a missing stylesheet: the class is on the element, the component
renders, and the padding, gap and corner rounding are simply absent. A control whose label
touches its edges is that bug, not a spacing decision.
A consumer does not write these lines. Importing @asheeui/web/styles brings the stylesheet,
and the stylesheet brings its own source list. The obligation is the import: after
@import "tailwindcss", once, at the root. A build that copies the theme block into its own
CSS, or that uses source(none) with an explicit list, has to state the patterns against the
installed packages itself.
Tokens, not literals
The scale lives in @asheeui/core's token module as names and steps rather than as class
strings or CSS values. A step is the Tailwind spacing multiplier: step 4 is 1rem on the web
and four density-independent pixels on native, which is why the name travels and the unit does
not.
| Token | Step | Gap class |
|---|---|---|
none |
0 | gap-0 |
xs |
1 | gap-1 |
sm |
2 | gap-2 |
md |
4 | gap-4 |
lg |
6 | gap-6 |
xl |
8 | gap-8 |
2xl |
12 | gap-12 |
Radii work the same way with names instead of steps (none through full), and a map turns
each name into its class (rounded-none through rounded-full). The native renderer keeps its
own radius map, because the first two steps map to different class names on its scale; the
token name is the contract and the class is the platform's spelling.
A component therefore resolves a token and looks up a class. It never computes a spacing,
colour or radius value, and it never builds the class from the token, the second half is what
the first scanner is about. Where the scale has no step, the dictionary writes the
arbitrary-value form so the exception is visible in review: text-[10px] for a badge's smallest
density, min-h-[44px] for a native touch target.
Layout and spacing states what the spacing tokens mean for a
page.
The two scanners
packages/web/src/test/class-conventions.test.ts walks the web package's source and fails on
either of two findings, and it first asserts that it read more than fifty files so the guard
cannot pass by scanning nothing.
No dynamically constructed utility names. The scanner flags a utility root welded to an interpolation or a concatenation inside a class-name context:
<div className={`bg-${colour}-500`} /> // flagged: the name never exists as text
<div className={"rounded-" + radius} /> // flagged: a concatenated name
el.classList.add(`border-${tone}`); // flagged: a DOM class built at runtime
A complete interpolation of a class string that already exists is a lookup rather than construction, and is accepted:
<div className={`${sizeClass} ${toneClass}`} /> // accepted: both strings are complete already
So is a template outside a class-name context, such as an id.
The reason is the one the @source section states: Tailwind generates what it can read, so a
class assembled at runtime was never generated. Nothing throws; the element renders without
that rule. The dictionary form, one complete string per key, is both the fix and what makes
the map exhaustive.
No raw palette colours. The scanner flags a colour utility that names a default-palette
colour, with or without a shade: bg-red-500, border-white/10, ring-offset-white,
border-x-slate-200. The only spelling the library accepts is the semantic one,
bg-primary, text-foreground/70, border-border, bg-danger/10, because a palette name is
baked in: it ignores the theme and does not follow light to dark.
Both scanners are biased towards missing a case rather than reporting a false one, and the scanner's own comment says so: the utility roots are a curated list, a class-name context is recognised by a keyword, and a name built across two lines can slip past. A green run is the floor rather than a proof. The scanners run over the web package's source; the rule they enforce applies to every class string in either package.
The theme variables
The stylesheet's @theme inline block maps Tailwind's colour names onto the framework's custom
properties:
@theme inline {
--color-primary: var(--ashee-primary);
--color-background: var(--ashee-background);
/* every colour role, and the scrollbar variables */
}
The theme controller writes the --ashee-* values for each theme and puts theme-<name> on
the document root (<html>); the mapping is what makes bg-primary mean "the active theme's
primary". The inline option matters because each mapped value is itself a variable reference:
the generated utility carries var(--ashee-primary) rather than a second variable that points
at it, so one class set serves every theme and switching themes swaps values rather than a
stylesheet.
The @layer base block holds the rules that are not one utility: the page's background and
text colour, the scrollbar styling driven by --ashee-scrollbar-*, the disabled form field
treatment, the two helpers that hide a scrollbar while keeping the scrolling
(.scrollable-hidden and .scrollbar-hide), and the keyframes and classes for the modal,
drawer and toast animations. Those are the only rules the library ships as CSS rather than as
a utility, because they are the ones a utility cannot express.
Theming declares and switches the palettes, and Configuration covers the scrollbar keys.
Styling is deliberately uneventful: a resolved name, one dictionary entry, one static class, and the consumer's build doing the compile. The cascade states where the name comes from, and Components is the index of the families that own a dictionary.