themed-styler
Client-side runtime styling engine for web and React Native with theme support and Tailwind-style utility classes defined in themes. It stores theme-aware selector styles using CSS property names and, at render time, infers which rules to emit from observed usage (tags, classes, and tag+class pairs). It can output either:
- Web CSS for currently-used selectors/classes
- React Native style objects for a selector combined with Tailwind-like utility classes
This crate is designed to be embedded in clients or tooling. A CLI wrapper is available via the hook-transpiler-cli crate.
Features
- Theme registry with default/current theme switching
- CSS-attribute-based style storage per selector
- Per-theme variables and breakpoints (xs, sm, md, lg, xl), with inheritance
- Tailwind utilities are provided by the theme (no whitelist). The default theme ships with a minimal set and other themes inherit from it.
- Output:
- Web: flat CSS string for currently used selectors/classes
- React Native: camelCased style object with basic unit conversion (e.g., "8px" → 8)
- Render-time inference of usage (no exact-string selector tracking): emits rules for observed tags, classes, and tag+class pairs without deep hierarchy selectors
Quick start (Rust)
use themed_styler::api::State;
use indexmap::IndexMap;
// 1) Start with a default state
let mut st = State::new_default();
// 2) Register what is currently used in your app (structured usage)
st.register_tags(["body".to_string(), "button".to_string()]);
st.register_tailwind_classes(["p-2".to_string()]);
st.register_tag_class("h1", "text-sm");
// 3) Emit CSS for the web
let css = st.css_for_web();
println!("{}", css);
// 4) Emit RN styles for a specific selector + classes
let rn = st.rn_styles_for("button", &["p-2".into()]);
println!("{}", serde_json::to_string_pretty(&rn).unwrap());
// 5) Themes/variables/breakpoints can be customized
st.set_theme("light").ok();
st.set_variables(IndexMap::from([
("primary".into(), "#2563eb".into()),
]));
st.set_breakpoints(IndexMap::from([
("md".into(), "768px".into()),
]));
Themed-styler utilities
- There is no runtime whitelist or generator anymore. Classes used at runtime are matched directly against the active theme’s selectors (for example, the class "p-2" is looked up as ".p-2" in the theme; "hover:p-2" is looked up as ".p-2:hover").
- The crate bundles a default YAML theme that includes a small subset of utilities as examples; apps can extend it by adding selectors to their own theme(s).
Newly supported dynamic utilities (generated at runtime if not present in the theme):
- rounded*: border radius utilities
- rounded, rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-2xl, rounded-3xl, rounded-full
- side variants: rounded-t, rounded-r, rounded-b, rounded-l (with optional size suffix, e.g., rounded-t-lg)
- cursor-*: sets CSS cursor (pointer, default, text, move, wait, not-allowed, etc.)
- transition*: transition shorthands
- transition / transition-all → transition-property: all; transition-duration: 150ms; ease-in-out
- transition-none → disables transitions
- transition-colors | transition-opacity | transition-transform | transition-shadow → limits transition-property with default duration/ease
- Width utilities: w-, min-w-, max-w-*
- Numeric scale: w-2 ⇒ width: 8px (n×4px)
- Fractions: w-1/2 ⇒ width: 50%
- Tokens: w-full (100%), w-screen (100vw), w-px (1px); min/max variants mirror the same mapping
Display, flex, hover, and breakpoints
- Display: block, inline-block, inline, inline-flex, grid, hidden
- Flex: flex, flex-row, flex-col, flex-1; alignment helpers items-, justify-
- Pseudo/state: hover: prefix supported anywhere in the token chain (e.g., hover:block, md:hover:flex)
- Breakpoints: xs:, sm:, md:, lg:, xl: prefixes wrap the rule in @media (min-width: ) using the active theme’s breakpoints
- Example: md:flex → @media (min-width: 768px) { .flex { display:flex } }
- Example: md:hover:block → @media (min-width: 768px) { .block:hover { display:block } }
Notes:
- React Native output will camelCase properties and convert px values to numbers when applicable (e.g., border-radius → borderRadius, width: "8px" → 8). Properties without RN equivalents (e.g., cursor, transition) are harmless and may be ignored on RN.
- RN ignores hover and breakpoint prefixes at runtime and applies the base class styles (e.g., md:flex → display:flex in RN output).
Theme format and inheritance
- The default state is defined in YAML and bundled with the crate: crates/themed-styler/theme.yaml.
- Each theme entry contains selectors, variables, and breakpoints, plus an optional inherits pointing to a parent theme.
- Inheritance merges default → parent(s) → child, with the child overriding parent/default on conflicts. This lets the default define canonical utility classes and common selectors, while other themes only specify overrides.
Example (YAML):
themes:
default:
selectors:
".p-2":
variables:
text: "#111b26"
bg: "#ffffff"
breakpoints:
xs: "480px"
md: "768px"
dark:
inherits: default
selectors:
body:
variables:
bg: "#0f172a"
default_theme: default
current_theme: dark
On load and theme switch, themed-styler computes effective selectors, variables, and breakpoints by following the inherits chain and finally the default theme.
Variables and breakpoints (per-theme)
- Variables and breakpoints live inside each theme under variables and breakpoints.
- Resolution order for variables: legacy global variables (lowest) → default theme variables → parent theme variables (via inherits chain) → current theme variables (highest). Breakpoints follow the same order.
- React Native output resolves var tokens too. Supported syntaxes: var(--name), var(name), and $name.
Example YAML (variables and breakpoints):
themes:
default:
selectors:
body:
background-color: "var(bg)"
color: "var(text)"
variables:
bg: "#ffffff"
text: "#111b26"
breakpoints:
xs: "480px"
sm: "640px"
default_theme: default
current_theme: default
CLI integration
The hook-transpiler-cli crate embeds themed-styler state management as subcommands. Example workflow:
# Initialize a state file
hook-transpiler-cli style init --file .themed-styler-state.json
# Register usage (selectors/classes)
hook-transpiler-cli style register-selectors --file .themed-styler-state.json body button
hook-transpiler-cli style register-classes --file .themed-styler-state.json p-2 hover:mx-1
# Output web CSS (stdout)
hook-transpiler-cli style css --file .themed-styler-state.json
# Output RN style object for a selector + classes
hook-transpiler-cli style rn --file .themed-styler-state.json button p-2
State format
State can be serialized/deserialized for tooling. Internally the crate uses Serde and accepts JSON; the built-in default is YAML.
Status & next steps
- Core theme storage & usage tracking:
Statecaptures selectors/styles per theme withdark/lightdefaults and now records structured runtime usage viaregister_tags,register_tailwind_classes, andregister_tag_class. The runtime wrappers keep the state in sync with components that render and unmount. - Theme overrides, variables, and breakpoints: CLI helpers (
add_theme,set_vars,set_bps,set_theme) are in place, yet we still need a shared theme file that exposes thedefault/dark/lightcombinations and gives consumers a place to override selectors before the runtime or tooling reads them. - Web CSS & RN output:
css_for_webandrn_styles_for(plus the CLIstyle css/style rncommands) infer emission at render time from observed tags/classes/tag+class pairs. No exact-stringused_selectorsfiltering is required. - Runtime Tailwind support: Tailwind utilities are theme-defined (no whitelist). The default YAML includes a minimal set (e.g.,
.p-2,.hover\:mx-1:hover) and apps can add more in their themes. - Client-web stylesheet updates: The web side should repaint or replace the global stylesheet whenever observed usage changes. A global style manager should call
css_for_web()after render to update a single tag. - Tailwind/nativewind removal & custom wrapper: The web and RN clients still ship with their existing Tailwind stylesheets and nativewind wiring. We need to delete
index.css/globals.cssand any Tailwind/nativewind references, then rewire both apps to use the themed-styler binary via the custom styled wrapper. - Example theme file & hooks: While
State::new_defaultprovides in-memory defaults, there is no filesystem example that can be shared with the template repo. We should add a JSON theme file (withdefaultaliasingdark, plus explicitdarkandlight) and refactortemplate-ui/theme.jsto use the new hooks to set selectors/themes/values instead of the old boilerplate. - React Native selectors: The backend supports selector registration; wrappers should capture the intended tag string used in themes (e.g.,
div,span,h1) so cross-platform selectors likediv.primaryremain coherent. - CLI commands & tests: The CLI already exposes
style init,register-selectors,register-classes,css, andrn, but the requirements also expect exposing theme/variables/breakpoint updates and tailored outputs for selectors. We need new unit tests that exerciseset-theme,add-theme,set-vars, etc., to ensure the commands manipulate the state as expected. - Tailwind CSS files: Legacy Tailwind CSS imports should be removed from clients; runtime styling comes from themed-styler + theme utilities.
Default theme file
A default YAML state is bundled at crates/themed-styler/theme.yaml and loaded automatically by State::new_default(). You can still manage state via JSON with the CLI if preferred.
Notes about selectors and HTML tag usage
- Use HTML tags for all selectors in themes (for example
div,button,h1). The React Native app will render JSXdivinto a styled nativeViewat runtime, so keeping HTML tags in themes makes selector logic identical between web and RN. - Themes store selectors as literal keys (e.g.,
h1,.text-sm,h1.text-sm). At runtime, the engine observes tags/classes and infers matches: a usage ofh1.text-smimplies bothh1and.text-smare eligible for emission.
Runtime integration (overview)
- The next step is adding a small createElement wrapper / hooks in the template/client apps that:
- preserve the original string tag used in JSX (e.g.,
div) so selectors likediv[type=primary]ordiv.myClasscan match in RN and web alike, - call the runtime to
register_selectorson mount andunregisteron unmount (to keepused_selectorsaccurate), and - on the web, call the style manager to fetch
css_for_web()and write it into a single global tag when the used selectors/classes set changes.
- preserve the original string tag used in JSX (e.g.,
We'll implement these runtime helpers in template-ui as the next task and provide concrete JS/TS files and usage examples for both client-web and client-react-native.
Notes
- Store attributes using CSS property names; RN output will camelCase and convert px to numbers when possible.
- CSS output is flat by design initially; media queries may be emitted in future iterations.
- Only styles for registered selectors/classes are emitted to keep output minimal.
License
MIT OR Apache-2.0
Web wrapper example (TSDiv)
The client-web app uses a very small wrapper component named TSDiv that reports usage to the unified themed-styler bridge and renders a normal DOM element. This keeps usage tracking opt-in, instead of wrapping every element globally.
Example (simplified):
// apps/client-web/src/components/TSDiv.tsx
import React, { useEffect } from 'react'
import { unifiedBridge, styleManager } from '@relay/shared'
type DivProps = React.DetailedHTMLProps, HTMLDivElement> & {
tag?: string
}
export const TSDiv: React.FC = ({ children, tag = 'div', ...props }) => {
useEffect(() => {
try {
unifiedBridge.registerUsage(tag, props as any)
styleManager.requestRender()
} catch {}
}, [props.className])
return React.createElement(tag, props, children)
}
Usage in the app:
import { TSDiv } from './components/TSDiv'
// Replace <div> with <TSDiv> and pass className normally
<TSDiv className="flex flex-col w-screen h-screen">
...
</TSDiv>
Notes:
- The wrapper passes the same tag string (default 'div', or via
tagprop) toregisterUsageso selectors likediv.myClasscan match across web and RN. - Only wrapped elements are registered; this prevents over-collecting and keeps the emitted stylesheet minimal.
Hierarchy selectors
Deep descendant selectors (e.g., div div div span) are not generated. Emission is based on tags, classes, and tag+class pairs only. This keeps CSS lightweight, decoupled from DOM depth, and easier to override.